[Bugfix] Preserve sampling masks in DELTA and TITO streaming outputs (#55935)

Signed-off-by: aoshen02 <aoshen@inferact.ai> Signed-off-by: fh3 <a@b> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: fh3 <a@b> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

aoshen02 committed Oct 1, 2026 at 14:43 UTC 4a9d6f957203902ea6bff0b275c26876e5a47e46
8 files changed +135 -6
docs/training/sampling_mask.md
+2 -2
@@ -53,6 +53,8 @@ The mask is also available via the `/inference/v1/generate` HTTP endpoint:
53 }
54 ```
55
56 +With `stream=true`, each chunk carries the masks of the tokens it emits.
57 +
58 ## Requirements
59
60 | Requirement | Reason |
@@ -145,5 +147,3 @@ Each position then carries the sampled token's logprob and the top-128
147 - **Engine-level flag:** `--return-sampling-mask` globally disables the
148 FlashInfer fused sampler. All requests pay the cost of the PyTorch sampling
149 path, even if they don't need the mask.
148 -- **No streaming support:** The mask is returned only in the final response,
149 - not in intermediate streaming chunks.
rust/src/server/src/routes/inference/generate.rs
+19 -1
@@ -231,6 +231,7 @@ async fn generate_chunk_stream(
231 logprobs,
232 finish_reason: finish_reason.map(|reason| reason.as_str().to_string()),
233 token_ids,
234 + sampling_mask: output.sampling_mask.map(|mask| mask.rows),
235 }],
236 usage: include_continuous_usage
237 .then(|| Usage::from_token_usage(usage, enable_prompt_tokens_details)),
@@ -331,6 +332,7 @@ fn collect_generate(
332 logprobs,
333 finish_reason: Some(finish_reason),
334 token_ids: collected.token_ids,
335 + sampling_mask: collected.sampling_mask.map(|mask| mask.rows),
336 }],
337 prompt_logprobs,
338 prompt_token_id_logprobs: collected
@@ -516,6 +518,7 @@ mod tests {
518 use futures::{TryStreamExt as _, stream};
519 use vllm_engine_core_client::protocol::multimodal::{MmModality, PlaceholderRange};
520 use vllm_engine_core_client::protocol::output::RequestSpecDecodeMetrics;
521 + use vllm_engine_core_client::protocol::sampling_mask::SamplingMask;
522 use vllm_llm::GeneratePromptInfo;
523
524 use super::*;
@@ -657,6 +660,18 @@ mod tests {
660 .expect("collect chunks")
661 }
662
663 + #[tokio::test]
664 + async fn generate_chunk_stream_returns_sampling_mask() {
665 + let mut output = stream_output(Some(&[11]), vec![33], Some(FinishReason::Length));
666 + output.sampling_mask = Some(SamplingMask {
667 + rows: vec![vec![33, 44]],
668 + });
669 +
670 + let chunks = collect_chunks(vec![output], false, None).await;
671 +
672 + assert_eq!(chunks[0].choices[0].sampling_mask, Some(vec![vec![33, 44]]));
673 + }
674 +
675 #[tokio::test]
676 async fn generate_chunk_stream_omits_prompt_metadata_by_default() {
677 let chunks = collect_chunks(
@@ -792,7 +807,9 @@ mod tests {
807 kv_transfer_params: None,
808 ec_transfer_params: None,
809 prompt_token_ids: vec![10, 20],
795 - sampling_mask: None,
810 + sampling_mask: Some(SamplingMask {
811 + rows: vec![vec![30, 40]],
812 + }),
813 spec_decode_metrics: None,
814 };
815
@@ -813,6 +830,7 @@ mod tests {
830
831 assert!(response.prompt_token_ids.is_none());
832 assert!(response.mm_placeholders.is_none());
833 + assert_eq!(response.choices[0].sampling_mask, Some(vec![vec![30, 40]]));
834 }
835
836 #[test]
rust/src/server/src/routes/inference/generate/types.rs
+2
@@ -64,6 +64,7 @@ pub(super) struct GenerateResponseChoice {
64 pub logprobs: Option<ChatLogProbs>,
65 pub finish_reason: Option<String>,
66 pub token_ids: Vec<u32>,
67 + pub sampling_mask: Option<Vec<Vec<u32>>>,
68 }
69
70 /// Mirrors the Python vLLM `GenerateResponseStreamChoice` class.
@@ -74,6 +75,7 @@ pub(super) struct GenerateResponseStreamChoice {
75 pub logprobs: Option<ChatLogProbs>,
76 pub finish_reason: Option<String>,
77 pub token_ids: Vec<u32>,
78 + pub sampling_mask: Option<Vec<Vec<u32>>>,
79 }
80
81 /// Mirrors the Python vLLM `GenerateStreamResponse` class.
tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py
+42 -1
@@ -34,7 +34,7 @@ from vllm.multimodal.inputs import (
34 MultiModalKwargsItem,
35 PlaceholderRange,
36 )
37 -from vllm.outputs import CompletionOutput, RequestOutput
37 +from vllm.outputs import CompletionOutput, RequestOutput, SamplingMask
38 from vllm.renderers import renderer_from_config
39 from vllm.renderers.online_renderer import OnlineRenderer
40 from vllm.sampling_params import SamplingParams
@@ -1154,3 +1154,44 @@ async def test_stream_prompt_tokens_details_zero_cached():
1154 # Zero cached tokens must be present, not omitted
1155 assert usage_chunk["usage"]["prompt_tokens_details"] is not None
1156 assert usage_chunk["usage"]["prompt_tokens_details"]["cached_tokens"] == 0
1157 +
1158 +
1159 +@pytest.mark.asyncio
1160 +@pytest.mark.parametrize("with_mask", [False, True])
1161 +async def test_stream_sampling_mask_matches_each_token_chunk(with_mask):
1162 + engine = _mock_engine()
1163 +
1164 + async def generate(*args, **kwargs):
1165 + for position, tokens in enumerate(([10], [20, 30])):
1166 + result = _make_request_output(
1167 + "req-mask",
1168 + token_ids=list(tokens),
1169 + finish_reason="length" if position else None,
1170 + finished=bool(position),
1171 + )
1172 + if with_mask:
1173 + result.outputs[0].sampling_mask = SamplingMask(
1174 + [[token, token + 1] for token in tokens]
1175 + )
1176 + yield result
1177 +
1178 + engine.generate = MagicMock(side_effect=generate)
1179 + serving = _build_serving_tokens(engine)
1180 + response = await serving.serve_tokens(
1181 + GenerateRequest(
1182 + model=MODEL_NAME, token_ids=[1, 2, 3], sampling_params={}, stream=True
1183 + )
1184 + )
1185 + chunks = _parse_sse_chunks([chunk async for chunk in response])
1186 + choices = [
1187 + choice
1188 + for chunk in chunks
1189 + if isinstance(chunk, dict)
1190 + for choice in chunk.get("choices", [])
1191 + ]
1192 + assert [choice["token_ids"] for choice in choices] == [[10], [20, 30]]
1193 + for choice in choices:
1194 + expected = (
1195 + [[token, token + 1] for token in choice["token_ids"]] if with_mask else None
1196 + )
1197 + assert choice["sampling_mask"] == expected
tests/v1/engine/test_output_processor.py
+56 -1
@@ -18,7 +18,7 @@ from tests.v1.engine.utils import (
18 from vllm import PoolingParams
19 from vllm.logprobs import FlatLogprobs, Logprob, PromptLogprobs, SampleLogprobs
20 from vllm.lora.request import LoRARequest
21 -from vllm.outputs import CompletionOutput, RequestOutput
21 +from vllm.outputs import CompletionOutput, RequestOutput, SamplingMask
22 from vllm.sampling_params import RequestOutputKind, SamplingParams
23 from vllm.tokenizers import TokenizerLike
24 from vllm.v1.engine import (
@@ -1575,3 +1575,58 @@ def test_abort_requests(runner: str, abort_by: str, dummy_test_vectors):
1575 output_processor.abort_requests([request.request_id], internal=True)
1576 else:
1577 output_processor.abort_requests([request.external_req_id], internal=False)
1578 +
1579 +
1580 +@pytest.mark.parametrize("output_kind", list(RequestOutputKind))
1581 +def test_sampling_masks_follow_output_kind(output_kind):
1582 + state = RequestState.__new__(RequestState)
1583 + state.detokenizer = MagicMock()
1584 + state.detokenizer.get_next_output_text.return_value = ""
1585 + state.logprobs_processor = MagicMock()
1586 + state.logprobs_processor.logprobs = None
1587 + state.logprobs_processor.cumulative_logprob = None
1588 + state.output_kind = output_kind
1589 + state.request_index = 0
1590 + state.sampling_mask_chunks = []
1591 + state.routed_experts_chunks = []
1592 + state.spec_decode_metrics = None
1593 +
1594 + supports = [[10, 11], [20], [30, 31]]
1595 + masks = []
1596 + for position, support in enumerate(supports):
1597 + state.sampling_mask_chunks.append(SamplingMaskLists(np.asarray(support)))
1598 + finish = FinishReason.LENGTH if position == len(supports) - 1 else None
1599 + mask = state._new_completion_output([support[0]], finish, None).sampling_mask
1600 + masks.append(mask.token_ids if mask is not None else None)
1601 +
1602 + if output_kind == RequestOutputKind.DELTA:
1603 + assert masks == [[support] for support in supports]
1604 + else:
1605 + assert masks == [None, None, supports]
1606 +
1607 +
1608 +def test_request_output_add_merges_delta_sampling_masks():
1609 + def delta(token_ids, sampling_mask):
1610 + completion = CompletionOutput(
1611 + index=0,
1612 + text="",
1613 + token_ids=token_ids,
1614 + cumulative_logprob=None,
1615 + logprobs=None,
1616 + sampling_mask=sampling_mask,
1617 + )
1618 + return RequestOutput(
1619 + request_id="request",
1620 + prompt=None,
1621 + prompt_token_ids=[1],
1622 + prompt_logprobs=None,
1623 + outputs=[completion],
1624 + finished=False,
1625 + )
1626 +
1627 + merged = delta([], None)
1628 + merged.add(delta([10], SamplingMask([[10, 11]])), aggregate=True)
1629 + merged.add(delta([20], SamplingMask([[20]])), aggregate=True)
1630 +
1631 + assert merged.outputs[0].token_ids == [10, 20]
1632 + assert merged.outputs[0].sampling_mask.token_ids == [[10, 11], [20]]
vllm/entrypoints/scale_out/token_in_token_out/serving.py
+5
@@ -515,6 +515,10 @@ class ServingTokens(GenerateBaseServing):
515 else None
516 )
517
518 + sampling_mask = None
519 + if output.sampling_mask is not None:
520 + sampling_mask = output.sampling_mask.token_ids
521 +
522 chunk = GenerateStreamResponse(
523 request_id=request_id,
524 choices=[
@@ -524,6 +528,7 @@ class ServingTokens(GenerateBaseServing):
528 finish_reason=finish_reason,
529 token_ids=as_list(delta_token_ids),
530 routed_experts=routed_experts_b64,
531 + sampling_mask=sampling_mask,
532 )
533 ],
534 )
vllm/outputs.py
+6
@@ -197,6 +197,12 @@ class RequestOutput:
197 if next_completion.logprobs:
198 assert completion.logprobs is not None
199 completion.logprobs.extend(next_completion.logprobs) # type: ignore[arg-type]
200 + if next_completion.sampling_mask is not None:
201 + if completion.sampling_mask is None:
202 + completion.sampling_mask = SamplingMask([])
203 + completion.sampling_mask.token_ids.extend(
204 + next_completion.sampling_mask.token_ids
205 + )
206 completion.cumulative_logprob = (
207 next_completion.cumulative_logprob
208 )
vllm/v1/engine/output_processor.py
+3 -1
@@ -439,7 +439,7 @@ class RequestState:
439 logprobs = logprobs[-num_new_tokens:] if num_new_tokens else logprobs[:0]
440
441 sampling_mask = None
442 - if finished and self.sampling_mask_chunks:
442 + if (delta or finished) and self.sampling_mask_chunks:
443 sampling_mask = SamplingMask(
444 [
445 position
@@ -447,6 +447,8 @@ class RequestState:
447 for position in chunk.to_nested_list()
448 ]
449 )
450 + if delta:
451 + self.sampling_mask_chunks.clear()
452
453 # Concatenate routed experts on finish
454 routed_experts = None