Prhub

#44993 [Bugfix][Structured Output][Spec Decode] Advance grammar across reasoning boundary

原始 PR 作者 yuyue0225sc 合并时间 2026-07-24 00:14 文件变更 3 提交数 29 评论 46 代码增减 +146 / -54

执行摘要

修复异步调度 + 推测解码下推理结束标记丢失导致结构化输出失效

PR 修复了三个上报 issue:#43388(json_object 约束在推理结束后未被应用)、#48228(输出出现重复花括号)、#34650(推测解码导致推理结束检测失败)。根本原因是 should_advance 使用的 delta 窗口基于 num_computed_tokens - num_output_placeholders,在异步调度+推测解码下,当部分 draft 被拒绝时占位符数量不准确,导致窗口跳过 </think>。即使窗口包含标记,后标记 token 也未在步内进入语法 FSM,造成约束丢失。详见 PR body 中 Bug1 和 Bug2 描述。

本 PR 值得精读,尤其是 should_advance 的重构和统一路径的设计决策。它展示了如何在跨多个上游后端的情况下通过 vLLM 层防御性修复设计缺陷。建议在合并后确认 E2E 回归(特别是非推测解码路径),并考虑后续删除占位符回退路径,强制所有调用点传递 new_token_ids

讨论亮点
  • 是否应由上游(xgrammar)修复:@chaunceyjiang 认为可在 xgrammar 侧增加参数解决,但 @yuyue0225sc 指出 vLLM 有四个结构化输出后端,不能依赖单一上游,且推理边界检测是解码层事件,理应在 vLLM 统一处理。共识维持当前设计。
  • 统一 JSON/STRUCTURAL_TAG 双路径:@yzong-rh 询问是否可取消 JSON 等约束的延迟推进(inline drain),@yuyue0225sc 分析后确认“后标记 token 在验证阶段已受 grammar_bitmask 约束,拒绝采样保证已提交 token 语法有效”,故最终版本移除双路径。
  • 跨模型独立验证:@Mazyod(Gemma-4-E2B + MTP)和 @cresslank(DeepSeek V4 Flash + DSpark)分别报告独立复现结果,确认修复后结构化输出失败率从 75–100% 降至 0%。
  • Mistral 推理解析器交互:@bbrowning 担心无 [THINK]is_reasoning_end_streaming 返回 True 导致 _find_reasoning_end_index 错误裁剪首个 token。经讨论确认调用方保证 reasoning_ended 在首个 delta 前已正确初始化,无需额外修补。

实现拆解

  1. delta 窗口修正should_advance 新增可选参数 new_token_ids: list[int] | None。当来自调度器主调路径时,以 len(all_token_ids) - len(new_token_ids) 为起点,以 new_token_ids 本身作为 delta 送入 reasoner。占位符回退路径保留用于两个 draft 验证调用点(它们没有已提交的 new_token_ids,行为不变)。——文件:vllm/v1/structured_output/__init__.py

  2. 统一语法推进路径:检测到推理结束时,should_advance 记录 reasoning_end_token_index 并统一返回 True(不再为 JSON/regex/choice 返回 False 并在方法内 drain)。原先 inline drain 逻辑被移除,结构化输出不再在 should_advance 内修改 grammar 状态,副作用限于推理边界状态变量。——文件:vllm/v1/structured_output/__init__.py

  3. 调度器侧推进update_from_output 中调用 should_advance 时增加 new_token_ids=new_token_ids。当返回 True 时,调用 trim_reasoning_for_advance 切去推理前缀,再将剩余的后标记后缀送入 grammar.accept_tokens。若拒绝则终止请求(与已存在的非推理路径一致)。——文件:vllm/v1/core/sched/scheduler.py

  4. 测试覆盖:新增 5 个测试用例覆盖精确 delta 窗口、占位符回退兼容、JSON 修剪路径;调整 test_should_advance_reasoning_just_ended 期望值(True 而非 False);合并原先分离的 test_should_advance_reasoning_just_ended_with_spec_decode_structural_tag 测试(已被统一路径覆盖)。——文件:tests/v1/structured_output/test_reasoning_structured_output.py

  5. 配套清理:移除不再需要的导入(StructuredOutputOptions, init_logger);调整 import order。

文件 模块 状态 重要度
vllm/v1/structured_output/__init__.py 结构化输出 modified 7.51
vllm/v1/core/sched/scheduler.py 调度器 modified 5.28
tests/v1/structured_output/test_reasoning_structured_output.py 测试 modified 7.01

关键符号

should_advance _find_reasoning_end_index trim_reasoning_for_advance

关键源码片段

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

核心文件:修改 `should_advance` 方法,添加 `new_token_ids` 参数并统一推理边界检测和语法推进逻辑。移除了双路径分流,是 PR 主要逻辑所在。

def should_advance(
    self,
    request: "Request",
    new_token_ids: list[int] | None = None,
) -> bool:
    # 快速路径:非结构化输出请求
    if not request.use_structured_output:
        return False
​
    reasoner = self._get_reasoner(request)
    if reasoner is None:
        return True
    if self.enable_in_reasoning:
        return True
​
    structured_req = request.structured_output_request
    # 推理已结束,应当推进语法
    if structured_req.reasoning_ended:
        return True
