Skip to content

[Frontend] Reuse prefill token ids on the decode chat path for disaggregated serving - #48145

Merged
NickLucche merged 18 commits into
vllm-project:mainfrom
eicherseiji:chat-token-in-text-out
Jul 29, 2026
Merged

[Frontend] Reuse prefill token ids on the decode chat path for disaggregated serving#48145
NickLucche merged 18 commits into
vllm-project:mainfrom
eicherseiji:chat-token-in-text-out

Conversation

@eicherseiji

@eicherseiji eicherseiji commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Why

In prefill and decode disaggregation, the prefill stage renders the prompt from messages and tokenizes it. The router forwards the same chat request to the decode stage, which renders and tokenizes it a second time. For long prompts, that repeated render and tokenize adds latency on the decode critical path.

The decode stage does not need to redo it. The prefill response already contains the token ids, and the router already forwards state to the decode request. This change makes the decode chat path use the forwarded ids and skip the render and tokenize.

The ids are carried on the chat path rather than /generate because the decode output must still be chat-shaped: detokenized text, tool and reasoning parsing, and streaming. /generate in #24261 returns raw tokens without that parsing. Running on the chat path also reuses the chat generator's existing stateful parser instead of adding a second one.

What changed

  • On decode, the renderer reads the forwarded ids from kv_transfer_params, builds the engine input from them, and skips chat templating and tokenization. It removes the key after reading it, so the ids are not copied into engine sampling metadata.
  • The reuse branch is in preprocess_chat and the Harmony path, not at the top of render_chat. Tool-choice validation and adjust_request still run, so grammar, structured output, and reasoning constraints still apply.
  • No public request field is added. protocol.py is unchanged, messages stays required, and a request without the ids renders as before.

Usage

The prefill response returns prompt_token_ids when return_token_ids is set. The router already writes kv_transfer_params on the decode request to coordinate the transfer. Reuse adds one more key to that dict.

  1. Send the prefill request with return_token_ids set. Read prompt_token_ids from the response.
  2. Set kv_transfer_params["prompt_token_ids"] on the decode request to those ids, next to the transfer flags.
  3. The decode stage returns the same chat-formatted, streamed output without re-tokenizing.
prefill = client.chat.completions.create(
    model=model, messages=messages,
    extra_body={"return_token_ids": True, "kv_transfer_params": {"do_remote_decode": True}},
)
ids = prefill.prompt_token_ids

decode = client.chat.completions.create(
    model=model, messages=messages, stream=True,
    extra_body={"kv_transfer_params": {"do_remote_prefill": True, "prompt_token_ids": ids}},
)

Why this is not a duplicate

Related work Relationship
#39756 skip decode-side re-tokenization Direct predecessor. Added a public prompt_token_ids field gated on transfer params and was abandoned in rebase. This version carries the ids in kv_transfer_params and adds no field.
#24261 /generate tokens in and out Merged. Returns raw tokens with no chat tool or reasoning parsing. This change reuses ids on the chat endpoint instead, so that parsing and detokenization still run.
#47161 RFC, #47301 streaming derender Open. The derender formats as a separate stateless service for scale-out setups such as llm-d and Dynamo, with client-carried state. #47301 is Part 1 and streams detokenized text only. Reasoning and tool parsing are stubbed for a later Part 2 that is not open yet. This change targets in-process prefill and decode and reuses the decode server's existing stateful parser, so parsed streaming chat output works today. Whether one parser can serve both is an open question, since this path runs in-process and stateful and the derender runs stateless across a fleet.
#22587 return_token_ids Merged. The token-out half. The prefill ids come from it.
#22817 Disaggregated Everything Open RFC. The parent token-in and token-out direction.

No open PR adds decode-side chat token reuse this way.

Open questions

  • Whether kv_transfer_params is the right channel for the ids, or whether a dedicated internal field is cleaner. The renderer reads a key from a dict that otherwise holds connector parameters.
  • Whether the streaming reasoning and tool parser should be shared with the derender work rather than written twice. The two run under different models, one stateful in-process and one stateless client-carried, so sharing is a design question for the derender and chat-serving owners, not a drop-in.

Model evaluation

This is a serving-path change and needs an accuracy check on a disaggregated setup, not only the mechanism tests below. Reusing the prefill ids should produce the same result as tokenizing on decode, and the round-trip test confirms the engine receives exactly the forwarded ids. The end-to-end accuracy run needs a prefill and decode deployment, which the single-GPU tests here do not cover.

  • GSM8K on a 1P1D setup, reuse on versus off, before merge.

Test plan

