vLLM Explained

v1/engine/async_llm.py: AsyncLLM, the engine client of vllm serve

IntermediateCode mapabout 10 min read + 3 min deep dives

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.

Filevllm/v1/engine/async_llm.py, 1,445 lines of Python. One exception class and one large class.
Main classAsyncLLM(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 inThe API server process (the front end), on its asyncio event loop. Raw-prompt preprocessing runs in the renderer's thread pool.
Called byThe API server start-up (from_vllm_config), the serving classes (generate, encode, abort, check_admission), the health route and the dev routes.
CallsThe renderer, InputProcessor, OutputProcessor, the engine core client (AsyncMPClient or a DP variant), StatLoggerManager.
Not in this fileSockets 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#

Figure: the parts that AsyncLLM owns (panel 1) and the path of one output back to its generate() task (panel 2).
Open full size ↗

Who calls it#

What it calls#

Key classes and functions#

NameLineWhat it does
InputStreamErrorvllm/v1/engine/async_llm.py:81-90Wraps an error of a streaming-input generator, so generate() can raise the cause.
AsyncLLM.__init__vllm/v1/engine/async_llm.py:96-247Builds renderer, processors, engine core client, loggers; may start the output handler.
from_vllm_config / from_engine_argsvllm/v1/engine/async_llm.py:249-300Pick the executor class and call the constructor.
shutdownvllm/v1/engine/async_llm.py:305-317Stops Prometheus, the renderer, the engine core client and the output handler.
check_admissionvllm/v1/engine/async_llm.py:325-383Rejects a request with 503 when a queue limit is full.
add_requestvllm/v1/engine/async_llm.py:397-565Makes the EngineCoreRequest and the collector; fans out n > 1.
_add_requestvllm/v1/engine/async_llm.py:567-587Registers in the output processor, then sends over ZMQ.
_add_streaming_input_requestvllm/v1/engine/async_llm.py:589-696Input streaming: one request fed by an async generator of prompt chunks.
generatevllm/v1/engine/async_llm.py:726-854The async generator that the serving classes iterate.
_run_output_handlervllm/v1/engine/async_llm.py:856-946Starts the one background task that processes all engine outputs.
abortvllm/v1/engine/async_llm.py:948-959Aborts in the output processor and in the engine core.
pause_generation / resume_generationvllm/v1/engine/async_llm.py:989-1028Pause modes abort, wait, keep, for weight updates.
encodevllm/v1/engine/async_llm.py:1034-1113Like generate, for pooling models.
check_healthvllm/v1/engine/async_llm.py:1129-1134Raises EngineDeadError when errored.
sleep, wake_up, LoRA, cache resetsvllm/v1/engine/async_llm.py:1179-1245Thin forwarders to utility calls of the engine core client.
collective_rpcvllm/v1/engine/async_llm.py:1247-1257Runs a method on every worker, through the engine core.
is_running, errored, dead_errorvllm/v1/engine/async_llm.py:1373-1392The 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,
        )
  1. Line 167. One renderer per AsyncLLM. It holds the tokenizer, the chat template and the thread pools.
  2. Line 170. The input processor shares this renderer.
  3. 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).
  4. Lines 180-186. The output processor gets the tokenizer for detokenization and the server-wide stream_interval.
  5. Lines 193-201. make_async_mp_client picks the client class by the data parallel settings. With no client_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)
  1. Line 481. A dict with a "type" key is a rendered engine input. This is the normal path of vllm serve, because the serving classes render the prompt first.
  2. Line 483. process_inputs runs in the event loop. It only checks and copies, so it is fast.
  3. Line 487. The supported tasks come from the engine core once, then from a cache (vllm/v1/engine/async_llm.py:390-395).
  4. 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
  1. Lines 516-519. Reasoning flags from the serving layer go into the struct; the engine core uses them for structured output.
  2. Line 521. The internal request id gets 8 random characters.
  3. 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.
  4. Line 529. One collector per request, keyed by the internal id.
  5. Line 532. From here on, the code uses the cloned parameters of the request, which process_inputs may have changed (for example max_tokens).
  6. 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
  1. Line 773. add_request runs on the first iteration, not on the call. The tour shows why this matters: the HTTP 200 goes out first (Tour 1, stop 2).
  2. Line 795. get_nowait() takes a ready output without a task switch. Only an empty slot leads to await q.get().
  3. Lines 800-802. The loop ends after the output with finished = True. The STREAM_FINISHED sentinel ends a streaming-input session and is not yielded.
  4. Lines 807-812. A client disconnect cancels the task. The request is aborted with its internal id, and the exception goes on.
  5. 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
  1. Line 880. Waits for the next EngineCoreOutputs. Errors of the socket task arrive here as exceptions.
  2. Lines 883-885. One IterationStats per engine message, only with stats on and only if there are outputs.
  3. Lines 891-893. Slices of chunk_size outputs (VLLM_V1_OUTPUT_PROC_CHUNK_SIZE, line 874).
  4. Lines 894-902. Multimodal only: hashes that the engine core's cache missed are marked invalid in the front-end cache.
  5. Lines 905-910. process_outputs puts each RequestOutput into 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())
  1. Lines 915-916. asyncio.sleep(0) between slices lets the generate() tasks run.
  2. 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).
  3. Line 924. LoRA request counts come from the scheduler stats.
  4. Lines 929-935. The stat loggers record once per engine message. logger_ref is a one-element list, so elastic EP can swap the logger manager.
  5. Lines 943-944. Any error is logged and put into every collector. Then the task ends, so is_running becomes 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))
  1. Lines 952-954. One id or many ids.
  2. 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.
  3. 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 dive