​
    all_token_ids = request.all_token_ids
    if new_token_ids:
        # 使用调用方提供的精确 delta 窗口(修复 Bug 1)
        start = len(all_token_ids) - len(new_token_ids)
        delta_ids: Iterable[int] = new_token_ids
    else:
        # 占位符回退路径,用于 draft 验证等无 new_token_ids 的调用点
        delta_from = request.num_computed_tokens - request.num_output_placeholders
        start = delta_from if delta_from >= 0 else max(len(all_token_ids) + delta_from, 0)
        delta_ids = itertools.islice(all_token_ids, start, None)
​
    if reasoner.is_reasoning_end_streaming(all_token_ids, delta_ids):
        structured_req.reasoning_ended = True
        # 记录推理结束位置,供调度器修剪推理前缀
        structured_req.reasoning_end_token_index = self._find_reasoning_end_index(
            reasoner, all_token_ids, start, delta_ids
        )
        return True # 统一返回 True,调度器后续 trim + accept
​
    return False
vllm/v1/core/sched/scheduler.py core-logic

调度器是调用方:修改 `update_from_output` 中调用 `should_advance` 时传递 `new_token_ids`,并在返回后执行 `trim_reasoning_for_advance` 和 `accept_tokens`。变更虽小但确保精确窗口传递给管理器。

# 在 update_from_output 中,请求已追加 new_token_ids 后
if new_token_ids and self.structured_output_manager.should_advance(
    request, new_token_ids=new_token_ids # 传递精确窗口
):
    struct_output_request = request.structured_output_request
    grammar = struct_output_request.grammar
    # 切除推理前缀,仅语法内容送入 accept_tokens
    advance_token_ids = (
        self.structured_output_manager.trim_reasoning_for_advance(
            request, new_token_ids
        )
    )
    if advance_token_ids and not grammar.accept_tokens(
        req_id, advance_token_ids
    ):
        # 不接受则终止请求(与通用路径一致)
        request.status = RequestStatus.FINISHED_ERROR
        request.resumable = False
        stopped = True

评论区精华

是否应该在 vLLM 侧添加推理边界检测 设计

@chaunceyjiang 认为应由上游 xgrammar 提供参数,@yuyue0225sc 指出 vLLM 有四个结构化输出后端不能依赖单一上游,且边界检测是解码层事件,理应在 vLLM 统一处理。

结论:维持 vLLM 侧修复。 · 已解决

统一所有约束类型的语法推进路径 设计

@yzong-rh 提出是否可取消 JSON 等约束的延迟推进(inline drain),@yuyue0225sc 分析后确认后标记 token 在验证阶段已受 grammar_bitmask 约束,拒绝采样保证已提交 token 语法有效。

结论:最终版本移除双路径,所有约束类型走统一 trim + accept 路径。 · 已解决

PR 描述中引用不可靠测试数据 other

@estrella159 指出 60%→100% 的数据点基于 n=1 且启动发散,不应引用。@yuyue0225sc 更新为配对 t 检验结果和正式测试数据。

结论:描述已修正。 · 已解决

Mistral 推理解析器无标记时交互问题 正确性

@bbrowning 担心无 `[THINK]` 时 `is_reasoning_end_streaming` 返回 True 导致 `_find_reasoning_end_index` 错误裁剪首个 token。

结论:确认调用方保证 reasoning_ended 在首个 delta 前已正确初始化,无需修补。 · 已解决

跨模型独立验证修复效果 测试

@Mazyod(Gemma-4-E2B + MTP)和 @cresslank(DSv4 Flash + DSpark)分别报告独立复现并验证修复有效,结构化输出失败率从 75–100% 降至 0%。

结论:确认修复跨模型有效。 · 已解决

风险与影响

  • 推理边界检测精度:依赖 reasoner.is_reasoning_end_streaming 的正确性。修改后使用 new_token_ids 作为 delta 窗口,使得窗口更精确,但若其他调用点未提供 new_token_ids 仍使用旧回退,存在不一致风险。
  • 向后兼容should_advance 新增可选参数,旧调用点无参调用仍依赖占位符计算,行为不变。但需确保所有调度器路径均在新调用路径下。
  • 性能影响:统一路径后取消了 should_advance 内的语法副效应(inline drain),将语法推进集中到调度器,可能略微增加调度器开销,但影响可忽略。
  • reject 路径变更:统一路径中 accept_tokens 拒绝时直接终止请求,而非之前的 warn-and-continue。虽然理论上不会出现(因为后标记 token 已受 grammar_bitmask 约束),但可能暴露现有 bug。
  • 用户:使用推测解码 + 结构化输出(JSON/regex/choice/grammar)的推理模型(如 Qwen3、DeepSeek V4、Gemma-4)将获得正确约束输出,消除 Markdown 包装、重复花括号等问题。
  • 系统:调度器与结构化输出管理器接口微调,should_advance 新增参数,所有约束类型统一路径。对非推测解码路径无影响。
  • 团队:需要关注回退路径的一致性,以及将来引入新的 reasoning parser 时确保 reasoning_ended 初始值正确。
推理边界检测精度 占位符回退一致性 调度器与管理器接口耦合 accept_tokens 拒绝路径变更

关联 Issue

#34650 Bug: Speculative Decoding (MTP) Causes </think> Detection Failure in Structured Output + Reasoning Mode
#43388 [Bug]: `json_object` structured output is not enforced after Qwen thinking because reasoning end token is missed with async scheduling + spec decode
#44297 [Bugfix][Structured Output][Spec Decode] Constrain bitmask and trim grammar advance at the reasoning boundary
#48228 [Bug]: response_format emits a doubled leading brace "{{" with reasoning enabled (Qwen3.6, --reasoning-parser qwen3); guided_json and enable_thinking=false are unaffected

完整报告

参与讨论