Issue
#38696 — Qwen3.5-9B、Gemma 4 等模型在使用 response_format: json_schema 结构化输出时,在 JSON 值位置(冒号后)持续输出空格,直到 max_tokens 耗尽,finish_reason: length。复现率 ~1/3。
根因
两层因素叠加:
- 模型侧:对歧义输入(如
"465564867"),特定 checkpoint 在 JSON 值位置给空格 token 分配高概率。Qwen3.5-9B 复现,Qwen3.5-4B 不复现,与模型大小和训练方式有关。 - 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_whitespace 和 max_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 时直接使用解析后的值,调用点一目了然。
关键文件
vllm/config/structured_outputs.py— 新增max_whitespace_cnt字段vllm/v1/structured_output/backend_xgrammar.py—_resolve_whitespace()+ 传递参数tests/test_config.py— 3 个配置验证测试tests/v1/structured_output/test_max_whitespace_cnt.py— 10 个测试(Mock 参数传递 + 真实 xgrammar FSM 行为验证)
测试
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:
max_whitespace_cnt=2:第 3 个空格被拒绝max_whitespace_cnt=None:无限空格max_whitespace_cnt=1:第 2 个空格被拒绝- 有限空格后 FSM 仍接受值 token
- 字符串值内空格不受影响
相关 PR 对比
| PR | 方案 | 贡献 | 局限 |
|---|---|---|---|
| #34790 | 传递 whitespace_pattern |
发现了细粒度控制 whitespace 的需求 | xgrammar 只支持 bool,不支持 regex |
| #42414 | 扩展 disable_any_whitespace 到 auto 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_whitespace 和 max_whitespace_cnt 可能冲突。通过在调用点用 _resolve_whitespace() 方法解析,而不是在 __post_init__ 里预计算私有属性,逻辑在调用点一目了然。
PR 描述中对比相关 work
多个社区 PR 触达同一区域但目标不同。在 PR 描述中添加 “Related Work” 部分,肯定其他贡献的同时说明本 PR 填补的空白,帮助 reviewer 理解必要性而不否定前人工作。
默认值的选择
2 不是随便选的:
None= 原 bug0= 不可读 JSON1= 太严格2= 可读 JSON 的最小上限