Prhub

#49227 [Bugfix][Structured Output] Mask request stop tokens in xgrammar until grammar terminates

原始 PR 作者 sfeng33 合并时间 2026-08-11 02:29 文件变更 7 提交数 2 评论 3 代码增减 +114 / -6

执行摘要

xgrammar 语法未终止前屏蔽请求级 stop token

Issue #42403 报告:Gemma 4 在 structured_outputs(JSON schema)下,模型偶尔在 grammar FSM 仍处于 JSON 字符串内部时采样 <end_of_turn>(token id 106),直接中断生成,输出为未闭合 JSON;在 NVFP4 量化的 Gemma 4 31B 上,10-20 个样本中约 20%-40% 概率复现。PR body 给出根因定位:xgrammar 只知道 tokenizer 的单一 eos(Gemma 4 为 1),而请求真实 stop 集合是 generation_config 的 eos 列表 [1, 106, 50] 加用户 stop_token_ids,106 和 50 对 grammar 不可见,因此能在非终止状态下逃逸 bitmask,把生成截断成非法 JSON。

值得精读。改动紧凑(7 个文件约 120 行)但切中一个真实的高频数据正确性问题,测试设计精巧——用 gpt2 的固定 token id 精确驱动 grammar 状态机,验证 stop token 在非终止/终止两种状态下的 bitmask 差异。值得学习的点:请求级 stop 集合(generation_config eos 列表 + 用户 stop_token_ids)与 tokenizer 默认 eos 的差异如何漏进采样掩码;xgrammar override_stop_tokens 的对接方式;以及抽象接口签名变更多后端同步的工程规范。建议跟进 guidance 后端遗留的同类问题。

讨论亮点