Data flow: types in and out

ValueTypeWhere
prompt of generateEngineCoreRequest | PromptType | EngineInput | AsyncGenerator[StreamingInput]; vllm serve passes an EngineInput dictvllm/v1/engine/async_llm.py:726-745
sampling_paramsSamplingParams (msgspec struct)vllm/v1/engine/async_llm.py:732
made by add_requestEngineCoreRequest, then a RequestOutputCollectorvllm/v1/engine/async_llm.py:483, vllm/v1/engine/async_llm.py:529
received by the handlerEngineCoreOutputsvllm/v1/engine/async_llm.py:880
yielded by generateRequestOutput with the external request idvllm/v1/engine/async_llm.py:745, vllm/v1/engine/async_llm.py:802
yielded by encodePoolingRequestOutputvllm/v1/engine/async_llm.py:1044

The page Data contracts between the parts of vLLM lists every field of these types.

Deep dive

Processes, 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:

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 dive

Worked 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).

  1. params.n == 2, so the code skips line 535 and makes a ParentRequest (vllm/v1/engine/async_llm.py:534-542).
  2. 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).
  3. The child ids are 0_chatcmpl-X-k3m9q2zv and 1_chatcmpl-X-k3m9q2zv. Without a seed, both children share one cached copy of the parameters with n = 1 (vllm/v1/engine/parallel_sampling.py:69-96).
  4. check_admission(2, ...) counts two slots (vllm/v1/engine/async_llm.py:551).
  5. Both children are registered with the same collector, index 0 and 1, and then sent (vllm/v1/engine/async_llm.py:553-561).
  6. The engine core sees two independent requests with the same prompt.
  7. In the front end, each child output becomes a CompletionOutput with its index. The collector merges outputs with the same index and keeps different indexes apart (vllm/outputs.py:217-255).
Deep dive

Error paths: what raises, what only logs

SituationResultWhere
engine already dead when a request arrivesEngineDeadErrorvllm/v1/engine/async_llm.py:418-419
queue limit reachedQueueOverflowError or MaxQueuedTokensError (HTTP 503)vllm/v1/engine/async_llm.py:359-383
prompt logprobs with --kv-sharing-fast-prefillVLLMValidationErrorvllm/v1/engine/async_llm.py:427-436
trace headers without an OTLP endpointwarning; headers droppedvllm/v1/engine/async_llm.py:421-423
streaming input with pooling parameters, n > 1, stop strings or FINAL_ONLYVLLMValidationErrorvllm/v1/engine/async_llm.py:705-719
unexpected exception in generate()abort, then EngineGenerateErrorvllm/v1/engine/async_llm.py:837-851
exception in the output handlerlogged with "AsyncLLM output_handler failed."; put into every collector; the task endsvllm/v1/engine/async_llm.py:936-944
a send of an n > 1 child failsabort the parent and its children, then raisevllm/v1/engine/async_llm.py:562-564
Deep dive

Environment variables

VariableDefaultRead atEffect
VLLM_V1_OUTPUT_PROC_CHUNK_SIZE128 (vllm/envs.py:1474-1476)vllm/v1/engine/async_llm.py:874Outputs per slice of the output handler.
VLLM_ELASTIC_EP_DRAIN_REQUESTS0 (vllm/envs.py:2211-2213)vllm/v1/engine/async_llm.py:1330-1331Elastic 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).

Check yourself#

  1. 1
    Where 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 first add_request (vllm/v1/engine/async_llm.py:523-526). _run_output_handler returns at once when the task exists (vllm/v1/engine/async_llm.py:858-859).

  2. 2
    Why does the output handler call asyncio.sleep(0), and when does it skip it?
    medium
    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, when end is not smaller than the number of outputs.

  3. 3
    A client disconnects during a stream. Which id does generate() pass to abort(), and with which flag?
    medium
    Show answer

    The internal id of the collector, q.request_id, with internal=True (vllm/v1/engine/async_llm.py:807-809). The collector was made with the internal id (vllm/v1/engine/async_llm.py:529).

  4. 4
    A request has n = 3 and seed 7. Which ids and seeds do the children get, and which object is not a copy?
    hard
    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 original EngineCoreRequest object; the first two are shallow copies (vllm/v1/engine/async_llm.py:546).