vLLM Explained

entrypoints/cli/serve.py: the vllm serve subcommand and its launch modes

IntermediateCode mapabout 8 min read + 3 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

Purpose#

vllm/entrypoints/cli/serve.py turns vllm serve ... into running processes: it decides how many API servers to start and which function starts them.

Filevllm/entrypoints/cli/serve.py, 407 lines of Python. One class and three functions.
Main classServeSubcommand(CLISubcommand) (vllm/entrypoints/cli/serve.py:48-174).
Main functionscmd_init (vllm/entrypoints/cli/serve.py:177-178), run_headless (vllm/entrypoints/cli/serve.py:181-266), run_multi_api_server (vllm/entrypoints/cli/serve.py:269-407).
Runs inThe CLI process, the first process of vllm serve, on the main thread. With one API server, this process then becomes the API server.
Called byvllm/entrypoints/cli/main.py: it collects the subcommands, parses sys.argv, validates and dispatches (vllm/entrypoints/cli/main.py:100-117).
Callsmake_arg_parser, validate_parsed_serve_args, run_server, run_dp_supervisor, serve_grpc, CoreEngineProcManager, launch_core_engines, APIServerProcessManager, RustFrontendProcessManager, wait_for_completion_or_failure.
Not in this fileThe flags themselves (FrontendArgs and EngineArgs), the HTTP server (launchers/api_server/entry.py, launchers/launcher.py), the engine start (v1/engine/utils.py), the API server processes (v1/utils.py).

The page The OpenAI-compatible server: launch modes, app, serving classes and streaming explains what each launch mode serves. This page reads the file line by line.

Where it sits#

Figure: the five branches of ServeSubcommand.cmd (panel 1) and the processes of three common modes (panel 2).
Open full size ↗

Who calls it#

What it calls#

Key classes and functions#

NameLineWhat it does
DESCRIPTIONvllm/entrypoints/cli/serve.py:39-45Help text; the default model is Qwen/Qwen3-0.6B (vllm/config/model.py:155).
ServeSubcommand.cmdvllm/entrypoints/cli/serve.py:54-156Resolves the mode and the API server count, then starts.
ServeSubcommand.validatevllm/entrypoints/cli/serve.py:158-159Calls validate_parsed_serve_args.
ServeSubcommand.subparser_initvllm/entrypoints/cli/serve.py:161-174Adds the serve subparser with all flags.
cmd_initvllm/entrypoints/cli/serve.py:177-178Returns [ServeSubcommand()] for cli/main.py.
run_headlessvllm/entrypoints/cli/serve.py:181-266Starts engine cores (or workers of a multi-node rank) with no API server.
run_multi_api_servervllm/entrypoints/cli/serve.py:269-407Starts the engines and N API server processes (or the Rust frontend), then supervises them.

Walk through the main methods#

cmd, part 1: model, gRPC, headless#

    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.launchers.grpc_server import serve_grpc

            uvloop.run(serve_grpc(args))
            return

        rust_frontend_path = (
            envs.VLLM_RUST_FRONTEND_PATH if envs.VLLM_USE_RUST_FRONTEND else None
        )

        if args.headless:
            if args.api_server_count is not None and args.api_server_count > 0:
                raise ValueError(
                    f"--api-server-count={args.api_server_count} cannot be "
                    "used with --headless (no API servers are started in "
                    "headless mode)."
                )
            # Default to 0 in headless mode (no API servers)
            args.api_server_count = 0
  1. Lines 56-57. The positional model (vllm serve MODEL) wins over --model.
  2. Lines 59-63. --grpc runs the gRPC server and returns; none of the checks below apply to it.
  3. Lines 65-67. The Rust frontend path is set only when VLLM_USE_RUST_FRONTEND is on.
  4. Lines 69-77. --headless forces the API server count to 0; an explicit positive count with it raises ValueError.

