vLLM Explained

Tour: Life of a chat request

BeginnerGuided toursabout 29 min read + 18 min deep dives

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

In one sentence

We follow one streaming chat request from the FastAPI route, through the engine core process and the GPU, to the last SSE chunk.

The idea#

vllm serve starts an OpenAI-compatible server. A client sends a chat request as JSON and gets the answer back as a stream of small pieces. This tour follows one such request through the code, from the HTTP route to the last piece, data: [DONE].

The request crosses two processes. The front end is the API server process: FastAPI, the chat template, the tokenizer and AsyncLLM. The engine core process holds the scheduler and, with one GPU, also the worker and the model runner. The two processes talk through ZMQ sockets. The messages are msgpack bytes.

The engine core does not run one request from start to end. It runs steps. In each step it selects a batch of requests, runs the model once for all of them and adds one new token to each request. So our request goes through the engine step once for each output token. Each new token goes back to the front end and becomes one SSE chunk. The idea of steps and continuous batching is not new: see Batching.

An everyday comparison. Think of a restaurant with a waiter and a kitchen. The waiter (the front end) writes the order on a ticket and passes it through a window (ZMQ). The kitchen (the engine core) cooks in rounds for all open orders. After each round, the waiter carries the new part of the dish to the table at once (streaming). The comparison stops being true in two places. A round adds only one token to each order, so an order needs hundreds of rounds. And the waiter must translate each part (token ids) into text before the guest can read it.

The example request#

We start the server on one GPU with a chat model, and run the example client of the repository with --stream:

vllm serve Qwen/Qwen2.5-1.5B-Instruct          # any chat model; one GPU, default settings
python examples/basic/online_serving/openai_chat_completion_client.py --stream

The client sends four messages: a system message, a user question, an assistant answer and a second user question (examples/basic/online_serving/openai_chat_completion_client.py:16-24). It calls client.chat.completions.create(..., stream=args.stream) (examples/basic/online_serving/openai_chat_completion_client.py:46-50). On the wire this is one HTTP request:

