v1/engine/output_processor.py: from token ids to RequestOutputs
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.
| File | vllm/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 classes | RequestOutputCollector (vllm/v1/engine/output_processor.py:53), RequestState (vllm/v1/engine/output_processor.py:183), OutputProcessor (vllm/v1/engine/output_processor.py:551). |
| Runs in | The API server process, on the event loop, inside the output handler task. Offline: in the caller's thread of LLMEngine.step. |
| Called by | AsyncLLM (add_request, abort, the output handler) and LLMEngine. |
| Calls | IncrementalDetokenizer, LogprobsProcessor, ParentRequest, IterationStats, the tracing helpers. |
| Not in this file | EOS 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#
AsyncLLM.__init__makes oneOutputProcessorwith the tokenizer,log_statsand the server-widestream_interval(vllm/v1/engine/async_llm.py:180-186).AsyncLLM._add_requestcallsadd_requestbefore it sends the request (vllm/v1/engine/async_llm.py:581).add_requestmakes the collector first (vllm/v1/engine/async_llm.py:529).- The output handler calls
process_outputsper slice andupdate_scheduler_statsper message; on an error it callspropagate_error(vllm/v1/engine/async_llm.py:905-910,vllm/v1/engine/async_llm.py:924,vllm/v1/engine/async_llm.py:944). AsyncLLM.abortcallsabort_requests(vllm/v1/engine/async_llm.py:955).AsyncLLM.check_admissionreadsget_num_unfinished_requestsandget_num_queued_tokens(vllm/v1/engine/async_llm.py:319-323).
What it calls#
IncrementalDetokenizer.from_new_request: a fast detokenizer with atokenizersDecodeStream, a slow Python one, or a no-op one when there is no tokenizer.detokenize=Falseremoves the tokenizer for the request (vllm/v1/engine/detokenizer.py:49-66,vllm/v1/engine/output_processor.py:297-298).LogprobsProcessor.from_new_requestandupdate_from_output(vllm/v1/engine/logprobs.py:46-71,vllm/v1/engine/logprobs.py:360-366).ParentRequest.get_outputsforn > 1(vllm/v1/engine/parallel_sampling.py:102-128).IterationStats.update_from_outputandupdate_from_finished_requestfor metrics (vllm/v1/engine/output_processor.py:1003-1047).
Key classes and functions#
| Name | Line | What it does |
|---|---|---|
RequestOutputCollector | vllm/v1/engine/output_processor.py:53-113 | One-slot queue per request; merges DELTA outputs. |
StreamingInputOutputCollector | vllm/v1/engine/output_processor.py:128-160 | Like the collector, but does not merge across input-chunk boundaries. |
RequestState | vllm/v1/engine/output_processor.py:183-252 | Everything the front end keeps per request. |
RequestState.from_new_request | vllm/v1/engine/output_processor.py:273-352 | Builds the state, the detokenizer and the logprobs processor from an EngineCoreRequest. |
RequestState.make_request_output | vllm/v1/engine/output_processor.py:354-420 | Decides if an output is due, and builds it. |
RequestState._new_completion_output | vllm/v1/engine/output_processor.py:475-529 | Text, token ids and logprobs for one sequence, by output kind. |
OutputProcessor.add_request | vllm/v1/engine/output_processor.py:663-693 | Registers a new request, or queues a streaming-input update. |
OutputProcessor.process_outputs | vllm/v1/engine/output_processor.py:728-894 | The loop over all outputs of a step. |
OutputProcessor.abort_requests | vllm/v1/engine/output_processor.py:599-661 | Removes requests by external or internal id; emits a final ABORT output. |
_trim_surplus_tokens | vllm/v1/engine/output_processor.py:896-915 | Drops tokens after a matched stop string. |
BaseIncrementalDetokenizer.update | vllm/v1/engine/detokenizer.py:96-155 | Decodes new ids into text and checks stop strings. |
check_stop_strings | vllm/v1/engine/detokenizer.py:321-373 | Finds 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- Lines 759-762. No state means the request was aborted, or finished on a stop string, in the front end. The output is ignored.
- Lines 764-772. An output with
errorset means the engine rejected the request on arrival, for example a paused engine. The collector gets the exception. - Lines 775-777. Timing stats, only when
iteration_statsexists (stats on). - Lines 790-800. The first output ends the prefill. Its
prefill_statsgive 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)- 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 withSTOP: EOS or a stop token id. - Lines 828-833. A stop string sets the finish reason to
STOP, with the string asstop_reason, and trims surplus tokens. - Line 842. Sample and prompt logprobs, if the request asked for them.
- Lines 849-857.
make_request_outputcan returnNone: inFINAL_ONLYmode before the end, or between two stream intervals. - 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,
)- Line 877.
_finish_requestremoves the state, the id mapping and, for the last child, the parent (vllm/v1/engine/output_processor.py:917-932). - 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). - 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,
)- Lines 367-369.
FINAL_ONLY(a request withoutstream) gets one output, at the end. - Lines 379-392. With
stream_interval > 1, an output goes out only at the end, for the first token, or afterstream_intervalnew tokens. - Lines 394-400. In that case the DELTA output carries all tokens since the last sent one.
- Line 402. The output uses the external id, the one the client knows.
- Lines 406-412. A child of an
n > 1request 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))
- 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_outputis set. So the final chunk of a chat stream has empty content. - Lines 116-124. Each new id gives the next piece of text through
decode_next. With stop strings,token_offsetsrecords the text length after each token. - 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- Line 134. Stop strings count only after
min_tokensoutput tokens. - Line 136. The new text starts at the first token after
min_tokens. - Lines 137-142.
check_stop_stringssearches 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). - 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- Lines 108-115. A finished child leaves
child_requests. A second finished output of the same child is not sent again. - Lines 117-121. Streaming: each child's output goes out as it comes, with its own
index. - Lines 122-125.
FINAL_ONLY: the parent collects allnoutputs and sends them together when the last child finishes. - 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 diveData flow: what RequestState holds
| Field | Type | Use |
|---|---|---|
request_id, external_req_id | str | internal key; id for the client |
parent_req, request_index | ParentRequest | None, int | n > 1 fan-in; the index of the choice |
output_kind | RequestOutputKind: CUMULATIVE, DELTA or FINAL_ONLY (vllm/sampling_params.py:208-214) | what each output carries |
prompt, prompt_token_ids, prompt_len | str | None, list[int] | None, int | copied into every RequestOutput |
detokenizer, logprobs_processor | see above | text, stop strings, logprobs |
is_prefilling, num_cached_tokens | bool, int | admission token count; prefix-cache hits for the usage info |
queue | RequestOutputCollector | None | AsyncLLM only |
stats | RequestStateStats | None | timing, only with log_stats |
stream_interval, sent_tokens_offset | int | chunking 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 diveWorked 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.
| Step | New token text | output_text | Stop check | Chunk 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 diveLogprobs in the front end
- The engine core sends logprobs as arrays: for each new token, the token ids, the logprobs and the ranks of the top candidates (
vllm/v1/outputs.py:34-45). The sampled token comes first (vllm/v1/engine/logprobs.py:110-111). _update_sample_logprobsadds the sampled token's logprob tocumulative_logproband decodes each candidate token on its own, not incrementally (vllm/v1/engine/logprobs.py:89-122).- A decoded candidate that ends with the replacement character "�" is corrected with the sampled context, because it can be part of a multi-byte character (
vllm/v1/engine/logprobs.py:341-358). - In DELTA mode, each
CompletionOutputcarries only the logprobs of its new tokens (vllm/v1/engine/output_processor.py:492-498). Prompt logprobs are sent once and then forgotten (vllm/v1/engine/output_processor.py:449-454).
Deep diveAborts by external or internal id
abort_requests accepts both kinds of id (vllm/v1/engine/output_processor.py:599-661):
- 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. - 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. - For each internal id with a state, the code removes the state and puts a final output with
finish_reason=ABORTinto the collector. So a waitinggenerate()ends cleanly. - For a parent id, it aborts all children first.
- 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 diveProcesses 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 diveError paths
| Situation | Result | Where |
|---|---|---|
| output for an unknown request | ignored | vllm/v1/engine/output_processor.py:759-762 |
output with error (rejected on arrival) | the collector gets the exception; the state is removed | vllm/v1/engine/output_processor.py:764-772 |
| engine-wide failure | propagate_error puts the exception into every collector | vllm/v1/engine/output_processor.py:593-597 |
| invalid token id in the fast detokenizer | logged; the token decodes as empty text | vllm/v1/engine/detokenizer.py:236-242 |
| "Invalid prefix" error of the decode stream | warning; the stream is reset and the token decoded again | vllm/v1/engine/detokenizer.py:243-258 |
| multimodal cache miss on a pooling request | a retryable RequestError in the output | vllm/v1/engine/output_processor.py:805-816 |
Related concepts#
- The engine client: AsyncLLM in the API server: the output handler and the collectors.
- Data contracts between the parts of vLLM:
EngineCoreOutputandRequestOutputfield by field. - Metrics and observability: how to watch a vLLM server: what the stats calls in this file feed.
- Tour 1, stop 15: this file in the path of one request.
Check yourself#
- 1Where 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;EngineCoreOutputcarriesnew_token_ids(vllm/v1/engine/__init__.py:209-210). - 2A request hasmedium
stream_interval4 on a server with the default 1. Which tokens make a newRequestOutput?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 atvllm/v1/engine/output_processor.py:379-392. - 3A request withmedium
n = 2and no stream finishes child 1 before child 0. When does the client get an output?Show answer
Only when child 0 finishes too. In
FINAL_ONLYmode the parent stores each final output and returns all of them when no child is left (vllm/v1/engine/parallel_sampling.py:122-128). - 4The stop strings arehard
["END", "STOP"], withoutinclude_stop_str_in_output. How many characters does the stream hold back, and why?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).