三位 reviewer 的交互中,最有价值的是 yzong-rh 的批准意见:"The issue occur in guidance as well, but it doesn't seem mask out stop tokens even if we override eos_token in llguidance_hf.from_tokenizer. Separate fix required."(同类问题在 guidance 后端也存在,即使 override eos_token 也不会屏蔽 stop token,需要单独修复)。这确认了本修复的正确性,同时暴露了多后端覆盖不一致的遗留缺口。bbrowning 本地拉取验证:"Pulled locally, reviewed, and confirmed the new test properly catches this regression."。另外 mergify 曾提示存在合并冲突需 rebase,作者 rebase 后触发 Buildkite CI(#83175)。

实现拆解

第 1 步:契约层——抽象接口新增 stop_token_ids 参数

vllm/v1/structured_output/backend_types.py 的抽象方法 StructuredOutputBackend.compile_grammar 新增可选参数 stop_token_ids: set[int] | None = None,并在 docstring 说明语义(请求的 EOS 与用户 stop token 集合,来自 SamplingParams.all_stop_token_ids)。随后同步更新另外三个后端 backend_guidance.pybackend_lm_format_enforcer.pybackend_outlines.pycompile_grammar 签名,仅保持接口一致、不改行为,避免多态调用时签名不匹配。

第 2 步:入口层——从采样参数提取请求级 stop 集合

vllm/v1/structured_output/__init__.py_create_grammar 在编译语法前读取 request.sampling_params.all_stop_token_ids(为空时传 None),随 request_typegrammar_spec 一起传给 compile_grammar。这一步把"请求的真实 stop 集合"从采样路径带入语法编译路径。

第 3 步:核心修复——xgrammar matcher 门控 stop token

vllm/v1/structured_output/backend_xgrammar.pyXgrammarBackend.compile_grammar 在构造 xgr.GrammarMatcher 时新增 override_stop_tokens=list(stop_token_ids) if stop_token_ids else None。xgrammar 据此在语法未达终止状态时屏蔽这些 token,语法完整后才允许其被采样。该参数是 matcher 级配置,不影响 GrammarCompiler 的 grammar 缓存。

第 4 步:回归测试与验证

新增 tests/v1/structured_output/test_backend_xgrammar_stop_tokens.py(82 行),用 gpt2 驱动 {"type": "string"} 语法:在字符串中间断言普通 token 可采样而 stop token 被屏蔽,闭合字符串后断言 stop token 恢复可采样、且 tokenizer 默认 eos 行为不变。PR body 还给出端到端复现:logit_bias={"106": 100} 强制 Gemma 4 采样 <end_of_turn>,修复前输出被截断为 {"name": ",修复后输出完整 JSON 且 stop_reason=106

文件 模块 状态 重要度
vllm/v1/structured_output/backend_xgrammar.py 结构化输出 modified 6.04
vllm/v1/structured_output/__init__.py 结构化输出 modified 5.88
tests/v1/structured_output/test_backend_xgrammar_stop_tokens.py 结构化输出 added 6.86
vllm/v1/structured_output/backend_types.py 结构化输出 modified 4.87
vllm/v1/structured_output/backend_guidance.py 结构化输出 modified 4.33
vllm/v1/structured_output/backend_lm_format_enforcer.py 结构化输出 modified 4.32
vllm/v1/structured_output/backend_outlines.py 结构化输出 modified 4.32

关键符号

compile_grammar _create_grammar test_request_stop_tokens_gated_to_grammar_terminal _token_allowed

关键源码片段

vllm/v1/structured_output/backend_xgrammar.py core-logic

核心修复所在:compile_grammar 新增 stop_token_ids 参数并透传给 xgr.GrammarMatcher 的 override_stop_tokens,是 stop token 门控到语法终止状态的关键实现。

# vllm/v1/structured_output/backend_xgrammar.py
# compile_grammar 在原有语法编译逻辑不变的前提下,
# 新增 stop_token_ids 参数并把它透传给底层 matcher。def compile_grammar(
    self,
    request_type: StructuredOutputOptions,
    grammar_spec: str,
    stop_token_ids: set[int] | None = None, # 请求级 stop 集合(eos 列表 + 用户 stop_token_ids)
) -> StructuredOutputGrammar:
    # 按请求类型编译 grammar,JSON / GRAMMAR / REGEX / STRUCTURAL_TAG 分支从略,
    # 这里以 JSON schema 为例。
    ctx = self.compiler.compile_json_schema(
        grammar_spec, any_whitespace=not self.disable_any_whitespace
    )
​
    # 关键修复:override_stop_tokens 让 xgrammar 在语法未终止时屏蔽这些 token,
    # 只有 grammar 到达终止状态后才允许它们被采样,从而避免 mid-object 截断。
    return XgrammarGrammar(
        matcher=xgr.GrammarMatcher(
            ctx,
            override_stop_tokens=list(stop_token_ids) if stop_token_ids else None,
            max_rollback_tokens=self.num_speculative_tokens,
        ),
        vocab_size=self.vocab_size,
        ctx=ctx,
    )
vllm/v1/structured_output/__init__.py core-logic

语法编译入口:_create_grammar 从 request.sampling_params.all_stop_token_ids 提取请求真实 stop 集合并传入后端,是数据链路打通的一步。

# vllm/v1/structured_output/__init__.py
# 语法编译入口:把请求的真实 stop 集合传给后端。def _create_grammar(self, request: "Request") -> StructuredOutputGrammar:
    struct_request = request.structured_output_request
    assert struct_request is not None
    try:
        request_type, grammar_spec = struct_request.structured_output_key
        assert self.backend is not None
        # generation_config 的 eos 列表与用户 stop_token_ids 都汇总在
        # all_stop_token_ids 中;xgrammar 默认只认识 tokenizer 的单一 eos,
        # 必须显式透传,否则 106/50 这类额外 stop token 会逃逸 bitmask。
        stop_token_ids = (
            request.sampling_params.all_stop_token_ids
            if request.sampling_params is not None
            else None
        )
        return self.backend.compile_grammar(
            request_type, grammar_spec, stop_token_ids=stop_token_ids
        )
    except Exception:
        logger.exception(
            "Failed to compile grammar for request %s", request.request_id
        )
        raise
tests/v1/structured_output/test_backend_xgrammar_stop_tokens.py test-coverage

新增回归测试(issue #42403),用 gpt2 固定 token id 精确驱动 grammar 状态机,验证 stop token 在非终止态被屏蔽、终止态放行,且 tokenizer 默认 eos 行为不变。

# tests/v1/structured_output/test_backend_xgrammar_stop_tokens.py
# 回归测试(issue #42403):验证 stop token 被门控到语法终止状态。def test_request_stop_tokens_gated_to_grammar_terminal(backend: XgrammarBackend):
    schema = '{"type": "string"}'
    default = backend.compile_grammar(StructuredOutputOptions.JSON, schema)
    override = backend.compile_grammar(
        StructuredOutputOptions.JSON, schema, stop_token_ids={EOS, LETTER}
    )
​
    # 打开引号:两个 grammar 都进入非终止状态(字符串内部)。
    for grammar in (default, override):
        assert grammar.accept_tokens("req", [QUOTE])
​
    bm_default = backend.allocate_token_bitmask(1)
    bm_override = backend.allocate_token_bitmask(1)
    default.fill_bitmask(bm_default, 0)
    override.fill_bitmask(bm_override, 0)
​
    # 字符串中间:默认 grammar 下普通 token 仍可采样(泄漏点);
    # 把它注册为 stop token 后,override grammar 将其屏蔽。
    assert _token_allowed(bm_default[0], LETTER)
    assert not _token_allowed(bm_override[0], LETTER)
​
    # 闭合引号 -> 语法完整但尚未终止。
    for grammar in (default, override):
        assert grammar.accept_tokens("req", [QUOTE])
        assert not grammar.is_terminated()
​
    default.fill_bitmask(bm_default, 0)
    override.fill_bitmask(bm_override, 0)
​
    # 额外 stop token 现在只允许在 override grammar 下终止;
    # tokenizer 默认 eos 在两种 grammar 下仍可终止,默认行为被保留。
    assert not _token_allowed(bm_default[0], LETTER)
    assert _token_allowed(bm_override[0], LETTER)
    assert _token_allowed(bm_default[0], EOS)
    assert _token_allowed(bm_override[0], EOS)

评论区精华

guidance 后端存在同源 stop token 泄漏 设计

yzong-rh 在批准意见中指出:问题在 guidance 后端同样存在,即使把 eos_token override 到 llguidance_hf.from_tokenizer,guidance 也不会屏蔽 stop token,需要单独的修复。

结论:本 PR 仅修复 xgrammar 后端,guidance 后端问题记录为后续单独修复项。 · approved with follow-up

回归测试有效性确认 测试

bbrowning 本地拉取代码并确认新测试能捕获该回归;yzong-rh 也确认测试通过、问题可复现。

结论:测试被两位 reviewer 验证有效,可防止该 bug 回归。 · 已解决

合并冲突与 CI 重跑 other

mergify 提示 PR 存在合并冲突需要 rebase;作者 rebase 后触发 Buildkite CI(#83175)。

结论:rebase 完成,CI 已触发并通过。 · 已解决

风险与影响

  1. 行为变化:修复前能从非终止状态采样到的 stop token 现在被屏蔽,对依赖旧行为的用户(极少数把 stop token 当普通 token 用的场景)有感知,但这正是修复目标。
  2. 多后端不一致:guidance 后端只改了签名、未实现屏蔽逻辑,同一请求在 xgrammar 与 guidance 后端下行为不同,需后续单独修复(yzong-rh 已确认)。
  3. 接口契约变更:compile_grammar 是所有结构化输出后端的公共抽象方法,签名变更会影响仓库外的自定义后端实现(TypeError),仓库内四个后端已同步。
  4. 终止风险:若 stop token 全被屏蔽且语法因与输入冲突永远无法到达终止态,理论上生成可能无法按时停止;但 override_stop_tokens 仅在非终止状态屏蔽、语法完成即放行,实际风险低。
  5. 性能:每请求多一次属性读取、set 传递与 list 转换,相对 grammar 编译与 bitmask 填充的开销可忽略。

影响面集中于 v1 结构化输出路径:所有使用 xgrammar 后端的 JSON / grammar / regex 请求都会把请求级 stop token 纳入 bitmask 门控。对 Gemma 4 等 generation_config 含多个 eos 的模型是直接受益者——结构化输出不再被 mid-object 的 stop 截断成非法 JSON;对单 eos 模型行为基本不变(默认 eos 原本就在 tokenizer 内)。系统层面,改动发生在 grammar 编译期,matcher 参数不影响 grammar compiler 缓存,采样热路径只增加一次 all_stop_token_ids 读取。团队层面,override_stop_tokens 的用法为其它后端(guidance、outlines、lmfe)修复同类问题提供了参照模板。

核心采样路径变更 多后端接口同步 guidance 后端缺口遗留 行为变化

关联 Issue

#42403 [Bug]: (Gemma 4) xgrammar bitmask lets <end_of_turn> escape during structured outputs, terminating generation mid-JSON

完整报告

参与讨论