[Bugfix][Spec Decode] Trim token_ids/logprobs left past a stop string under speculative decoding (#47616)

Signed-off-by: Ting Sun <suntcrick@gmail.com>
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Co-authored-by: Nick Hill <nickhill123@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ting SUN
2026-10-09 14:22:31 -07:00
committed by GitHub
co-authored by Nick Hill Claude Opus 5.5
parent 35e920d671
commit 51144dd83e
3 changed files with 158 additions and 13 deletions
@@ -1,11 +1,14 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import numpy as np
import pytest
from vllm.sampling_params import SamplingParams
from vllm.v1.engine import EngineCoreRequest
from vllm.v1.engine import EngineCoreOutput, EngineCoreRequest, FinishReason
from vllm.v1.engine.detokenizer import BaseIncrementalDetokenizer
from vllm.v1.engine.output_processor import OutputProcessor
from vllm.v1.outputs import LogprobsLists, SamplingMaskLists
@pytest.fixture(params=[True, False])
@@ -82,7 +85,7 @@ def test_stop_string_while_stop_token_terminates(include_stop_str_in_output: boo
# Simulate that the last token ('Z') is a stop token (stop_terminated=True).
result = detok.update(new_token_ids=token_ids, stop_terminated=True)
# The update should not report a stop string
# The update should report the matched stop string.
assert result == stop_string
# Output text should reflect stop-string handling:
@@ -91,9 +94,111 @@ def test_stop_string_while_stop_token_terminates(include_stop_str_in_output: boo
expected_text = "abcd" if include_stop_str_in_output else "ab"
assert detok.output_text == expected_text
# The skipped final token should still be recorded in token_ids.
assert detok.output_token_ids == token_ids
# Tokens after the stop string, including the stop token, are dropped.
assert detok.output_token_ids == token_ids[:4]
# get_next_output_text should return the full text when finished=True.
# (Buffering only applies during streaming when finished=False.)
assert detok.get_next_output_text(finished=True, delta=False) == expected_text
@pytest.mark.parametrize(
"min_tokens,num_kept",
[(0, 2), (1, 2), (2, 4), (3, 4), (4, None)],
)
@pytest.mark.parametrize("num_prev_tokens", [0, 1])
def test_stop_string_min_tokens_within_step(
include_stop_str_in_output: bool, min_tokens, num_kept, num_prev_tokens
):
"""Stop strings completing within min_tokens are ignored, even when the
min_tokens boundary falls in the middle of a multi-token step."""
token_ids = [ord(c) for c in "cdcdx"]
req = _make_request(
stop=["cd"],
include_stop_str_in_output=include_stop_str_in_output,
min_tokens=min_tokens,
)
detok = _DummyDetokenizer(req)
if num_prev_tokens:
assert detok.update(token_ids[:num_prev_tokens], False) is None
result = detok.update(token_ids[num_prev_tokens:], False)
if num_kept is None:
assert result is None
assert detok.output_text == "cdcdx"
assert detok.output_token_ids == token_ids
return
assert result == "cd"
text_len = num_kept if include_stop_str_in_output else num_kept - 2
assert detok.output_text == "cdcdx"[:text_len]
assert detok.output_token_ids == token_ids[:num_kept]
@pytest.mark.parametrize(
"decoded_tokens,keep",
[
(["a", "b", "c", "d", "e", "f"], 4),
(["ab", "cdx", "ef"], 2),
(["ab", "", "c", "", "d", "", "ef"], 5),
],
)
@pytest.mark.parametrize("stop_terminated", [False, True])
def test_stop_string_trims_speculative_overflow(
include_stop_str_in_output: bool,
decoded_tokens,
keep,
stop_terminated: bool,
monkeypatch,
):
if stop_terminated:
# The engine also stopped on a stop token at the end of the batch.
decoded_tokens = decoded_tokens + [""]
token_ids = list(range(len(decoded_tokens)))
stop_string = "cd"
expected_token_ids = token_ids[:keep]
req = _make_request(
stop=[stop_string], include_stop_str_in_output=include_stop_str_in_output
)
req.external_req_id = req.request_id
assert req.sampling_params is not None
req.sampling_params.logprobs = 0
detok = _DummyDetokenizer(req)
monkeypatch.setattr(detok, "decode_next", decoded_tokens.__getitem__)
processor = OutputProcessor(tokenizer=None, log_stats=False)
processor.add_request(req, prompt=None)
processor.request_states[req.request_id].detokenizer = detok
outputs = processor.process_outputs(
[
EngineCoreOutput(
request_id=req.request_id,
new_token_ids=token_ids,
finish_reason=FinishReason.STOP if stop_terminated else None,
new_logprobs=LogprobsLists(
np.array(token_ids).reshape(-1, 1),
np.zeros((len(token_ids), 1)),
np.ones(len(token_ids), dtype=int),
None,
),
new_sampling_mask=SamplingMaskLists(
np.array(token_ids), np.arange(len(token_ids) + 1)
),
routed_experts=np.array(token_ids).reshape(-1, 1, 1),
)
]
)
result = outputs.request_outputs[0].outputs[0]
assert result.stop_reason == stop_string
expected_text = "abcd" if include_stop_str_in_output else "ab"
assert detok.output_text == expected_text
assert detok.output_token_ids == expected_token_ids
assert result.token_ids == expected_token_ids
assert result.logprobs is not None
assert len(result.logprobs) == len(expected_token_ids)
assert result.sampling_mask is not None
assert result.sampling_mask.token_ids == [[token] for token in expected_token_ids]
assert result.routed_experts is not None
assert result.routed_experts.ravel().tolist() == expected_token_ids
+19 -5
View File
@@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import sys
from abc import ABC, abstractmethod
from bisect import bisect_left
import tokenizers.decoders
from tokenizers import Tokenizer
@@ -112,21 +113,27 @@ class BaseIncrementalDetokenizer(IncrementalDetokenizer, ABC):
skipped_stop_token_id = None
# 1) Detokenize the new token ids incrementally.
stop_check_offset = len(self.output_text)
num_prev_tokens = self.num_output_tokens()
first_new_token_index = len(self.token_ids)
# Output text offsets at the boundaries of the new tokens.
token_offsets = [len(self.output_text)] if self.stop else None
for new_token_id in new_token_ids:
self.token_ids.append(new_token_id)
self.output_text += self.decode_next(new_token_id)
# Support min_tokens, see https://github.com/vllm-project/vllm/pull/22014
if self.min_tokens and self.num_output_tokens() <= self.min_tokens:
stop_check_offset = len(self.output_text)
if token_offsets is not None:
token_offsets.append(len(self.output_text))
if skipped_stop_token_id is not None:
# Cleanup after skipping detokenization.
self.token_ids.append(skipped_stop_token_id)
if token_offsets is not None:
token_offsets.append(len(self.output_text))
# 2) Evaluate stop strings.
stop_string = None
if self.stop and self.num_output_tokens() > self.min_tokens:
if token_offsets is not None and self.num_output_tokens() > self.min_tokens:
# Support min_tokens, see https://github.com/vllm-project/vllm/pull/22014
stop_check_offset = token_offsets[max(self.min_tokens - num_prev_tokens, 0)]
stop = check_stop_strings(
output_text=self.output_text,
new_char_count=len(self.output_text) - stop_check_offset,
@@ -135,6 +142,13 @@ class BaseIncrementalDetokenizer(IncrementalDetokenizer, ABC):
)
if stop is not None:
stop_string, truncate_to = stop
stop_end = len(self.output_text) if truncate_to == -1 else truncate_to
if not self.include_stop_str_in_output:
stop_end += len(stop_string)
# Drop tokens generated after the one completing the stop string
# (possible when multiple tokens are appended in one step).
num_kept = bisect_left(token_offsets, stop_end, lo=1)
del self.token_ids[first_new_token_index + num_kept :]
if truncate_to != -1:
self.output_text = self.output_text[:truncate_to]
+30 -4
View File
@@ -736,17 +736,22 @@ class OutputProcessor:
if pooling_output is None:
assert req_state.detokenizer is not None
assert req_state.logprobs_processor is not None
if engine_core_output.new_sampling_mask is not None:
req_state.sampling_mask_chunks.append(
engine_core_output.new_sampling_mask
)
# 2) Detokenize the token ids into text and perform stop checks.
num_prev_tokens = req_state.detokenizer.num_output_tokens()
stop_string = req_state.detokenizer.update(
new_token_ids, finish_reason == FinishReason.STOP
)
if stop_string:
finish_reason = FinishReason.STOP
stop_reason = stop_string
self._trim_surplus_tokens(
req_state, engine_core_output, num_prev_tokens
)
if engine_core_output.new_sampling_mask is not None:
req_state.sampling_mask_chunks.append(
engine_core_output.new_sampling_mask
)
# 3) Compute sample and prompt logprobs for request,
# if required.
@@ -799,6 +804,27 @@ class OutputProcessor:
reqs_to_abort=reqs_to_abort,
)
@staticmethod
def _trim_surplus_tokens(
req_state: RequestState, output: EngineCoreOutput, num_prev_tokens: int
) -> None:
"""Drop new tokens generated past a stop string matched by the
detokenizer, e.g. when multiple tokens are accepted in one step."""
assert req_state.detokenizer is not None
num_kept = req_state.detokenizer.num_output_tokens() - num_prev_tokens
num_dropped = len(output.new_token_ids) - num_kept
if num_dropped <= 0:
return
del output.new_token_ids[num_kept:]
if output.new_logprobs is not None:
output.new_logprobs = output.new_logprobs.slice_request(0, num_kept)
if output.new_sampling_mask is not None:
output.new_sampling_mask = output.new_sampling_mask.slice_request(
0, num_kept
)
if output.routed_experts is not None:
req_state.routed_experts_chunks[-1] = output.routed_experts[:-num_dropped]
def _finish_request(self, req_state: RequestState) -> None:
req_id = req_state.request_id
self.request_states.pop(req_id)