vLLM Explained

v1/engine/output_processor.py: from token ids to RequestOutputs

AdvancedCode mapabout 9 min read + 4 min deep dives

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

Purpose#

vllm/v1/engine/output_processor.py turns the token ids that come back from the engine core into RequestOutputs with text. It is the only place in the front end that loops over all outputs of a step.

Filevllm/v1/engine/output_processor.py, 1,047 lines of Python. Helpers in detokenizer.py (373 lines), logprobs.py (366 lines) and parallel_sampling.py (152 lines).
Main classesRequestOutputCollector (vllm/v1/engine/output_processor.py:53), RequestState (vllm/v1/engine/output_processor.py:183), OutputProcessor (vllm/v1/engine/output_processor.py:551).
Runs inThe API server process, on the event loop, inside the output handler task. Offline: in the caller's thread of LLMEngine.step.
Called byAsyncLLM (add_request, abort, the output handler) and LLMEngine.
CallsIncrementalDetokenizer, LogprobsProcessor, ParentRequest, IterationStats, the tracing helpers.
Not in this fileEOS and stop-token-id checks (the scheduler in the engine core), the SSE format (the serving classes), the Prometheus metrics (vllm/v1/metrics/).

The docstring of process_outputs states the design rule: "vLLM V1 minimizes the number of python loops over the full batch ... This is the only function that should loop over EngineCoreOutputs" (vllm/v1/engine/output_processor.py:746-753).

Where it sits#

The engine core client gives the output handler one EngineCoreOutputs per step. The handler passes slices of its outputs list to process_outputs. For each entry, the output processor finds the RequestState, updates it, and puts a RequestOutput into the collector of the request. The generate() task of the request takes it from there. The diagram on The engine client: AsyncLLM in the API server shows this path.

Who calls it#

What it calls#

Key classes and functions#

NameLineWhat it does
RequestOutputCollectorvllm/v1/engine/output_processor.py:53-113One-slot queue per request; merges DELTA outputs.
StreamingInputOutputCollectorvllm/v1/engine/output_processor.py:128-160Like the collector, but does not merge across input-chunk boundaries.
RequestStatevllm/v1/engine/output_processor.py:183-252Everything the front end keeps per request.
RequestState.from_new_requestvllm/v1/engine/output_processor.py:273-352Builds the state, the detokenizer and the logprobs processor from an EngineCoreRequest.
RequestState.make_request_outputvllm/v1/engine/output_processor.py:354-420Decides if an output is due, and builds it.
RequestState._new_completion_outputvllm/v1/engine/output_processor.py:475-529Text, token ids and logprobs for one sequence, by output kind.
OutputProcessor.add_requestvllm/v1/engine/output_processor.py:663-693Registers a new request, or queues a streaming-input update.
OutputProcessor.process_outputsvllm/v1/engine/output_processor.py:728-894The loop over all outputs of a step.
OutputProcessor.abort_requestsvllm/v1/engine/output_processor.py:599-661Removes requests by external or internal id; emits a final ABORT output.
_trim_surplus_tokensvllm/v1/engine/output_processor.py:896-915Drops tokens after a matched stop string.
BaseIncrementalDetokenizer.updatevllm/v1/engine/detokenizer.py:96-155Decodes new ids into text and checks stop strings.
check_stop_stringsvllm/v1/engine/detokenizer.py:321-373Finds the earliest stop string in the new text.

Walk through the main methods#

