v1/engine/async_llm.py: AsyncLLM, the engine client of vllm serve
Code version: vLLM @ 3e44c0d · 2026-10-22
Purpose#
vllm/v1/engine/async_llm.py defines AsyncLLM: the object that the API server uses as its EngineClient. It accepts requests, sends them to the engine core process, and streams the results back as async generators.
| File | vllm/v1/engine/async_llm.py, 1,445 lines of Python. One exception class and one large class. |
|---|---|
| Main class | AsyncLLM(EngineClient) (vllm/v1/engine/async_llm.py:93). The old name AsyncLLMEngine is only an alias (vllm/engine/async_llm_engine.py:4-6). |
| Runs in | The API server process (the front end), on its asyncio event loop. Raw-prompt preprocessing runs in the renderer's thread pool. |
| Called by | The API server start-up (from_vllm_config), the serving classes (generate, encode, abort, check_admission), the health route and the dev routes. |
| Calls | The renderer, InputProcessor, OutputProcessor, the engine core client (AsyncMPClient or a DP variant), StatLoggerManager. |
| Not in this file | Sockets and message framing (core_client.py), detokenization and stop strings (output_processor.py, detokenizer.py), parameter checks (input_processor.py), the HTTP routes (vllm/entrypoints/), the offline LLMEngine (llm_engine.py). |
The page The engine client: AsyncLLM in the API server explains the ideas. This page reads the file method by method.
Where it sits#
Who calls it#
- API server start-up.
build_async_engine_client_from_engine_argscallsAsyncLLM.from_vllm_configwith the client index and count of this API server (vllm/entrypoints/launchers/api_server/entry.py:89-103). The gRPC server does the same (vllm/entrypoints/launchers/grpc_server.py:77-82). - Serving classes. The chat handler calls
generate()(vllm/entrypoints/openai/chat_completion/serving.py:396-408). Before any response starts,_preflightreadserroredand callscheck_admission(vllm/entrypoints/generate/base/serving.py:179-181). - Health and errors.
/healthcallscheck_health()(vllm/entrypoints/serve/instrumentator/health.py:29-33). The watchdog readserroredandis_running(vllm/entrypoints/launchers/launcher.py:325-337). - Dev routes (only with
VLLM_SERVER_DEV_MODE):/sleepcallssleep(),/wake_upcallswake_up()(vllm/entrypoints/serve/dev/sleep/api_router.py:26-55).
What it calls#
renderer_from_config,InputProcessor,OutputProcessorandEngineCoreClient.make_async_mp_clientin the constructor (vllm/v1/engine/async_llm.py:167-201).load_stat_logger_plugin_factoriesandStatLoggerManager(vllm/v1/engine/async_llm.py:155-156,vllm/v1/engine/async_llm.py:204-214).ParentRequestforn > 1(vllm/v1/engine/async_llm.py:542).- The
*_asyncmethods of the engine core client for every request and control call, for exampleadd_request_async,abort_requests_async,sleep_async(vllm/v1/engine/async_llm.py:584,vllm/v1/engine/async_llm.py:956,vllm/v1/engine/async_llm.py:1199).
Key classes and functions#
| Name | Line | What it does |
|---|---|---|
InputStreamError | vllm/v1/engine/async_llm.py:81-90 | Wraps an error of a streaming-input generator, so generate() can raise the cause. |
AsyncLLM.__init__ | vllm/v1/engine/async_llm.py:96-247 | Builds renderer, processors, engine core client, loggers; may start the output handler. |
from_vllm_config / from_engine_args | vllm/v1/engine/async_llm.py:249-300 | Pick the executor class and call the constructor. |
shutdown | vllm/v1/engine/async_llm.py:305-317 | Stops Prometheus, the renderer, the engine core client and the output handler. |
check_admission | vllm/v1/engine/async_llm.py:325-383 | Rejects a request with 503 when a queue limit is full. |
add_request | vllm/v1/engine/async_llm.py:397-565 | Makes the EngineCoreRequest and the collector; fans out n > 1. |
_add_request | vllm/v1/engine/async_llm.py:567-587 | Registers in the output processor, then sends over ZMQ. |
_add_streaming_input_request | vllm/v1/engine/async_llm.py:589-696 | Input streaming: one request fed by an async generator of prompt chunks. |
generate | vllm/v1/engine/async_llm.py:726-854 | The async generator that the serving classes iterate. |
_run_output_handler | vllm/v1/engine/async_llm.py:856-946 | Starts the one background task that processes all engine outputs. |
abort | vllm/v1/engine/async_llm.py:948-959 | Aborts in the output processor and in the engine core. |
pause_generation / resume_generation | vllm/v1/engine/async_llm.py:989-1028 | Pause modes abort, wait, keep, for weight updates. |
encode | vllm/v1/engine/async_llm.py:1034-1113 | Like generate, for pooling models. |
check_health | vllm/v1/engine/async_llm.py:1129-1134 | Raises EngineDeadError when errored. |
sleep, wake_up, LoRA, cache resets | vllm/v1/engine/async_llm.py:1179-1245 | Thin forwarders to utility calls of the engine core client. |
collective_rpc | vllm/v1/engine/async_llm.py:1247-1257 | Runs a method on every worker, through the engine core. |
is_running, errored, dead_error | vllm/v1/engine/async_llm.py:1373-1392 | The state that the server checks. |
Walk through the main methods#
__init__: build the front-end parts#
self.renderer = renderer = renderer_from_config(self.vllm_config)
# Convert EngineInput --> EngineCoreRequest.
self.input_processor = InputProcessor(self.vllm_config, renderer)
self.admission_stats = (
SharedAdmissionStats(client_addresses, client_count, client_index)
if client_addresses is not None
and "mp_admission_counters" in client_addresses
else None
)
# Converts EngineCoreOutputs --> RequestOutput.
self.output_processor = OutputProcessor(
renderer.tokenizer,
log_stats=self.log_stats,
stream_interval=self.vllm_config.scheduler_config.stream_interval,
tracing_enabled=tracing_endpoint is not None,
admission_stats=self.admission_stats,
)
# EngineCore (starts the engine in background process).
# Hand the renderer to the client so it can start the frontend MM
# warmup only after engine-core fork (the why is in
# BaseRenderer.start_mm_warmup_in_background). The warmup is joined
# by reset_mm_cache / warmup / shutdown.
self.engine_core = EngineCoreClient.make_async_mp_client(
vllm_config=vllm_config,
executor_class=executor_class,
log_stats=self.log_stats,
client_addresses=client_addresses,
client_count=client_count,
client_index=client_index,
renderer=renderer,
)- Line 167. One renderer per AsyncLLM. It holds the tokenizer, the chat template and the thread pools.
- Line 170. The input processor shares this renderer.
- Lines 172-177. Shared admission counters exist only when several API servers share an engine and a queue limit is set. The API server manager creates them (
vllm/v1/utils.py:207-214). - Lines 180-186. The output processor gets the tokenizer for detokenization and the server-wide
stream_interval. - Lines 193-201.
make_async_mp_clientpicks the client class by the data parallel settings. With noclient_addresses, it also starts the engine core processes, and it returns only when they are ready.
After this block, the constructor makes the stat loggers if log_stats is true, and tries to start the output handler (vllm/v1/engine/async_llm.py:203-224). asyncio.get_running_loop() raises RuntimeError outside a loop; the code catches it and starts the handler later.
add_request: from prompt to collector#
else:
if isinstance(prompt, dict) and "type" in prompt:
# Rendered EngineInput; no blocking preprocessing needed.
request = self.input_processor.process_inputs(
request_id,
prompt,
params,
supported_tasks=await self.get_supported_tasks(),
arrival_time=arrival_time,
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,
)
else:
# Raw prompts require tokenization and possibly multimodal
# processing, which must not block the event loop.
request = await self.input_processor.process_inputs_async(
request_id,
prompt,
params,
supported_tasks=await self.get_supported_tasks(),
arrival_time=arrival_time,
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, _, _ = extract_prompt_components(self.model_config, prompt)- Line 481. A dict with a
"type"key is a rendered engine input. This is the normal path ofvllm serve, because the serving classes render the prompt first. - Line 483.
process_inputsruns in the event loop. It only checks and copies, so it is fast. - Line 487. The supported tasks come from the engine core once, then from a cache (
vllm/v1/engine/async_llm.py:390-395). - Line 500. A raw prompt still needs tokenization, so it goes to the thread pool with
process_inputs_async.
if reasoning_ended is not None:
request.reasoning_ended = reasoning_ended
if reasoning_parser_kwargs is not None:
request.reasoning_parser_kwargs = reasoning_parser_kwargs
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- Lines 516-519. Reasoning flags from the serving layer go into the struct; the engine core uses them for structured output.
- Line 521. The internal request id gets 8 random characters.
- Line 526. The output handler starts here if the constructor could not start it. The comment gives the reason: the constructor can then run before the event loop.
- Line 529. One collector per request, keyed by the internal id.
- Line 532. From here on, the code uses the cloned parameters of the request, which
process_inputsmay have changed (for examplemax_tokens). - Line 535. The common case,
n == 1, returns after one_add_request.
generate: the async generator#
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 # If the request is disconnected by the client, generate()
# is cancelled or the generator is garbage collected. So,
# we abort the request if we end up here.
except (asyncio.CancelledError, GeneratorExit):
if q is not None:
await self.abort(q.request_id, internal=True)
if self.log_requests:
logger.info("Request %s aborted.", request_id)
raise
# Engine is dead. Do not abort since we shut down.
except EngineDeadError:
if self.log_requests:
logger.info("Request %s failed (engine dead).", request_id)
raise- Line 773.
add_requestruns on the first iteration, not on the call. The tour shows why this matters: the HTTP 200 goes out first (Tour 1, stop 2). - Line 795.
get_nowait()takes a ready output without a task switch. Only an empty slot leads toawait q.get(). - Lines 800-802. The loop ends after the output with
finished = True. TheSTREAM_FINISHEDsentinel ends a streaming-input session and is not yielded. - Lines 807-812. A client disconnect cancels the task. The request is aborted with its internal id, and the exception goes on.
- Lines 815-818. When the engine is dead, there is no abort, because no engine can receive it.
_run_output_handler: the one background task#
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- Line 880. Waits for the next
EngineCoreOutputs. Errors of the socket task arrive here as exceptions. - Lines 883-885. One
IterationStatsper engine message, only with stats on and only if there are outputs. - Lines 891-893. Slices of
chunk_sizeoutputs (VLLM_V1_OUTPUT_PROC_CHUNK_SIZE, line 874). - Lines 894-902. Multimodal only: hashes that the engine core's cache missed are marked invalid in the front-end cache.
- Lines 905-910.
process_outputsputs eachRequestOutputinto its collector, so the returned list must be empty (line 912).
# Allow other asyncio tasks to run between chunks
if end < num_outputs:
await asyncio.sleep(0)
# 3) Abort any reqs that finished due to stop strings.
if processed_outputs.reqs_to_abort:
await engine_core.abort_requests_async(
processed_outputs.reqs_to_abort
)
output_processor.update_scheduler_stats(outputs.scheduler_stats)
# 4) Logging.
# TODO(rob): make into a coroutine and launch it in
# background thread once Prometheus overhead is non-trivial.
if logger_ref[0]:
logger_ref[0].record(
engine_idx=outputs.engine_index,
scheduler_stats=outputs.scheduler_stats,
iteration_stats=iteration_stats,
mm_cache_stats=renderer.stat_mm_cache(),
)
except Exception as e:
if get_elastic_ep_rank_retired() and isinstance(e, EngineDeadError):
logger.info(
"[Elastic EP] Retired rank stopped receiving EngineCore outputs"
)
output_processor.propagate_error(e)
return
logger.exception("AsyncLLM output_handler failed.")
output_processor.propagate_error(e)
self.output_handler = asyncio.create_task(output_handler())- Lines 915-916.
asyncio.sleep(0)between slices lets thegenerate()tasks run. - Lines 918-922. Requests that a stop string finished are aborted in the engine core. This block is inside the slice loop, so the aborts of each slice are sent before the next slice is processed (
vllm/v1/engine/async_llm.py:891-922). - Line 924. LoRA request counts come from the scheduler stats.
- Lines 929-935. The stat loggers record once per engine message.
logger_refis a one-element list, so elastic EP can swap the logger manager. - Lines 943-944. Any error is logged and put into every collector. Then the task ends, so
is_runningbecomes false (vllm/v1/engine/async_llm.py:1373-1376).
abort#
async def abort(
self, request_id: str | Iterable[str], internal: bool = False
) -> None:
"""Abort RequestId in OutputProcessor and EngineCore."""
request_ids = (
(request_id,) if isinstance(request_id, str) else as_list(request_id)
)
all_request_ids = self.output_processor.abort_requests(request_ids, internal)
await self.engine_core.abort_requests_async(all_request_ids)
if self.log_requests:
logger.info("Aborted request(s) %s.", ",".join(request_ids))- Lines 952-954. One id or many ids.
- Line 955. The output processor removes the requests and returns all internal ids, including children of a parent request. With
internal=False(the default), it also accepts external ids. - Line 956. One ABORT message to the engine core.
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveData flow: types in and out
| Value | Type | Where |
|---|---|---|
prompt of generate | EngineCoreRequest | PromptType | EngineInput | AsyncGenerator[StreamingInput]; vllm serve passes an EngineInput dict | vllm/v1/engine/async_llm.py:726-745 |
sampling_params | SamplingParams (msgspec struct) | vllm/v1/engine/async_llm.py:732 |
made by add_request | EngineCoreRequest, then a RequestOutputCollector | vllm/v1/engine/async_llm.py:483, vllm/v1/engine/async_llm.py:529 |
| received by the handler | EngineCoreOutputs | vllm/v1/engine/async_llm.py:880 |
yielded by generate | RequestOutput with the external request id | vllm/v1/engine/async_llm.py:745, vllm/v1/engine/async_llm.py:802 |
yielded by encode | PoolingRequestOutput | vllm/v1/engine/async_llm.py:1044 |
The page Data contracts between the parts of vLLM lists every field of these types.
Deep diveProcesses, tasks and threads
All code of this file runs in the API server process, on one event loop. Three kinds of asyncio task use it:
- One response task per HTTP request. It iterates
generate()and so runsadd_requestand the collector reads. - The output handler task, made at
vllm/v1/engine/async_llm.py:946. - The socket task of the engine core client, which feeds
get_output_async()(vllm/v1/engine/core_client.py:1260-1262).
The only thread hop in this file is process_inputs_async for raw prompts (line 500), and asyncio.to_thread for the front-end profiler (vllm/v1/engine/async_llm.py:1169). The output processor has no lock. It does not need one, because all its callers run on the same event loop. (Derived from the call sites above.)
With several API servers, each one has its own AsyncLLM, output processor and collectors. Each engine core sends a request's outputs only to the API server that sent the request (vllm/v1/engine/core.py:2089-2091).
Deep diveWorked example: n = 2 fan-out
A client sends a chat request with "n": 2, no seed and stream: true. The external id is chatcmpl-X; assign_request_id makes the internal id chatcmpl-X-k3m9q2zv (8 random characters).
params.n == 2, so the code skips line 535 and makes aParentRequest(vllm/v1/engine/async_llm.py:534-542).- The loop makes two children. Child 0 is a shallow copy of the request; child 1 (the last) reuses the original object (
vllm/v1/engine/async_llm.py:544-549). - The child ids are
0_chatcmpl-X-k3m9q2zvand1_chatcmpl-X-k3m9q2zv. Without a seed, both children share one cached copy of the parameters withn = 1(vllm/v1/engine/parallel_sampling.py:69-96). check_admission(2, ...)counts two slots (vllm/v1/engine/async_llm.py:551).- Both children are registered with the same collector, index 0 and 1, and then sent (
vllm/v1/engine/async_llm.py:553-561). - The engine core sees two independent requests with the same prompt.
- In the front end, each child output becomes a
CompletionOutputwith its index. The collector merges outputs with the same index and keeps different indexes apart (vllm/outputs.py:217-255).
Deep diveError paths: what raises, what only logs
| Situation | Result | Where |
|---|---|---|
| engine already dead when a request arrives | EngineDeadError | vllm/v1/engine/async_llm.py:418-419 |
| queue limit reached | QueueOverflowError or MaxQueuedTokensError (HTTP 503) | vllm/v1/engine/async_llm.py:359-383 |
prompt logprobs with --kv-sharing-fast-prefill | VLLMValidationError | vllm/v1/engine/async_llm.py:427-436 |
| trace headers without an OTLP endpoint | warning; headers dropped | vllm/v1/engine/async_llm.py:421-423 |
streaming input with pooling parameters, n > 1, stop strings or FINAL_ONLY | VLLMValidationError | vllm/v1/engine/async_llm.py:705-719 |
unexpected exception in generate() | abort, then EngineGenerateError | vllm/v1/engine/async_llm.py:837-851 |
| exception in the output handler | logged with "AsyncLLM output_handler failed."; put into every collector; the task ends | vllm/v1/engine/async_llm.py:936-944 |
a send of an n > 1 child fails | abort the parent and its children, then raise | vllm/v1/engine/async_llm.py:562-564 |
Deep diveEnvironment variables
| Variable | Default | Read at | Effect |
|---|---|---|---|
VLLM_V1_OUTPUT_PROC_CHUNK_SIZE | 128 (vllm/envs.py:1474-1476) | vllm/v1/engine/async_llm.py:874 | Outputs per slice of the output handler. |
VLLM_ELASTIC_EP_DRAIN_REQUESTS | 0 (vllm/envs.py:2211-2213) | vllm/v1/engine/async_llm.py:1330-1331 | Elastic EP only: wait for running requests to finish before a scale step. |
The admission limits are config fields, not environment variables: scheduler_config.max_num_queued_reqs and max_num_queued_tokens (vllm/v1/engine/async_llm.py:352, vllm/v1/engine/async_llm.py:371).
Related concepts#
- The engine client: AsyncLLM in the API server: the ideas behind this file, with errors and engine death.
- The process and thread architecture of vllm serve: where the API server process sits.
- Data contracts between the parts of vLLM: the types that go in and out.
- v1/engine/core_client.py: the engine core clients, v1/engine/input_processor.py: from engine input to EngineCoreRequest, v1/engine/output_processor.py: from token ids to RequestOutputs: the parts that AsyncLLM owns.
- Tour: Life of a chat request: stops 4, 6 and 15 run code of this file.
Check yourself#
- 1Where does AsyncLLM start the output handler task?easy
Show answer
In the constructor when an event loop already runs (
vllm/v1/engine/async_llm.py:218-224); otherwise on the firstadd_request(vllm/v1/engine/async_llm.py:523-526)._run_output_handlerreturns at once when the task exists (vllm/v1/engine/async_llm.py:858-859). - 2Why does the output handler callmedium
asyncio.sleep(0), and when does it skip it?Show answer
To give the event loop to other tasks between slices, so one large step does not block it (
vllm/v1/engine/async_llm.py:887-889,vllm/v1/engine/async_llm.py:914-916). It skips the sleep after the last slice, whenendis not smaller than the number of outputs. - 3A client disconnects during a stream. Which id doesmedium
generate()pass toabort(), and with which flag?Show answer
The internal id of the collector,
q.request_id, withinternal=True(vllm/v1/engine/async_llm.py:807-809). The collector was made with the internal id (vllm/v1/engine/async_llm.py:529). - 4A request hashard
n = 3and seed 7. Which ids and seeds do the children get, and which object is not a copy?Show answer
The ids are
0_<id>,1_<id>,2_<id>(vllm/v1/engine/parallel_sampling.py:94). With a seed, each child gets its own copy of the parameters with seed 7, 8 and 9 (vllm/v1/engine/parallel_sampling.py:69-81). The last child reuses the originalEngineCoreRequestobject; the first two are shallow copies (vllm/v1/engine/async_llm.py:546).