fix: stop-string check misses early matches during speculative decoding (#23802)

Co-authored-by: xythink <xythink@users.noreply.github.com>
Co-authored-by: hnyls2002 <lsyincs@gmail.com>
Co-authored-by: Liangsheng Yin <hnyls2002@gmail.com>
This commit is contained in:
Yi Xie
2026-06-08 23:25:58 -07:00
committed by GitHub
co-authored by xythink hnyls2002 Liangsheng Yin
parent 991689fd0d
commit d145a6127a
2 changed files with 70 additions and 5 deletions
+10 -5
View File
@@ -1192,7 +1192,7 @@ class Req(ReqDllmMixin):
return self.surr_and_decode_ids, self.read_offset - self.surr_offset
def tail_str(self) -> str:
def tail_str(self, new_accepted_len: int = 1) -> str:
# Check stop strings and stop regex patterns together
if (
len(self.sampling_params.stop_strs) == 0
@@ -1205,7 +1205,12 @@ class Req(ReqDllmMixin):
self.sampling_params.stop_regex_max_len + 1,
)
tail_len = min(max_len_tail_str, len(self.output_ids))
# Spec decode accepts multiple tokens per step; widen the window to cover
# the whole accepted chunk so a stop string landing mid-chunk (with more
# tokens accepted after it) is not pushed out of view.
tail_len = min(
max_len_tail_str + max(new_accepted_len - 1, 0), len(self.output_ids)
)
return self.tokenizer.decode(self.output_ids[-tail_len:])
def check_match_stop_str_prefix(self) -> bool:
@@ -1261,12 +1266,12 @@ class Req(ReqDllmMixin):
return False
def _check_str_based_finish(self):
def _check_str_based_finish(self, new_accepted_len: int = 1):
if (
len(self.sampling_params.stop_strs) > 0
or len(self.sampling_params.stop_regex_strs) > 0
):
tail_str = self.tail_str()
tail_str = self.tail_str(new_accepted_len)
# Check stop strings
if len(self.sampling_params.stop_strs) > 0:
@@ -1331,7 +1336,7 @@ class Req(ReqDllmMixin):
if self._check_vocab_boundary_finish(new_accepted_tokens):
return
if self._check_str_based_finish():
if self._check_str_based_finish(new_accepted_len):
return
def reset_for_retract(self):
@@ -0,0 +1,60 @@
"""Regression: under speculative decoding (multi-token commits) a stop string
committed mid-chunk must still trigger the finish check, else the request
over-generates. Drives the real `Req.update_finish_state`; pure CPU."""
import unittest
from array import array
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.sampling.sampling_params import SamplingParams
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
STOP_ID = 1
ID_TO_TEXT = {STOP_ID: "STOP", **{i: chr(ord("a") + i % 26) for i in range(10, 40)}}
# "STOP" (index 3) sits 6 tokens back: outside the old (stop_str_max_len + 1)
# window, inside the one widened by new_accepted_len.
MIDCHUNK = [10, 11, 12, STOP_ID, 20, 21, 22, 23, 24]
class _FakeTokenizer:
eos_token_id = -1
additional_stop_token_ids = None
def decode(self, ids):
return "".join(ID_TO_TEXT[int(i)] for i in ids)
def _make_req(output_ids, stop):
sp = SamplingParams(max_new_tokens=1000, stop=stop)
sp.normalize(tokenizer=None) # char-based stop_str_max_len
req = Req(
rid="t",
origin_input_text="",
origin_input_ids=array("q", [0]),
sampling_params=sp,
eos_token_ids=set(),
vocab_size=10_000,
)
req.tokenizer = _FakeTokenizer()
req.output_ids = array("q", output_ids)
return req
class TestStopStrSpeculative(unittest.TestCase):
def test_stop_str_midchunk_finishes(self):
req = _make_req(MIDCHUNK, stop=["STOP"])
req.update_finish_state(new_accepted_len=6)
self.assertTrue(req.finished())
self.assertEqual(req.finished_reason.matched, "STOP")
def test_no_stop_str_does_not_finish(self):
req = _make_req([10, 11, 12, 20, 21, 22, 23, 24], stop=["STOP"])
req.update_finish_state(new_accepted_len=6)
self.assertFalse(req.finished())
if __name__ == "__main__":
unittest.main()