process_outputs, part 1: find the state, update stats#

        for engine_core_output in engine_core_outputs:
            req_id = engine_core_output.request_id
            req_state = self.request_states.get(req_id)
            if req_state is None:
                # Ignore output for already-aborted request.
                continue

            error = engine_core_output.error
            if error is not None and req_state.queue is not None:
                # Rejected on arrival, e.g. by a paused engine; nothing was run.
                req_state.queue.put(error.to_exception())
                self._finish_request(req_state)
                if req_state.streaming_input:
                    # Input chunks sent since may re-create the session.
                    reqs_to_abort.append(req_id)
                continue

            # 1) Compute stats for this iteration.
            self._update_stats_from_output(
                req_state, engine_core_output, engine_core_timestamp, iteration_stats
            )
            if req_state.is_prefilling:
                if engine_core_output.prefill_stats is not None:
                    req_state.num_cached_tokens = (
                        engine_core_output.prefill_stats.num_cached_tokens
                    )
                    req_state.num_cache_creation_tokens = (
                        engine_core_output.prefill_stats.num_cache_creation_tokens
                    )
                if req_state.remote_prefill_cached_tokens is not None:
                    req_state.num_cached_tokens = req_state.remote_prefill_cached_tokens
                req_state.is_prefilling = False
  1. Lines 759-762. No state means the request was aborted, or finished on a stop string, in the front end. The output is ignored.
  2. Lines 764-772. An output with error set means the engine rejected the request on arrival, for example a paused engine. The collector gets the exception.
  3. Lines 775-777. Timing stats, only when iteration_stats exists (stats on).
  4. Lines 790-800. The first output ends the prefill. Its prefill_stats give the number of prompt tokens that came from the prefix cache.

process_outputs, part 2: detokenize, logprobs, output#

            if pooling_output is None:
                assert req_state.detokenizer is not None
                assert req_state.logprobs_processor is not None
                if new_token_ids and weight_version is not None:
                    req_state.track_weight_version(weight_version)
                # 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
                    )

                if engine_core_output.new_sampling_mask is not None:
                    req_state.sampling_mask_chunks.append(
                        engine_core_output.new_sampling_mask
                    )

                # 3) Compute sample and prompt logprobs for request,
                # if required.
                req_state.logprobs_processor.update_from_output(engine_core_output)
            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)
                else:
                    # LLMEngine: return list of RequestOutputs.
                    request_outputs.append(request_output)
  1. Lines 824-827. The detokenizer appends the new ids and returns a matched stop string or None. The second argument is true when the engine stopped the request with STOP: EOS or a stop token id.
  2. Lines 828-833. A stop string sets the finish reason to STOP, with the string as stop_reason, and trims surplus tokens.
  3. Line 842. Sample and prompt logprobs, if the request asked for them.
  4. Lines 849-857. make_request_output can return None: in FINAL_ONLY mode before the end, or between two stream intervals.
  5. Lines 861-866. AsyncLLM: into the collector. LLMEngine: into the returned list.

process_outputs, part 3: free finished requests#

            # Free completed requests.
            if finish_reason is not None:
                if streaming_chunk_boundary:
                    if req_state.input_chunk_queue:
                        update = req_state.input_chunk_queue.popleft()
                        req_state.apply_streaming_update(update)
                    else:
                        req_state.input_chunk_queue = None
                else:
                    self._finish_request(req_state)
                    if req_state.streaming_input or not engine_core_output.finished:
                        # EngineCore may still have the request: a stop string
                        # was detected here, or an in-flight streaming-input
                        # chunk may re-create the session.
                        reqs_to_abort.append(req_id)

                    # Track per-request stats
                    self._update_stats_from_finished(
                        req_state, finish_reason, iteration_stats
                    )
                    if self.tracing_enabled:
                        self.do_tracing(engine_core_output, req_state, iteration_stats)

        return OutputProcessorOutput(
            request_outputs=request_outputs,
            reqs_to_abort=reqs_to_abort,
        )
  1. Line 877. _finish_request removes the state, the id mapping and, for the last child, the parent (vllm/v1/engine/output_processor.py:917-932).
  2. Lines 878-882. If the engine core did not finish the request itself (a stop string), its id goes to reqs_to_abort. The output handler sends these aborts after each slice (vllm/v1/engine/async_llm.py:918-922).
  3. Lines 885-889. Finished-request stats and an optional trace span.

