Prhub

#22367 fix: correct off-by-one in vocab boundary check for token validation

原始 PR 作者 Ricardo-M-L 合并时间 2026-06-10 06:24 文件变更 2 提交数 3 评论 3 代码增减 +83 / -1

执行摘要

修复 vocab 边界检查的 off-by-one 错误

PR 作者指出原来的 token_id > self.vocab_size 检查会漏掉 token_id == vocab_size 的情况,而有效 token ID 范围是 [0, vocab_size),因此 token_id == vocab_size 是越界的,应当触发 finish 条件(如 NaN 采样保护)。该问题在 spec_utils.py 中已有正确实现(使用 >=),本次修复旨在保持一致性。

此 PR 值得精读,特别是对于涉及 token 生成边界处理的开发者。它展示了一个经典的 off-by-one 错误的检测和修复过程,以及如何通过全面测试覆盖来确保修复的正确性。测试文件的设计(使用 __new__ 绕过复杂初始化)也是一个值得学习的技巧。

讨论亮点

Review 中 gemini-code-assist[bot] 提出了一个可读性改进建议:将 if token_id >= self.vocab_size or token_id < 0: 改写为 if not (0 <= token_id < self.vocab_size): 以利用 Python 链式比较,使有效范围 [0, vocab_size) 更直观。该建议未被采纳,但理由充分(原写法与负值检查并列,逻辑清晰)。此外,评论者 fzyzcjy 要求添加回归测试以避免回归,最终确实新增了完整的测试文件。

实现拆解

  1. 修正核心边界条件:在 python/sglang/srt/managers/schedule_batch.py_check_vocab_boundary_finish 方法中,将条件 token_id > self.vocab_size 改为 token_id >= self.vocab_size,使得当 token_id 等于 vocab_size 时也能被正确检测为越界。
  2. 新增全套回归测试:创建 test/registered/unit/managers/test_vocab_boundary_finish.py,使用 Req.__new__ 构建最小 Req 对象,覆盖五个场景:token_id == vocab_size 越界、token_id > vocab_size 越界、负数 token 越界、最大有效 token 不越界、以及无 eos 时回退到 stop token。测试通过 register_cpu_ci 集成到 CI 套件中,执行时间约 2 秒。
  3. 依赖与注册:测试文件引用了 sglang.srt.managers.schedule_batch 中的 FINISH_MATCHED_STRReq,并通过 maybe_stub_sgl_kernel 确保环境兼容。
文件 模块 状态 重要度
python/sglang/srt/managers/schedule_batch.py 调度器 modified 5.29
test/registered/unit/managers/test_vocab_boundary_finish.py 测试 added 7.67

关键符号

_check_vocab_boundary_finish

关键源码片段

test/registered/unit/managers/test_vocab_boundary_finish.py test-coverage

新增的全面回归测试文件,覆盖了五个关键场景,确保修复的正确性并防止未来回归。测试设计精巧,使用 `Req.__new__` 避免了复杂的构造函数依赖。

"""Regression tests for Req._check_vocab_boundary_finish NaN guard boundary."""import unittestfrom sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase, maybe_stub_sgl_kernelmaybe_stub_sgl_kernel()from sglang.srt.managers.schedule_batch import FINISH_MATCHED_STR, Reqregister_cpu_ci(est_time=2, suite="base-a-test-cpu")VOCAB_SIZE = 1000
​
​
def _make_req(*, output_ids: list, eos_token_ids: set, stop_token_ids: set) -> Req:
    # Build a bare Req without running __init__; only the fields touched by
    # _check_vocab_boundary_finish are populated.
    req = Req.__new__(Req)
    req.output_ids = list(output_ids)
    req.vocab_size = VOCAB_SIZE
    req.eos_token_ids = eos_token_ids
    req.finished_reason = None
    req.finished_len = None
​
    class _SamplingParams:
        pass
​
    req.sampling_params = _SamplingParams()
    req.sampling_params.stop_token_ids = stop_token_ids
    return req
