Tour: Life of a chat request
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:
EngineCoreRequestone way,EngineCoreOutputsthe 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#
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).
This interactive visual needs JavaScript. Turn on JavaScript in your browser to use it. The text on this page explains the same idea.
| Stop | What happens | Where (file and function) | Runs in |
|---|---|---|---|
| 1 | The route receives the request | openai/chat_completion/api_router.py: create_chat_completion | front end |
| 2 | The serving class makes sampling parameters and a generator | openai/chat_completion/serving.py: _create_chat_completion | front end |
| 3 | The chat template and the tokenizer make token ids | renderers/base.py: render_chat_async; renderers/hf.py | front end, thread pool |
| 4 | The stream starts and calls AsyncLLM.generate() | v1/engine/async_llm.py: generate | front end |
| 5 | The input processor makes an EngineCoreRequest | v1/engine/input_processor.py: process_inputs | front end |
| 6 | The front end registers the request and sends it over ZMQ | async_llm.py: _add_request; core_client.py: add_request_async | front end |
| 7 | The input thread decodes the request | v1/engine/core.py: process_input_sockets | engine core, input thread |
| 8 | The busy loop gives it to the scheduler | core.py: run_busy_loop; Scheduler.add_request | engine core, main thread |
| 9 | One engine step with the batch queue | core.py: step_with_batch_queue | engine core, main thread |
| 10 | The scheduler gives tokens and KV blocks | v1/core/sched/scheduler.py: schedule | engine core, main thread |
| 11 | Model Runner V2 prepares the inputs | v1/worker/gpu/model_runner.py: execute_model | engine core and GPU |
| 12 | The forward pass | model_runner.py: execute_model | GPU |
| 13 | Logits, sampler and the copy back | model_runner.py: sample_tokens | GPU and engine core |
| 14 | The scheduler appends the token and checks for stop; the output thread sends it | scheduler.py: update_from_output; core.py: process_output_sockets | engine core |
| 15 | The front end detokenizes and fills the collector | async_llm.py: output_handler; v1/engine/output_processor.py | front end |
| 16 | The stream generator writes SSE chunks and data: [DONE] | serving.py: chat_completion_stream_generator | front 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",
)- Line 57:
@with_cancellationruns the handler as an asyncio task and watches for a client disconnect at the same time (see Deep dive, cancellation). - Line 63:
chat(raw_request)returnsapp.state.openai_serving_chat(vllm/entrypoints/openai/chat_completion/api_router.py:35-36). The server makes thisOpenAIServingChatobject at start-up, if the model supports the"generate"task (vllm/entrypoints/generate/api_router.py:149-151). - Line 67: the serving class returns one of three things: an
ErrorResponse, a fullChatCompletionResponse(no streaming), or an async generator of strings (streaming). - Lines 82-85: for our request it is a generator. The route wraps it in a
StreamingResponsewith the media typetext/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 diveStop 1 in detail: decorators, keep-alive and the request id
@load_aware_callcounts requests inapp.state.server_load_metrics, but only when server load tracking is on (vllm/entrypoints/serve/utils/api_utils.py:186-230).with_sse_keep_alivereturns the generator unchanged when the interval is not positive. Else it sends an SSE comment when no chunk comes inintervalseconds (vllm/entrypoints/serve/utils/sse_keep_alive.py:21-33). The route reads the interval fromargs.sse_keep_alive_interval, with 0 as the fallback (vllm/entrypoints/openai/chat_completion/api_router.py:80-81).- The request id field has a default:
default_factory=random_uuid(vllm/entrypoints/openai/chat_completion/protocol.py:403-410). AnX-Request-Idheader wins over it (vllm/entrypoints/serve/engine/serving.py:133-143). - The streaming response format is OpenAPI-documented as SSE events whose
datais a JSON chunk, a JSON error or"[DONE]"(vllm/entrypoints/serve/utils/api_utils.py:117-142).
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,
)- Line 314:
render_chat_requestfirst checks the model name and runs_preflight, then calls the renderer (vllm/entrypoints/openai/chat_completion/serving.py:278-285). - Lines 320-322: the request id is
"chatcmpl-" + request_id. - Line 349:
get_max_tokenstakes the smallest ofmax_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 nomax_tokens. So, if the generation config of the model sets no default either, the model length limits the answer. - Line 366:
to_sampling_paramssetsoutput_kind = DELTAbecausestreamis true (vllm/entrypoints/openai/chat_completion/protocol.py:797-799). DELTA means: each output carries only the new tokens. - Line 396:
self.engine_client.generate(...)isAsyncLLM.generate. It is an async generator function, so this call does not run it. It only makes the generator object. - 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 diveStop 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- Line 1380:
arrival_timeis set here. The TTFT metric of vLLM starts at this moment (see Deep dive, timing). - Line 1386:
render_messages_asyncapplies the chat template. - Line 1396:
tokenize_prompts_asyncturns the text into ids. - Line 1404:
process_for_engine_asyncmakes the engine input and storesarrival_timein 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 diveStop 3 in detail: template resolution, timeout and errors
safe_apply_chat_templateresolves the template in four steps (vllm/renderers/hf.py:264-319). The order is: the given template (trusted request or--chat-template), the processor template (no tools only), the tokenizer template, a built-in fallback. With no template it raisesChatTemplateResolutionError(vllm/renderers/hf.py:836-847). Details: Renderers and chat templates: from messages to token ids.- A
developermessage becomes asystemmessage when the template does not know that role (vllm/renderers/hf.py:848-856). - The render timeout is
VLLM_CHAT_TEMPLATE_RENDER_TIMEOUT, default 30 seconds (vllm/envs.py:817-818). On a timeout the renderer replaces its thread pool, because the stuck thread cannot be stopped (vllm/renderers/hf.py:1038-1056). - The pool size is
model_config.renderer_num_workers. Multimodal work has its own one-thread pool, so tokenization never waits behind image processing (vllm/renderers/base.py:140-150). - The ChatML example template adds
<|im_end|>after each message and<|im_start|>assistantat the end whenadd_generation_promptis true (examples/template_chatml.jinja:1-2).
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- Line 773:
add_requestprocesses the input and sends it to the engine core (stops 5 and 6). It returns a RequestOutputCollector, here calledq. - Line 795: the loop takes the next output from the collector.
get_nowait()avoids a task switch when an output is already there. - Line 802: each
RequestOutputgoes up to the SSE generator (stop 16). The loop ends when an output hasfinished = 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 diveStop 4 in detail: how generate() ends
generate() has one except branch per kind of failure (vllm/v1/engine/async_llm.py:807-851):
| Exception | What generate() does |
|---|---|
CancelledError, GeneratorExit (client gone) | aborts the request in the front end and in the engine core, then raises again |
EngineDeadError | raises; no abort, because the engine is gone |
VLLMClientError, GracefulHTTPError, GenerationError | aborts, then raises |
InputStreamError | aborts, then raises the cause |
any other Exception | aborts, 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:
- It clones the sampling parameters, so a shared object of the caller does not change (
vllm/v1/engine/input_processor.py:447-449). - It adds the generation config and the EOS token id of the model to the sampling parameters (
vllm/v1/engine/input_processor.py:489-494). - It takes
arrival_timefrom the engine input of stop 3 (vllm/v1/engine/input_processor.py:403-404).
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 diveStop 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:
| Field | Type | Our value |
|---|---|---|
request_id | str | internal id with 8 random characters |
prompt_token_ids | list[int] | None | 61 ids |
sampling_params | SamplingParams | None | DELTA output, max_tokens from stop 2 |
arrival_time | float | wall-clock time from stop 3 |
client_index | int | 0 (one API server) |
data_parallel_rank | int | None | None (from the X-data-parallel-rank header if present) |
external_req_id | str | None | the 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 diveStop 6 in detail: the sockets and the encoder
- The front end binds a ZMQ
ROUTERsocket for input and aPULLsocket for output (vllm/v1/engine/core_client.py:670-682). With all engines on this machine, the addresses areipc://paths; else they are TCP (vllm/v1/engine/utils.py:1139-1151). - The engine identity is the DP rank as 2 little-endian bytes (
vllm/v1/engine/core_client.py:749-751). The ROUTER socket uses the first frame to select the engine. - The request types are single bytes:
ADD = b"\x00",ABORT = b"\x01",UTILITY = b"\x03"(vllm/v1/engine/__init__.py:300-313). MsgpackEncoder.encodereturns a list of buffers. The first is the msgpack message. Large tensors and arrays go as extra frames without a copy (vllm/v1/serial_utils.py:135-177). Our request has no tensors, so it is one frame.- The front end uses
MsgpackEncoderfor requests andMsgpackDecoder(EngineCoreOutputs)for outputs (vllm/v1/engine/core_client.py:731-732). - The output processor makes a
RequestStatewith an incremental detokenizer and the collector queue (vllm/v1/engine/output_processor.py:663-693).
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))- Line 1980: one message: the type frame and the data frames.
- Line 1991:
add_request_decoderis aMsgpackDecoder(EngineCoreRequest)(vllm/v1/engine/core.py:1930-1932), so the result is a typed struct again. - Line 1993:
preprocess_add_requestmakes the engine's ownRequestobject in this thread, so it runs in parallel with the model (vllm/v1/engine/core.py:1114-1136). - Line 2028: the thread puts
(ADD, (request, wave))intoinput_queuefor 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 diveStop 7 in detail: hashes, failures and the ready message
- The block hasher exists when prefix caching is on or a KV connector is set. Prefix caching is on by default for a decoder-only generative model: the engine arguments use
model_config.is_prefix_caching_supported(vllm/engine/arg_utils.py:3062-3063,vllm/config/model.py:2289-2291). The hash algorithm isprefix_caching_hash_algo, default"sha256"(vllm/config/cache.py:256,vllm/v1/engine/core.py:216-225). - The default block size is 16 (
DEFAULT_BLOCK_SIZE,vllm/config/cache.py:182). The attention backend can choose another size (vllm/platforms/interface.py:771-781). - If preprocessing fails, the thread does not crash. A validation error and any other error each have a handler, and the thread continues with the next message (
vllm/v1/engine/core.py:1999-2008). - At start-up each input socket first sends a ready message. The ROUTER of the front end can only send to a DEALER that it has heard from (
vllm/v1/engine/core.py:1961-1968). - An ABORT message goes into
aborts_queueand also intoinput_queue, so an abort can act early and still stay in order (vllm/v1/engine/core.py:2020-2025).
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 diveStop 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(- Line 725: the scheduler makes a SchedulerOutput (stop 10).
- Lines 727-729: the executor runs
execute_modelwithnon_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_blockonly moves the wait for the GPU result into a future (vllm/v1/executor/uniproc_executor.py:108-117). - Lines 743-745: the executor runs
sample_tokens, also non-blocking (stop 13). - Lines 753 and 759: the step goes into the queue. If the queue is not full, the function returns with no outputs.
- Lines 768-773: else it takes the oldest step and waits for its result.
- Line 783:
update_from_outputturns 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_tokensSo 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_tokensThe 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 diveStop 10 in detail: budget defaults, chunking and the async placeholders
- Budget default. For
vllm serveon a GPU with 70 GiB or more (not an A100 and not a GB10), the defaults aremax_num_batched_tokens = 8192andmax_num_seqs = 1024. On a GPU with 160 GiB or more they are 16384 and 1024. On other GPUs they are 2048 and 256 (vllm/engine/arg_utils.py:2960-2993).--performance-mode throughputdoubles the values that you did not set (vllm/engine/arg_utils.py:3171-3175). - Chunked prefill. It is on by default for a generative model that is not encoder-decoder: the engine arguments use
model_config.is_chunked_prefill_supported(vllm/engine/arg_utils.py:3031-3035,vllm/config/model.py:2229-2236). A long prompt then gets only the rest of the budget in this step (line 1231), and the rest in the next steps. With chunked prefill off, a prompt that does not fit stops the waiting pass (vllm/v1/core/sched/scheduler.py:1220-1229). - Running requests. In later steps our request is in the running pass. There,
num_new_tokens = num_tokens_with_spec + num_output_placeholders - num_computed_tokens(vllm/v1/core/sched/scheduler.py:759-763). - Placeholders.
AsyncScheduleradds one placeholder for each token in flight. It does this after it schedules a step that samples a token: a decode step, or the last chunk of a prefill (vllm/v1/core/sched/async_scheduler.py:32-47). It removes the placeholder when the token arrives (vllm/v1/core/sched/async_scheduler.py:71-73). So the next step can schedule one token before the CPU knows the token of this step. - Preemption. If a running request cannot get blocks, the scheduler preempts the last running request (FCFS) or the lowest-priority one (
vllm/v1/core/sched/scheduler.py:850-858). See preemption and The V1 scheduler: one token budget, no phases.
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 diveStop 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)- Line 2032:
FULLmode replays a captured CUDA graph of the whole model. The comment says the inputs are already in the graph's input buffers. - Line 2041: the other modes run inside
set_forward_context(...). The forward context gives each attention layer its metadata and slot mapping. - Line 2065:
PIECEWISEruns a graph with the attention parts outside of it. - Line 2070:
NONEcalls 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, stoppedcheck_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 TrueIf 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 diveStop 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_outputsOutputProcessor.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 diveStop 15 in detail: stop strings, aborts and chunking
- If the detokenizer finds a stop string, the finish reason becomes
STOPand surplus tokens are trimmed (vllm/v1/engine/output_processor.py:828-833). The engine core does not know this yet, so the request id goes intoreqs_to_abort(vllm/v1/engine/output_processor.py:876-882), and the output handler sends an ABORT (vllm/v1/engine/async_llm.py:918-922). - The handler processes at most
VLLM_V1_OUTPUT_PROC_CHUNK_SIZEoutputs (default 128) at a time, and yields the event loop between chunks (vllm/envs.py:1474-1476,vllm/v1/engine/async_llm.py:887-893,vllm/v1/engine/async_llm.py:914-916). - With
stream_interval > 1,make_request_outputreturnsNoneuntil enough tokens are there, except for the first and the last output (vllm/v1/engine/output_processor.py:379-398). - An output for a request that is already aborted is ignored (
vllm/v1/engine/output_processor.py:760-764).
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 diveStop 16 in detail: usage chunk and the id the client sees
- The chunk
idis the"chatcmpl-..."id of stop 2, not the internal id with the random suffix. The output processor puts the external id back intoRequestOutput(vllm/v1/engine/output_processor.py:402,vllm/v1/engine/output_processor.py:460). - With
stream_options: {"include_usage": true}(or--enable-force-include-usage), one more chunk withchoices: []andusagecomes before[DONE](vllm/entrypoints/openai/chat_completion/serving.py:854-903). - The final chunk has
finish_reasonandstop_reason(vllm/entrypoints/openai/chat_completion/serving.py:790-795). For an EOS token,stop_reasonisNone; for a stop token id, it is that id (vllm/v1/core/sched/utils.py:105-112). A finish reason"error"raisesGenerationError(vllm/entrypoints/generate/base/serving.py:200-207), which becomes an error chunk (vllm/entrypoints/openai/chat_completion/serving.py:937-940). - The serialization is
model_dump_json(exclude_unset=True, exclude_none=not chunk.choices)(lines 500-502). So only the fields that the code sets appear, and a field set toNone(for exampletoken_ids) appears asnullin chunks with choices.
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.
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 diveData types at each boundary
| # | Object | Made in | Where it goes | Format |
|---|---|---|---|---|
| 1 | request JSON | client | FastAPI → ChatCompletionRequest (pydantic) | HTTP body, JSON |
| 2 | rendered prompt (text) | safe_apply_chat_template (vllm/renderers/hf.py:872-876) | tokenizer | str |
| 3 | token ids + arrival_time | render_chat_async (vllm/renderers/base.py:1380-1411) | AsyncLLM.generate | EngineInput dict, list[int] |
| 4 | EngineCoreRequest | InputProcessor.process_inputs (vllm/v1/engine/input_processor.py:538-555) | engine core process | msgspec struct → msgpack over ZMQ ROUTER/DEALER |
| 5 | Request | Request.from_engine_core_request (vllm/v1/request.py:244-270) | scheduler | Python object (engine core only) |
| 6 | SchedulerOutput | Scheduler.schedule (vllm/v1/core/sched/scheduler.py:1588-1608) | executor → worker → model runner | dataclass; plain call with TP = 1 |
| 7 | AsyncOutput → ModelRunnerOutput | sample_tokens (vllm/v1/worker/gpu/model_runner.py:2195-2218) | Scheduler.update_from_output | GPU tensors → async copy → list[list[int]] |
| 8 | EngineCoreOutputs | update_from_output (vllm/v1/core/sched/scheduler.py:2487-2490) | front end | msgspec struct → msgpack over ZMQ PUSH/PULL |
| 9 | RequestOutput | OutputProcessor.process_outputs (vllm/v1/engine/output_processor.py:849-863) | generate() → SSE generator | Python object in a RequestOutputCollector |
| 10 | SSE event | chat_completion_stream_generator (vllm/entrypoints/openai/chat_completion/serving.py:497-508) | client | data: {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 diveTiming: 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- TTFT start:
arrival_time = time.time()inrender_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. - TTFT end:
iteration_timestamp, the wall-clock time when the output handler makes theIterationStatsobject 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. - ITL: the difference between the
EngineCoreOutputs.timestampvalues of two outputs. These aretime.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 diveWhat 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 diveWhat 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 diveWhat 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 diveCancellation: when the client disconnects
The client can close the connection at any time. Two mechanisms find out:
- Before the route returns,
with_cancellationruns the handler andlisten_for_disconnectas two tasks and cancels the other when one ends (vllm/entrypoints/serve/utils/api_utils.py:93-109). - After the route returns a
StreamingResponse, the response itself listens for the disconnect. The docstring ofwith_cancellationsays 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 diveErrors
| When | What happens | What the client gets |
|---|---|---|
| Invalid JSON or fields | FastAPI RequestValidationError | HTTP 400 (vllm/entrypoints/serve/exception_handling/register.py:29) |
| Wrong model name, bad template, bad tool choice | the serving class returns an ErrorResponse | JSON error with its code (vllm/entrypoints/openai/chat_completion/api_router.py:69-72) |
Prompt longer than max_model_len | the 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_admission | HTTP 503 (vllm/v1/engine/async_llm.py:328-329) |
| Error after the stream started | the raw generator catches it and yields an ErrorResponse | a 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 GenerationError | an error chunk with its status code (vllm/entrypoints/openai/chat_completion/serving.py:937-940) |
| Engine core process dies | the 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_thresholdlimits 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_TIMEOUTseconds (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#
- 1In 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.
HfRendererwrapssafe_apply_chat_templatewithmake_async(..., executor=self._executor)(vllm/renderers/hf.py:1011-1013). So the template does not block the asyncio event loop. - 2Which two objects cross the ZMQ sockets, and how are they encoded?easy
Show answer
EngineCoreRequestgoes from the front end to the engine core, andEngineCoreOutputscomes back. Both aremsgspecstructs witharray_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). - 3The client gets HTTP 200, but the stream holds only an error andmedium
data: [DONE]. How can this happen?Show answer
The route returns the
StreamingResponsebeforeAsyncLLM.generate()runs, becauseengine_client.generate(...)only makes an async generator (stop 2). The status line is already sent when the request reaches the engine. Later errors becomedata:chunks, followed bydata: [DONE](vllm/entrypoints/openai/chat_completion/serving.py:937-943,vllm/entrypoints/openai/chat_completion/serving.py:497-508). - 4With 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
AsyncScheduleradds a placeholder per token in flight (num_output_placeholders,vllm/v1/core/sched/async_scheduler.py:45-47). The running pass computesnum_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 fromreq_states.last_sampled_tokenson the GPU incombine_sampled_and_draft_tokens(vllm/v1/worker/gpu/model_runner.py:1493-1500).postprocess_sampledwrote it there in the step before (vllm/v1/worker/gpu/model_runner.py:1715-1726). - 5A 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()inrender_chat_async(vllm/renderers/base.py:1380) and ends when the output handler makesIterationStatsfor 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.