POST /v1/chat/completions
{"model": "Qwen/Qwen2.5-1.5B-Instruct", "stream": true,
 "messages": [{"role": "system", "content": "You are a helpful assistant."},
              {"role": "user", "content": "Who won the world series in 2020?"},
              {"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
              {"role": "user", "content": "Where was it played?"}]}

What you will see#

  • Why the HTTP status 200 goes out before the request reaches the engine core (stops 1, 2 and 4).
  • Where the chat template runs, and why the tokenizer runs in a thread pool (stop 3).
  • The exact objects that cross the process boundary: EngineCoreRequest one way, EngineCoreOutputs the other way (stops 5, 6 and 14).
  • Three threads in the engine core process, and the busy loop in the middle (stops 7 to 9).
  • How the scheduler uses one token budget and the prefix cache for our prompt (stop 10).
  • How Model Runner V2 prepares the inputs, runs the forward pass and samples the token on the GPU (stops 11 to 13).
  • How a token id becomes text and then an SSE chunk (stops 15 and 16).
  • How async scheduling overlaps the CPU work of one step with the GPU work of the step before (the loop).

The picture#

Figure: the two processes of vllm serve on one GPU, what crosses each boundary, and which threads do the work.
Open full size ↗

Read the diagram from top to bottom. Blue is the front end, violet is the engine core process, green is the GPU and amber is a ZMQ message. With one GPU and TP = 1, there is no separate worker process. The executor is "uni" when world_size == 1 (vllm/config/parallel.py:1099-1100). UniProcExecutor makes the worker object in its own process (vllm/v1/executor/uniproc_executor.py:51-69).

Figure: the map of the 16 stops. Stops 9 to 16 repeat once for each output token.
Open full size ↗
InteractiveStep through the 16 stops

This interactive visual needs JavaScript. Turn on JavaScript in your browser to use it. The text on this page explains the same idea.

Click Next, a stop number, or use the arrow keys. The process map shows where the code of the stop runs. Values are illustrative. Read stop N jumps to the full stop below.
StopWhat happensWhere (file and function)Runs in
1The route receives the requestopenai/chat_completion/api_router.py: create_chat_completionfront end
2The serving class makes sampling parameters and a generatoropenai/chat_completion/serving.py: _create_chat_completionfront end
3The chat template and the tokenizer make token idsrenderers/base.py: render_chat_async; renderers/hf.pyfront end, thread pool
4The stream starts and calls AsyncLLM.generate()v1/engine/async_llm.py: generatefront end
5The input processor makes an EngineCoreRequestv1/engine/input_processor.py: process_inputsfront end
6The front end registers the request and sends it over ZMQasync_llm.py: _add_request; core_client.py: add_request_asyncfront end
7The input thread decodes the requestv1/engine/core.py: process_input_socketsengine core, input thread
8The busy loop gives it to the schedulercore.py: run_busy_loop; Scheduler.add_requestengine core, main thread
9One engine step with the batch queuecore.py: step_with_batch_queueengine core, main thread
10The scheduler gives tokens and KV blocksv1/core/sched/scheduler.py: scheduleengine core, main thread
11Model Runner V2 prepares the inputsv1/worker/gpu/model_runner.py: execute_modelengine core and GPU
12The forward passmodel_runner.py: execute_modelGPU
13Logits, sampler and the copy backmodel_runner.py: sample_tokensGPU and engine core
14The scheduler appends the token and checks for stop; the output thread sends itscheduler.py: update_from_output; core.py: process_output_socketsengine core
15The front end detokenizes and fills the collectorasync_llm.py: output_handler; v1/engine/output_processor.pyfront end
16The stream generator writes SSE chunks and data: [DONE]serving.py: chat_completion_stream_generatorfront end

All paths are under vllm/. Stops 1 to 8 run once. Stops 9 to 16 run once per output token.

1The route receives POST /v1/chat/completions#

We are in vllm/entrypoints/openai/chat_completion/api_router.py. With one API server, vllm serve runs the server in the CLI process itself (uvloop.run(run_server(args)), vllm/entrypoints/cli/serve.py:154-156). FastAPI parses the JSON body into a ChatCompletionRequest object and calls the route function:

@with_cancellation
@load_aware_call
async def create_chat_completion(request: ChatCompletionRequest, raw_request: Request):
    metrics_header_format = raw_request.headers.get(
        ENDPOINT_LOAD_METRICS_FORMAT_HEADER_LABEL, ""
    )
    handler = chat(raw_request)
    if handler is None:
        raise NotImplementedError("The model does not support Chat Completions API")

    generator = await handler.create_chat_completion(request, raw_request)

    if isinstance(generator, ErrorResponse):
        return JSONResponse(
            content=generator.model_dump(), status_code=generator.error.code
        )

    elif isinstance(generator, ChatCompletionResponse):
        return JSONResponse(
            content=generator.model_dump(),
            headers=metrics_header(metrics_header_format),
        )

    args = getattr(raw_request.app.state, "args", None)
    keep_alive_interval = getattr(args, "sse_keep_alive_interval", 0)
    return StreamingResponse(
        content=with_sse_keep_alive(generator, float(keep_alive_interval)),
        media_type="text/event-stream",
    )
  1. Line 57: @with_cancellation runs the handler as an asyncio task and watches for a client disconnect at the same time (see Deep dive, cancellation).
  2. Line 63: chat(raw_request) returns app.state.openai_serving_chat (vllm/entrypoints/openai/chat_completion/api_router.py:35-36). The server makes this OpenAIServingChat object at start-up, if the model supports the "generate" task (vllm/entrypoints/generate/api_router.py:149-151).
  3. Line 67: the serving class returns one of three things: an ErrorResponse, a full ChatCompletionResponse (no streaming), or an async generator of strings (streaming).
  4. Lines 82-85: for our request it is a generator. The route wraps it in a StreamingResponse with the media type text/event-stream.

What the data looks like now (illustrative):

request.messages      # 4 messages
request.stream        # True
request.request_id    # "3f9c...e1" (a random uuid, default_factory)
raw_request           # the Starlette Request (headers, app.state)
Deep dive

Stop 1 in detail: decorators, keep-alive and the request id

2OpenAIServingChat makes the sampling parameters and a generator#

We are in vllm/entrypoints/openai/chat_completion/serving.py, in OpenAIServingChat._create_chat_completion. It renders the prompt (stop 3), makes the request id, computes max_tokens and the SamplingParams, and asks the engine client for a result generator:

        result = await self.render_chat_request(request)
        if isinstance(result, ErrorResponse):
            return result

        conversation, engine_inputs = result

        request_id = (
            f"chatcmpl-{self._base_request_id(raw_request, request.request_id)}"
        )
            max_tokens = get_max_tokens(
                max_model_len,
                request.max_completion_tokens
                if request.max_completion_tokens is not None
                else request.max_tokens,
                self._extract_prompt_len(engine_input),
                self.default_sampling_params,
                self.override_max_tokens,
                truncate_prompt_tokens=request.truncate_prompt_tokens,
            )

            sampling_params: SamplingParams | BeamSearchParams
            if request.use_beam_search:
                sampling_params = request.to_beam_search_params(
                    max_tokens, self.default_sampling_params
                )
            else:
                sampling_params = request.to_sampling_params(
                    max_tokens,
                    self.default_sampling_params,
                )
                generator = self.engine_client.generate(
                    engine_input,
                    sampling_params,
                    sub_request_id,
                    lora_request=lora_request,
                    trace_headers=trace_headers,
                    priority=self._get_priority(request, raw_request),
                    data_parallel_rank=data_parallel_rank,
                    session_id=session_id,
                    **self._engine_reasoning_kwargs(
                        request, parser, chat_template_kwargs, prompt_token_ids
                    ),
                )

            generators.append(generator)

        assert len(generators) == 1
        (result_generator,) = generators

        if request.stream:
            return self.chat_completion_stream_generator(
                request,
                result_generator,
                request_id,
                model_name,
                conversation,
                tokenizer,
                request_metadata,
                chat_template_kwargs=chat_template_kwargs,
                mm_token_counts=mm_token_counts,
            )
  1. Line 314: render_chat_request first checks the model name and runs _preflight, then calls the renderer (vllm/entrypoints/openai/chat_completion/serving.py:278-285).
  2. Lines 320-322: the request id is "chatcmpl-" + request_id.
  3. Line 349: get_max_tokens takes the smallest of max_model_len - prompt_len, the request value (or the server default), an optional override and a platform limit (vllm/entrypoints/serve/utils/api_utils.py:253-270). The client sends no max_tokens. So, if the generation config of the model sets no default either, the model length limits the answer.
  4. Line 366: to_sampling_params sets output_kind = DELTA because stream is true (vllm/entrypoints/openai/chat_completion/protocol.py:797-799). DELTA means: each output carries only the new tokens.
  5. Line 396: self.engine_client.generate(...) is AsyncLLM.generate. It is an async generator function, so this call does not run it. It only makes the generator object.
  6. Lines 415-416: the serving class returns another generator, chat_completion_stream_generator, which wraps the first one.

What the data looks like now (illustrative):

request_id       = "chatcmpl-3f9c...e1"
sampling_params  = SamplingParams(temperature=..., top_p=..., max_tokens=32707,   # 32768 - 61
                                  output_kind=RequestOutputKind.DELTA, ...)
result_generator = <async_generator AsyncLLM.generate>    # not started
Deep dive

Stop 2 in detail: where the sampling defaults come from

For each sampling field, to_sampling_params uses the request value, then the server default, then the OpenAI default: "Priority: user -> server default -> OpenAI default" (vllm/entrypoints/openai/chat_completion/protocol.py:740-748). Server-default stop token ids are merged with the request stop ids (vllm/entrypoints/openai/chat_completion/protocol.py:754-764). The request can ask for a larger stream interval with stream_interval. The field description says values below the server setting are clamped up to it (vllm/entrypoints/openai/chat_completion/protocol.py:543-551). The server default --stream-interval is 1 (vllm/config/scheduler.py:216).

_preflight raises the engine's dead error if the engine has failed, and calls check_admission (vllm/entrypoints/generate/base/serving.py:179-181). Admission control rejects a request with HTTP 503 when max_num_queued_reqs or max_num_queued_tokens is set and full (vllm/v1/engine/async_llm.py:325-383).

The example in the code block uses max_model_len = 32768. That value comes from the model config, not from vLLM, so it is illustrative.

3The chat template and the tokenizer make token ids#

The model reads one sequence of token ids, not a list of messages. The chat template (a Jinja template from the tokenizer files) writes the messages into one text, with special marker tokens. Then the tokenizer cuts the text into ids. Tokens and tokenizers are explained in Tokens and tokenizers.

We are in the renderer. OnlineRenderer.preprocess_chat calls renderer.render_chat_async (vllm/renderers/online_renderer.py:805-808). For a Hugging Face tokenizer, it asks the template for text, not ids: tokenize is true only for Mistral tokenizers or prompt embeddings (vllm/renderers/online_renderer.py:774-781).

        arrival_time = time.time()

        if tok_params is None:
            tok_params = self.default_chat_tok_params

        rendered = [
            self.render_messages_async(conversation, chat_params)
            for conversation in conversations
        ]

        out_conversations = list[list["ConversationMessage"]]()
        dict_prompts = list[DictPrompt]()
        for conv, prompt in await asyncio.gather(*rendered):
            out_conversations.append(conv)
            dict_prompts.append(prompt)

        tok_prompts = await self.tokenize_prompts_async(dict_prompts, tok_params)

        prompt_extras = dict(prompt_extras or {})
        prompt_extras["media_io_kwargs"] = chat_params.media_io_kwargs or {}
        self._apply_prompt_extras(tok_prompts, prompt_extras)

        eng_prompts = await asyncio.gather(
            *(
                self.process_for_engine_async(
                    p, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params
                )
                for p in tok_prompts
            )
        )

        return out_conversations, eng_prompts
  1. Line 1380: arrival_time is set here. The TTFT metric of vLLM starts at this moment (see Deep dive, timing).
  2. Line 1386: render_messages_async applies the chat template.
  3. Line 1396: tokenize_prompts_async turns the text into ids.
  4. Line 1404: process_for_engine_async makes the engine input and stores arrival_time in it (vllm/renderers/base.py:1255-1276).

Both blocking jobs run in a thread pool, so they do not block the asyncio event loop. The HF renderer wraps safe_apply_chat_template with make_async (vllm/renderers/hf.py:1011-1013) and calls it with a timeout:

        prompt_raw = await self._render_with_timeout(
            self._apply_chat_template_async(
                model_config,
                tokenizer,
                conversation,
                **chat_template_kwargs,
            )
        )

The tokenizer call goes to the same kind of pool: _tokenize_prompt_async = make_async(self._tokenize_prompt, executor=self._executor) (vllm/renderers/base.py:156-158). _tokenize_prompt calls tokenizer(prompt["prompt"], **kwargs) (vllm/renderers/base.py:663-678).

What the data looks like now (illustrative, ChatML template):

"<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n"
"<|im_start|>user\nWho won the world series in 2020?<|im_end|>\n"
"<|im_start|>assistant\nThe Los Angeles Dodgers won the World Series in 2020.<|im_end|>\n"
"<|im_start|>user\nWhere was it played?<|im_end|>\n"
"<|im_start|>assistant\n"                    # add_generation_prompt: the model answers next
engine_input = {"type": "token", "prompt_token_ids": [...61 ids...],
                "prompt": "<|im_start|>system...", "arrival_time": 1761140000.12}
Deep dive

Stop 3 in detail: template resolution, timeout and errors

4The stream starts: AsyncLLM.generate()#

Starlette now iterates the body of the StreamingResponse. The SSE generator asks for its first chunk. That pulls on the result generator from stop 2 (async for res in result_generator, vllm/entrypoints/openai/chat_completion/serving.py:568). Now AsyncLLM.generate runs for the first time. We are in vllm/v1/engine/async_llm.py:

        q: RequestOutputCollector | None = None
        try:
            q = await self.add_request(
                request_id,
                prompt,
                sampling_params,
                lora_request=lora_request,
                tokenization_kwargs=tokenization_kwargs,
                trace_headers=trace_headers,
                priority=priority,
                data_parallel_rank=data_parallel_rank,
                session_id=session_id,
                kv_hints=kv_hints,
                prompt_text=prompt_text,
                reasoning_ended=reasoning_ended,
                reasoning_parser_kwargs=reasoning_parser_kwargs,
            )

            # The output_handler task pushes items into the queue.
            # This task pulls from the queue and yields to caller.
            finished = False
            while not finished:
                # Note: drain queue without await if possible (avoids
                # task switching under load which helps performance).
                out = q.get_nowait() or await q.get()

                # Note: both OutputProcessor and EngineCore handle their
                # own request cleanup based on finished.
                assert isinstance(out, RequestOutput)
                finished = out.finished
                if out is not STREAM_FINISHED:
                    yield out
  1. Line 773: add_request processes the input and sends it to the engine core (stops 5 and 6). It returns a RequestOutputCollector, here called q.
  2. Line 795: the loop takes the next output from the collector. get_nowait() avoids a task switch when an output is already there.
  3. Line 802: each RequestOutput goes up to the SSE generator (stop 16). The loop ends when an output has finished = True.

The code that fills q is not in this task. A separate background task, output_handler, does it (stop 15). The docstring says the same: "A separate output_handler loop runs in a background AsyncIO task" (vllm/v1/engine/async_llm.py:752-754).

Deep dive

Stop 4 in detail: how generate() ends

generate() has one except branch per kind of failure (vllm/v1/engine/async_llm.py:807-851):

ExceptionWhat generate() does
CancelledError, GeneratorExit (client gone)aborts the request in the front end and in the engine core, then raises again
EngineDeadErrorraises; no abort, because the engine is gone
VLLMClientError, GracefulHTTPError, GenerationErroraborts, then raises
InputStreamErroraborts, then raises the cause
any other Exceptionaborts, then raises EngineGenerateError

In all cases the finally block calls q.close() (vllm/v1/engine/async_llm.py:852-854).

5add_request: the input processor makes an EngineCoreRequest#

The engine core needs a small, fixed object that it can send as bytes. This is the EngineCoreRequest. AsyncLLM.add_request gets a rendered engine input (a dict with a "type" key). So it calls the synchronous process_inputs of the input processor. No tokenization is left to do (vllm/v1/engine/async_llm.py:481-496).

        return EngineCoreRequest(
            request_id=request_id,
            prompt_token_ids=prompt_token_ids,
            prompt_embeds=prompt_embeds,
            prompt_is_token_ids=prompt_is_token_ids,
            mm_features=mm_features,
            sampling_params=sampling_params,
            pooling_params=pooling_params,
            arrival_time=arrival_time,
            lora_request=lora_request,
            cache_salt=decoder_input.get("cache_salt"),
            priority=priority,
            data_parallel_rank=data_parallel_rank,
            trace_headers=trace_headers,
            resumable=resumable,
            session_id=session_id,
            kv_hints=kv_hints,
        )

process_inputs also does the last checks and fills in defaults:

Then add_request makes the request id unique. It keeps the client id as external_req_id and adds 8 random characters:

        request.external_req_id = request.request_id
        if envs.VLLM_DISABLE_REQUEST_ID_RANDOMIZATION:
            logger.warning_once(
                "VLLM_DISABLE_REQUEST_ID_RANDOMIZATION is set and will be "
                "removed in a future release. Duplicate externally-provided "
                "request IDs may cause failures and/or subtle correctness errors."
            )
        else:
            request.request_id = f"{request.external_req_id}-{random_uuid():.8}"

What the data looks like now (illustrative):

EngineCoreRequest(                      # msgspec.Struct, array_like=True
    request_id="chatcmpl-3f9c...e1-a1b2c3d4",
    external_req_id="chatcmpl-3f9c...e1",
    prompt_token_ids=[...61 ids...],
    sampling_params=SamplingParams(..., output_kind=DELTA, eos_token_id=...),
    arrival_time=1761140000.12, mm_features=None, lora_request=None,
    cache_salt=None, data_parallel_rank=None, priority=0)
Deep dive

Stop 5 in detail: the struct and its encoding

EngineCoreRequest is a msgspec.Struct with array_like=True, omit_defaults=True and gc=False (vllm/v1/engine/__init__.py:113-118). array_like means that msgpack writes the fields as an array in field order, without field names. So the message is short. The fields are listed in vllm/v1/engine/__init__.py:119-165. The ones that matter for this tour:

FieldTypeOur value
request_idstrinternal id with 8 random characters
prompt_token_idslist[int] | None61 ids
sampling_paramsSamplingParams | NoneDELTA output, max_tokens from stop 2
arrival_timefloatwall-clock time from stop 3
client_indexint0 (one API server)
data_parallel_rankint | NoneNone (from the X-data-parallel-rank header if present)
external_req_idstr | Nonethe client-visible id

If max_tokens is still None, process_inputs sets it to max_model_len - prompt_len (vllm/v1/engine/input_processor.py:476-481). The env var VLLM_DISABLE_REQUEST_ID_RANDOMIZATION turns off the random suffix, with a warning about duplicate ids (vllm/v1/engine/input_processor.py:364-369).

6Register the request, then send it over ZMQ#

Before the request leaves the process, the front end must be ready for its outputs. add_request starts the output handler task (only once, vllm/v1/engine/async_llm.py:856-859) and makes the collector:

        self.input_processor.assign_request_id(request)

        # We start the output_handler on the first call to add_request() so
        # we can call __init__ before the event loop, which enables us
        # to handle startup failure gracefully in the OpenAI server.
        self._run_output_handler()

        # Create a new output collector for the request.
        queue = RequestOutputCollector(params.output_kind, request.request_id)

        # Use cloned params that may have been updated in process_inputs()
        params = request.params

        if is_pooling or params.n == 1:
            await self._add_request(request, prompt_text, None, 0, queue)
            return queue

_add_request registers the request in the OutputProcessor first and sends it after that. The comment gives the reason: "Register locally before the first await so concurrent tasks see this request."

        if parent_req is None and not self.output_processor.has_request(
            request.request_id
        ):
            self.check_admission(request_id=request.request_id)

        # Register locally before the first await so concurrent tasks see this request.
        self.output_processor.add_request(request, prompt, parent_req, index, queue)

        # Add the EngineCoreRequest to EngineCore (separate process).
        await self.engine_core.add_request_async(request)

The engine core client for one engine is AsyncMPClient (vllm/v1/engine/core_client.py:158-173). It sets client_index, then sends a multipart ZMQ message: the engine identity, one type byte and the msgpack frames:

    def _send_input(
        self,
        request_type: EngineCoreRequestType,
        request: Any,
        engine: EngineIdentity | None = None,
    ) -> Awaitable[Any]:
        if engine is None:
            engine = self.core_engine

        message = (request_type.value, *self.encoder.encode(request))
        return self._send_input_message(message, engine)

    def _send_input_message(
        self, message: tuple[bytestr, ...], engine: EngineIdentity
    ) -> Awaitable[Any]:
        self.ensure_alive()
        # Any zero-copy tensor/ndarray frames are kept alive by zmq itself
        # until it's finished sending them (there is a ref chain from the underlying
        # memoryview back to the original owning tensor/ndarray).
        return self.input_socket.send_multipart((engine,) + message, copy=False)
    async def add_request_async(self, request: EngineCoreRequest) -> None:
        request.client_index = self.client_index
        await self._send_input(EngineCoreRequestType.ADD, request)
        self._ensure_output_queue_task()

What the data looks like now (illustrative):

output_processor.request_states["chatcmpl-...-a1b2c3d4"] = RequestState(detokenizer=..., queue=q)
frames = [b"\x00\x00",            # engine identity: DP rank 0 as 2 little-endian bytes
          b"\x00",                # EngineCoreRequestType.ADD
          <msgpack bytes of EngineCoreRequest>]
Deep dive

Stop 6 in detail: the sockets and the encoder

7The engine core input thread decodes the request#

We are now in the engine core process, vllm/v1/engine/core.py. EngineCoreProc has two I/O threads beside the main thread. The comment explains why: socket I/O and (de)serialization then overlap with the GPU work (vllm/v1/engine/core.py:1238-1263). The input thread connects a DEALER socket to the ROUTER of the front end (vllm/v1/engine/core.py:1935-1942) and waits:

            while True:
                for input_socket, _ in poller.poll():
                    # (RequestType, RequestData)
                    type_frame, *data_frames = input_socket.recv_multipart(copy=False)
                    # NOTE(yongji): ignore READY message sent by DP coordinator
                    # that is used to notify newly started engines
                    if type_frame.buffer == b"READY":
                        assert input_socket == coord_socket
                        continue
                    request_type = EngineCoreRequestType(bytes(type_frame.buffer))

                    # Deserialize the request data.
                    request: Any
                    if request_type == EngineCoreRequestType.ADD:
                        req: EngineCoreRequest = add_request_decoder.decode(data_frames)
                        try:
                            request = self.preprocess_add_request(req)
                    # Push to input queue for core busy loop.
                    self.input_queue.put_nowait((request_type, request))
  1. Line 1980: one message: the type frame and the data frames.
  2. Line 1991: add_request_decoder is a MsgpackDecoder(EngineCoreRequest) (vllm/v1/engine/core.py:1930-1932), so the result is a typed struct again.
  3. Line 1993: preprocess_add_request makes the engine's own Request object in this thread, so it runs in parallel with the model (vllm/v1/engine/core.py:1114-1136).
  4. Line 2028: the thread puts (ADD, (request, wave)) into input_queue for the main thread.

The Request starts as WAITING with num_computed_tokens = 0 (vllm/v1/request.py:100, vllm/v1/request.py:184). At the end of __init__ it computes the hashes of its full blocks for the prefix cache (vllm/v1/request.py:225-230).

What the data looks like now (illustrative, block size 16):

Request(request_id="chatcmpl-...-a1b2c3d4", status=WAITING,
        num_prompt_tokens=61, num_computed_tokens=0, max_tokens=32707,
        block_hashes=[h0, h1, h2])     # 61 // 16 = 3 full blocks; the last 13 tokens have no hash yet
Deep dive

Stop 7 in detail: hashes, failures and the ready message

8The busy loop gives the request to the scheduler#

The main thread of the engine core process runs the busy loop for its whole life:

    def run_busy_loop(self):
        """Core busy loop of the EngineCore."""
        while self._handle_shutdown():
            # 1) Poll the input queue until there is work to do.
            self._process_input_queue()
            # Publish request counts before and after GPU step to ensure freshness.
            self._maybe_publish_request_counts()
            # 2) Step the engine core and return the outputs.
            self._process_engine_step()
            self._maybe_publish_request_counts()

        raise SystemExit

_process_input_queue blocks on input_queue while there is no work, and then handles all queued messages (vllm/v1/engine/core.py:1635-1664). For ADD it calls EngineCore.add_request (vllm/v1/engine/core.py:1745-1749), which checks the request and calls self.scheduler.add_request(request) (vllm/v1/engine/core.py:548). The scheduler puts it into the waiting queue and into its requests dict (vllm/v1/core/sched/scheduler.py:2730-2734).

Now has_work() is true, so the loop calls _process_engine_step. That runs self.step_fn() and puts each output into output_queue (vllm/v1/engine/core.py:1666-1676).

Deep dive

Stop 8 in detail: which step function

step_fn is self.step without a batch queue, and self.step_with_batch_queue with one (vllm/v1/engine/core.py:227-229). The batch queue exists when max_concurrent_batches > 1 (vllm/v1/engine/core.py:205-211). With async scheduling and Model Runner V2, max_concurrent_batches is pp_size + 1, so 2 on one GPU (vllm/config/vllm.py:587-597).

Async scheduling is on by default: when the flag is not set and nothing is incompatible, the config sets it to True (vllm/config/vllm.py:1664-1698). The uniproc executor supports it (vllm/v1/executor/uniproc_executor.py:156-158). With async scheduling, the scheduler class is AsyncScheduler (vllm/config/scheduler.py:231-238). So the default path of our request is step_with_batch_queue and AsyncScheduler.

has_work() is also true while the batch queue is not empty (vllm/v1/engine/core.py:1596-1602). So the loop keeps stepping until the last in-flight step is processed.

9One engine step: step_with_batch_queue#

An engine step has three parts: schedule, execute and update. With async scheduling, the engine uses the batch queue. This version does not wait for a step right after it starts it. It starts the next step first:

        if self.scheduler.has_requests():
            scheduler_output = self.scheduler.schedule(self._should_throttle_prefills())
            with self.log_error_detail(scheduler_output):
                exec_future = self.model_executor.execute_model(
                    scheduler_output, non_block=True
                )
            if not self.is_mm_encoder_only:
                model_executed = scheduler_output.total_num_scheduled_tokens > 0

            if self.is_pooling_model or not model_executed:
                # No sampling required (no requests scheduled).
                future = cast(Future[ModelRunnerOutput], exec_future)
            else:
                if not scheduler_output.pending_structured_output_tokens:
                    # We aren't waiting for any tokens, get any grammar output
                    # and sample immediately.
                    grammar_output = self.scheduler.get_grammar_bitmask(
                        scheduler_output
                    )
                    future = self.model_executor.sample_tokens(
                        grammar_output, non_block=True
                    )
                else:
                    # We need to defer sampling until we have processed the model output
                    # from the prior step.
                    deferred_scheduler_output = scheduler_output

            if not deferred_scheduler_output:
                # Add this step's future to the queue.
                batch_queue.appendleft((future, scheduler_output, exec_future))
                if len(batch_queue) < self.batch_queue_size and (
                    model_executed or self.scheduler.has_requests()
                ):
                    # Don't block on next worker response unless the queue is full
                    # or there are no more requests to schedule.
                    return None, model_executed
        # Block until the next result is available.
        future, scheduler_output, exec_model_fut = batch_queue.pop()
        with (
            self.capture_iteration_details(scheduler_output) as iteration_details,
            self.log_error_detail(scheduler_output),
        ):
            model_output = future.result()
            if model_output is None:
                # None from sample_tokens() implies that the original execute_model()
                # call failed - raise that exception.
                exec_model_fut.result()
                raise RuntimeError("unexpected error")

        # Before processing the model output, process any aborts that happened
        # during the model execution.
        self._process_aborts_queue()
        engine_core_outputs = self.scheduler.update_from_output(
  1. Line 725: the scheduler makes a SchedulerOutput (stop 10).
  2. Lines 727-729: the executor runs execute_model with non_block=True (stops 11 and 12). The uniproc executor runs the CPU part of the model runner at once, in this thread. The GPU work only goes into the CUDA queue. non_block only moves the wait for the GPU result into a future (vllm/v1/executor/uniproc_executor.py:108-117).
  3. Lines 743-745: the executor runs sample_tokens, also non-blocking (stop 13).
  4. Lines 753 and 759: the step goes into the queue. If the queue is not full, the function returns with no outputs.
  5. Lines 768-773: else it takes the oldest step and waits for its result.
  6. Line 783: update_from_output turns that result into outputs (stop 14).

For our request: the first call schedules and starts step 1 and returns. The second call schedules and starts step 2, then waits for step 1. So the first token is processed while step 2 is already on the GPU. The overlap works because the Model Runner V2 code of execute_model and sample_tokens does not wait for the GPU. The only wait is copy_event.synchronize() in get_output() (stop 13). A debug mode can report any other GPU-to-CPU wait in these two worker methods (vllm/utils/gpu_sync_debug.py:386-392, vllm/v1/worker/gpu_worker.py:1316-1327). The loop shows this in a diagram.

10Scheduler.schedule(): token budget, prefix cache and blocks#

We are in vllm/v1/core/sched/scheduler.py. The vLLM scheduler has no prefill phase and no decode phase. The comment at the top of schedule() explains the single rule:

    def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
        self.current_step += 1
        # NOTE(woosuk) on the scheduling algorithm:
        # There's no "decoding phase" nor "prefill phase" in the scheduler.
        # Each request just has the num_computed_tokens and
        # num_tokens_with_spec. num_tokens_with_spec =
        # len(prompt_token_ids) + len(output_token_ids) + len(spec_token_ids).
        # At each step, the scheduler tries to assign tokens to the requests
        # so that each request's num_computed_tokens can catch up its
        # num_tokens_with_spec. This is general enough to cover
        # chunked prefills, prefix caching, speculative decoding,
        # and the "jump decoding" optimization in the future.

        scheduled_new_reqs: list[Request] = []
        scheduled_resumed_reqs: list[Request] = []
        scheduled_running_reqs: list[Request] = []
        preempted_reqs: list[Request] = []

        req_to_new_blocks: dict[str, KVCacheBlocks] = {}
        num_scheduled_tokens: dict[str, int] = {}
        token_budget = self.max_num_scheduled_tokens

So every request has two numbers: num_computed_tokens (tokens with KV in the cache) and the number of tokens it has. The scheduler gives each request tokens until the first number catches up with the second. The total per step is the token budget, max_num_scheduled_tokens (line 642). Prefill and decode are explained in Prefill and decode.

The scheduler first serves the running requests, then the waiting ones. Our request is new, so it is in the waiting pass. There it looks for a prefix cache hit:

                # Get already-cached tokens.
                if request.num_computed_tokens == 0:
                    did_prefix_cache_lookup = True
                    (
                        new_computed_blocks,
                        num_new_local_computed_tokens,
                        request.shared_prefix_boundary,
                        hit_diverged,
                    ) = self._get_local_prefix_cache_hit(request)

The KV cache manager returns only full, cached blocks, and never the whole prompt: "we must recompute the last token to obtain logits" (vllm/v1/core/kv_cache_manager.py:289-300). Then the scheduler computes the tokens to run, takes the smaller of that and the budget, and allocates KV blocks:

                    num_new_tokens = request.num_tokens - num_computed_tokens
                    num_new_tokens = min(num_new_tokens, request_token_budget)
                new_blocks = self.kv_cache_manager.allocate_slots(
                    request,
                    num_tokens_past_hit,
                    num_new_computed_tokens=num_new_local_computed_tokens,
                    new_computed_blocks=new_computed_blocks,
                    num_lookahead_tokens=effective_lookahead_tokens,

If allocate_slots returns None, there are not enough free blocks, and the waiting pass stops (vllm/v1/core/sched/scheduler.py:1339-1346). For us it succeeds, and the request becomes RUNNING:

                num_scheduled_tokens[request_id] = num_new_tokens
                token_budget -= num_new_tokens
                input_budget -= num_new_tokens + draft_slots
                request.status = RequestStatus.RUNNING
                request.num_computed_tokens = num_computed_tokens

The KV cache and its blocks are explained in The KV cache. At the end, schedule() builds the SchedulerOutput (vllm/v1/core/sched/scheduler.py:1588-1591) and advances num_computed_tokens by the scheduled tokens (vllm/v1/core/sched/scheduler.py:1714-1716).

What the data looks like now (illustrative: an earlier turn of the same chat left 3 blocks in the cache):

num_new_local_computed_tokens = 48            # 3 cached blocks x 16 tokens
num_new_tokens                = 61 - 48 = 13  # well below a token budget of 8192
SchedulerOutput(
    scheduled_new_reqs=[NewRequestData(req_id=..., prompt_token_ids=[61 ids],
                                       block_ids=([7, 3, 12, 40],), num_computed_tokens=48, ...)],
    num_scheduled_tokens={"chatcmpl-...-a1b2c3d4": 13},
    total_num_scheduled_tokens=13, finished_req_ids=set(), ...)
request.num_computed_tokens   = 61            # advanced in _update_after_schedule
Deep dive

Stop 10 in detail: budget defaults, chunking and the async placeholders

11Model Runner V2 prepares the inputs and the attention metadata#

The executor calls the worker in the same process. UniProcExecutor.execute_model runs collective_rpc("execute_model", ...) (vllm/v1/executor/uniproc_executor.py:119-132), which calls the method directly on the worker object (vllm/v1/executor/uniproc_executor.py:108-111). Worker.execute_model calls self.model_runner.execute_model (vllm/v1/worker/gpu_worker.py:1350-1353). The model runner is Model Runner V2, in vllm/v1/worker/gpu/model_runner.py.

First the runner updates its own copy of the request states from the SchedulerOutput. Our request is new, so add_requests stores its prompt and block ids:

        if not dummy_run:
            # Update the request states.
            self.update_pp_decode_requests()
            self.finish_requests(scheduler_output)
            self.free_states(scheduler_output)
            self.add_requests(scheduler_output)
            self.update_requests(scheduler_output)
            self.block_tables.apply_staged_writes()

Then it picks a CUDA-graph mode for the batch shape (dispatch_cg_and_sync_dp, vllm/v1/worker/gpu/model_runner.py:1806-1822) and fills the input buffers and the attention inputs:

            input_batch = self.prepare_inputs(
                scheduler_output, batch_req_state, batch_desc, num_active_loras
            )
            block_tables, slot_mappings = self.prepare_attn(input_batch)

prepare_inputs builds query_start_loc, the positions and the sequence lengths, and copies the prompt tokens of a prefill (vllm/v1/worker/gpu/model_runner.py:1446-1489). For a decode step, the input token comes from last_sampled_tokens, a tensor that stays on the GPU:

        # Some input token ids are directly read from the last sampled tokens
        # and draft tokens. Also, get the logits indices to sample tokens from.
        logits_indices = combine_sampled_and_draft_tokens(
            self.input_buffers.input_ids,
            idx_mapping,
            self.req_states.last_sampled_tokens,
            query_start_loc,
            seq_lens,
            self.req_states.prefill_len.gpu,
            self.req_states.draft_tokens,

prepare_attn gathers the block tables and computes the slot mappings: the cache slot of each new token (vllm/v1/worker/gpu/model_runner.py:1577-1597). Then the model state builds the attention metadata for each layer group (vllm/v1/worker/gpu/model_runner.py:1940-1950). These packed varlen inputs are the same idea as in Input metadata.

What the data looks like now (step 1, illustrative):

input_ids        int32 [13 (+ padding)]   # prompt tokens 48..60
positions        int64 [13 (+ padding)] = [48, 49, ..., 60]
query_start_loc  int32 [2]  = [0, 13]
seq_lens         int32 [1]  = [61]
slot_mappings    [num_kv_cache_groups, 13 (+ padding)]   # block 40 holds positions 48..60
logits_indices   [1] = [12]                   # only the last position is sampled
Deep dive

Stop 11 in detail: what runs on the CPU and what on the GPU

The runner keeps the request states as tensors on the GPU (for example req_states.num_computed_tokens.gpu and req_states.last_sampled_tokens). It copies small CPU arrays to the GPU without a wait (async_tensor_h2d, vllm/v1/worker/gpu/model_runner.py:1455-1456). Prefill tokens, positions and the sampled-token merge are GPU functions that read these states (vllm/v1/worker/gpu/model_runner.py:1469-1500). This is why the scheduler does not need the real token of the last step (stop 10, placeholders).

The tour Tour: One forward pass follows the attention metadata into the layers. The page The model runner (Model Runner V2) explains the persistent request states of MRV2.

12The forward pass: CUDA graph or eager#

Now the model runs on the GPU. The runner chooses one of three ways, by the CUDA-graph mode of the batch:

        if batch_desc.cg_mode == CUDAGraphMode.FULL:
            # Use explicit cudagraph replay for FULL mode.
            # NOTE(woosuk): Here, we don't need to pass the input tensors,
            # because they are already copied to the CUDA graph input buffers.
            assert self.cudagraph_manager is not None
            self.kv_connector.pre_forward(
                scheduler_output, input_batch, attn_metadata=attn_metadata
            )
            model_output = self.cudagraph_manager.run_fullgraph(batch_desc)
        else:
            # For piecewise and eager mode, just call model().
            batch_descriptor = BatchDescriptor(
                num_tokens=input_batch.num_tokens_after_padding,
                has_lora=self.lora_config is not None,
                num_active_loras=batch_desc.num_active_loras,
            )

            with set_forward_context(
                attn_metadata,
                self.vllm_config,
                num_tokens=input_batch.num_tokens_after_padding,
                cudagraph_runtime_mode=batch_desc.cg_mode,
                num_tokens_across_dp=dp_sync_state
                and dp_sync_state.num_tokens_across_dp,
                batch_descriptor=batch_descriptor,
                ubatch_slices=ubatch_slices,
                slot_mapping=slot_mappings_by_layer,
                skip_compiled=skip_compiled,
                is_padding=input_batch.is_padding,
            ):
                self.kv_connector.pre_forward(scheduler_output, input_batch)
                if ubatch_state is not None:
                    assert self.ubatch_runner is not None
                    model_output = self.ubatch_runner.run(
                        self.model, model_inputs, ubatch_state
                    )
                elif batch_desc.cg_mode == CUDAGraphMode.PIECEWISE:
                    # Run the PIECEWISE graph (compiled PW cudagraph or breakable
                    # cudagraph, chosen inside run_pw_graph). cg_mode is only
                    # PIECEWISE after the cudagraph manager exists.
                    assert self.cudagraph_manager is not None
                    model_output = self.cudagraph_manager.run_pw_graph(
                        self.model, model_inputs
                    )
                else:
                    # Eager (NONE): call the raw model directly.
                    model_output = self.model(**model_inputs)
  1. Line 2032: FULL mode replays a captured CUDA graph of the whole model. The comment says the inputs are already in the graph's input buffers.
  2. Line 2041: the other modes run inside set_forward_context(...). The forward context gives each attention layer its metadata and slot mapping.
  3. Line 2065: PIECEWISE runs a graph with the attention parts outside of it.
  4. Line 2070: NONE calls the model eagerly.

CUDA graphs remove the CPU cost of many kernel launches: see CUDA graphs. In step 1 our batch has 13 query tokens of one prefill. In later steps it has 1 query token (a decode). The default optimization level is O2 (vllm/config/vllm.py:434), and O2 sets cudagraph_mode = FULL_AND_PIECEWISE (vllm/config/vllm.py:310). With this mode the runner picks the graph by the batch shape (vllm/v1/worker/gpu/cudagraph_utils.py:523-553):

  • A uniform decode batch gets a FULL graph, if it has no more tokens than the largest captured size. In such a batch every request has exactly 1 query token (no spec decoding). Our decode steps are of this kind.
  • Another batch, for example our 13-token prefill, gets a PIECEWISE graph, padded to the next captured size (vllm/v1/worker/gpu/cudagraph_utils.py:352-370).
  • A batch with more tokens than the largest captured size runs eagerly (NONE).

At start-up the attention backend can lower the mode, if it does not support FULL graphs (vllm/v1/worker/gpu/model_runner.py:754-763). See torch.compile and CUDA graphs in vLLM.

The result is hidden_states, one vector per token. The runner stores it in execute_model_state and returns None (vllm/v1/worker/gpu/model_runner.py:2088-2111). Sampling is a separate call (stop 13).

What the data looks like now (step 1, illustrative):

hidden_states   bfloat16 [13 (+ padding), hidden_size]
KV cache        keys and values of positions 48..60 written into block 40

13sample_tokens: logits, the sampler and the copy back#

The engine core calls sample_tokens on the executor right after execute_model (stop 9). The worker passes it to the runner (vllm/v1/worker/gpu_worker.py:1318-1321). The runner computes logits only for the rows that need a token, and calls the sampler:

            sample_hidden_states = hidden_states[input_batch.logits_indices]
            logits = self.model.compute_logits(sample_hidden_states)
        if input_batch.num_reqs == 0:
            # This rank owns no requests this step. It contributes an
            # all-padding block to the gather below.
            sampler_output = None
        elif input_batch.num_draft_tokens == 0 or self.rejection_sampler is None:
            assert self.sampler is not None
            sampler_output = self.sampler(logits, input_batch)

The sampler applies the logits processors, temperature and min-p, when a request needs them (vllm/v1/worker/gpu/sample/sampler.py:238-268). Then it draws the token. A fused FlashInfer kernel can do top-k/top-p and the draw in one call. It does so when the batch uses top-k or top-p, has no greedy or seeded request and needs no processed logprobs (vllm/v1/worker/gpu/sample/sampler.py:304-315, vllm/v1/worker/gpu/sample/sampler.py:339-343). This sampler is on by default where the hardware supports it (vllm/envs.py:883-890). Else the sampler applies top-k/top-p and draws with a Gumbel sample (vllm/v1/worker/gpu/sample/sampler.py:346-359). The sampling steps are explained in Sampling.

Then the runner starts the copy of the result to the CPU and returns without a wait:

        # Start async output copy here so that it can overlap with speculator proposal.
        async_output = AsyncOutput(
            model_runner_output=model_runner_output,
            sampler_output=sampler_output,
            num_sampled_tokens=num_sampled,
            main_stream=self.main_stream,
            copy_stream=self.output_copy_stream,
            check_ep_fault=self.check_ep_fault,
            pending_aux_output=pending_aux_output,
        )
        with stream(copy_stream, main_stream):
            copy_stream.wait_stream(main_stream)

            self.sampled_token_ids = async_copy_to_np(sampler_output.sampled_token_ids)

AsyncOutput copies on a separate copy stream and records an event (vllm/v1/worker/gpu/async_utils.py:171). postprocess_sampled writes the new token into last_sampled_tokens on the GPU, for the next step (vllm/v1/worker/gpu/model_runner.py:1715-1726). The engine core waits only later, in get_output(), which synchronizes the event and makes Python lists (vllm/v1/worker/gpu/async_utils.py:173-184). In the uniproc executor, AsyncOutputFuture.result() calls get_output() (vllm/v1/executor/uniproc_executor.py:32-47).

What the data looks like now (step 1, illustrative):

logits             [1, vocab_size]
sampled_token_ids  [1, 1] = [[785]]           # "The"
ModelRunnerOutput(req_ids=["chatcmpl-...-a1b2c3d4"], sampled_token_ids=[[785]], ...)   # after get_output()

14update_from_output: append, check for stop, send back#

Back in the busy loop, the engine core takes the result of the oldest step (stop 9) and calls Scheduler.update_from_output. For each request with new tokens it appends them and checks the stop conditions:

            # Check for stop and update request status.
            if new_token_ids:
                new_token_ids, stopped = self._update_request_with_output(
                    request, new_token_ids, is_stale=output_is_stale
                )
        for num_new, output_token_id in enumerate(new_token_ids, 1):
            request.append_output_token_ids(output_token_id)

            # Check for stop and update request state.
            # This must be called before we make the EngineCoreOutput.
            stopped = check_stop(request, self.max_model_len)
            if stopped:
                # num_computed_tokens covers all new tokens but the last one.
                # Keep that true after trimming: resumable requests resume from it.
                if not is_stale:
                    request.num_computed_tokens -= len(new_token_ids) - num_new
                del new_token_ids[num_new:]  # Trim new tokens if needed.
                break
        return new_token_ids, stopped

check_stop tests the last token in a fixed order: EOS, a stop token id, the model length and max_tokens, then min_tokens and repetition:

def check_stop(request: Request, max_model_len: int) -> bool:
    assert not request.pooling_params

    sampling_params = request.sampling_params
    assert sampling_params is not None

    last_token_id = request.output_token_ids[-1]
    if last_token_id == sampling_params.eos_token_id:
        request.status = RequestStatus.FINISHED_STOPPED
        return True

    if last_token_id in (sampling_params.stop_token_ids or ()):
        request.status = RequestStatus.FINISHED_STOPPED
        request.stop_reason = last_token_id
        return True

    if (
        request.num_tokens >= max_model_len
        or request.num_output_tokens >= request.max_tokens
    ):
        request.status = RequestStatus.FINISHED_LENGTH_CAPPED
        return True

If the request stopped, the scheduler frees it, and its blocks go back to the block pool (vllm/v1/core/sched/scheduler.py:2316-2323). Then it adds one EngineCoreOutput for the request, under the index of its front end:

                # Add EngineCoreOutput for this Request.
                outputs[request.client_index].append(
                    EngineCoreOutput(
                        request_id=req_id,
                        new_token_ids=new_token_ids,
                        finish_reason=finish_reason,
                        new_logprobs=new_logprobs,

The outputs are grouped into one EngineCoreOutputs per client (vllm/v1/core/sched/scheduler.py:2487-2490). The busy loop puts them into output_queue (stop 8). The output thread encodes them with msgpack and sends them on a PUSH socket to the PULL socket of the front end:

            while True:
                output = self.output_queue.get()
                if output == EngineCoreProc.ENGINE_CORE_DEAD:
                    for socket in sockets:
                        socket.send(output)
                    break
                assert not isinstance(output, bytes)
                client_index, outputs = output
                outputs.engine_index = engine_index
                buffer = reuse_buffers.pop() if reuse_buffers else bytearray()
                buffers = encoder.encode_into(outputs, buffer)
                tracker = self._send_msg_tracking_payload(
                    sockets[client_index], buffers
                )

What the data looks like now (after step 1, illustrative):

request.output_token_ids = [785];  request.status = RUNNING
EngineCoreOutputs(engine_index=0, timestamp=<monotonic>,
    outputs=[EngineCoreOutput(request_id="chatcmpl-...-a1b2c3d4", new_token_ids=[785],
                              finish_reason=None, events=[QUEUED, SCHEDULED], ...)],
    scheduler_stats=SchedulerStats(...))
Deep dive

Stop 14 in detail: finish reasons and the last step

FinishReason is an integer enum, for a shorter message: STOP = 0, LENGTH = 1, ABORT = 2, ERROR = 3, REPETITION = 4 (vllm/v1/engine/__init__.py:49-68). The request status gives the reason: FINISHED_STOPPED for EOS or a stop token, FINISHED_LENGTH_CAPPED for the length limits (vllm/v1/core/sched/utils.py:105-119).

If the step produced more tokens than allowed (with spec decoding), _update_request_with_output trims the tokens after the stop and corrects num_computed_tokens (vllm/v1/core/sched/scheduler.py:2604-2610).

Stop strings are not checked here. The engine core has no text. The detokenizer in the front end checks them (stop 15).

EngineCoreOutputs.timestamp is time.monotonic() in the engine core process, set when the object is made (vllm/v1/engine/__init__.py:295-297). The front end uses it for the inter-token latency (Deep dive, timing).

15The front end detokenizes and fills the collector#

We are back in the API server process. A background task, EngineCoreOutputQueueTask, reads the PULL socket, decodes the msgpack and puts the EngineCoreOutputs into an asyncio queue (vllm/v1/engine/core_client.py:1208-1213, vllm/v1/engine/core_client.py:1253-1254). The output_handler task of AsyncLLM takes it from there:

        async def output_handler():
            try:
                while True:
                    # 1) Pull EngineCoreOutputs from the EngineCore.
                    outputs = await engine_core.get_output_async()
                    num_outputs = len(outputs.outputs)

                    iteration_stats = (
                        IterationStats() if (log_stats and num_outputs) else None
                    )

                    # Split outputs into chunks of at most
                    # VLLM_V1_OUTPUT_PROC_CHUNK_SIZE, so that we don't block the
                    # event loop for too long.
                    engine_core_outputs = outputs.outputs
                    for start in range(0, num_outputs, chunk_size):
                        end = start + chunk_size
                        outputs_slice = engine_core_outputs[start:end]
                        if mm_processor_cache is not None:
                            missing_hashes = [
                                mm_hash
                                for eco in outputs_slice
                                for mm_hash in eco.mm_cache_miss_hashes or ()
                            ]
                            # Mark before publishing errors so even queued
                            # renders invalidate before their next cache lookup.
                            renderer.queue_mm_cache_invalidations(missing_hashes)

                        # 2) Process EngineCoreOutputs.
                        processed_outputs = output_processor.process_outputs(
                            outputs_slice,
                            outputs.timestamp,
                            iteration_stats,
                            weight_version=outputs.weight_version,
                        )
                        # NOTE: RequestOutputs are pushed to their queues.
                        assert not processed_outputs.request_outputs

OutputProcessor.process_outputs is the only loop over all outputs of a step, as its docstring says (vllm/v1/engine/output_processor.py:747-754). For our request it detokenizes (incremental detokenization) and checks stop strings:

                # 2) Detokenize the token ids into text and perform stop checks.
                num_prev_tokens = req_state.detokenizer.num_output_tokens()
                stop_string = req_state.detokenizer.update(
                    new_token_ids, finish_reason == FinishReason.STOP
                )
                if stop_string:
                    finish_reason = FinishReason.STOP
                    stop_reason = stop_string
                    self._trim_surplus_tokens(
                        req_state, engine_core_output, num_prev_tokens
                    )

Then it makes a RequestOutput and puts it into the collector of our request:

            if request_output := req_state.make_request_output(
                new_token_ids,
                pooling_output,
                finish_reason,
                stop_reason,
                kv_transfer_params,
                ec_transfer_params,
                error=request_error,
            ):
                if streaming_chunk_boundary:
                    request_output.finished = False

                if req_state.queue is not None:
                    # AsyncLLM: put into queue for handling by generate().
                    req_state.queue.put(request_output)

The template tokens are already in the decoder: the fast detokenizer primes a tokenizers DecodeStream with the prompt ids (vllm/v1/engine/detokenizer.py:195-198). Each new id then gives the next piece of text (vllm/v1/engine/detokenizer.py:118-123). With DELTA output, the CompletionOutput carries only the new text (vllm/v1/engine/output_processor.py:484-487).

The collector wakes up the generate() loop of stop 4. If the producer is faster than the consumer, put() merges DELTA outputs into one, so no token gets lost (vllm/v1/engine/output_processor.py:69-81).

What the data looks like now (after step 1, illustrative):

RequestOutput(request_id="chatcmpl-3f9c...e1",            # the external id again
              prompt="<|im_start|>system...", prompt_token_ids=[61 ids],
              outputs=[CompletionOutput(index=0, text="The", token_ids=[785],
                                        finish_reason=None)],
              finished=False, num_cached_tokens=48)
Deep dive

Stop 15 in detail: stop strings, aborts and chunking

16The stream generator writes SSE chunks and data: [DONE]#

The generate() loop yields the RequestOutput to chat_completion_stream_generator_raw in serving.py. On the first output it sends one chunk with delta = {"role": "assistant", "content": ""} (vllm/entrypoints/openai/chat_completion/serving.py:590-596). Then, for each output, it skips empty outputs, runs the stream processor and builds a ChatCompletionStreamResponse (vllm/entrypoints/openai/chat_completion/serving.py:775-814):

                    delta_text = output.text

                    if (
                        not delta_text
                        and not output.token_ids
                        and not processor.num_output_tokens
                        and output.finish_reason is None
                    ):
                        # Chunked prefill case, don't return empty chunks
                        continue

                    delta_message, finish_reason = processor.step(
                        delta_text,
                        as_list(output.token_ids),
                        output.finish_reason,
                        res.prompt_token_ids,
                    )

Without a tool or reasoning parser, the stream processor only wraps the text: DeltaMessage(content=delta_text) (vllm/entrypoints/openai/chat_completion/stream_processor.py:73-74). The outer generator turns each chunk into one SSE event and ends with [DONE]:

        try:
            async for chunk in self.chat_completion_stream_generator_raw(
                request,
                result_generator,
                request_id,
                model_name,
                conversation,
                tokenizer,
                request_metadata,
                chat_template_kwargs=chat_template_kwargs,
                mm_token_counts=mm_token_counts,
            ):
                if isinstance(chunk, ErrorResponse):
                    data = chunk.model_dump_json()
                else:
                    data = chunk.model_dump_json(
                        exclude_unset=True, exclude_none=not chunk.choices
                    )
                yield f"data: {data}\n\n"
        except Exception as e:
            logger.exception("Error serializing chat completion stream.")
            data = self.create_streaming_error_response(e)
            yield f"data: {data}\n\n"
        yield "data: [DONE]\n\n"

Starlette writes each string to the HTTP response at once. So the client sees one SSE event per engine step. Then stops 9 to 16 repeat for the next token, until an output has a finish reason.

What the client sees (illustrative ids and times; "..." stands for the object, created and model fields of the first chunk):

data: {"id":"chatcmpl-3f9c...e1","object":"chat.completion.chunk","created":1761140000,"model":"Qwen/Qwen2.5-1.5B-Instruct","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],"prompt_token_ids":null,"prompt_text":null}

data: {"id":"chatcmpl-3f9c...e1",...,"choices":[{"index":0,"delta":{"content":"The"},"logprobs":null,"finish_reason":null,"token_ids":null}]}

...

data: {"id":"chatcmpl-3f9c...e1",...,"choices":[{"index":0,"delta":{"content":""},"logprobs":null,"finish_reason":"stop","stop_reason":null,"token_ids":null,"weight_versions":null}],"system_fingerprint":"vllm-<version>-<hash8>"}

data: [DONE]

The last chunk has empty content: the detokenizer does not decode the EOS token that stopped the request (vllm/v1/engine/detokenizer.py:107-111). The first chunk sets prompt_token_ids and prompt_text to None explicitly (vllm/entrypoints/openai/chat_completion/serving.py:602-614). The last chunk gets system_fingerprint when the server has one and usage is off (vllm/entrypoints/openai/chat_completion/serving.py:819-824).

Deep dive

Stop 16 in detail: usage chunk and the id the client sees

The loop: one engine step per output token#

Stops 9 to 16 repeat. Our request gets one step for the prompt (or more with chunked prefill) and then one step per new token. Each step gives one SSE chunk. The prefill step already samples the first token, so a request with 40 output tokens (EOS included) needs 40 engine steps. With async scheduling, one more step for the request is often already scheduled when it stops; the scheduler ignores its result.

Figure: one engine step per token, and how async scheduling overlaps the CPU work of step k+1 with the GPU work of step k.
Open full size ↗

The overlap. With async scheduling, the engine core does not wait between steps. It schedules and starts step k+1 first, and only then waits for the token of step k (stop 9). The scheduler can plan step k+1 because AsyncScheduler counts a placeholder for the token in flight (panel 3). The model runner then reads the real token from last_sampled_tokens on the GPU (stop 11). So the CPU work of scheduling and output handling hides behind the GPU work. DeepInfer uses the same idea: see Zero-overhead scheduling. The page Async scheduling: overlap the CPU and the GPU explains the vLLM version in full.

Deep dive#

Deep dive

Data types at each boundary

#ObjectMade inWhere it goesFormat
1request JSONclientFastAPI → ChatCompletionRequest (pydantic)HTTP body, JSON
2rendered prompt (text)safe_apply_chat_template (vllm/renderers/hf.py:872-876)tokenizerstr
3token ids + arrival_timerender_chat_async (vllm/renderers/base.py:1380-1411)AsyncLLM.generateEngineInput dict, list[int]
4EngineCoreRequestInputProcessor.process_inputs (vllm/v1/engine/input_processor.py:538-555)engine core processmsgspec struct → msgpack over ZMQ ROUTER/DEALER
5RequestRequest.from_engine_core_request (vllm/v1/request.py:244-270)schedulerPython object (engine core only)
6SchedulerOutputScheduler.schedule (vllm/v1/core/sched/scheduler.py:1588-1608)executor → worker → model runnerdataclass; plain call with TP = 1
7AsyncOutput → ModelRunnerOutputsample_tokens (vllm/v1/worker/gpu/model_runner.py:2195-2218)Scheduler.update_from_outputGPU tensors → async copy → list[list[int]]
8EngineCoreOutputsupdate_from_output (vllm/v1/core/sched/scheduler.py:2487-2490)front endmsgspec struct → msgpack over ZMQ PUSH/PULL
9RequestOutputOutputProcessor.process_outputs (vllm/v1/engine/output_processor.py:849-863)generate() → SSE generatorPython object in a RequestOutputCollector
10SSE eventchat_completion_stream_generator (vllm/entrypoints/openai/chat_completion/serving.py:497-508)clientdata: {JSON}\n\n, then data: [DONE]\n\n

The fields of SchedulerOutput are in vllm/v1/core/sched/output.py:232-313. A new request travels as NewRequestData with its prompt and block ids (vllm/v1/core/sched/output.py:41-56). In later steps it is in CachedRequestData, which carries only new block ids and counts (vllm/v1/core/sched/output.py:138-152). EngineCoreOutput carries new_token_ids, never text (vllm/v1/engine/__init__.py:203-247). The page Data contracts between the parts of vLLM explains each contract.

Deep dive

Timing: where TTFT and ITL are measured

The time to first token and the inter-token latency metrics of vLLM are measured in the front end, in IterationStats.update_from_output:

        if is_prefilling:
            if output.prefill_stats is not None:
                self.prompt_token_stats.update_from_output(output.prefill_stats)

            first_token_latency = self._time_since(req_stats.arrival_time)
            self.time_to_first_tokens_iter.append(first_token_latency)
            req_stats.first_token_latency = first_token_latency
        # Process the batch-level "new tokens" engine core event
        if is_prefilling:
            req_stats.first_token_ts = engine_core_timestamp
        else:
            itl = engine_core_timestamp - req_stats.last_token_ts
            self.inter_token_latencies_iter.append(itl)

        req_stats.last_token_ts = engine_core_timestamp
  1. TTFT start: arrival_time = time.time() in render_chat_async (stop 3, vllm/renderers/base.py:1380). So it does not include the HTTP transfer, the JSON parse and the model check before rendering.
  2. TTFT end: iteration_timestamp, the wall-clock time when the output handler makes the IterationStats object for a batch of outputs (vllm/v1/metrics/stats.py:477-478, vllm/v1/engine/async_llm.py:883-885). That is after ZMQ, before detokenization and before the SSE write.
  3. ITL: the difference between the EngineCoreOutputs.timestamp values of two outputs. These are time.monotonic() values from the engine core process (vllm/v1/engine/__init__.py:295-297).

A client measures TTFT from its own send time to the first content chunk. Its value is larger: it adds the network, the HTTP parse, the work before arrival_time (model check, _preflight), the detokenization and the SSE write. Remember also that the first SSE chunk only has the role; the first text comes in the same RequestOutput (stop 16). The page Serving metrics: TTFT, TPOT, ITL, throughput, goodput defines the metrics, and Metrics and observability: how to watch a vLLM server lists the Prometheus names.

The stats exist only when log_stats is true. disable_log_stats is False by default (vllm/engine/arg_utils.py:624).

Deep dive

What changes with TP > 1

With --tensor-parallel-size 2, the world size is 2, so the executor is "mp" on one node (vllm/config/parallel.py:1055-1096). Each GPU gets its own worker process. Stops 1 to 10 and 14 to 16 do not change. Stop 11 changes: the engine core does not call the worker directly. MultiprocExecutor.execute_model sends the call to all workers through a shared-memory message queue (rpc_broadcast_mq.enqueue, vllm/v1/executor/multiproc_executor.py:342-353, vllm/v1/executor/multiproc_executor.py:428). Only one worker replies, the first TP rank of the last PP stage (vllm/v1/executor/multiproc_executor.py:549-560). The workers exchange activations with all-reduce inside the forward pass (tensor parallelism). The tour Tour: One step across GPUs follows this path.

Deep dive

What changes with DP > 1

With --data-parallel-size 2, there are two engine core processes, one per DP rank. The front end then uses DPLBAsyncMPClient (internal load balancing) or DPAsyncMPClient (external load balancer) (vllm/v1/engine/core_client.py:158-169). For each new request, DPLBAsyncMPClient picks the engine with the lowest score. The score is the larger of two counts: waiting plus running requests, and the in-flight requests of this client. Waiting requests add a penalty when the KV cache usage is above 50% (vllm/v1/engine/core_client.py:1763-1789). An X-data-parallel-rank header can force a rank (vllm/entrypoints/generate/base/serving.py:215-227). For a dense model the DP ranks are independent. For an MoE model the engine core is a DPEngineCoreProc, which keeps the ranks in step (vllm/v1/engine/core.py:1537-1547). See Data parallel serving: several engine cores behind one endpoint and Tour: One step across GPUs.

Deep dive

What changes with more API servers

By default api_server_count is the DP size with internal load balancing. It is the local DP size with hybrid load balancing, and 1 with an external load balancer (vllm/entrypoints/cli/serve.py:113-130). With more than one, run_multi_api_server starts several front-end processes that share one listen socket with reuse_port (vllm/entrypoints/cli/serve.py:269, vllm/entrypoints/cli/serve.py:298). So stops 1 to 6 and 15 to 16 run in one of N processes. The engine core opens one input socket and one output socket for each front end (vllm/v1/engine/core.py:1936-1943, vllm/v1/engine/core.py:2047-2052). It sends each output to the socket of the client that sent the request: client_index selects the socket (vllm/v1/engine/core.py:2089-2091). So each front end detokenizes only its own requests, and N processes share this work.

Deep dive

Cancellation: when the client disconnects

The client can close the connection at any time. Two mechanisms find out:

  1. Before the route returns, with_cancellation runs the handler and listen_for_disconnect as two tasks and cancels the other when one ends (vllm/entrypoints/serve/utils/api_utils.py:93-109).
  2. After the route returns a StreamingResponse, the response itself listens for the disconnect. The docstring of with_cancellation says this (vllm/entrypoints/serve/utils/api_utils.py:85-87). The stream generator is then stopped.

When the stream stops, AsyncLLM.generate() gets CancelledError or GeneratorExit and calls abort(q.request_id, internal=True) (vllm/v1/engine/async_llm.py:807-812). abort removes the request from the output processor and sends ABORT to the engine core (vllm/v1/engine/async_llm.py:948-956, vllm/v1/engine/core_client.py:1338-1340). In the engine core, abort_requests calls finish_requests(..., FINISHED_ABORTED), which frees the blocks (vllm/v1/engine/core.py:554-560). The engine core also checks aborts_queue before each update_from_output, so an abort that arrives during the forward pass acts at once (vllm/v1/engine/core.py:780-782).

Deep dive

Errors

WhenWhat happensWhat the client gets
Invalid JSON or fieldsFastAPI RequestValidationErrorHTTP 400 (vllm/entrypoints/serve/exception_handling/register.py:29)
Wrong model name, bad template, bad tool choicethe serving class returns an ErrorResponseJSON error with its code (vllm/entrypoints/openai/chat_completion/api_router.py:69-72)
Prompt longer than max_model_lenthe length check of the renderer raises VLLMValidationError when it tokenizes the prompt (stop 3, vllm/renderers/params.py:518-538)HTTP 400, by the VLLMError handler (vllm/entrypoints/serve/exception_handling/register.py:32, vllm/entrypoints/serve/exception_handling/error_response.py:43-46)
Queue full (max_num_queued_reqs)QueueOverflowError in check_admissionHTTP 503 (vllm/v1/engine/async_llm.py:328-329)
Error after the stream startedthe raw generator catches it and yields an ErrorResponsea data: {error JSON} chunk, then data: [DONE] (vllm/entrypoints/openai/chat_completion/serving.py:941-943, vllm/entrypoints/openai/chat_completion/serving.py:497-498, vllm/entrypoints/openai/chat_completion/serving.py:508)
Request-level error in the engine (finish reason error)_raise_if_error raises GenerationErroran error chunk with its status code (vllm/entrypoints/openai/chat_completion/serving.py:937-940)
Engine core process diesthe output task puts the exception in the queue; output_handler sends it to every collector (vllm/v1/engine/core_client.py:1255-1258, vllm/v1/engine/output_processor.py:593-597)every open stream gets an error chunk; new requests fail in _preflight

What can go wrong#

  • Slow first token under load. The new request waits in the waiting queue until running requests leave room in the token budget and the KV cache (stop 10). Watch the waiting count and the cache usage; see Tuning vLLM: throughput and latency knobs, their real defaults and what each one does.
  • A long prompt delays all streams. With chunked prefill, a long prompt takes a large part of the token budget for several steps. Every running request then gets its next token later. long_prefill_token_threshold limits the tokens of one prompt per step (vllm/v1/core/sched/scheduler.py:764-765).
  • Preemption. When blocks run out, the scheduler preempts a running request and computes it again later (vllm/v1/core/sched/scheduler.py:850-858). The stream of that request pauses.
  • Chat template timeout. A very slow template fails after VLLM_CHAT_TEMPLATE_RENDER_TIMEOUT seconds (default 30, vllm/renderers/hf.py:1044-1056).
  • Prompt too long. HTTP 400 "This model's maximum context length is ... tokens. However, your prompt contains ... input tokens" (vllm/renderers/params.py:524-538).
  • Busy front end. One front end does rendering, detokenization and JSON for all streams in one event loop. At high load it can be the bottleneck; more API servers share this work (Deep dive).

Why it matters. Most questions about a slow or broken stream start with "which stop is it?". A slow first token is often a scheduling question (stop 10). A slow stream with a fast GPU can be the front end (stops 15 and 16). A missing token can be a stop-string question (stop 15). With the map of this tour you can open the right file at once.

Check yourself#

  1. 1
    In which process does the chat template run, and in which thread?
    easy
    Show answer

    In the API server process (the front end), in a thread pool. HfRenderer wraps safe_apply_chat_template with make_async(..., executor=self._executor) (vllm/renderers/hf.py:1011-1013). So the template does not block the asyncio event loop.

  2. 2
    Which two objects cross the ZMQ sockets, and how are they encoded?
    easy
    Show answer

    EngineCoreRequest goes from the front end to the engine core, and EngineCoreOutputs comes back. Both are msgspec structs with array_like=True, encoded as msgpack (vllm/v1/engine/__init__.py:113-118, vllm/v1/engine/__init__.py:266-271, vllm/v1/engine/core_client.py:731-732).

  3. 3
    The client gets HTTP 200, but the stream holds only an error and data: [DONE]. How can this happen?
    medium
    Show answer

    The route returns the StreamingResponse before AsyncLLM.generate() runs, because engine_client.generate(...) only makes an async generator (stop 2). The status line is already sent when the request reaches the engine. Later errors become data: chunks, followed by data: [DONE] (vllm/entrypoints/openai/chat_completion/serving.py:937-943, vllm/entrypoints/openai/chat_completion/serving.py:497-508).

  4. 4
    With async scheduling, how can the scheduler schedule the next decode step of our request before it knows the token of the current step? And where does the model runner get that token?
    hard
    Show answer

    AsyncScheduler adds a placeholder per token in flight (num_output_placeholders, vllm/v1/core/sched/async_scheduler.py:45-47). The running pass computes num_new_tokens = num_tokens_with_spec + num_output_placeholders - num_computed_tokens, which is 1 (vllm/v1/core/sched/scheduler.py:759-763). The model runner reads the real token from req_states.last_sampled_tokens on the GPU in combine_sampled_and_draft_tokens (vllm/v1/worker/gpu/model_runner.py:1493-1500). postprocess_sampled wrote it there in the step before (vllm/v1/worker/gpu/model_runner.py:1715-1726).

  5. 5
    A user measures TTFT in the client as 180 ms, but the vLLM TTFT metric says 120 ms. Name three parts of the 60 ms that the vLLM metric does not include.
    hard
    Show answer

    The vLLM metric starts at arrival_time = time.time() in render_chat_async (vllm/renderers/base.py:1380) and ends when the output handler makes IterationStats for the batch with the first token (vllm/v1/metrics/stats.py:525, vllm/v1/engine/async_llm.py:883-885). It leaves out the network and the HTTP parse before the route, and the model check before rendering. It also leaves out the work after that moment: detokenization (stop 15), the JSON and the SSE write (stop 16), and the network.