cmd, part 2: the data-parallel load-balancing mode#

        # Detect LB mode for defaulting api_server_count.
        # Multi-port: --data-parallel-multi-port-external-lb
        # External LB: --data-parallel-external-lb or --data-parallel-rank
        # Hybrid LB: --data-parallel-hybrid-lb or --data-parallel-start-rank
        is_external_lb = (
            args.data_parallel_external_lb or args.data_parallel_rank is not None
        )

        # If --data_parallel_multi_port_external_lb and --data_parallel_hybrid_lb
        # are unset, default to hybrid if --data-parallel-start-rank is set
        is_hybrid_lb = is_multi_port = False
        if (
            not args.data_parallel_hybrid_lb
            and not args.data_parallel_multi_port_external_lb
        ):
            is_hybrid_lb = args.data_parallel_start_rank is not None
        else:
            is_hybrid_lb = args.data_parallel_hybrid_lb
            is_multi_port = args.data_parallel_multi_port_external_lb

        if sum([is_multi_port, is_external_lb, is_hybrid_lb]) > 1:
            raise ValueError(
                "Cannot use more than one data parallel load balancing mode. "
                "Choose one of: --data-parallel-multi-port-external-lb, "
                "--data-parallel-external-lb (or --data-parallel-rank), "
                "--data-parallel-hybrid-lb (or --data-parallel-start-rank)."
            )
  1. Lines 83-85. External load balancing: --data-parallel-external-lb, or any --data-parallel-rank.
  2. Lines 90-97. Hybrid load balancing: --data-parallel-hybrid-lb, or --data-parallel-start-rank when neither hybrid nor multi-port is set.
  3. Lines 99-105. Two modes at once raise ValueError.

cmd, part 3: the API server count and the dispatch#

        if args.api_server_count is None:
            if is_multi_port or is_external_lb or rust_frontend_path:
                args.api_server_count = 1
            elif is_hybrid_lb:
                args.api_server_count = args.data_parallel_size_local or 1
                if args.api_server_count > 1:
                    logger.info(
                        "Defaulting api_server_count to data_parallel_size_local "
                        "(%d) for hybrid LB mode.",
                        args.api_server_count,
                    )
            else:
                args.api_server_count = args.data_parallel_size
                if args.api_server_count > 1:
                    logger.info(
                        "Defaulting api_server_count to data_parallel_size (%d).",
                        args.api_server_count,
                    )
        elif rust_frontend_path and args.api_server_count > 1:
            logger.warning(
                "Ignoring --api-server-count=%d when using rust front-end process",
                args.api_server_count,
            )
            args.api_server_count = 1

        # Elastic EP currently only supports running with at most one API server.
        if getattr(args, "enable_elastic_ep", False) and args.api_server_count > 1:
            logger.warning(
                "Elastic EP only supports running with with at most one API server. "
                "Capping api_server_count from %d to 1.",
                args.api_server_count,
            )
            args.api_server_count = 1

        if is_multi_port:
            run_dp_supervisor(args)
        elif args.api_server_count < 1:
            run_headless(args)
        elif args.api_server_count > 1 or rust_frontend_path:
            run_multi_api_server(args)
        else:
            # Single API server (this process).
            args.api_server_count = None
            uvloop.run(run_server(args))
  1. Lines 114-115. Multi-port, external load balancing and the Rust frontend default to 1 API server.
  2. Lines 116-117. Hybrid: the local DP size.
  3. Lines 124-125. Otherwise (internal load balancing, the normal case): the full DP size. So -dp 4 on one node gives 4 API servers.
  4. Lines 131-136. The Rust frontend ignores an explicit count above 1, with a warning.
  5. Lines 139-145. Elastic EP caps the count at 1.
  6. Lines 147-156. The dispatch. Only the last branch keeps the server in this process; it sets the count back to None first.

run_headless#