make_request_output: is an output due?#

        finished = finish_reason is not None
        final_only = self.output_kind == RequestOutputKind.FINAL_ONLY

        if not finished and final_only:
            # Only the final output is required in FINAL_ONLY mode.
            return None

        if pooling_output is not None:
            return self._new_request_output(
                self.external_req_id,
                [self._new_pooling_output(pooling_output)],
                finished,
                error=error,
            )

        if self.stream_interval > 1:
            assert self.detokenizer is not None

            # Send output request only when
            # 1. It has finished, or
            # 2. It is the first token, or
            # 3. It has reached the stream interval number of tokens
            if not (
                finished
                or self.sent_tokens_offset == 0
                or self.detokenizer.num_output_tokens() - self.sent_tokens_offset
                >= self.stream_interval
            ):
                return None

            if self.output_kind == RequestOutputKind.DELTA:
                # Send tokens from the offset in DELTA mode, otherwise all
                # tokens are sent.
                new_token_ids = self.detokenizer.output_token_ids[
                    self.sent_tokens_offset :
                ]
                self.sent_tokens_offset = self.detokenizer.num_output_tokens()
        external_req_id = self.external_req_id

        output = self._new_completion_output(new_token_ids, finish_reason, stop_reason)

        if self.parent_req is None:
            outputs = [output]
        else:
            outputs, finished = self.parent_req.get_outputs(self.request_id, output)
            if not outputs:
                return None
            external_req_id = self.parent_req.external_req_id

        return self._new_request_output(
            external_req_id,
            outputs,
            finished,
            kv_transfer_params,
            ec_transfer_params,
        )
  1. Lines 367-369. FINAL_ONLY (a request without stream) gets one output, at the end.
  2. Lines 379-392. With stream_interval > 1, an output goes out only at the end, for the first token, or after stream_interval new tokens.
  3. Lines 394-400. In that case the DELTA output carries all tokens since the last sent one.
  4. Line 402. The output uses the external id, the one the client knows.
  5. Lines 406-412. A child of an n > 1 request asks its parent which outputs to send, and uses the parent's external id.

Incremental detokenization and stop strings#

        if not new_token_ids:
            # Skip detokenization if no new token ids.
            return None

        if stop_terminated and not self.include_stop_str_in_output:
            # If stop-terminated, exclude last token from detokenization
            # based on include_stop_str_in_output parameter.
            skipped_stop_token_id = new_token_ids[-1]
            new_token_ids = new_token_ids[:-1]
        else:
            skipped_stop_token_id = None

        # 1) Detokenize the new token ids incrementally.
        num_prev_tokens = self.num_output_tokens()
        first_new_token_index = len(self.token_ids)
        # Output text offsets at the boundaries of the new tokens.
        token_offsets = [len(self.output_text)] if self.stop else None
        for new_token_id in new_token_ids:
            self.token_ids.append(new_token_id)
            self.output_text += self.decode_next(new_token_id)
            if token_offsets is not None:
                token_offsets.append(len(self.output_text))

        if skipped_stop_token_id is not None:
            # Cleanup after skipping detokenization.
            self.token_ids.append(skipped_stop_token_id)
            if token_offsets is not None:
                token_offsets.append(len(self.output_text))
  1. Lines 107-111. When the engine stopped the request on EOS or a stop token, the last token is not decoded, unless include_stop_str_in_output is set. So the final chunk of a chat stream has empty content.
  2. Lines 116-124. Each new id gives the next piece of text through decode_next. With stop strings, token_offsets records the text length after each token.
  3. Lines 126-128. The skipped stop token still counts as an output token.
        # 2) Evaluate stop strings.
        stop_string = None
        if token_offsets is not None and self.num_output_tokens() > self.min_tokens:
            # Support min_tokens, see https://github.com/vllm-project/vllm/pull/22014
            stop_check_offset = token_offsets[max(self.min_tokens - num_prev_tokens, 0)]
            stop = check_stop_strings(
                output_text=self.output_text,
                new_char_count=len(self.output_text) - stop_check_offset,
                stop=self.stop,
                include_in_output=self.include_stop_str_in_output,
            )
            if stop is not None:
                stop_string, truncate_to = stop
                stop_end = len(self.output_text) if truncate_to == -1 else truncate_to
                if not self.include_stop_str_in_output:
                    stop_end += len(stop_string)
                # Drop tokens generated after the one completing the stop string
                # (possible when multiple tokens are appended in one step).
                num_kept = bisect_left(token_offsets, stop_end, lo=1)
                del self.token_ids[first_new_token_index + num_kept :]
                if truncate_to != -1:
                    self.output_text = self.output_text[:truncate_to]

        return stop_string
  1. Line 134. Stop strings count only after min_tokens output tokens.
  2. Line 136. The new text starts at the first token after min_tokens.
  3. Lines 137-142. check_stop_strings searches the new text plus up to (stop string length − 1) characters before it. So it finds a stop string that two tokens complete. It picks the stop string that ends first (vllm/v1/engine/detokenizer.py:336-361).
  4. Lines 150-153. Tokens after the token that completed the stop string are dropped, and the text is cut.