Added two tests to tests/entrypoints/openai/chat_completion/test_chat_completion.py. The decode request carries different messages from the reused ids, so a response whose prompt_token_ids match the forwarded ids confirms the ids were used and not the request's own messages. Generated text is not compared across requests because vLLM greedy decoding is not bitwise-reproducible.

pytest tests/entrypoints/openai/chat_completion/test_chat_completion.py -k prompt_token_ids -v

Result on 1x A10G: 2 passed, round-trip and streaming. ruff check and ruff format pass.

Note

AI assistance from Claude Code was used to prepare this change. The submitter reviewed it.

Add an optional `prompt_token_ids` field to `ChatCompletionRequest`.
When set, `OnlineRenderer.render_chat` feeds the ids straight to the
engine and skips chat templating and tokenization; the output is still
detokenized to text and runs tool/reasoning parsing (token-in,
text-out). This mirrors the token-in support already on
`/v1/completions` and lets disaggregated-serving frontends tokenize a
chat prompt once upstream and reuse the ids.

`messages` becomes optional (at least one of `messages` or
`prompt_token_ids` is required). When both are set, `prompt_token_ids`
takes precedence and `messages` is ignored for prompt construction.
Chat-template options that cannot apply to pre-tokenized input
(chat_template, chat_template_kwargs, documents, add_generation_prompt,
continue_final_message, add_special_tokens, echo) are rejected with a
400 rather than silently ignored.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Pre-tokenized input carries text token ids only; it cannot represent the
multimodal features (image/audio/video) that message content would
supply. Previously such content was silently dropped because
`prompt_token_ids` ignores `messages`, producing generation from bare
placeholder tokens.

Reject with a 400 when `prompt_token_ids` is combined with multimodal
message content, and add the multimodal-processing options
(`mm_processor_kwargs`, `media_io_kwargs`) to the incompatible-options
list. Text-only message content is still accepted and ignored, so the
priority semantics are unchanged.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Address review of the pre-tokenized chat input:

- Move the token-in short-circuit out of the top of render_chat and into
  preprocess_chat / _make_request_with_harmony. It now skips only chat
  templating and tokenization; tool-choice validation and the
  adjust_request hook (tool-call grammar, structured output, reasoning
  constraints) still run, matching the templated path. Placing it at the
  top previously bypassed adjust_request, so tool_choice="required" or a
  reasoning parser silently lost its constraints.
- Reject `truncate_prompt_tokens` / `truncation_side` with prompt_token_ids.
  The short-circuit does not truncate, but get_max_tokens still sized the
  output budget as if it had, so the two could desync past max_model_len.
- Check incompatible options by truthiness instead of key presence, so an
  explicit no-op (documents=None, add_special_tokens=False) is not rejected.
- Detect multimodal content structurally (any non-text content part) instead
  of a hand-maintained key list that duplicated and drifted from the
  chat_utils modality registry.
- Drop the redundant /tokenize round-trip in the rejection test.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Signed-off-by: Seiji Eicher <seiji@anyscale.com>
@mergify mergify Bot added the frontend label Jul 9, 2026
Fold the pre-tokenized chat input tests into the existing chat completions
suite instead of a standalone file, reusing its server and client fixtures
and running the equivalence check against real model weights.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Add the pre-tokenized chat input tests (token-in vs messages equivalence
and rejection of incompatible options and multimodal content) to the
existing chat completions suite, reusing its server and client fixtures.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
The equivalence test compared generated text between the messages path and
the token-in path. vLLM greedy decoding is not bitwise-reproducible across
requests (batch and chunked-prefill variance), so two calls with identical
prompt ids can diverge, which made the assertion flaky. A cold first request
diverging from later ones is enough to fail it.

Assert the deterministic contract instead: token-in feeds the engine exactly
the token ids the messages path produced (round-trip via return_token_ids)
and still returns detokenized text. Also drop the /tokenize round-trip and
the requests import.

Verified on 1x A10G: 4 passed (round-trip plus the three rejection cases).

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Add a streaming test for prompt_token_ids: the first chunk carries the
prompt token ids, and the deltas reconstruct the text with generated token
ids. This exercises the capability the scale-out path lacks. Its /generate
endpoint streams raw tokens, but /derender (tokens to a chat response with
tool and reasoning parsing) is non-streaming and one-shot, so streaming
chat-formatted output from pre-tokenized input is only available here.

Verified on 1x A10G: 5 passed (round-trip, streaming, three rejection cases).

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Pre-tokenized input now requires messages to be empty and rejects any
option that shapes templating, truncation, or multimodal processing,
returning 400 rather than silently ignoring them. The previous behavior
silently ignored messages while rejecting options, which was
inconsistent. Requiring empty messages also subsumes the multimodal
check, so the hand-rolled content-part scan is removed.