​
​
class TestVocabBoundaryFinish(CustomTestCase):
    def test_token_equal_to_vocab_size_is_out_of_bounds(self):
        # Valid ids are [0, vocab_size); id == vocab_size must trip the NaN guard.
        req = _make_req(
            output_ids=[5, VOCAB_SIZE], eos_token_ids={2}, stop_token_ids=set()
        )
        self.assertTrue(req._check_vocab_boundary_finish([5, VOCAB_SIZE]))
        self.assertIsInstance(req.finished_reason, FINISH_MATCHED_STR)
        self.assertEqual(req.finished_reason.matched, "NaN happened")
        self.assertEqual(req.finished_len, 2)
        # The offending slot is rewritten to the eos token.
        self.assertEqual(req.output_ids[1], 2)
​
    def test_token_above_vocab_size_is_out_of_bounds(self):
        # A wildly large garbage id (typical of NaN sampling) is caught.
        req = _make_req(
            output_ids=[5, VOCAB_SIZE + 12345], eos_token_ids={2}, stop_token_ids=set()
        )
        self.assertTrue(req._check_vocab_boundary_finish([5, VOCAB_SIZE + 12345]))
        self.assertEqual(req.finished_len, 2)
​
    def test_negative_token_is_out_of_bounds(self):
        # Negative ids also indicate corrupted sampling output.
        req = _make_req(output_ids=[5, -1], eos_token_ids={2}, stop_token_ids=set())
        self.assertTrue(req._check_vocab_boundary_finish([5, -1]))
        self.assertEqual(req.output_ids[1], 2)
​
    def test_max_valid_token_is_in_bounds(self):
        # id == vocab_size - 1 is the largest valid token and must not trip.
        req = _make_req(
            output_ids=[5, VOCAB_SIZE - 1], eos_token_ids={2}, stop_token_ids=set()
        )
        self.assertFalse(req._check_vocab_boundary_finish([5, VOCAB_SIZE - 1]))
        self.assertIsNone(req.finished_reason)
        self.assertIsNone(req.finished_len)
        self.assertEqual(req.output_ids[1], VOCAB_SIZE - 1)
​
    def test_stop_token_used_when_no_eos(self):
        # Without eos tokens, the slot is rewritten to a stop token instead.
        req = _make_req(
            output_ids=[VOCAB_SIZE], eos_token_ids=set(), stop_token_ids={7}
        )
        self.assertTrue(req._check_vocab_boundary_finish([VOCAB_SIZE]))
        self.assertEqual(req.output_ids[0], 7)
​
​
if __name__ == "__main__":
    unittest.main()

评论区精华

使用 Pythonic 链式比较改进可读性 style

gemini-code-assist[bot] 建议将 `if token_id >= self.vocab_size or token_id < 0:` 改为 `if not (0 <= token_id < self.vocab_size):`,认为链式比较更能直观表达有效范围。

结论:建议未被采纳,原写法保持清晰且与负值检查并列易于理解。 · 已解决

要求添加回归测试 测试

评论者 fzyzcjy 要求添加回归测试以避免回归,建议参考 PR#27556 的做法。

结论:已新增完整的测试文件 test_vocab_boundary_finish.py,覆盖五个边界场景。 · 已解决

风险与影响

该变更仅修改了一行逻辑,风险极低。边界条件从 > 改为 >= 只会增加一个检测条件(当 token_id == vocab_size 时),不会影响其他情况。新增的测试覆盖了所有关键边界,进一步降低了回归风险。唯一潜在风险是:如果某处实际产生了 token_id == vocab_size 且是合法 token(但根据设计 vocab_size 本身就是无效 ID,因为索引从 0 开始),则行为会从“继续生成”变为“标记为 NaN 并停止”,但这正是修复目标。

  • 对用户:修复了在罕见情况下(由 NaN 采样或 logits 错误导致 token_id == vocab_size)可能产生的非法 token 输出,提高了生成稳定性。
  • 对系统_check_vocab_boundary_finish 在每次 token 生成后调用,新增的检测几乎无性能开销。
  • 对团队:新增的测试文件可以作为类似边界检查的测试范本,提高代码健壮性。
核心路径变更 缺少测试覆盖(已添加)

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论