The fast detokenizer primes a DecodeStream with the prompt ids, so the first output token decodes in context (vllm/v1/engine/detokenizer.py:191-198). The text that a stream may send is shorter than the decoded text by stop_buffer_length = the longest stop string minus 1. This holds back a stop string that is only partly generated (vllm/v1/engine/detokenizer.py:85-90, vllm/v1/engine/detokenizer.py:161-176).

n > 1: the parent request#

    def get_outputs(
        self,
        child_request_id: str,
        completion_output: CompletionOutput,
    ) -> tuple[list[CompletionOutput], bool]:
        already_finished_and_returned: bool = False
        if completion_output.finished():
            if child_request_id in self.child_requests:
                self.child_requests.remove(child_request_id)
            else:
                # child request ID is not available in child_requests
                # which means the request had finished in previous
                # batch step and returned to the client earlier
                already_finished_and_returned = True

        if self.sampling_params.output_kind != RequestOutputKind.FINAL_ONLY:
            # If streaming, just return the current output
            #
            # DO NOT output finished and already returned child request to client again
            outputs = [] if already_finished_and_returned else [completion_output]
        else:
            # If not streaming, aggregate the n final outputs.
            self.output_aggregator[completion_output.index] = completion_output
            outputs = [] if self.child_requests else self.output_aggregator

        finished = not self.child_requests
        return outputs, finished
  1. Lines 108-115. A finished child leaves child_requests. A second finished output of the same child is not sent again.
  2. Lines 117-121. Streaming: each child's output goes out as it comes, with its own index.
  3. Lines 122-125. FINAL_ONLY: the parent collects all n outputs and sends them together when the last child finishes.
  4. Line 127. The parent is finished when no child is left.

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: what RequestState holds

FieldTypeUse
request_id, external_req_idstrinternal key; id for the client
parent_req, request_indexParentRequest | None, intn > 1 fan-in; the index of the choice
output_kindRequestOutputKind: CUMULATIVE, DELTA or FINAL_ONLY (vllm/sampling_params.py:208-214)what each output carries
prompt, prompt_token_ids, prompt_lenstr | None, list[int] | None, intcopied into every RequestOutput
detokenizer, logprobs_processorsee abovetext, stop strings, logprobs
is_prefilling, num_cached_tokensbool, intadmission token count; prefix-cache hits for the usage info
queueRequestOutputCollector | NoneAsyncLLM only
statsRequestStateStats | Nonetiming, only with log_stats
stream_interval, sent_tokens_offsetintchunking of the stream

Sources: vllm/v1/engine/output_processor.py:208-252. A request's own stream_interval can only raise the server value, never lower it (vllm/v1/engine/output_processor.py:300-302).

Deep dive

Worked example: a stop string across two tokens

A request has stop=["END"], include_stop_str_in_output=False, min_tokens=0 and a stream (DELTA). So stop_buffer_length = 3 − 1 = 2. The tokens are illustrative.

StepNew token textoutput_textStop checkChunk sent
1"The""The" (3)no match"T": text[0:1], because 3 − 2 = 1
2" answer""The answer" (10)no match"he answ": text[1:8]
3" E""The answer E" (12)searches the last 2 + 3 − 1 characters; no match"er": text[8:10]
4"ND""The answer END" (14), then cut to "The answer " (11)"END" at index 11; truncate to 11" ": text[10:11], with finish_reason="stop", stop_reason="END"

