Issue

#38696 — Qwen3.5-9B、Gemma 4 等模型在使用 response_format: json_schema 结构化输出时,在 JSON 值位置(冒号后)持续输出空格,直到 max_tokens 耗尽,finish_reason: length。复现率 ~1/3。

根因

两层因素叠加:

  1. 模型侧:对歧义输入(如 "465564867"),特定 checkpoint 在 JSON 值位置给空格 token 分配高概率。Qwen3.5-9B 复现,Qwen3.5-4B 不复现,与模型大小和训练方式有关。
  2. vLLM 侧compile_json_schema() 未传 max_whitespace_cnt(默认 None),xgrammar 的 FSM 在结构 token 之间({ 后、: 后、, 后)允许无限空格自循环。由于空格是合法 JSON,grammar mask 始终放行,结构化输出失效。

修复

StructuredOutputsConfig 新增 max_whitespace_cnt: int | None = 2

配置 效果
默认(2 {"a": 1},可读 + 安全
None 无限空格(原 bug 行为,不推荐)
1 更严格
disable_any_whitespace=True 语法糖,等价于 max_whitespace_cnt=0,紧凑 JSON

限制仅作用于结构 token 之间,不影响字符串值内部的空格(如 "hello world" 不受影响)。

参数统一设计

disable_any_whitespacemax_whitespace_cnt 可能冲突,通过 _resolve_whitespace() 在调用点统一解析:

def _resolve_whitespace(self) -> tuple[bool, int | None]:
    if self.disable_any_whitespace:
        return False, 0  # 紧凑 JSON
    return True, self.max_whitespace_cnt  # 有限空格

编译 grammar 时直接使用解析后的值,调用点一目了然。

关键文件

测试

13 个单元测试,全部 CPU,不需要下载模型:

.venv/bin/python -m pytest tests/test_config.py -k "structured_outputs_config" -v
.venv/bin/python -m pytest tests/v1/structured_output/test_max_whitespace_cnt.py -v

FSM 测试直接用 xgrammar 的真实 API:

相关 PR 对比

PR 方案 贡献 局限
#34790 传递 whitespace_pattern 发现了细粒度控制 whitespace 的需求 xgrammar 只支持 bool,不支持 regex
#42414 扩展 disable_any_whitespaceauto backend 改善跨 backend 参数兼容性 用户仍只有二选一:无空格或无限空格
#44619 新增 max_whitespace_cnt 默认值 填补空白:有界空格 = 可读 + 安全

Sunt-ing 在 #38696 上独立发现了相同根因,提出 max_whitespace_cnt 并用实测数据验证(=1/2 时 0/64 触发率)。

学到的问题

xgrammar FSM 是 mask 不是后处理

xgrammar 不改写输出,而是在采样时 mask 不允许的 token。如果模型给空格高概率且 FSM 无限制,空格会被持续采样。修复在 FSM 层面,不是模型层面。

模型相关 bug 的 E2E 测试不可靠

Runaway whitespace 是 checkpoint 特定行为。Qwen3.5-9B 复现,Qwen2.5-1.5B 和 Qwen3.5-0.8B 都不复现。用小模型跑 E2E 无论怎么调 prompt 和 seed 都测不出 bug。

解法:直接在 xgrammar FSM 层面用 GrammarMatcher.accept_string() 测试,确定性的、不依赖模型。

相关参数要统一避免冲突

disable_any_whitespacemax_whitespace_cnt 可能冲突。通过在调用点用 _resolve_whitespace() 方法解析,而不是在 __post_init__ 里预计算私有属性,逻辑在调用点一目了然。

PR 描述中对比相关 work

多个社区 PR 触达同一区域但目标不同。在 PR 描述中添加 “Related Work” 部分,肯定其他贡献的同时说明本 PR 填补的空白,帮助 reviewer 理解必要性而不否定前人工作。

默认值的选择

2 不是随便选的: