Prhub

#56408 [Frontend] Use XGrammar schema constraints for DeepSeek V4.1

原始 PR 作者 Ubospica 合并时间 2026-09-15 16:36 文件变更 2 提交数 2 评论 7 代码增减 +90 / -4

执行摘要

DSv4.1 工具调用启用 XGrammar schema 约束并保留旧版回退

PR body 明确指出痛点:"The Python DeepSeek V4.1 builder currently accepts missing, repeated, undeclared, and incorrectly typed parameters even with strict=true"——此前 deepseek_v41 的 grammar 只约束工具名和 DSML 值语法,strict 参数形同虚设。作者因此 "Prefer XGrammar's new V4.1 builtin to enforce parameter schemas and the string=\"true|false\" wire types",同时明确 "Serving already owns the reasoning boundary, so the delegated grammar uses reasoning=\"disabled\"" 与 "Requires the next XGrammar release. The existing format-only builder remains available for older versions",即必须带版本回退。作者还做了重复劳动核查:#56235 只改 Rust 前端且显式不动 Python builder,本 PR 只改 Python 的 structural-tag registry 与既有测试。

值得精读,但重点不在代码量。源码只有 13 行,真正的价值是三点设计模式:用 getattr(module, name, None) 做跨版本能力探测以规避 ImportError、把 grammar 构造委托给上游 builtin 时显式覆盖 reasoning="disabled" 以维持 serving 的推理边界契约、以及用 skipif(hasattr(...)) + monkeypatch 双写测试同时锁定新路径与回退路径。建议读者额外关注:(1) 后续 XGrammar bump PR 是否把 ≥ 0.2.7 落到依赖与镜像;(2) 是否补一条带真实权重的 tool-calling eval,验证 strict 约束不会引发重采样或误截断;(3) 回退分支的删除时机与对应测试清理。

讨论亮点

讨论集中在两件事:回退语义是否被正确保证,以及 XGrammar 版本何时 bump。

  • 回退路径确认(abmfy 提问 → Ubospica 回答):abmfy 问 "What's the behavior before we bump to the new XGrammar release with grammar template for DSV41F? Should we bump it here in this PR after new XGrammar release is out?",Ubospica 答 "Yes, the PR will fall back when the template does not exist. The recently released XGrammar v0.2.7 ... contains support for that. We have carefully checked that it follows the official DS tool call convention." abmfy 随后在 approve 中独立复核了该行为:"Checked and it will fallback to the current bahavior (not constraining the tool parameters) if XGrammar doesn't contain DSV4.1F template"。
  • 版本 bump 归属(sfeng33):approve 时追问 "Do you plan to bump the xgrammar version in vllm later as well?",与 abmfy 的 "we should bump XGrammar after a new release come out" 形成一致结论——bump 留给后续独立 PR,本 PR 只做接线与回退。
  • 审查信号强度:claude[bot] 因 PR 来自 fork 而自动 review 被禁用("automated review is disabled"),作者自述 AI 辅助、人工逐行审查仍待完成;最终由两名 maintainer 人工 approve 并合并,属于人工兜底的审查模式。

实现拆解

  1. 入口点与能力探测:vllm/tool_parsers/structural_tag_registry.py 顶部新增 from xgrammar import builtin_structural_tag as xgrammar_builtin_structural_tag,并在 get_deepseek_v41_structural_tag(注册名 deepseek_v41)函数体开头用 getattr(xgrammar_builtin_structural_tag, "get_deepseek_v4_1_structural_tag", None) 做软探测。用 getattr 而非直接导入,是为了让旧版 XGrammar 仍能正常 import 整个模块。
  2. 委托分支:探测命中即 return builder(tools=..., builtin_tools=..., tool_choice=..., reasoning="disabled")。这里刻意不透传调用方传入的 reasoning,而是钉死 "disabled",原因是 vLLM serving 已经在推理边界之后才启用这段可见文本语法,若让 builtin 自行处理 reasoning 会要求模型再输出一次 think 结束标记。
  3. 回退分支:命中失败时执行原有的 del builtin_tools, reasoning, token_suffix,保留 _V41_CALLS_START/_V41_CALLS_END/_V41_INVOKE_END/_V41_PARAMETER_END 常量与 TagFormat + SequenceFormat + OrFormat(string="true" 走 AnyTextFormat 排除三个结束标记、string="false" 走 JSONSchemaFormat)组合;注释由 # TODO: lower parameter schemas into DSML constraints 改为 # Compatibility with xgrammar releases without the V4.1 builtin,语义仍是参数名/存在性/唯一性/schema 均不受约束,包括 strict=true,请求层仍用 strict 决定 auto 是否激活 grammar。
  4. 测试配套(diff 主体):tests/tool_parsers/test_structural_tag_registry.py 新增 builtin_structural_tag 导入;把 test_deepseek_v41_format_constraints_ignore_parameter_schema 重命名为 test_deepseek_v41_older_xgrammar_keeps_format_constraints,并用 monkeypatch.setattr(..., None, raising=False) 显式模拟旧版 XGrammar;新增 test_deepseek_v41_constrains_parameters_after_reasoning(skipif + hasattr 门控、choice × reasoning 参数化,断言缺必填、重复、未声明、string 类型不符、写错 DSML 分隔符、直接吐 JSON body 六类输出全被拒)和 test_deepseek_v41_non_strict_parallel_calls_keep_typed_dsml(strict=False 仍允许多次并行调用);同时修正 test_deepseek_v41_named_choice_emits_one_call 的期望字符串,补上必填参数 city 的 DSML 行以匹配新 builtin 行为。
  5. 依赖与部署配套:本 PR 不动任何依赖 pin,只在开发版与 XGrammar 0.2.1 上做过隔离验证(4 passed / 7 skipped),线上生效依赖后续独立 bump PR;CI 由 reviewer sfeng33 手动 /ci run 触发(Buildkite #88910),因为作者未通过上游 pre-run-check 的授权门槛。
文件 模块 状态 重要度
vllm/tool_parsers/structural_tag_registry.py 工具解析 modified 6.47
tests/tool_parsers/test_structural_tag_registry.py 解析测试 modified 6.85

关键符号

get_deepseek_v41_structural_tag test_deepseek_v41_older_xgrammar_keeps_format_constraints test_deepseek_v41_constrains_parameters_after_reasoning test_deepseek_v41_non_strict_parallel_calls_keep_typed_dsml test_deepseek_v41_named_choice_emits_one_call

关键源码片段

vllm/tool_parsers/structural_tag_registry.py dependency-wiring

唯一源码改动:DeepSeek V4.1 的 structural tag 构造器新增 XGrammar builtin 能力探测、委托分支(reasoning="disabled")与旧版回退分支,决定 strict 模式下工具参数是否被 schema 约束。

# vllm/tool_parsers/structural_tag_registry.py
from xgrammar import builtin_structural_tag as xgrammar_builtin_structural_tag
​
​
@register_vllm_structural_tag("deepseek_v41")
def get_deepseek_v41_structural_tag(
    tools: list[FunctionToolParam],
    builtin_tools: list[BuiltinToolParam],
    tool_choice: SimplifiedToolChoice,
    reasoning: bool,
    token_suffix: str = "",
) -> StructuralTag:
    # vLLM serving 已经在 reasoning 边界之后才启用这段可见文本语法,
    # 因此委托给 XGrammar builtin 时统一传 reasoning="disabled",
    # 避免语法再要求模型补输出一次 think 结束标记。
    builder = getattr(
        xgrammar_builtin_structural_tag, "get_deepseek_v4_1_structural_tag", None
    )
    if builder is not None:
        # 只有带 V4.1 builtin 的新版 XGrammar 才走这里:它会按 strict=true 的
        # JSON Schema 约束 DSML 参数名、必填性、唯一性以及 string="true|false" 线上类型。
        return builder(
            tools=tools,
            builtin_tools=builtin_tools,
            tool_choice=tool_choice,
            reasoning="disabled",
        )
​
    del builtin_tools, reasoning, token_suffix
​
    # 兼容路径:旧版 XGrammar 没有 V4.1 builtin,退回 vLLM 自带的格式语法,
    # 只约束工具名与 DSML/value 语法;参数名、存在性、唯一性和取值 schema 都不受限,
    # 即使请求里 strict=true 也一样。请求层仍用 strict 决定 auto 是否激活 grammar。
    parameter = TagFormat(
        begin='<|DSML| parameter name="',
        content=SequenceFormat(
            elements=[
                RegexFormat(pattern=r'[^"]+'), # 参数名不做校验,任意非引号串都接受
                ConstStringFormat(value='" string="'), # DSML 线上类型开关
                OrFormat(
                    elements=[
                        # string="true":自由文本,只排除三个 DSML 结束标记
                        SequenceFormat(
                            elements=[
                                ConstStringFormat(value='true">'),
                                AnyTextFormat(
                                    excludes=[
                                        _V41_PARAMETER_END,
                                        _V41_INVOKE_END,
                                        _V41_CALLS_END,
                                    ]
                                ),
                            ]
                        ),
                        # string="false":要求是合法 JSON 值
                        SequenceFormat(
                            elements=[
                                ConstStringFormat(value='false">'),
                                JSONSchemaFormat(json_schema=True),
                            ]
                        ),
                    ]
                ),
            ]
        ),
    )
    # …… 其余按 tool_choice 组装 invoke / calls / triggered tags 的逻辑保持不变

评论区精华

未 bump XGrammar 时的回退行为是否可靠 设计

abmfy 提问:"What's the behavior before we bump to the new XGrammar release with grammar template for DSV41F?";Ubospica 回复 PR 会 fallback,且已发布的 XGrammar v0.2.7 含该 builtin,并已核对符合官方 DS tool call 约定。abmfy 在 approve 中复述确认 "it will fallback to the current bahavior (not constraining the tool parameters)"。

结论:回退路径被 reviewer 独立复核认可:无 builtin 时行为与改动前一致,风险受控;新能力需等版本到位。 · 已解决

是否在本 PR 内 bump xgrammar 版本 question

sfeng33 在 APPROVED 时追问 "Do you plan to bump the xgrammar version in vllm later as well?";abmfy 也表态 "we should bump XGrammar after a new release come out"。

结论:一致决定 bump 留给后续独立 PR,本 PR 只完成接线与兼容回退,避免把依赖升级耦合进前端改动。 · 待处理

AI 辅助贡献的审查与 CI 授权门槛 other

PR body 自述由 Codex 实现、人工逐行审查与独立测试执行 pending;github-actions[bot] 提示上游 pre-run-check 需要 verified/ready 标签或作者至少四次已合并贡献,claude[bot] 也因 fork 来源禁用自动 review。最终由 sfeng33 手动 `/ci run`(Buildkite #88910)并由两名 maintainer 人工 approve 合入。

结论:自动化审查缺位,质量保证主要依赖两名 maintainer 的人工复核;本地验证(110 passed、XGrammar 0.2.1 上 4 passed/7 skipped)由作者单方提供。 · 已解决

风险与影响

  1. 默认不生效的版本门控:本 PR 不 bump XGrammar,若线上 pin 版本仍无 get_deepseek_v4_1_structural_tag,行为与改动前完全一致,容易被误读为「strict 已经修好」;sfeng33 与 abmfy 都指出需要后续 bump PR。
  2. 约束强度突变带来的生成分布变化:strict=true 时新路径会拒绝缺必填、重复、未声明参数,以及 string="true" 参数写成 string="false">42 之类线上类型不符的输出;测试中甚至显式断言 '{"city":"Paris"}' 这种「直接吐 JSON body」被拒("the encoder emits DSML parameters, not a JSON body")。如果真实模型有这种输出习惯,可能出现拒绝采样/重采样循环或提前结束,需要真实权重端到端 eval 验证。
  3. reasoning="disabled" 硬编码假设:代码注释与 PR body 都依赖「serving 掌管 reasoning 边界」;旧实现是直接 del reasoning 丢弃入参,新实现仅对 builtin 固定禁用推理。若未来有调用路径以 reasoning=True 进入该 builder(例如需要输出 think 段的模板),约束语义可能与预期不符。
  4. 双路径长期并存:回退分支与新路径接受的字符串集合明显不同(旧路径允许省略必填、重复参数),切换时线上语法行为会突变;test_deepseek_v41_named_choice_emits_one_call 已因新行为改写期望输出,未来删除回退分支需要同步清理这类测试。
  5. 验证证据以 CPU 协议测试为主:作者自述 110 passed、1266 次 next-token mask 检查,但 "without model weights or inference accuracy/throughput measurements",且上游 pre-commit/CI 依赖 reviewer 手动触发,缺少第三方独立复跑。
  6. monkeypatch 模拟旧版本的保真度:monkeypatch.setattr(builtin_structural_tag, "get_deepseek_v4_1_structural_tag", None, raising=False) 只覆盖模块属性;若 XGrammar 后续改由注册表或其它符号暴露 builtin,这条回退测试可能失真但仍会通过。

用户侧:DeepSeek V4.1(含 Flash)在 OpenAI 兼容 chat completions 接口上使用 tool_choice=auto/required/named 且 strict=true 的请求,会在 XGrammar 版本足够时获得参数级 schema 约束;strict=False 的请求保持原有的「typed DSML 但不校验参数 schema」行为,auto 不带任何约束时仍返回 None。
系统侧:改动只落在 Python 前端的 structural tag 构造层,不触碰 kernel、模型执行、调度、KV cache 或 CUDA graph,运行时开销只是 getattr 探测与一次分支判断;但由于它决定 grammar 的接受语言,采样层的拒绝率与输出分布会受影响。
团队侧:本 PR 与 #56235(Rust 前端)构成 DeepSeek V4.1 工具调用约束的 Python/Rust 双线补齐;后续需要一个明确的 XGrammar bump PR,并建议配套 tool-call eval(PR 自述的协议回放可作为回归基线)。

依赖版本门控(未 bump XGrammar,线上默认不生效) strict=true 约束强度突变,可能改变采样接受率 reasoning="disabled" 硬编码假设 serving 掌管推理边界 回退路径与新路径语法不一致,长期双轨维护 缺少真实权重端到端 / 精度验证 CI 依赖 reviewer 手动触发,独立复跑证据不足

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论