def run_headless(args: argparse.Namespace):
    if args.api_server_count > 1:
        raise ValueError("api_server_count can't be set in headless mode")

    if args.reasoning_parser_plugin and len(args.reasoning_parser_plugin) > 3:
        ReasoningParserManager.import_reasoning_parser(args.reasoning_parser_plugin)

    # Create the EngineConfig.
    engine_args = vllm.AsyncEngineArgs.from_cli_args(args)
    propagate_flash_late_interaction(args, engine_args)
    usage_context = UsageContext.OPENAI_API_SERVER
    vllm_config = engine_args.create_engine_config(
        usage_context=usage_context, headless=True
    )

    if engine_args.data_parallel_hybrid_lb:
        raise ValueError("data_parallel_hybrid_lb is not applicable in headless mode")

    parallel_config = vllm_config.parallel_config
    local_engine_count = parallel_config.data_parallel_size_local

    if local_engine_count <= 0:
        raise ValueError("data_parallel_size_local must be > 0 in headless mode")

    shutdown_requested = False

    # Catch SIGTERM and SIGINT to allow graceful shutdown.
    def signal_handler(signum, frame):
        nonlocal shutdown_requested
        logger.debug("Received %d signal.", signum)
        if not shutdown_requested:
            shutdown_requested = True
            raise SystemExit

    signal.signal(signal.SIGTERM, signal_handler)
    signal.signal(signal.SIGINT, signal_handler)

    if parallel_config.node_rank_within_dp > 0:
        from vllm.version import __version__ as VLLM_VERSION

        # Run headless workers (for multi-node PP/TP).
        host = parallel_config.master_addr
        head_node_address = f"{host}:{parallel_config.master_port}"
        logger.info(
            "Launching vLLM (v%s) headless multiproc executor, "
            "with head node address %s for torch.distributed process group.",
            VLLM_VERSION,
            head_node_address,
        )

        executor = MultiprocExecutor(vllm_config, monitor_workers=False)
        executor.start_worker_monitor(inline=True)
        return
  1. Lines 185-186. A reasoning parser plugin is imported here, because the engine cores need the reasoning parser for structured outputs (Structured outputs: JSON schemas, grammars and token bitmasks in vLLM).
  2. Lines 189-194. The config is made with headless=True and the API server usage context.
  3. Lines 202-203. A node with no local DP ranks cannot run headless.
  4. Lines 218-233. On a secondary node of one DP rank (multi-node TP or PP), this process starts only the workers of its node, through MultiprocExecutor, and blocks in the worker monitor.

Otherwise it starts the local engine cores with CoreEngineProcManager, which connect to the head node's handshake address, and waits on their liveness (vllm/entrypoints/cli/serve.py:235-266). SIGTERM and SIGINT raise SystemExit once, and the shutdown then waits up to shutdown_timeout seconds (lines 205-216 and 260-266).

run_multi_api_server#

    if num_api_servers > 1:
        setup_multiprocess_prometheus()

    shutdown_requested = False

    # Catch SIGTERM and SIGINT to allow graceful shutdown.
    def signal_handler(signum, frame):
        nonlocal shutdown_requested
        logger.debug("Received %d signal.", signum)
        if not shutdown_requested:
            shutdown_requested = True
            raise SystemExit

    signal.signal(signal.SIGTERM, signal_handler)
    signal.signal(signal.SIGINT, signal_handler)

    listen_address, sock = setup_server(args, reuse_port=num_api_servers > 1)
    # `--grpc-port` is only accepted for the Rust frontend, which inherits this
    # listener like the HTTP one. gRPC follows the HTTP TCP host, or IPv4
    # loopback when HTTP uses a Unix socket.
    grpc_sock = None
    if args.grpc_port is not None:
        grpc_host = "127.0.0.1" if args.uds else (args.host or "")
        grpc_sock = create_server_socket((grpc_host, args.grpc_port), reuse_port=False)

    engine_args = vllm.AsyncEngineArgs.from_cli_args(args)
    propagate_flash_late_interaction(args, engine_args)
    engine_args._api_process_count = num_api_servers
    engine_args._api_process_rank = -1

    usage_context = UsageContext.OPENAI_API_SERVER
    vllm_config = engine_args.create_engine_config(usage_context=usage_context)

    if num_api_servers > 1 and envs.VLLM_ALLOW_RUNTIME_LORA_UPDATING:
        raise ValueError(
            "VLLM_ALLOW_RUNTIME_LORA_UPDATING cannot be used with api_server_count > 1"
        )
  1. Lines 282-283. More than one API server needs Prometheus multiprocess mode; a temporary directory is made unless the user set PROMETHEUS_MULTIPROC_DIR (vllm/v1/metrics/prometheus.py:17-36).
  2. Line 298. One listen socket, with SO_REUSEPORT when there are several servers (vllm/entrypoints/launchers/launcher.py:340-355).
  3. Lines 307-310. The engine config records the API process count; rank -1 marks the parent.
  4. Line 313. The VllmConfig is made here, in the supervisor, once.
  5. Lines 315-318. Runtime LoRA updates are refused with several API servers.
    from vllm.v1.engine.utils import bind_engine_zmq_listeners

    zmq_listeners = bind_engine_zmq_listeners(vllm_config, num_api_servers)
    addresses = zmq_listeners.addresses

    with launch_core_engines(
        vllm_config, executor_class, log_stats, addresses
    ) as engine_launch:
        local_engine_manager = engine_launch.engine_manager
        coordinator = engine_launch.coordinator
        addresses = engine_launch.addresses
        stats_update_address = (
            coordinator.get_stats_publish_address() if coordinator else None
        )

        if rust_frontend_path:
            if parallel_config.local_engines_only:
                expected_engine_start_index = parallel_config.data_parallel_rank
                expected_engine_count = parallel_config.data_parallel_size_local
            else:
                expected_engine_start_index = 0
                expected_engine_count = parallel_config.data_parallel_size
            # Start rust front-end process.
            api_server_manager = RustFrontendProcessManager(
                binary_path=rust_frontend_path,
                sock=sock,
                grpc_sock=grpc_sock,
                args=args,
                input_listener=zmq_listeners.inputs[0],
                output_listener=zmq_listeners.outputs[0],
                engine_start_index=expected_engine_start_index,
                engine_count=expected_engine_count,
                data_parallel_size=parallel_config.data_parallel_size,
                stats_update_address=stats_update_address,
            )
        else:
            # Start API server(s).
            api_server_manager = APIServerProcessManager(
                listen_address=listen_address,
                sock=sock,
                args=args,
                num_servers=num_api_servers,
                input_listeners=zmq_listeners.inputs,
                output_listeners=zmq_listeners.outputs,
                stats_update_address=stats_update_address,
                tensor_queue=engine_launch.tensor_queue,
            )

        # Set frontend processes to watch during engine startup.
        # If any of these processes exit before the engines are up, the engine startup
        # will be aborted with an error.
        engine_launch.watched_frontend_processes = api_server_manager.processes

    # Wait for API servers.
    try:
        wait_for_completion_or_failure(
            api_server_manager=api_server_manager,
            engine_manager=local_engine_manager,
            coordinator=coordinator,
        )
  1. Line 333. One input and one output ZMQ listener per API server, bound here.
  2. Lines 336-338. launch_core_engines is a context manager: it starts the engine cores (and the DP coordinator if needed) on entry, and waits for their handshake on exit (vllm/v1/engine/utils.py:1384-1395).
  3. Line 354 / lines 368-377. Inside the block, the Rust frontend or the Python API servers start. So the API servers start while the engines load.
  4. Line 382. If an API server dies during the engine start-up, the start-up is aborted.
  5. Lines 386-390. The supervisor waits; any child that exits with a non-zero code raises RuntimeError (vllm/v1/utils.py:575-594).

