[Frontend] Attach resolved logprobs to streaming derender chunks (#55029)

Signed-off-by: Shimi Bandiel <shimib@google.com> Co-authored-by: Claude <noreply@anthropic.com>

Shimi Bandiel committed Sep 29, 2026 at 11:43 UTC 4dc57d44c4a790a210ad17cde2e1af98146f8bca
4 files changed +224 -8
docs/serving/online_serving/derenderer.md
+10 -1
@@ -98,7 +98,7 @@ The server keeps no state between calls. Everything the next call needs is in `s
98 - **Send the same context on every call.** `chat_request` and `prompt_token_ids` aren't kept between calls, so they go with every chunk including the usage chunk. Both are required when a tool or reasoning parser is configured. `prompt_token_ids` is the `token_ids` of the `GenerateRequest` returned by `/render`.
99 - **Don't forward `[DONE]`.** It marks the end of the generate stream and isn't a chunk.
100
101 -Streaming chunks don't carry logprobs yet, so `logprobs` on a generate chunk are dropped. The non-streaming endpoints do resolve them, including `token_id:N` placeholders.
101 +Streaming derender resolves `logprobs` on the plain detokenization path, including `token_id:N` placeholders, same as the non-streaming endpoints (see [Streaming state and logprobs](#streaming-state-and-logprobs)). The parser path doesn't resolve them yet and drops them.
102
103 ## Streaming cost
104
@@ -115,6 +115,15 @@ When a tool or reasoning parser is configured, parser internal state (buffered m
115
116 `output_token_ids` and `prompt_token_ids` are both bounded by `max_model_len` but callers streaming long reasoning traces through a parser configured model should expect materially more state transport and CPU cost than the plain detokenization path.
117
118 +## Streaming state and logprobs
119 +
120 +Streaming derender is stateless on the server side: all mutable state lives in the client-carried `stream_state` (`DerenderStreamState`), passed back on every per-chunk call. Besides the bounded incremental detokenization window (`prev_tokens`, `prefix_offset`, `read_offset`), `role_sent`, and the parser path's replay fields (see [Streaming cost](#streaming-cost)), the state carries two fields for logprob handling:
121 +
122 +- `logprob_context_token_ids`: the trailing sampled token IDs (at most 4) from previous chunks, used to seed byte-fallback (U+FFFD) correction so multi-byte characters whose tokens split across chunk boundaries still resolve to real strings
123 +- `logprob_text_offset`: the cumulative emitted text length, so `text_offset` in completion streaming logprobs stays absolute across chunks instead of restarting at 0
124 +
125 +When a streamed `GenerateResponseStreamChoice` carries `logprobs`, the `token_id:N` placeholders are resolved per chunk and the resolved logprobs are attached to the corresponding streamed choice — for chat as `ChatCompletionLogProbs`, for completions converted to the flat `CompletionLogProbs` lists. Chunks without `logprobs` produce choices with `logprobs: null`.
126 +
127 ## Example
128
129 The example below drives the full `render → generate → derender` round trip for a chat request against a GPU less render server (`/render`, `/derender`) and a token-in / token-out engine (`/inference/v1/generate`).
tests/entrypoints/scale_out/derender/test_derender_stream.py
+127
@@ -169,6 +169,7 @@ def _make_stream_chunk(
169 request_id: str = "test-req",
170 usage: dict | None = None,
171 metrics: PerRequestMetrics | None = None,
172 + logprobs: dict | None = None,
173 ) -> GenerateStreamResponse:
174 """Build a GenerateStreamResponse SSE chunk."""
175 return GenerateStreamResponse(
@@ -178,6 +179,7 @@ def _make_stream_chunk(
179 index=index,
180 token_ids=token_ids,
181 finish_reason=finish_reason,
182 + logprobs=logprobs,
183 )
184 ],
185 usage=UsageInfo(**usage) if usage else None,
@@ -185,6 +187,22 @@ def _make_stream_chunk(
187 )
188
189
190 +def _placeholder_logprobs(token_ids: list[int]) -> dict:
191 + """Per-token logprob entries using token_id:N placeholders, as sent by
192 + the generate worker."""
193 + return {
194 + "content": [
195 + {
196 + "token": f"token_id:{tid}",
197 + "logprob": -0.5,
198 + "bytes": None,
199 + "top_logprobs": [],
200 + }
201 + for tid in token_ids
202 + ]
203 + }
204 +
205 +
206 def _make_usage_chunk(
207 completion_tokens: int,
208 prompt_tokens: int = 0,
@@ -536,6 +554,115 @@ class TestDerenderCompletionStream:
554 assert chunk.choices[0].finish_reason == "length"
555
556
557 +class TestStreamLogprobs:
558 + """Streaming derender must carry per-chunk logprobs with placeholders
559 + resolved, matching what the generate streaming path emits."""
560 +
561 + @pytest.mark.asyncio
562 + async def test_chat_stream_logprobs_resolved_per_chunk(self, derenderer, tokenizer):
563 + """Each streamed chunk carries logprobs with token_id:N resolved."""
564 + token_ids = tokenizer.encode("hello world")[:6]
565 + mid = len(token_ids) // 2
566 +
567 + state = None
568 + for part in (token_ids[:mid], token_ids[mid:]):
569 + chunk, state = await derenderer.derender_chat_stream(
570 + model=MODEL_NAME,
571 + generate_chunk=_make_stream_chunk(
572 + part, logprobs=_placeholder_logprobs(part)
573 + ),
574 + state=state,
575 + )
576 + logprobs = chunk.choices[0].logprobs
577 + assert logprobs is not None and logprobs.content is not None
578 + assert len(logprobs.content) == len(part)
579 + for entry in logprobs.content:
580 + assert not entry.token.startswith("token_id:"), (
581 + f"placeholder not resolved: {entry.token!r}"
582 + )
583 +
584 + @pytest.mark.asyncio
585 + async def test_chat_stream_logprobs_multibyte_across_chunks(
586 + self, derenderer, tokenizer
587 + ):
588 + """Byte-fallback correction works when a multi-byte character's
589 + tokens are split across chunks (context carried in stream_state)."""
590 + token_ids = tokenizer.encode("👍", add_special_tokens=False)
591 + if len(token_ids) < 2 or "�" not in tokenizer.decode([token_ids[-1]]):
592 + pytest.skip("Tokenizer does not byte-split this character")
593 +
594 + state = None
595 + last_chunk = None
596 + # One token per chunk: every entry after the first needs cross-chunk
597 + # context to resolve without U+FFFD.
598 + for i, tid in enumerate(token_ids):
599 + last_chunk, state = await derenderer.derender_chat_stream(
600 + model=MODEL_NAME,
601 + generate_chunk=_make_stream_chunk(
602 + [tid], logprobs=_placeholder_logprobs([tid])
603 + ),
604 + state=state,
605 + )
606 +
607 + assert last_chunk is not None
608 + final_entry = last_chunk.choices[0].logprobs.content[0]
609 + assert not final_entry.token.endswith("�"), (
610 + "byte-fallback correction failed across chunk boundary: "
611 + f"{final_entry.token!r}"
612 + )
613 +
614 + @pytest.mark.asyncio
615 + async def test_completion_stream_logprobs_text_offset_absolute(
616 + self, derenderer, tokenizer
617 + ):
618 + """text_offset continues across chunks instead of restarting at 0."""
619 + token_ids = tokenizer.encode("hello world")[:6]
620 + mid = len(token_ids) // 2
621 +
622 + chunk1, state = await derenderer.derender_completion_stream(
623 + model=MODEL_NAME,
624 + generate_chunk=_make_stream_chunk(
625 + token_ids[:mid], logprobs=_placeholder_logprobs(token_ids[:mid])
626 + ),
627 + )
628 + chunk2, _ = await derenderer.derender_completion_stream(
629 + model=MODEL_NAME,
630 + generate_chunk=_make_stream_chunk(
631 + token_ids[mid:], logprobs=_placeholder_logprobs(token_ids[mid:])
632 + ),
633 + state=state,
634 + )
635 +
636 + assert chunk1.choices[0].logprobs.text_offset[0] == 0
637 + assert chunk2.choices[0].logprobs.text_offset[0] == len(chunk1.choices[0].text)
638 +
639 + @pytest.mark.asyncio
640 + async def test_stream_logprob_state_stays_bounded(self, derenderer, tokenizer):
641 + """Carried logprob context never exceeds the 4-token window."""
642 + token_ids = tokenizer.encode("the quick brown fox jumps over the lazy dog")
643 + state = None
644 + for tid in token_ids:
645 + _, state = await derenderer.derender_chat_stream(
646 + model=MODEL_NAME,
647 + generate_chunk=_make_stream_chunk(
648 + [tid], logprobs=_placeholder_logprobs([tid])
649 + ),
650 + state=state,
651 + )
652 + assert state is not None
653 + assert len(state.logprob_context_token_ids) <= 4
654 +
655 + @pytest.mark.asyncio
656 + async def test_stream_without_logprobs_unchanged(self, derenderer, tokenizer):
657 + """Chunks without logprobs keep logprobs=None on the output choice."""
658 + token_ids = tokenizer.encode("hello")[:3]
659 + chunk, _ = await derenderer.derender_chat_stream(
660 + model=MODEL_NAME,
661 + generate_chunk=_make_stream_chunk(token_ids),
662 + )
663 + assert chunk.choices[0].logprobs is None
664 +
665 +
666 class TestDerenderChatStream:
667 """derender_chat_stream: plain detok branch (no parser)."""
668
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+11
@@ -546,6 +546,17 @@ class DerenderStreamState(BaseModel):
546 window is transiently empty (e.g. usage only final chunk).
547 """
548
549 + logprob_context_token_ids: list[int] = Field(default_factory=list, max_length=4)
550 + """Trailing sampled token IDs carried across chunks so byte-fallback
551 + (U+FFFD) correction during logprob placeholder resolution has context
552 + at chunk boundaries. Bounded to the 4-token window that
553 + ``_correct_decoded_token`` reads."""
554 +
555 + logprob_text_offset: int = Field(default=0, ge=0)
556 + """Cumulative emitted text length. Seeds ``text_offset`` for completion
557 + streaming logprobs so offsets stay absolute across chunks, mirroring
558 + ``initial_text_offset`` in the generate streaming path."""
559 +
560 output_token_ids: list[int] = Field(default_factory=list)
561 """All output tokens seen so far. Parser path only.
562
vllm/renderers/online_derenderer.py
+76 -7
@@ -1,5 +1,6 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from collections.abc import Sequence
4 from typing import Any
5
6 from vllm.config import ModelConfig
@@ -368,9 +369,30 @@ class OnlineDerenderer:
369 tokenizer, delta_tids, updated_state, skip_special_tokens=skip_special
370 )
371
372 + # NOTE: parser-configured servers dispatch to
373 + # _derender_chat_stream_parsed above and never reach this plain
374 + # path. That parsed path does not resolve logprobs yet; when it
375 + # does, mirror the generate chat streaming path, which suppresses
376 + # logprobs entirely when a parser is configured and reasoning is
377 + # hidden, because decoded logprob token text would leak hidden
378 + # reasoning.
379 + resolved_logprobs = None
380 + if choice.logprobs is not None:
381 + resolved_logprobs = _resolve_logprobs(
382 + choice.logprobs,
383 + tokenizer,
384 + initial_context_token_ids=state.logprob_context_token_ids,
385 + )
386 +
387 include_role = not updated_state.role_sent
372 - if include_role:
373 - updated_state = updated_state.model_copy(update={"role_sent": True})
388 + updated_state = updated_state.model_copy(
389 + update={
390 + "role_sent": True,
391 + "logprob_context_token_ids": _logprob_context_tail(
392 + state.logprob_context_token_ids, delta_tids
393 + ),
394 + }
395 + )
396
397 delta = DeltaMessage(
398 role="assistant" if include_role else None,
@@ -380,6 +402,7 @@ class OnlineDerenderer:
402 ChatCompletionResponseStreamChoice(
403 index=choice.index,
404 delta=delta,
405 + logprobs=resolved_logprobs,
406 finish_reason=choice.finish_reason,
407 )
408 )
@@ -721,10 +744,32 @@ class OnlineDerenderer:
744 new_text, updated_state = await self._detokenize_delta_async(
745 tokenizer, delta_tids, updated_state, skip_special_tokens=skip_special
746 )
747 +
748 + completion_logprobs = None
749 + if choice.logprobs is not None:
750 + resolved = _resolve_logprobs(
751 + choice.logprobs,
752 + tokenizer,
753 + initial_context_token_ids=state.logprob_context_token_ids,
754 + )
755 + completion_logprobs = _convert_chat_logprobs_to_completion_logprobs(
756 + resolved, initial_text_offset=state.logprob_text_offset
757 + )
758 +
759 + updated_state = updated_state.model_copy(
760 + update={
761 + "logprob_context_token_ids": _logprob_context_tail(
762 + state.logprob_context_token_ids, delta_tids
763 + ),
764 + "logprob_text_offset": state.logprob_text_offset + len(new_text),
765 + }
766 + )
767 +
768 stream_choices.append(
769 CompletionResponseStreamChoice(
770 index=choice.index,
771 text=new_text,
772 + logprobs=completion_logprobs,
773 finish_reason=choice.finish_reason,
774 )
775 )
@@ -750,6 +795,18 @@ class OnlineDerenderer:
795 return chunk, updated_state
796
797
798 +# Number of preceding sampled token IDs `_correct_decoded_token` reads to
799 +# repair U+FFFD from byte-fallback tokenization.
800 +_LOGPROB_CONTEXT_WINDOW = 4
801 +
802 +
803 +def _logprob_context_tail(
804 + context_token_ids: list[int], delta_token_ids: list[int]
805 +) -> list[int]:
806 + """Advance the carried logprob context by this chunk's sampled tokens."""
807 + return (list(context_token_ids) + list(delta_token_ids))[-_LOGPROB_CONTEXT_WINDOW:]
808 +
809 +
810 def _parse_token_id_placeholder(token: str) -> int | None:
811 """Extract token ID from a 'token_id:N' placeholder string."""
812 if not token.startswith("token_id:"):
@@ -799,13 +856,20 @@ def _correct_decoded_token(
856
857
858 def _resolve_logprobs(
802 - logprobs: ChatCompletionLogProbs, tokenizer: TokenizerLike
859 + logprobs: ChatCompletionLogProbs,
860 + tokenizer: TokenizerLike,
861 + initial_context_token_ids: Sequence[int] = (),
862 ) -> ChatCompletionLogProbs:
804 - """Resolve token_id:N placeholders in a ChatCompletionLogProbs object."""
863 + """Resolve token_id:N placeholders in a ChatCompletionLogProbs object.
864 +
865 + ``initial_context_token_ids`` seeds the byte-fallback correction context
866 + with sampled IDs from preceding chunks (streaming), so multi-byte
867 + characters split across chunk boundaries still resolve.
868 + """
869 if logprobs.content is None:
870 return logprobs
871
808 - context_token_ids: list[int] = []
872 + context_token_ids: list[int] = list(initial_context_token_ids)
873 resolved_content = []
874
875 for entry in logprobs.content:
@@ -845,9 +909,14 @@ def _resolve_logprobs(
909
910 def _convert_chat_logprobs_to_completion_logprobs(
911 logprobs: ChatCompletionLogProbs,
912 + initial_text_offset: int = 0,
913 ) -> CompletionLogProbs:
914 """Convert ChatCompletionLogProbs (per-token objects) to CompletionLogProbs
850 - (parallel flat lists) as required by the /v1/completions response schema."""
915 + (parallel flat lists) as required by the /v1/completions response schema.
916 +
917 + ``initial_text_offset`` keeps ``text_offset`` absolute across streaming
918 + chunks, mirroring the generate streaming path.
919 + """
920 if logprobs.content is None:
921 return CompletionLogProbs()
922
@@ -856,7 +925,7 @@ def _convert_chat_logprobs_to_completion_logprobs(
925 top_logprobs_list: list[dict[str, float] | None] = []
926 text_offset: list[int] = []
927
859 - offset = 0
928 + offset = initial_text_offset
929 for entry in logprobs.content:
930 text_offset.append(offset)
931 tokens.append(entry.token)