217 lines
8.1 KiB
Python
217 lines
8.1 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""在宿主机启动独立的 Qwen3-ASR VLLM 服务。"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import os
|
|||
|
|
import shutil
|
|||
|
|
import signal
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
from pathlib import Path
|
|||
|
|
from urllib.error import URLError
|
|||
|
|
from urllib.request import urlopen
|
|||
|
|
|
|||
|
|
from dotenv import load_dotenv
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 启动器自动读取 demo/.env;系统环境变量仍然优先,便于部署平台临时覆盖配置。
|
|||
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|||
|
|
load_dotenv(PROJECT_ROOT / ".env")
|
|||
|
|
|
|||
|
|
# 将服务端口集中在代码变量中维护,启动时不需要额外传入端口参数;健康检查、
|
|||
|
|
# VLLM 子进程命令和就绪提示都使用同一个端口,避免配置不一致导致误判。
|
|||
|
|
SERVER_PORT = int(os.getenv("VLLM_PORT", "9950"))
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from .model_manifest import load_manifest, model_directory, resolve_model_id
|
|||
|
|
except ImportError:
|
|||
|
|
from model_manifest import load_manifest, model_directory, resolve_model_id
|
|||
|
|
|
|||
|
|
|
|||
|
|
def has_model_weights(model_path: Path) -> bool:
|
|||
|
|
"""检查 VLLM 加载模型前必须存在的最小本地文件集合。"""
|
|||
|
|
if not model_path.is_dir() or not (model_path / "config.json").is_file():
|
|||
|
|
return False
|
|||
|
|
return any(model_path.rglob("*.safetensors")) or any(model_path.rglob("*.bin"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def positive_int(value: str) -> int:
|
|||
|
|
"""解析启动轮询使用的正整数参数,并拒绝零和负数。"""
|
|||
|
|
parsed = int(value)
|
|||
|
|
if parsed < 1:
|
|||
|
|
raise argparse.ArgumentTypeError("value must be at least 1")
|
|||
|
|
return parsed
|
|||
|
|
|
|||
|
|
|
|||
|
|
def non_negative_float(value: str) -> float:
|
|||
|
|
"""解析启动轮询间隔,并拒绝会导致逻辑异常的负数。"""
|
|||
|
|
parsed = float(value)
|
|||
|
|
if parsed < 0:
|
|||
|
|
raise argparse.ArgumentTypeError("value must be non-negative")
|
|||
|
|
return parsed
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_parser() -> argparse.ArgumentParser:
|
|||
|
|
"""创建宿主机启动参数解析器,默认值允许通过环境变量统一覆盖。"""
|
|||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|||
|
|
add_arguments(parser)
|
|||
|
|
return parser
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|||
|
|
"""注册模型、网络端点和启动检查循环相关的命令行参数。"""
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--model",
|
|||
|
|
default=os.getenv("QWEN3_ASR_MODEL", "default"),
|
|||
|
|
help="Model alias, exact model ID, or default",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--models-dir",
|
|||
|
|
type=Path,
|
|||
|
|
default=Path(os.getenv("MODEL_DIR", str(Path(__file__).resolve().parents[1] / "models"))),
|
|||
|
|
help="Root directory containing downloaded model files",
|
|||
|
|
)
|
|||
|
|
parser.add_argument("--host", default=os.getenv("VLLM_HOST", "0.0.0.0"))
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--display-host",
|
|||
|
|
default=os.getenv("VLLM_DISPLAY_HOST", "127.0.0.1"),
|
|||
|
|
help="Host name shown in the ready message; does not change the bind address",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--probe-host",
|
|||
|
|
default=os.getenv("VLLM_PROBE_HOST", "127.0.0.1"),
|
|||
|
|
help="Host used by the startup health probe",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--startup-check-loops",
|
|||
|
|
type=positive_int,
|
|||
|
|
default=positive_int(os.getenv("VLLM_STARTUP_CHECK_LOOPS", "60")),
|
|||
|
|
help="Maximum number of health checks before startup fails",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--startup-check-interval",
|
|||
|
|
type=non_negative_float,
|
|||
|
|
default=non_negative_float(os.getenv("VLLM_STARTUP_CHECK_INTERVAL_SECONDS", "2")),
|
|||
|
|
help="Seconds between startup health checks",
|
|||
|
|
)
|
|||
|
|
parser.add_argument("--served-model-name", default=os.getenv("VLLM_SERVED_MODEL_NAME"))
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--gpu-memory-utilization",
|
|||
|
|
default=os.getenv("VLLM_GPU_MEMORY_UTILIZATION", "0.3"),
|
|||
|
|
)
|
|||
|
|
parser.add_argument("--max-model-len", default=os.getenv("VLLM_MAX_MODEL_LEN", "16384"))
|
|||
|
|
parser.add_argument("--max-num-seqs", default=os.getenv("VLLM_MAX_NUM_SEQS", "16"))
|
|||
|
|
parser.add_argument("--tensor-parallel-size", default=os.getenv("VLLM_TENSOR_PARALLEL_SIZE", "1"))
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--enforce-eager",
|
|||
|
|
action=argparse.BooleanOptionalAction,
|
|||
|
|
default=os.getenv("VLLM_ENFORCE_EAGER", "true").lower() == "true",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_server_command(args: argparse.Namespace, model_id: str, model_path: Path) -> list[str]:
|
|||
|
|
"""构造新版 VLLM 原生启动命令,不依赖 qwen-asr-serve。"""
|
|||
|
|
# Qwen3-ASR 已由新版 VLLM 原生支持,因此这里调用 vllm serve,避免
|
|||
|
|
# qwen-asr-serve 对旧版 VLLM 的固定依赖影响 GB10 部署环境。
|
|||
|
|
executable_name = os.getenv("VLLM_EXECUTABLE", "vllm")
|
|||
|
|
executable = shutil.which(executable_name)
|
|||
|
|
if executable is None:
|
|||
|
|
raise RuntimeError(f"{executable_name} was not found; install requirements-deploy.txt first")
|
|||
|
|
|
|||
|
|
served_model_name = args.served_model_name or model_id
|
|||
|
|
command = [
|
|||
|
|
executable,
|
|||
|
|
"serve",
|
|||
|
|
str(model_path),
|
|||
|
|
"--host",
|
|||
|
|
args.host,
|
|||
|
|
"--port",
|
|||
|
|
str(SERVER_PORT),
|
|||
|
|
"--served-model-name",
|
|||
|
|
served_model_name,
|
|||
|
|
"--gpu-memory-utilization",
|
|||
|
|
str(args.gpu_memory_utilization),
|
|||
|
|
"--max-model-len",
|
|||
|
|
str(args.max_model_len),
|
|||
|
|
"--max-num-seqs",
|
|||
|
|
str(args.max_num_seqs),
|
|||
|
|
"--tensor-parallel-size",
|
|||
|
|
str(args.tensor_parallel_size),
|
|||
|
|
]
|
|||
|
|
if args.enforce_eager:
|
|||
|
|
command.append("--enforce-eager")
|
|||
|
|
return command
|
|||
|
|
|
|||
|
|
|
|||
|
|
def wait_until_ready(process: subprocess.Popen[bytes], probe_url: str, loops: int, interval: float) -> None:
|
|||
|
|
"""按调用方指定的次数和间隔轮询 VLLM 健康接口,直到服务就绪或失败。"""
|
|||
|
|
for attempt in range(1, loops + 1):
|
|||
|
|
if process.poll() is not None:
|
|||
|
|
raise RuntimeError(f"VLLM exited during startup with code {process.returncode}")
|
|||
|
|
try:
|
|||
|
|
with urlopen(probe_url, timeout=2) as response:
|
|||
|
|
if 200 <= response.status < 300:
|
|||
|
|
return
|
|||
|
|
except (OSError, URLError):
|
|||
|
|
pass
|
|||
|
|
print(f"Waiting for VLLM startup ({attempt}/{loops})...", flush=True)
|
|||
|
|
if attempt < loops:
|
|||
|
|
time.sleep(interval)
|
|||
|
|
raise TimeoutError(f"VLLM did not become ready after {loops} health checks: {probe_url}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def stop_process(process: subprocess.Popen[bytes]) -> None:
|
|||
|
|
"""向 VLLM 子进程转发优雅停止信号,并在超时后执行兜底清理。"""
|
|||
|
|
if process.poll() is not None:
|
|||
|
|
return
|
|||
|
|
if os.name == "nt":
|
|||
|
|
process.send_signal(signal.CTRL_BREAK_EVENT)
|
|||
|
|
else:
|
|||
|
|
process.send_signal(signal.SIGINT)
|
|||
|
|
try:
|
|||
|
|
process.wait(timeout=10)
|
|||
|
|
except subprocess.TimeoutExpired:
|
|||
|
|
process.terminate()
|
|||
|
|
process.wait(timeout=10)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
"""解析一个本地模型、启动 VLLM,并持续托管宿主机子进程。"""
|
|||
|
|
args = build_parser().parse_args()
|
|||
|
|
|
|||
|
|
manifest = load_manifest()
|
|||
|
|
model_id = resolve_model_id(args.model, manifest)
|
|||
|
|
model_path = model_directory(model_id, manifest, args.models_dir.resolve())
|
|||
|
|
if not has_model_weights(model_path):
|
|||
|
|
print(f"Model is missing or incomplete: {model_id} ({model_path})", file=sys.stderr)
|
|||
|
|
print("Run scripts/download_models.py for the same model first.", file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
command = build_server_command(args, model_id, model_path)
|
|||
|
|
probe_url = f"http://{args.probe_host}:{SERVER_PORT}/health"
|
|||
|
|
display_url = f"http://{args.display_host}:{SERVER_PORT}"
|
|||
|
|
print(f"Starting Qwen3-ASR VLLM service on host: {display_url}", flush=True)
|
|||
|
|
print(f"Model: {model_id}", flush=True)
|
|||
|
|
print(f"Startup checks: {args.startup_check_loops} x {args.startup_check_interval}s", flush=True)
|
|||
|
|
|
|||
|
|
process = subprocess.Popen(command)
|
|||
|
|
try:
|
|||
|
|
wait_until_ready(process, probe_url, args.startup_check_loops, args.startup_check_interval)
|
|||
|
|
print(f"VLLM ready: {display_url}", flush=True)
|
|||
|
|
print(f"OpenAI endpoint: {display_url}/v1", flush=True)
|
|||
|
|
while process.poll() is None:
|
|||
|
|
time.sleep(0.5)
|
|||
|
|
return int(process.returncode or 0)
|
|||
|
|
except (KeyboardInterrupt, TimeoutError, RuntimeError) as exc:
|
|||
|
|
print(str(exc), file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
finally:
|
|||
|
|
stop_process(process)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
raise SystemExit(main())
|