The finally block shuts down the API servers, then the engines, then the coordinator, all within one shutdown_timeout deadline after a signal (vllm/entrypoints/cli/serve.py:391-407).

Deep dive#

The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.

Deep dive

Worked example: vllm serve M -dp 2 on one node

A dense model, one node, two GPUs, -dp 2, no other DP flags.

  1. cmd: no gRPC, no Rust, not headless. is_external_lb, is_hybrid_lb and is_multi_port are all false (lines 83-97).
  2. api_server_count is None, so it becomes the DP size, 2, and the log says "Defaulting api_server_count to data_parallel_size (2)." (lines 124-130).
  3. 2 > 1, so run_multi_api_server runs (line 152).
  4. The supervisor makes the Prometheus directory, binds port 8000 with SO_REUSEPORT, and builds the VllmConfig with _api_process_count = 2 (lines 282-313).
  5. bind_engine_zmq_listeners(vllm_config, 2) binds two input and two output listeners (line 333).
  6. launch_core_engines starts 2 engine core processes and, because this is internal load balancing, the DP coordinator (vllm/v1/engine/utils.py:1226-1232). Each engine core starts one worker process (TP = 1 with DP > 1).
  7. APIServerProcessManager spawns ApiServer_0 and ApiServer_1, each with its listener pair and client_index 0 or 1 (vllm/v1/utils.py:230-254).
  8. On leaving the with block, the supervisor waits for both engines' ready messages, while it watches the two API server processes (line 382).
  9. Each API server builds its own AsyncLLM, which connects to both engines, and then serves HTTP.

Result: 8 processes: the supervisor, 2 API servers, the DP coordinator, 2 engine cores and 2 workers. This matches the count in The process and thread architecture of vllm serve.

Deep dive

Processes and threads

