[Frontend] Reuse prefill token ids on the decode chat path for disaggregated serving - #48145
Conversation
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>
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>
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>
NickLucche
left a comment
There was a problem hiding this comment.
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.
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>
|
Documentation preview: https://vllm--48145.org.readthedocs.build/en/48145/ |
|
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-filesThen, commit the changes and push to your branch. For future commits, |
Signed-off-by: Seiji Eicher <seiji@anyscale.com>
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>
Head branch was pushed to by a user without write access
…regated serving (vllm-project#48145) Signed-off-by: Seiji Eicher <seiji@anyscale.com> Signed-off-by: aoshen02 <aoshen@inferact.ai>
…regated serving (vllm-project#48145) Signed-off-by: Seiji Eicher <seiji@anyscale.com>
…regated serving (vllm-project#48145) Signed-off-by: Seiji Eicher <seiji@anyscale.com> Signed-off-by: Tej Kiran <kiran.tej@amd.com>
…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>
Why
In prefill and decode disaggregation, the prefill stage renders the prompt from
messagesand 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
/generatebecause the decode output must still be chat-shaped: detokenized text, tool and reasoning parsing, and streaming./generatein #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
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.preprocess_chatand the Harmony path, not at the top ofrender_chat. Tool-choice validation andadjust_requeststill run, so grammar, structured output, and reasoning constraints still apply.protocol.pyis unchanged,messagesstays required, and a request without the ids renders as before.Usage
The prefill response returns
prompt_token_idswhenreturn_token_idsis set. The router already writeskv_transfer_paramson the decode request to coordinate the transfer. Reuse adds one more key to that dict.return_token_idsset. Readprompt_token_idsfrom the response.kv_transfer_params["prompt_token_ids"]on the decode request to those ids, next to the transfer flags.Why this is not a duplicate
prompt_token_idsfield gated on transfer params and was abandoned in rebase. This version carries the ids inkv_transfer_paramsand adds no field./generatetokens in and outreturn_token_idsNo open PR adds decode-side chat token reuse this way.
Open questions
kv_transfer_paramsis 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.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.
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 whoseprompt_token_idsmatch 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.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.