Add a test that a request with neither messages nor prompt_token_ids is
rejected, and cover the messages-present conflict.

Verified on 1x A10G: 6 passed.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Replace the public prompt_token_ids field with a private decode-side
reuse path. In disaggregated serving the router forwards the prefill
stage's prompt token ids in kv_transfer_params; the decode-side renderer
reads them, builds the engine input directly, skips re-rendering and
re-tokenizing, and strips the key so the id list is not duplicated into
engine sampling metadata. tool-choice validation and adjust_request
still run, and output is detokenized with streaming.

This adds no public request surface. messages stays required, and there
is no field to make mutually exclusive, so the exclusivity validators
are removed. Requests that do not carry the ids render unchanged.

Verified on 1x A10G: 2 passed (round-trip, streaming).

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
@eicherseiji eicherseiji changed the title [Frontend] Support pre-tokenized input on chat completions [Frontend] Reuse prefill token ids on the decode chat path for disaggregated serving Jul 9, 2026
Comment thread vllm/renderers/online_renderer.py
Cover the _make_request_with_harmony reuse branch, which the existing
tests miss because they run a non-harmony model. A GPT-OSS server is
impractical to stand up in CI, so this unit-tests the branch directly on
a harmony-configured renderer: forwarded kv_transfer_params ids build the
engine input, the key is consumed, and other transfer params are kept.

Addresses review feedback on PR vllm-project#48145.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Comment thread vllm/renderers/online_renderer.py

@NickLucche NickLucche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @eicherseiji thanks a lot for the PR!
I am also not fully sure where we should be placing the token_ids, as the original tokens-in-out API entails the final "completions wrapping" to be delegated to the router/coordinator.
However, I agree we should avoid re-tokenization on D even for completions API and I agree it makes sense to do that while also allowing D to stream back completions chunks (instead of "raw" GenerateResponse).
Stuffing everything inside kv_transfer_params makes the interface opaque and basically undiscoverable, but I think we can still get this merged for now until tokens-in-out becomes more standard.

I would suggest adding the token_ids path to the docs, but other than that this LGTM.

@NickLucche NickLucche added the ready ONLY add when PR is ready to merge/full CI is needed label Jul 23, 2026
Add a section to the disaggregated prefilling guide covering how the decode chat path reuses prefill token ids carried in kv_transfer_params to skip re-tokenization, including the return_token_ids handshake and a usage example.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Tighten the disaggregated prefilling token-reuse section: scope it to the /v1/chat/completions endpoint, note the KV-connector prerequisite and that messages stays required, and trim to the request handshake and usage example.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
@mergify

mergify Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Documentation preview: https://vllm--48145.org.readthedocs.build/en/48145/

@mergify mergify Bot added the documentation Improvements or additions to documentation label Jul 23, 2026
@mergify

mergify Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Hi @eicherseiji, the pre-commit checks have failed. Please run:

uv pip install pre-commit>=4.5.1
pre-commit install
pre-commit run --all-files

Then, commit the changes and push to your branch.

For future commits, pre-commit will run automatically on changed files before each commit.

Comment thread vllm/renderers/online_renderer.py
@NickLucche
NickLucche enabled auto-merge (squash) July 24, 2026 09:51
eicherseiji and others added 2 commits July 27, 2026 12:14
test_kv_transfer_prompt_token_ids_round_trip and test_kv_transfer_prompt_token_ids_streaming
assert the Python renderer's kv_transfer_params prompt_token_ids reuse. The Rust
frontend re-tokenizes the request messages and does not implement that path, so
add both to the -k exclusion in the Rust OpenAI Coverage step (and its AMD copy).

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
auto-merge was automatically disabled July 28, 2026 17:27

Head branch was pushed to by a user without write access

@NickLucche
NickLucche merged commit 6370e53 into vllm-project:main Jul 29, 2026
65 checks passed
aoshen02 pushed a commit to zllion/vllm that referenced this pull request Aug 1, 2026
…regated serving (vllm-project#48145)

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
pranavthakur0-0 pushed a commit to pranavthakur0-0/vllm that referenced this pull request Aug 4, 2026
…regated serving (vllm-project#48145)

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
itej89 pushed a commit to itej89/vllm that referenced this pull request Aug 4, 2026
…regated serving (vllm-project#48145)

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Signed-off-by: Tej Kiran <kiran.tej@amd.com>
aditi-amd pushed a commit to aditi-amd/vllm that referenced this pull request Aug 4, 2026
…regated serving (vllm-project#48145)

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Signed-off-by: root <root@smci355-ccs-aus-m02-09.cs-aus.dcgpu>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/build documentation Improvements or additions to documentation frontend ready ONLY add when PR is ready to merge/full CI is needed rust

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants