Prhub

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

原始 PR 作者 xythink 合并时间 2026-06-09 14:25 文件变更 2 提交数 5 评论 1 代码增减 +70 / -5

执行摘要

修复推测解码中 stop-string 检查漏掉早期匹配

When speculative decoding accepts multiple tokens in one step, _check_str_based_finish() can miss stop strings that appear early in the accepted batch. Root cause: tail_str() decodes only the last stop_str_max_len + 1 tokens, but speculative decoding may accept far more tokens per step. A stop string near the beginning of the accepted tokens falls outside this window and is never detected. Impact: Affects all speculative decoding backends (EAGLE/MTP/DFlash/NGRAM) when using string-based stop parameters.

值得精读。展示了在推测解码多 token 接受场景下保持停止条件正确性的通用设计模式:通过传播接受长度参数,使尾部字符串窗口能够覆盖整个接受 chunk。关注点在于使用默认参数保持向后兼容,这一实践值得在类似修复中借鉴。

讨论亮点

Reviewer hnyls2002 直接批准并触发 CI 重跑,没有提出异议。主要设计权衡是向后兼容性:通过给 tail_str_check_str_based_finish 添加默认参数 new_accepted_len=1,非推测解码路径行为不变,降低了回归风险。

实现拆解

  1. 修改 tail_str 方法python/sglang/srt/managers/schedule_batch.py):添加 new_accepted_len 参数,将解码窗口从 max_len_tail_str 扩展为 max_len_tail_str + max(new_accepted_len - 1, 0),使得 stop-string 在接受的 token chunk 中即使出现在靠前位置也能被解码检测。
  2. 修改 _check_str_based_finish 方法:接受 new_accepted_len 参数并传递给 tail_str,确保 stop-string 和 stop 正则检查使用扩展后的尾部字符串。
  3. 修改 update_finish_state:在调用 _check_str_based_finish 时传入 new_accepted_len,使得推测解码路径的停止条件检查覆盖所有新接受的 token。
  4. 新增单元测试test/registered/unit/managers/test_stop_str_speculative.py):使用一个伪造的 tokenizer 和真实的 Req 对象,构造一个包含 stop-string 在 chunk 中间的输出序列,验证 update_finish_state 能正确检测到 stop-string 并标记请求完成;同时测试无 stop-string 时不会错误完成。测试注册到 CPU CI 套件。
文件 模块 状态 重要度
python/sglang/srt/managers/schedule_batch.py 完成检查 modified 6.95
test/registered/unit/managers/test_stop_str_speculative.py 测试配套 added 6.9

关键符号

tail_str _check_str_based_finish test_stop_str_midchunk_finishes test_no_stop_str_does_not_finish

关键源码片段

python/sglang/srt/managers/schedule_batch.py core-logic

核心调度批处理模块,包含完成状态检查逻辑。修改了 `tail_str` 和 `_check_str_based_finish` 方法,添加 `new_accepted_len` 参数以支持推测解码的多 token 接受场景。

# 摘自 python/sglang/srt/managers/schedule_batch.py
# 修改后的 tail_str 方法:支持指定 new_accepted_len 以扩展解码窗口def tail_str(self, new_accepted_len: int = 1) -> str:
    # 如果没有 stop 字符串或 stop 正则,直接返回空
    if (
        len(self.sampling_params.stop_strs) == 0
        and len(self.sampling_params.stop_regex_strs) == 0
    ):
        return ""
​
    max_len_tail_str = max(
        self.sampling_params.stop_str_max_len + 1,
        self.sampling_params.stop_regex_max_len + 1,
    )
​
    # 对于推测解码,一次接受多个 token,需要扩展窗口以包含整个接受 chunk
    # 如果 stop-string 出现在 chunk 靠前位置但后有更多 token,旧窗口会将其排除
    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:])# 调用链路:update_finish_state -> _check_str_based_finish(new_accepted_len) -> tail_str(new_accepted_len)
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(new_accepted_len)
        # 检查 stop 字符串
        if len(self.sampling_params.stop_strs) > 0:
            for stop_str in self.sampling_params.stop_strs:
                if stop_str in tail_str or stop_str in self.decoded_text:
                    self.finished_reason = FINISH_MATCHED_STR(matched=stop_str)
                    return True
        # 检查 stop 正则(略,与字符串类似)
test/registered/unit/managers/test_stop_str_speculative.py test-coverage

新增的纯 CPU 单元测试,验证推测解码场景下 stop-string 早于 chunk 末尾时仍能被检测。使用真实 `Req` 对象和伪造 tokenizer,确保测试与生产逻辑一致。

# 摘自 test/registered/unit/managers/test_stop_str_speculative.py
# 回归测试:在推测解码多 token 接受下,stop-string 出现在 chunk 中间时必须触发完成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_ciregister_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)}}# MIDCHUNK: 第 4 个 token 是 STOP (index 3), 距离末尾 6 步
# 旧窗口(stop_str_max_len + 1)无法覆盖,新窗口通过 new_accepted_len=6 可以覆盖
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 reqclass 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())

评论区精华

触发 CI 重跑 other

Reviewer `hnyls2002` 在批准时留下评论 '/tag-and-rerun-ci',指示 CI 重新运行测试。

结论:CI 已重新触发,无进一步讨论。 · 已解决

风险与影响

  1. 性能风险:在推测解码步骤中,tail_str 解码的 token 数从 max_len_tail_str 增加到 max_len_tail_str + new_accepted_len - 1,但 new_accepted_len 通常很小(如 5-10),且该操作仅在字符串停止条件启用时执行,对整体吞吐影响可忽略。
  2. 回归风险tail_str 还被 check_match_stop_str_prefix 调用,此处使用默认参数不改变行为。非推测解码路径因默认值而完全不受影响。
  3. 测试覆盖:新增的单元测试覆盖了关键回归场景,但仅测试了字符串停止条件,未覆盖 stop 正则。由于 stop 正则的处理路径与字符串类似,风险较低。

用户影响:所有使用 stop 参数(字符串形式)并启用推测解码(EAGLE/MTP/DFlash/NGRAM)的用户将正确停止生成,不再出现停止字符串丢失导致输出过长的 bug。对于不使用推测解码或不使用字符串 stop 的用户无影响。
系统影响:无配置、监控或部署变更。
团队影响:修复简单,易于审查,对核心完成逻辑的修改经过充分讨论和测试。

核心路径变更 低回归风险 测试新增覆盖

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论