[Bugfix] Handle empty FlatLogprobs slices and delta output (#53704)

Signed-off-by: tony <864832769@qq.com>

Tony committed Aug 26, 2026 at 21:00 UTC 903a02192fca19e4c89705af7017c0f24971ea4f
4 files changed +53 -7
tests/test_logprobs.py
+7
@@ -208,3 +208,10 @@ def test_flat_logprobs_access() -> None:
208 assert logprobs_last2.logprobs == [0.4, 0.5, 0.6, 0.1]
209 assert logprobs_last2.ranks == [40, 50, 60, 10]
210 assert logprobs_last2.decoded_tokens == ["40", "50", "60", "10"]
211 +
212 + for empty_slice in (slice(0, 0), slice(3, 3), slice(10, 10)):
213 + empty = logprobs[empty_slice]
214 + assert isinstance(empty, FlatLogprobs)
215 + assert len(empty) == 0
216 + assert empty.start_indices == []
217 + assert empty.end_indices == []
tests/v1/engine/test_output_processor.py
+32 -2
@@ -3,6 +3,7 @@
3
4 import math
5 import time
6 +from unittest.mock import MagicMock
7
8 import pytest
9
@@ -14,7 +15,7 @@ from tests.v1.engine.utils import (
15 MockEngineCore,
16 )
17 from vllm import PoolingParams
17 -from vllm.logprobs import PromptLogprobs, SampleLogprobs
18 +from vllm.logprobs import FlatLogprobs, Logprob, PromptLogprobs, SampleLogprobs
19 from vllm.lora.request import LoRARequest
20 from vllm.outputs import CompletionOutput, RequestOutput
21 from vllm.sampling_params import RequestOutputKind, SamplingParams
@@ -26,10 +27,39 @@ from vllm.v1.engine import (
27 EngineCoreRequest,
28 FinishReason,
29 )
29 -from vllm.v1.engine.output_processor import OutputProcessor, RequestOutputCollector
30 +from vllm.v1.engine.output_processor import (
31 + OutputProcessor,
32 + RequestOutputCollector,
33 + RequestState,
34 +)
35 from vllm.v1.metrics.stats import IterationStats, SchedulerStats
36
37
38 +@pytest.mark.parametrize("flat_logprobs", [False, True])
39 +def test_delta_output_without_new_tokens_returns_empty_logprobs(
40 + flat_logprobs: bool,
41 +) -> None:
42 + accumulated = FlatLogprobs() if flat_logprobs else []
43 + accumulated.append({1: Logprob(logprob=-0.5, rank=1)})
44 +
45 + state = RequestState.__new__(RequestState)
46 + state.detokenizer = MagicMock()
47 + state.detokenizer.get_next_output_text.return_value = ""
48 + state.logprobs_processor = MagicMock()
49 + state.logprobs_processor.logprobs = accumulated
50 + state.logprobs_processor.cumulative_logprob = -0.5
51 + state.output_kind = RequestOutputKind.DELTA
52 + state.request_index = 0
53 + state.sampling_mask_chunks = []
54 + state.routed_experts_chunks = []
55 + state.spec_decode_metrics = None
56 +
57 + output = state._new_completion_output([], None, None)
58 +
59 + assert isinstance(output.logprobs, FlatLogprobs if flat_logprobs else list)
60 + assert len(output.logprobs) == 0
61 +
62 +
63 def _ref_convert_id_to_token(
64 tokenizer: TokenizerLike,
65 token_id: int,
vllm/logprobs.py
+9 -4
@@ -119,13 +119,18 @@ class FlatLogprobs(MutableSequence[LogprobsOnePosition | None]):
119 for i in range(self.start_indices[index], self.end_indices[index])
120 }
121 elif isinstance(index, slice):
122 - min_index = self.start_indices[index][0]
123 - max_index = self.end_indices[index][-1]
122 + selected_starts = self.start_indices[index]
123 + selected_ends = self.end_indices[index]
124 + # Empty slices have no source offset to normalize.
125 + if not selected_starts:
126 + return FlatLogprobs()
127 + min_index = selected_starts[0]
128 + max_index = selected_ends[-1]
129 return FlatLogprobs(
130 # Shift updated start_indices and end_indices to
131 # be 0-indexed
127 - start_indices=[i - min_index for i in self.start_indices[index]],
128 - end_indices=[i - min_index for i in self.end_indices[index]],
132 + start_indices=[i - min_index for i in selected_starts],
133 + end_indices=[i - min_index for i in selected_ends],
134 token_ids=self.token_ids[min_index:max_index],
135 logprobs=self.logprobs[min_index:max_index],
136 ranks=self.ranks[min_index:max_index],
vllm/v1/engine/output_processor.py
+5 -1
@@ -411,7 +411,11 @@ class RequestState:
411 # Prepare logprobs, based on delta mode
412 logprobs = self.logprobs_processor.logprobs
413 if delta and logprobs:
414 - logprobs = logprobs[-len(token_ids) :]
414 + num_new_tokens = len(token_ids)
415 + # Avoid [-0:], which returns the full accumulated history when a
416 + # delta contains no new token IDs. [:0] preserves the concrete
417 + # list or FlatLogprobs representation while returning no entries.
418 + logprobs = logprobs[-num_new_tokens:] if num_new_tokens else logprobs[:0]
419
420 sampling_mask = None
421 if finished and self.sampling_mask_chunks: