[RFC]: Request level text and derender output on `/inference/v1/generate`
Motivation.
The disaggregated serving path in vLLM is render → generate → derender. /render turns an OpenAI request into a GenerateRequest (#36166). /inference/v1/generate runs it on the GPU tier and returns token IDs (#22817, #24261).
/v1/chat/completions/derender and /v1/completions/derender turn the result back into an OpenAI response (#42729).
This works well for batch and RL traffic where the caller already holds the complete GenerateResponse.
Latency sensitive interactive traffic in prefill/decode (P/D) deployments is a poor fit. Streaming derender (#47161) is a stateless per chunk translator, so every SSE chunk from generate costs the client another HTTP round trip:
- One
POSTper chunk with the client echoingstream_stateback on each call . - On the parsing path the full
chat_requestis re-sent with every chunk. - Parser state is rebuilt by replaying all output tokens each chunk. #50550 documents this as O(n³) character work over a generation and defers caching it.
All of this lands on the TPOT critical path and for parsed output the cost grows with response length. A benchmark
of the streaming derender PRs measured it (https://github.com/vllm-project/vllm/issues/56851#issuecomment-5685698033): 1×A100-40G, Qwen3-8B, hermes + qwen3 parsers, main plus #50550). At matched load, detokenize and parse on the derender tier cost ~9× the in-process CPU, and E2E p50 rose 15% on multi-thousand-token reasoning outputs. Tokens-in / text-out in a single call already works on the main server (/v1/completions accepts token IDs in prompt). The gap is specific to deployments whose wire format is GenerateRequest.
Tokenizer availability is per pool. Detokenization is already per request. Only one of these is a launch time decision:
| Granularity | Where it's decided today | |
|---|---|---|
| Tokenizer loaded | per pool, at launch | --tokens-only sets skip_tokenizer_init |
| Detokenize this request | per request | SamplingParams.detokenize, default True. GenerateRequest.sampling_params is a full SamplingParams, so it's on the wire today. |
The only thing that makes detokenization look launch level is --tokens-only overriding the request value. A generate server launched with --enable-scale-out and without --tokens-only already runs a FastIncrementalDetokenizer for every request. The handler then builds the response from output.token_ids only and throws output.text away.
P/D makes request level the natural grain:
- Only the terminal leg's output reaches the client. Proxies run the prefill leg with
max_tokens=1and keep onlykv_transfer_params. Decode recomputes the last prompt token to sample the first output token itself. - Decode already has everything detokenization needs.
GenerateRequest.token_idscarries the full prompt and the detokenizer primes itsDecodeStreamfrom it. No detokenizer state crosses the P→D boundary. - State lives with the live request.
RequestStateowns the detokenizer and is popped on finish and on abort/disconnect. There's no session registry or eviction. That's the property #47161 couldn't get on the stateless derender tier which has no live request to hang state on. - No new pool flavor. P/D fleets already run prefill and decode pools with different launch configs. Prefill pools stay
--tokens-only. Decode pools load a tokenizer.
This also continues #22817's original design, which says "We can make Detokenization step optional in the AsyncLLM" and sketches a service mapping GenerateRequest → ChatCompletionResponse.
Proposed Change.
1. A request-level output_mode field
Add a top level field to GenerateRequest:
output_mode: Literal["tokens", "text", "derender"] = "tokens"
It's a new field rather than a reuse of sampling_params.detokenize. That field defaults to True, so keying response content off it would change every existing response on tokenizer loaded servers. It's also an engine field and response shape is a serving concern.
| Level | Response adds | Equivalent today | Server needs |
|---|---|---|---|
tokens (default) |
nothing, unchanged behavior | /inference/v1/generate |
nothing |
text |
detokenized text, resolved logprob tokens |
/v1/completions/derender |
tokenizer |
derender |
content, reasoning, tool_calls |
/v1/chat/completions/derender |
tokenizer, parser config, per request parser context |
2. Response shape (proposed, see open question 1)
Keep the generate format and add optional fields that reuse existing OpenAI types:
class GenerateResponseChoice(BaseModel):
... # index, logprobs, finish_reason, token_ids, routed_experts, sampling_mask
text: str | None = None # output_mode="text"
message: ChatMessage | None = None # output_mode="derender"
class GenerateResponseStreamChoice(BaseModel):
...
text: str | None = None # output_mode="text", delta text
delta: DeltaMessage | None = None # output_mode="derender"
- One response schema for the endpoint regardless of mode. Generate only fields (
token_ids,routed_experts,sampling_mask,kv_transfer_params) keep their place. message/deltaare the same types/derenderand/v1/chat/completionsemit, so the parity check compares field for field.- New fields are
Noneand omitted unless requested, so existing clients see no change.
Example decode-leg request and stream chunk:
{"token_ids": [151644, 872, ...], "sampling_params": {"max_tokens": 512},
"stream": true, "output_mode": "text", "kv_transfer_params": {...}}
{"request_id": "...", "choices": [{"index": 0, "token_ids": [9707], "text": " Hello"}]}
3. Parser context for the derender level (proposed, see open question 2)
Parsers are built per request from context GenerateRequest doesn't carry today: tools, tool_choice, chat_template_kwargs. Batch /derender solves this by taking the post adjust_request
ChatCompletionRequest. The proposal mirrors it:
chat_request: ChatCompletionRequest | None = None # used only when output_mode="derender"
- It's sent once per request, not once per chunk as with streaming derender.
- It reuses the batch derender path (
OnlineDerenderer.derender_chat) and its parity tests with stop string handling added (section 9). That path runs on the renderer's executor which hasrenderer_num_workersthreads, default1. It runs once per request and decode pools do no prompt tokenization, so contention is low but the pool size caps concurrentderendercalls. - Without
chat_request, a server with a reasoning or tool parser configured returns 400 (section 6). Batch/derenderfalls back to plain detokenization instead which can mix reasoning or tool call markup intomessage.content. Streaming derender already fails closed for parser configured models. Clients that want plain text usetext. On a server with no parser,derenderwithoutchat_requestputs the plain text inmessage.content.
4. Streaming
text:textcarries each chunk's delta from the request's existing incremental detokenizer (RequestOutputKind.DELTA). No extra state.derender: oneParserper choice, created when the request starts and kept for its lifetime, the same way/v1/chat/completionsstreams today. There's no replay, nostream_stateand no per chunkchat_request.- End of stream: at
textandderenderlevels, the stream emits a chunk whenever the engine output carries new text or afinish_reason, even with no new token IDs. That covers the final output the engine produces on/abort_requestswhich has no token IDs but can flush text held back for stop string matching. The generate stream skips outputs with no new token IDs today. Onmain, #53187 only keeps them while prompt metadata is pending), sotokensstreams already drop a finish only chunk. Changing that fortokensalters the existing stream, so it's a separate fix.
5. Logprobs
At text and derender levels, logprob entries carry decoded token strings and bytes, as /derender produces, instead of the token_id:N placeholders generate emits today. At the tokens level nothing changes.
Dense logprobs are the expensive case. At top_logprobs=20 the benchmark measured 355 µs per logprob-bearing token and 0.83 of a frontend core within 1.2× of the GPU's token rate. On a tokenizer loaded server that cost is already paid at the tokens level: LogprobsProcessor decodes every top-k token whenever sampling_params.detokenize is trueand generate then discards the strings. Clients that want wide logprobs off the decode host sendoutput_mode="tokens"withdetokenize: false(which rules outstopstrings) or use a--tokens-only` pool. The Phase 1 docs should say so.
6. Validation (fail loud)
output_mode != "tokens"on a server without a tokenizer → 400. Today's no-op detokenizer returns"", so a naive implementation would return empty text with a 200 for any request routed to a--tokens-onlypool.output_mode != "tokens"withsampling_params.detokenizeexplicitlyfalse→ 400.chat_requestset withoutoutput_mode="derender"→ 400, rather than silently ignoring it.output_mode="derender"withoutchat_requeston a server with a reasoning or tool parser configured → 400, rather than falling back to plain text that may contain reasoning or tool call markup.
Rollout note: GenerateRequest is a plain pydantic BaseModel, so servers without this change ignore output_mode and return tokens only with a 200. Clients should check for text / message in the response.
7. Role agnostic P/D usage
The field has no behavior tied to pod role or kv_transfer_params. The same decode pod may serve some requests decode only and others after a remote prefill, so role keyed behavior would differ across those paths. Orchestrators set output_mode and chat_request on the terminal leg only. If they reach a tokenizer free prefill pool anyway, the 400 above surfaces the misconfiguration.
For llm-d: the coordinator builds the prefill request from scratch, so it needs no change. The NIXL v2 sidecar reuses the client body for prefill and overrides a fixed set of fields (stream, token limits, kv_transfer_params). It would need to reset output_mode and drop chat_request on the prefill request, keeping both for decode. Resetting only output_mode leaves chat_request on a tokens request, which section 6 rejects.
8. Decoupling --tokens-only
A decode pool needs the generate API surface with a tokenizer. --tokens-only currently does four jobs:
| Job | Where | Proposal |
|---|---|---|
| Skip tokenizer init | [arg_utils][tokens-only-tokenizer] | keep |
Force detokenize=False |
[factories][factories-tokens-only] → [serving][force-no-detok] | keep |
Register /abort_requests |
[api_router][abort-gate] | also register when generate is served on a tokenizer loaded server (open question 4) |
| Imply scale-out endpoints | factories.py:73 |
keep |
A decode pool then runs --enable-scale-out with a tokenizer and without --tokens-only.
9. Parity and shared code
The inline path and /derender must produce identical output or clients get different text depending on deployment.
- Both go through the same
Parsercode, with no parallel copy. - Extend the round-trip parity test (#48617) to assert
generate(output_mode=X)matchesderender(generate(output_mode="tokens"))fortextandderender, batch and streaming. - Streaming derender drops logprobs on
main, so the streaming logprob checks depend on #55029. - Stop strings. Inline levels follow the engine's stop handling, like
/v1/completionsand/v1/chat/completions: a matched stop string is cut from the text unlessinclude_stop_str_in_outputis set. Token IDs keep every generated token, so decoding them in/derenderbrings the stop string back, plus anything after it in the same token. For requests withstop, the test asserts inline output matches the coupled endpoint and that/derenderoutput starts with the inline text and differs only after it. Thetokensleg has to run on a tokenizer-loaded server, since--tokens-onlyignoresstoptoday (related bug below). OnlineDerenderer.derender_chat` re-decodes token IDs, so the Phase 2 inline path has to apply the stop truncation before parsing. - End of stream. Tests cover a final chunk carrying only buffered text or only a
finish_reason, including after/abort_requests(section 4).
10. Rust frontend
rust/src/server/src/routes/inference/generate/types.rs mirrors GenerateRequest / GenerateResponse. The protocol change should be agreed here once and mirrored there. The Rust derender level depends on the Rust /derender work (#53223).
11. Phasing
| Phase | Scope |
|---|---|
| 1 | output_mode field, text level (batch and streaming), logprob resolution, validation, /abort_requests decoupling, docs, latency benchmarks (section 12) |
| 2 | derender level, batch: chat_request context, message on the choice, reusing OnlineDerenderer.derender_chat |
| 3 | derender level, streaming: request-scoped parsers, delta on stream choices, parity CI extension, latency benchmarks (section 12) |
| — | Rust frontend mirror, tracked with the Rust frontend owners |
12. Latency benchmarks
The CPU numbers above don't cover latency. The Phase 1 and Phase 3 PRs report TTFT and TPOT for:
- inline
text/derenderagainst generate plus streaming derender, across concurrency levels and response lengths. tokensrequests on the same pod, with and withouttextorderendertraffic mixed in.
Alternatives considered
- Streaming derender as-is (#47161). Keeps the GPU tier tokenizer free but puts a round trip and echoed state on every chunk, plus replay for parsed output. It stays the right tool for stateless postprocessing (RL, batch, gateways that own postprocessing). This RFC complements it and doesn't replace it.
- The gateway composes generate + derender. The per chunk hop moves from client to gateway rather than going away. Holding state in the gateway instead means reimplementing postprocessing there.
- Server-side derender sessions keyed by
request_id. Rejected in #47161 (Option B) over session lifecycle, eviction and memory. The derender tier has no live request to attach state to. The generate server does. - Token IDs on
/v1/chat/completions. Chat has no token ID input (messagesis required). Even with one, a render producedGenerateRequestcarries resolvedSamplingParamsthat don't map back onto OpenAI request fields without re-resolving and orchestrators on the generate format would have to run two wire formats. - Run the coupled OpenAI server on decode pools. Decode pods leave the generate protocol with the same two format problem as (4).
- Reuse
sampling_params.detokenizeas the switch. It defaults toTrue, so every existing response on a tokenizer loaded server would grow text and token only clients would pay for detokenization by default.
Costs and risks
- CPU on the decode host. Detokenization and parsing run in the API server process, not the engine core. They don't block the scheduler but they use host CPU.
textadds little on a tokenizer loaded server because detokenization already runs there today. Parsing and logprob resolution are the new work. In the benchmark, with the GPU saturated, the in-process frontend stayed under 0.25 of a core for chat, reasoning and tool-calling traffic (24–144 µs per token), at least 3.5× headroom over the GPU's token rate. That was one A100 and a faster GPU narrows the gap.--api-server-countscales the frontend if it becomes the limit. Dense logprobs are the exception (section 5). Opt-in per request bounds the cost to the traffic that asks for it. - Parser config drift. Decode pools need the same
--reasoning-parser,--tool-call-parserand--enable-auto-tool-choiceas the render tier. That's the same obligation the derender tier already has. - Two call sites for postprocessing. Mitigated by shared
Parsercode and the parity CI in section 9. - Security. The inline path postprocesses engine produced tokens bounded by the request's resolved limits. It doesn't accept caller supplied output token IDs the way
/derenderdoes, so it doesn't need/derender's explicit bounds checks.chat_requestis caller supplied and needs the same validation as on the chat and derender endpoints.
Open questions
- Response shape. An extended generate format with
text/message/delta(proposed) or returnChatCompletionResponse/ChatCompletionStreamResponsedirectly foroutput_mode="derender"? The latter is a drop-in for existing/derenderconsumers but the endpoint's response schema would then depend on a request field and the generate only fields would need a home.- Feedback so far: @nilig supports extending the generate response format (https://github.com/vllm-project/vllm/issues/56851#issuecomment-5692634586).
- Parser context. Embed the post-
adjust_requestchat_request(proposed), or have/renderemit a slim derender context (tools,tool_choice,chat_template_kwargs, plusadjust_request-derived state) that clients pass through? The slim form is smaller but first needs a settled answer to the openadjust_requestquestion from #42729. - Naming.
output_modewithtokens | text | derender, or something else? /abort_requests. Register it whenever generate is served, add a separate flag or leave it tied to--tokens-only?- Feedback so far: @nilig wants it whenever generate is served, including on decode pools that load a tokenizer (https://github.com/vllm-project/vllm/issues/56851#issuecomment-5692634586).
- Completions shaped parsing. Is
textenough for completions shaped clients or does anyone need reasoning/tool parsing on completions? Parsing on/v1/completions/derenderis chat-only today.- Feedback so far: @shimib's logprob-heavy traffic is completions shaped and needs
textplus resolved logprobs, not parsing (https://github.com/vllm-project/vllm/issues/56851#issuecomment-5685698033).
- Feedback so far: @shimib's logprob-heavy traffic is completions shaped and needs
Feedback Period.
No response
CC List.
@chaunceyjiang @DarkLight1337 @vMaroon @NickLucche @nilig @njhill @robertgshaw2-redhat @sagearc
Any Other Things.
Related work
- #47161 / #50550: streaming derender and its parse path. Complementary to this RFC (Alternatives, item 1).
- #47313: adds
stop_reasonto generate response choices. It touches the same protocol and serving files. and the matched stop string would let/derendercut its text there too (section 9). - #36983 / #51641:
delta_textanddelta_token_idsdesync when stop strings are buffered. Streamingtextinherits this and should use the fix. - #53223: Rust
/derenderendpoints. - #55029: resolved logprobs on streaming derender chunks. The streaming logprob parity checks in section 9 need it.
Related bug, filed separately: #TBD. On --tokens-only servers, stop strings are silently ignored. SamplingParams validates while detokenize is still True, then the server sets it to False and the no-op detokenizer never matches. Generation runs to EOS or max_tokens with a 200. It should be a 400, independent of this RFC. Once decode pools run with a tokenizer, stop also works there.
Out of scope
- Skipping detokenization for
output_mode="tokens"on tokenizer loaded servers where today it runs and its output is discarded. It's a worthwhile performance follow up but it changes existing behavior (stopstrings need detok). - Any change to
/derenderitself.
Before submitting a new issue...
- Make s
Source: vllm-project/vllm