ModeWhat the CLI process doesChildren it starts
one API serverruns the asyncio loop of the API server (uvloop.run)engine core processes, through AsyncLLM (vllm/entrypoints/launchers/api_server/entry.py:93-103)
several API serverssupervisor: waits on process sentinels (vllm/v1/utils.py:550-594)engine cores, DP coordinator, ApiServer_i (spawn)
Rust frontendsupervisorengine cores, the Rust binary
headlessengine manager: waits on engine livenesslocal engine cores, or workers of a multi-node rank
multi-port external LBDPSupervisor with its own aggregated health server (vllm/entrypoints/launchers/dp_supervisor.py:562-564)one full server per local DP rank
gRPCruns the gRPC server with AsyncLLM (vllm/entrypoints/launchers/grpc_server.py:56-85)engine core processes

The API server processes always use the spawn start method (vllm/v1/utils.py:204).

Deep dive

Error paths

SituationResultLines
--headless with --api-server-count > 0ValueErrorvllm/entrypoints/cli/serve.py:69-75
two DP load-balancing modesValueErrorvllm/entrypoints/cli/serve.py:99-105
headless with hybrid load balancingValueErrorvllm/entrypoints/cli/serve.py:196-197
headless with no local DP ranksValueErrorvllm/entrypoints/cli/serve.py:202-203
Rust frontend with more than one API serverValueError (only if reached; cmd caps it first)vllm/entrypoints/cli/serve.py:277-280
VLLM_ALLOW_RUNTIME_LORA_UPDATING with several API serversValueErrorvllm/entrypoints/cli/serve.py:315-318
--enable-auto-tool-choice without a parserTypeError in validatevllm/entrypoints/common/cli_args.py:523-525
a child process diesRuntimeError "Process ... died with exit code ..."vllm/v1/utils.py:584-589
Deep dive

Environment variables

VariableDefaultRead atEffect
VLLM_USE_RUST_FRONTENDoff (vllm/envs.py:1454-1456)vllm/entrypoints/cli/serve.py:65-67Use the Rust frontend binary instead of Python API servers.
VLLM_RUST_FRONTEND_PATHauto (vllm/envs.py:593)samePath of the Rust binary; auto means the vllm-rs file in the package folder (vllm/envs.py:604-615).
VLLM_ALLOW_RUNTIME_LORA_UPDATINGoff (vllm/envs.py:1197-1200)vllm/entrypoints/cli/serve.py:315Refused with several API servers.
PROMETHEUS_MULTIPROC_DIRunsetvllm/v1/metrics/prometheus.py:21Kept if you set it (with a warning); else a temporary directory.

Check yourself#

  1. 1
    With vllm serve M and no DP flags, where does the HTTP server run?
    easy
    Show answer

    In the CLI process itself: api_server_count defaults to the DP size, 1, so the last branch runs uvloop.run(run_server(args)) (vllm/entrypoints/cli/serve.py:124-125, vllm/entrypoints/cli/serve.py:153-156).

  2. 2
    What is the default number of API servers for -dp 4 --data-parallel-external-lb, and why?
    medium
    Show answer

    1. With an external load balancer, each vllm serve serves its own rank, so the default is one API server (vllm/entrypoints/cli/serve.py:83-85, vllm/entrypoints/cli/serve.py:114-115).

  3. 3
    In run_multi_api_server, do the API server processes start before or after the engines are ready?
    medium
    Show answer

    Before. They start inside the with launch_core_engines(...) block, and the wait for the engines' ready messages runs when the block exits (vllm/entrypoints/cli/serve.py:336-382, vllm/v1/engine/utils.py:1384-1395).

  4. 4
    On a second node you run vllm serve M --headless --data-parallel-size 4 --data-parallel-size-local 2 --data-parallel-start-rank 2 --data-parallel-address HEAD (TP = 1). What does this process start, and what does it not start?
    hard
    Show answer

    --headless sets the API server count to 0 before the load-balancing checks, so the dispatch runs run_headless (vllm/entrypoints/cli/serve.py:69-77, vllm/entrypoints/cli/serve.py:149-150). The start rank makes the local variable is_hybrid_lb true, but the count is already set, and run_headless checks only the explicit --data-parallel-hybrid-lb flag (vllm/entrypoints/cli/serve.py:90-94, vllm/entrypoints/cli/serve.py:196-197). With TP = 1 this node is the first node of its DP ranks, so it starts the two local engine cores with CoreEngineProcManager; they connect to the head's handshake address (vllm/entrypoints/cli/serve.py:235-256). It starts no API server and opens no HTTP socket.