Skip to content

vllm CLI : du terminal à AsyncLLM

源码版本v0.25.1

Responsabilités

La commande vllm est le point d'entrée le plus utilisé : vllm serve Qwen/Qwen3-0.6B démarre un serveur compatible OpenAI, vllm bench throughput lance un benchmark, vllm run-batch fait du traitement par lot. L'implémentation vit dans vllm/entrypoints/cli/, et la fonction d'entrée est main()(main.py:17-97). Cette couche est très fine : argparse enregistre chaque sous-commande (subcommand) comme un subparser, argv est résolu en argparse.Namespace, puis le contrôle est dispatché vers la méthode statique cmd(args) de la sous-commande correspondante.

Le squelette des sous-commandes est abstrait dans CLISubcommand(types.py:13-29) : chaque sous-commande déclare name, implémente cmd(args) pour faire le travail, peut redéfinir validate(args) pour des vérifications préalables, et subparser_init(subparsers) enregistre ses flags sur un subparser du parser principal. Au démarrage, main() parcourt CMD_MODULES(main.py:30-37), appelle cmd_init() sur chaque module pour récupérer une liste de CLISubcommand, puis cmd.subparser_init(subparsers).set_defaults(dispatch_function=cmd.cmd)(main.py:86-89) attache le dispatcher au namespace. Après parser.parse_args(), args.dispatch_function(args)(main.py:94-95) cède le contrôle à la sous-commande.

serve est la sous-commande la plus importante et la chaîne la plus longue : CLI parser → make_arg_parserAsyncEngineArgs.from_cli_argscreate_engine_configAsyncLLM.from_vllm_config → FastAPI build_app → uvicorn serve_http. Toute la chaîne est en v1, mais chaque étape est suffisamment découpée pour être testée isolément. La couche CLI ne touche pas aux poids du modèle ; son rôle se résume à « traduire argv en EngineArgs et confier le tout à l'aval ».

Motivation de conception

  • Lazy load des modules de sous-commande : en tête de main(), on trouve des imports import vllm.entrypoints.cli.serve etc.(main.py:18-23) plutôt qu'un chargement global en début de module, car certaines sous-commandes (par exemple serve) tirent des modules lourds comme CUDA/torch, dont vllm bench n'a pas besoin. La docstring note explicitement must be lazily loaded within main to avoid certain eager import breakage(main.py:3-6).
  • Abstraction CLISubcommand plutôt que if/elif : main() énumère les modules de sous-commande via CMD_MODULES(main.py:30-37). Chaque module expose une fonction factory cmd_init() -> list[CLISubcommand]. Ajouter une sous-commande (par exemple vllm launch render) se résume à écrire une sous-classe de CLISubcommand et à implémenter cmd_init, sans toucher à main().
  • vllm bench bascule la plateforme à l'avance : vllm bench throughput est un benchmark qui peut tourner sur CPU pur, mais current_platform vaut par défaut UnspecifiedPlatform, ce qui ferait échouer l'inférence du device type. Aussi, quand main() détecte sys.argv[1] == "bench", elle substitue CpuPlatform() à current_platform(main.py:58-71).
  • --omni délègue tout : le flag --omni n'est pas une sous-commande vLLM, il transmet l'intégralité d'argv au paquet vllm-omni(main.py:42-55). find_spec détecte d'abord la présence du paquet, sinon sys.exit(1) ; s'il est installé, omni_main() prend le relais.
  • Modes multiples pour api_server_count : ServeSubcommand.cmd(serve.py:49-148) choisit l'un des quatre modes selon api_server_count, data_parallel_external_lb et VLLM_RUST_FRONTEND_PATHrun_server mono-processus, headless sans API server, run_multi_api_server multi-processus API server, run_dp_supervisor avec LB externe — c'est l'aiguillage d'entrée de l'architecture multi-frontend de v1.
  • argparse réutilise intégralement le schéma EngineArgs : make_arg_parser(cli_args.py:339-383) se contente de deux choses : ajouter quelques flags spécifiques serveur (--host, --port, --headless, --api-server-count, --config, --grpc), puis appeler FrontendArgs.add_cli_args(parser) + AsyncEngineArgs.add_cli_args(parser)(cli_args.py:380-381) pour enregistrer en une fois les centaines de champs d'EngineArgs. Ainsi, serve et LLM(...) restent naturellement alignés côté paramètres.

Fichiers clés

Flux de données

Pour vllm serve Qwen/Qwen3-0.6B --tensor-parallel-size 2, la chaîne commence dans main()(main.py:17) : branche else, FlexibleArgumentParser + subparsers enregistrent toutes les sous-commandes, parser.parse_args() donne args.subparser == "serve". Ensuite cmds["serve"].validate(args)(main.py:91-92) lance validate_parsed_serve_args (contrôle du chat template), puis args.dispatch_function(args)(main.py:94-95) entre dans ServeSubcommand.cmd :