The client receives "The answer " without "END". In step 4, engine_core_output.finished is false, because the engine core did not stop the request. So the request goes to reqs_to_abort, and the engine core gets an ABORT (vllm/v1/engine/output_processor.py:878-882). The arithmetic follows vllm/v1/engine/detokenizer.py:132-153, vllm/v1/engine/detokenizer.py:161-176 and vllm/v1/engine/detokenizer.py:351-373.

Deep dive

Logprobs in the front end

Deep dive

Aborts by external or internal id

abort_requests accepts both kinds of id (vllm/v1/engine/output_processor.py:599-661):

  1. With internal=True, each id is an internal id, or the id of a parent request. The code removes it from the external-to-internal map.
  2. With internal=False, each id is an external id. All internal ids that share it are aborted. This is how one external id can stop several requests.
  3. For each internal id with a state, the code removes the state and puts a final output with finish_reason=ABORT into the collector. So a waiting generate() ends cleanly.
  4. For a parent id, it aborts all children first.
  5. It returns the internal ids, which the caller sends to the engine core.

A client-disconnect abort skips the finished-request stats: abort_requests does not call _update_stats_from_finished.

Deep dive

Processes and threads

The output processor has no lock. All its methods run on the event loop of one API server process: add_request and abort_requests in request tasks, process_outputs in the output handler task. They do not run in parallel, because asyncio runs one task at a time, and these methods do not await. (Derived from the code: none of the methods is async.)

The CPU cost of this file grows with the number of output tokens per step: one detokenizer step per token, plus logprobs work. That cost is why the output handler cuts large steps into slices (vllm/v1/engine/async_llm.py:887-893).

Deep dive

Error paths

SituationResultWhere
output for an unknown requestignoredvllm/v1/engine/output_processor.py:759-762
output with error (rejected on arrival)the collector gets the exception; the state is removedvllm/v1/engine/output_processor.py:764-772
engine-wide failurepropagate_error puts the exception into every collectorvllm/v1/engine/output_processor.py:593-597
invalid token id in the fast detokenizerlogged; the token decodes as empty textvllm/v1/engine/detokenizer.py:236-242
"Invalid prefix" error of the decode streamwarning; the stream is reset and the token decoded againvllm/v1/engine/detokenizer.py:243-258
multimodal cache miss on a pooling requesta retryable RequestError in the outputvllm/v1/engine/output_processor.py:805-816

Check yourself#

  1. 1
    Where are stop strings checked, and why not in the engine core?
    easy
    Show answer

    In the front end, in the detokenizer that the output processor calls (vllm/v1/engine/output_processor.py:825-833). The engine core works with token ids only and has no text; EngineCoreOutput carries new_token_ids (vllm/v1/engine/__init__.py:209-210).

  2. 2
    A request has stream_interval 4 on a server with the default 1. Which tokens make a new RequestOutput?
    medium
    Show answer

    The first token, then each time 4 new tokens are ready, and the last one. The request value is clamped up to at least the server value, so 4 applies (vllm/v1/engine/output_processor.py:300-302). The rule is at vllm/v1/engine/output_processor.py:379-392.

  3. 3
    A request with n = 2 and no stream finishes child 1 before child 0. When does the client get an output?
    medium
    Show answer

    Only when child 0 finishes too. In FINAL_ONLY mode the parent stores each final output and returns all of them when no child is left (vllm/v1/engine/parallel_sampling.py:122-128).

  4. 4
    The stop strings are ["END", "STOP"], without include_stop_str_in_output. How many characters does the stream hold back, and why?
    hard
    Show answer

    3: the longest stop string has 4 characters, minus 1 (vllm/v1/engine/detokenizer.py:87-88). The last 3 characters could be the start of "STOP", so the stream must not send them before the next token decides. When the request finishes, the buffer is 0 and the rest goes out (vllm/v1/engine/detokenizer.py:165).