python
# vllm/entrypoints/cli/serve.py L49-L148
@staticmethod
def cmd(args: argparse.Namespace) -> None:
    # If model is specified in CLI (as positional arg), it takes precedence
    if hasattr(args, "model_tag") and args.model_tag is not None:
        args.model = args.model_tag

    if getattr(args, "grpc", False):
        from vllm.entrypoints.grpc_server import serve_grpc
        uvloop.run(serve_grpc(args))
        return

    if args.headless:
        ...
        args.api_server_count = 0

    # Detect LB mode for defaulting api_server_count.
    is_external_lb = (
        args.data_parallel_external_lb or args.data_parallel_rank is not None
    )
    ...
    if args.api_server_count is None:
        if is_multi_port or is_external_lb or envs.VLLM_RUST_FRONTEND_PATH:
            args.api_server_count = 1
        elif is_hybrid_lb:
            args.api_server_count = args.data_parallel_size_local or 1
        else:
            args.api_server_count = args.data_parallel_size
    ...
    if is_multi_port:
        run_dp_supervisor(args)
    elif args.api_server_count < 1:
        run_headless(args)
    elif args.api_server_count > 1 or envs.VLLM_RUST_FRONTEND_PATH:
        run_multi_api_server(args)
    else:
        # Single API server (this process).
        args.api_server_count = None
        uvloop.run(run_server(args))

model_tag (argument positionnel) est réécrit dans args.model, puis grpc / headless / multi_port / hybrid_lb / external_lb / Rust frontend sont aiguillés. Le cas le plus courant tombe sur la dernière branche uvloop.run(run_server(args))(serve.py:146-148), qui appelle run_server_worker(api_server.py:701-723) :

python
# vllm/entrypoints/openai/api_server.py L712-L721
async with build_async_engine_client(
    args,
    client_config=client_config,
) as engine_client:
    shutdown_task = await build_and_serve(
        engine_client, listen_address, sock, args, **uvicorn_kwargs
    )
# NB: Await server shutdown only after the backend context is exited
try:
    await shutdown_task
finally:
    sock.close()

Dans build_async_engine_client(api_server.py:78-105), AsyncEngineArgs.from_cli_args(args) reconstitue EngineArgs à partir du Namespace, puis build_async_engine_client_from_engine_args(api_server.py:109-154) appelle engine_args.create_engine_config() pour obtenir un VllmConfig, puis AsyncLLM.from_vllm_config(...) démarre le moteur asynchrone. Une fois le moteur prêt, build_and_serve monte l'app FastAPI, les routes, les middlewares et uvicorn, puis serve_http bloque le thread principal jusqu'à SIGTERM.

Limites et échecs

  • Paquet --omni manquant : si find_spec("vllm_omni") renvoie None, logger.error + sys.exit(1)(main.py:46-50), plutôt que de laisser l'utilisateur sur un ImportError ambigu.
  • Conflit headless et api_server_count : en mode --headless, passer --api-server-count>0 lève directement(serve.py:62-69) — headless est par construction le scénario multi-nœud sans API server.
  • Modes LB mutuellement exclusifs : is_multi_port / is_external_lb / is_hybrid_lb levèvent une erreur si deux sont activés ensemble(serve.py:91-97) ; les trois topologies LB sont exclusives.
  • Plafond elastic EP : avec enable_elastic_ep=True et api_server_count > 1, on force le cap à 1(serve.py:131-137) avec un warning, l'EP élastique ne supportant pour l'instant qu'un seul API server.
  • Rust frontend ne supporte pas le multi API server : si VLLM_RUST_FRONTEND_PATH + api_server_count > 1, on ignore et on remet à 1(serve.py:123-128) — le frontend Rust est multi-thread, pas multi-processus.
  • VLLM_ALLOW_RUNTIME_LORA_UPDATING + multi API server : run_multi_api_server détecte la combinaison et lève une erreur(serve.py:293-296), car les mises à jour LoRA runtime ne peuvent pas se synchroniser entre plusieurs processus API server indépendants.
  • Validation des plugins tool_parser / reasoning_parser : validate_api_server_args(api_server.py:536-551) vérifie que --tool-call-parser / reasoning_parser figurent dans la liste enregistrée, sinon KeyError.
  • Bind du socket avant le moteur : setup_server s'exécute avant build_async_engine_client(api_server.py:697-698), pour éviter la concurrence de port avec Ray (un commentaire renvoie à l'issue #8204).
  • SIGTERM pendant l'initialisation : run_server installe manuellement SIGTERM → KeyboardInterrupt avant qu'uvicorn ne pose son propre signal handler(api_server.py:690-695) ; ainsi un SIGTERM reçu pendant l'initialisation du moteur permet de quitter immédiatement au lieu de rester bloqué.

Résumé

Le CLI vllm est un dispatcher très fin : main() utilise l'abstraction CLISubcommand pour enregistrer toutes les sous-commandes (serve / launch / openai / run-batch / bench / collect-env) comme subparsers, et cmd_init() offre à chaque module une fonction factory. La sous-commande la plus importante et la plus appelée, vllm serve, dans ServeSubcommand.cmd, aiguille vers quatre modes selon api_server_count et le mode LB ; le plus courant tombe sur run_serverbuild_async_engine_clientAsyncLLM.from_vllm_config, qui transforme réellement EngineArgs en moteur asynchrone vivant. La couche CLI ne touche ni aux poids ni à la passe avant ; elle ne fait que traduire argv proprement et choisir le bon mode d'exécution. Pour la façon dont EngineArgs assemble des centaines de champs en VllmConfig, voir /startup/engine-args ; pour la manière dont AsyncLLM, une fois démarré, achemine les requêtes vers EngineCore, voir /startup/llm-class et /engine/engine-core.

Voir la documentation officielle : Documentation vLLM · README