Prhub

#48200 [Refactor] StructuredOutputManager x Speculative Decoding Refactor

原始 PR 作者 yzong-rh 合并时间 2026-09-15 21:48 文件变更 12 提交数 2 评论 49 代码增减 +595 / -1191

执行摘要

重构结构化输出与投机解码的约束起点契约

RFC issue #48197 明确指出:StructuredOutputManager 的存在是因为「约束解码往往要等模型结束 reasoning 才生效」,而配合投机解码后,grammar 非法的 draft token 需要被拒绝、被拒绝的 draft 不能推进 grammar、reasoning 结束边界还可能落在 draft 窗口中间,调度器与 manager 必须「careful dance」才能正确,直接导致了 #34650 这类 bug。Issue 里提出的改造目标是:「Let StructuredOutputManager handle the token validation and grammar advancement」,删除 should_advance(request) 与 trim_reasoning_for_advance(verified tokens),新增 validate_tokens(draft tokens) 与 accept_tokens(verified tokens),并让三个入口共享同一个 _get_constraint_start() helper。PR body 引用 #44297 的 repro 作为回归基准,并给出 JSON 约束场景下 main 68% 成功率、本 PR 100% 的对比数据(其中 main 的失败由 #44993 修复,本 PR 保持通过)。

值得精读,且建议按「契约 → 消费方 → 测试」的顺序读:先看 vllm/v1/structured_output/__init__.py 的 _get_constraint_start(),它是整个 PR 唯一的语义来源;再看 scheduler.py 三个调用点确认新契约的用法;最后看 test_structured_output_manager.py 的 FlowCase / _run_real_flow() 理解验证方式。重点关注四个设计决策:(1) 用 len + 1 哨兵而非 None 表达「本窗口无约束」,三个消费点的 >= / > / <= 各有语义,不要「顺手统一」;(2) 降序扫描把语义从「第一次边界」改成「最后一次边界」,这对交错思考模型更合理但改变了行为;(3) 断言降级为日志是刻意的(避免 engine core 被打挂),代价是失败请求要等 accept 阶段兜底;(4) 「reasoning parser 必须无状态」目前只是文档契约,落地前不建议在生产上把 stateful parser 与投机解码 + 结构化输出组合使用。

讨论亮点

1. 引擎级致命断言(abmfy,CHANGES_REQUESTED → 已解决)

「This assert replaces the old post_reasoning_end_in_window tolerance, and it can fire in practice... When it fires, the AssertionError propagates out of schedule() and takes down the whole engine core」

abmfy 用 CPU-only 的 manager 级 probe 复现出 Step3p5ReasoningParser 的一次性 latch _end_token_pending 会让 validate_tokens 与 grammar_bitmask 两次 walk 得到不同的 constraint_start,未验证的 marker token 落进 i >= constraint_start 区域并触发断言。作者在 9bd40d7 中把断言换成 failed 标志 + logger.error(fail-closed),并解释「We could assert False here but that would crash the entire engine. The right fix is the fail the request but that requires some additional plumbing that should probably be a follow up」。顺带修掉了 state_advancements += 1 无条件自增在 python -O 下多回滚一个 token 的隐患。

2. reasoning parser 的纯度契约(design,部分未解决,形成前置依赖)

abmfy:「This walk runs from three entry points per step... which implicitly requires is_reasoning_end_streaming to be pure」
yzong-rh:「is_reasoning_end_streaming should probably be pure and idempotent. Will fix Step3p5ReasoningParser in another PR」

作者拒绝在热路径加 __debug__ 双重探针(理由是性能与「Two calls can return the same value even with state mutation」),改为在 abstract_parser.py 写入纯度契约。abmfy 最终 approve 时给出排序条件:「#53174 should land before this PR」,并提示应在 PR 描述里声明该依赖。

3. 热路径 O(context) 拷贝(performance,已修复)

abmfy:「every structured-output request pays an O(context) copy per step for its entire lifetime... ~355µs @200k, per request per step on the engine-core critical path — That's the regression class #50886 just removed」

两处修复:32a5a63 让切片发生在 reasoning_ended / 无 reasoner 的早退之后,并对空窗口提前返回;ee9e30f 去掉 islice 与星号解包,改用 delta_ids = spec_tokens[:num_spec_tokens]。作者实测(Qwen3.5-2B,MTP + reasoning + JSON)输出吞吐 +19%、Mean ITL −16%。

4. -1 padding 行的填充行为(correctness,已修复)

abmfy:「the old loop stopped real-filling at the first -1... For diffusion LLMs under async scheduling it's a correctness change」

cba6819 恢复「首个 -1 之后翻转为 memset」,seen_padding 同时作用于 bonus row;作者承认「My bad」。

5. 测试覆盖缺口(testing,未采纳)

abmfy:「The suite's only reasoner is stateless and delta-only, so the invariant the whole design rests on... has zero coverage」

作者回应 MockReasoner 实现的就是 refactor 前已假设的无状态契约,多 token marker 与基类默认实现属于 parser 层测试,「More or less won't fix」;但采纳了删除 test_scheduler.py / test_async_scheduler.py 中已失效的 grammar mock 残留。

6. 哨兵语义与 re-open 窗口(design,留白)

abmfy:「the len(spec_tokens) + 1 sentinel forces three different comparisons at the three consumers... Anyone 'harmonizing' these later gets the silent-permanent-loss failure mode」

作者保留哨兵(计划缓存 _get_constraint_start 结果,None 留给 cache miss),只补文档。mgoin 随后指出 Kimi K3 parser 下「close reasoning → 非法 token → reopen reasoning」会让整窗不被 mask,作者确认该场景在 main 上同样没有处理,并解释降序扫描只能看到最后一次边界:「Main handled this inconsistently... With this PR, we scan right-to-left only across all three stages」。

实现拆解

  1. 新增统一契约 helper:在 vllm/v1/structured_output/__init__.py 中新增 _get_constraint_start(request, spec_tokens, spec_tokens_committed=False),返回「窗口内第一个需要被 grammar 约束的下标」,无约束时返回 len(spec_tokens) + 1 哨兵。它合并了原先散落在 should_advance()、trim_reasoning_for_advance()、should_fill_bitmask() 中的 reasoning 边界判断:先看 enable_in_reasoning / reasoning_ended / 是否有 reasoner 的快路径,再优先调用 ParserEngineReasoningAdapter.find_reasoning_end_offset(),否则回退到自右向左收缩窗口调用 is_reasoning_end_streaming()。request.py 中因此删除了不再需要的 reasoning_end_token_index 字段(旧的绝对索引方案被窗口内相对下标取代)。
  2. 重写 bitmask 与 grammar 推进逻辑:grammar_bitmask() 串行分支改为「constraint_start 之前不约束、之后才 real-fill」,用 seen_padding 在首个 -1 后恢复 memset(消除 #44006 性能与扩散模型正确性回归),用 failed 标志把旧的 raise AssertionError 降级为 logger.error + 剩余行不再约束;试探性推进的 grammar 状态仍然通过 grammar.rollback(state_advancements) 回滚,保持幂等。并行分支的 apply_bitmask 也从 should_fill_bitmask() 改为 _get_constraint_start(request, ()) == 0。
  3. 新增两个公共入口:validate_tokens() 先把 -1 padding 下沉剥离(strip_speculative_padding),只对约束后缀调用 grammar.validate_tokens(),保留未约束前缀;accept_tokens() 用 spec_tokens_committed=True 复用同一 helper,只有 constraint_start > len(new_token_ids) 才提前返回 True,否则置位 structured_req.reasoning_ended = True(这是 reasoning_ended 唯一的 latch 点)并推进 grammar,被拒绝时返回 False。
  4. 调度器侧收敛:vllm/v1/core/sched/scheduler.py 不再 import StructuredOutputGrammar 与 strip_speculative_padding;update_from_output() 用 not accept_tokens(request, new_token_ids) 直接判定 FINISHED_ERROR,update_draft_token_ids() 与 update_draft_token_ids_in_output() 都用 validate_tokens(request, spec_token_ids),后者仍把被裁掉的数量换算成 -1 回填并写入 num_invalid_spec_tokens。
  5. 契约文档化与解析器对齐:vllm/parser/abstract_parser.py 为 is_reasoning_end / is_reasoning_end_streaming 补上「Must be a pure function」约束,并把 find_reasoning_end_offset() 的「窗口内未发现边界」返回值从 None 改为 len(token_ids);vllm/parser/engine/parser_engine.py 同步该语义,使调用方可以用 offset + 1 直接得到约束起点。
  6. 测试整合(配套改动):删除 tests/v1/structured_output/test_reasoning_structured_output.py(557 行)与 tests/v1/spec_decode/test_mtp_structured_output.py 中的大部分用例,新增 tests/v1/structured_output/test_structured_output_manager.py,用 FlowCase 在真实 xgrammar/guidance 后端上跑 validate → bitmask → accept → 再取 bitmask 全流程;tests/v1/core/test_scheduler.py / test_async_scheduler.py 改为 mock manager.accept_tokens,test_mtp_structured_output.py 保留 xgrammar 终止语义与引擎 reasoner 的窗口探测用例。
文件 模块 状态 重要度
vllm/v1/structured_output/__init__.py 结构化输出 modified 8.92
vllm/v1/core/sched/scheduler.py 调度器 modified 7.3
tests/v1/structured_output/test_structured_output_manager.py 结构化输出 added 7.65
tests/v1/spec_decode/test_mtp_structured_output.py 投机解码 modified 7.16
vllm/parser/abstract_parser.py 解析器契约 modified 5.26
vllm/v1/structured_output/request.py 结构化输出 modified 5.27
vllm/parser/engine/parser_engine.py 解析器引擎 modified 4.93
tests/v1/core/test_scheduler.py 调度器 modified 5.84

关键符号

_get_constraint_start validate_tokens accept_tokens grammar_bitmask strip_speculative_padding update_from_output update_draft_token_ids update_draft_token_ids_in_output find_reasoning_end_offset

关键源码片段

vllm/v1/core/sched/scheduler.py dependency-wiring

消费新契约的三个调用点被收敛:不再 import grammar 与 padding 工具,update_from_output 直接依赖 manager.accept_tokens 的布尔返回,两个 draft 校验入口统一调用 manager.validate_tokens。

# vllm/v1/core/sched/scheduler.py
# 三个调用点收敛为两个 manager 方法,调度器不再直接接触 grammar。
​
# 1) update_from_output(): 采样 / 验证后的 token 落地时推进 grammar。
# manager 内部用 _get_constraint_start() 裁掉 reasoning 前缀,
# 因此这里不再需要 should_advance() + trim_reasoning_for_advance() 两步。
if new_token_ids and not self.structured_output_manager.accept_tokens(
    request, new_token_ids
):
    logger.error(
        'Unexpected: grammar rejected tokens %s for request %s. '
        'Terminating request.',
        new_token_ids,
        req_id,
    )
    request.status = RequestStatus.FINISHED_ERROR
    request.resumable = False
    stopped = True
​
# 2) update_draft_token_ids(): ngram/ 草案生成后立即做一次约束校验。
# -1 padding 的剥离已下沉到 manager,这里只透传。
request.spec_token_ids = self.structured_output_manager.validate_tokens(
    request, spec_token_ids
)
​
# 3) update_draft_token_ids_in_output(): 写入 scheduler output 前同名校验,
# 被裁掉的数量换算成 -1 回填,供 rejection sampler 识别为占位。
spec_token_ids = self.structured_output_manager.validate_tokens(
    request, spec_token_ids
)
num_invalid_tokens = orig_num_spec_tokens - len(spec_token_ids)
if num_invalid_tokens:
    spec_token_ids.extend([-1] * num_invalid_tokens)
    num_invalid_spec_tokens[req_id] = num_invalid_tokens
tests/v1/structured_output/test_structured_output_manager.py test-coverage

测试策略的载体:用 FlowCase 在真实 xgrammar/guidance 后端上跑 validate → bitmask → accept → 再取 bitmask 的完整时序,替代原先按方法逐个 mock 的测试。

# tests/v1/structured_output/test_structured_output_manager.py
# 测试策略:不 mock grammar,而是用真实 xgrammar/guidance 后端,
# 按调度器真实时序把一条请求跑完 validate -> bitmask -> accept -> bitmask。
@dataclass(frozen=True)
class FlowCase:
    """One validate -> bitmask -> accept -> post-accept bitmask scenario."""
​
    raw_drafts: tuple[str, ...] # 草案字面量;EOS 用哨兵,-1 由流程自行补齐
    expected_row_pattern: str # 每行期望 C(约束)/ U(未约束)
    expected_reasoning: bool | None # 流程结束后 reasoning_ended 的期望值
    expect_terminated: bool # 流程结束后 grammar 是否应终止
    prefix: str = '' # 已提交前缀,用于构造 reasoning / JSON 边界
    expected_validated: tuple[str, ...] | None = None
    reasoning_ended: bool | None = False
    xfail_guidance: str | None = None # guidance 在终止态 EOS 校验上的已知差异
​
​
def _row_pattern(bitmask) -> str:
    # 整行全 -1 视为未约束(U),否则视为被 grammar 约束(C)。
    return ''.join('U' if (row == -1).all() else 'C' for row in bitmask)
​
​
def _run_real_flow(
    manager: StructuredOutputManager,
    request: Request,
    raw_drafts: list[int],
    expected_validated: list[int],
    expected_row_pattern: str,
    expected_reasoning: bool | None,
    expect_terminated: bool,
) -> None:
    assert len(expected_validated) <= len(raw_drafts)
​
    validated = manager.validate_tokens(request, list(raw_drafts))
    assert validated == expected_validated
​
    # 被拒绝的草案用 -1 补齐后再进入 bitmask,直接覆盖 padding 场景。
    padded = validated + [-1] * (len(raw_drafts) - len(validated))
    bitmask = manager.grammar_bitmask(
        requests={request.request_id: request},
        structured_output_request_ids=[request.request_id],
        scheduled_spec_decode_tokens={request.request_id: padded},
    )
    assert bitmask is not None
    assert bitmask.shape[0] == len(raw_drafts) + 1
    assert _row_pattern(bitmask) == expected_row_pattern
​
    # grammar_bitmask() 必须回滚试探性推进,重复调用结果稳定(幂等)。
    assert manager.validate_tokens(request, list(raw_drafts)) == expected_validated
​
    structured_req = request.structured_output_request
    grammar = structured_req.grammar
    assert not grammar.is_terminated()
    # bitmask[i] 是「已接受前 i 个草案」后的 grammar 状态,
    # 因此提交 accepted 前缀后重新取到的 bitmask 必须与 row i 一致。
    expected_post_accept_row = bitmask[len(expected_validated)].copy()
​
    request.append_output_token_ids(list(expected_validated))
    assert manager.accept_tokens(request, list(expected_validated))
​
    post_accept_bitmask = manager.grammar_bitmask(
        requests={request.request_id: request},
        structured_output_request_ids=[request.request_id],
        scheduled_spec_decode_tokens={},
    )
    assert post_accept_bitmask is not None
    assert post_accept_bitmask.shape[0] == 1
    assert (post_accept_bitmask[0] == expected_post_accept_row).all()
    assert structured_req.reasoning_ended == expected_reasoning
    assert grammar.is_terminated() is expect_terminated

评论区精华

grammar_bitmask 断言可能打挂整个 engine core 正确性

abmfy 用 CPU-only probe 复现:Step3p5 的一次性 latch 使 validate_tokens 与 grammar_bitmask 对同一窗口算出不同 constraint_start,未验证的 marker token 触发 `assert accepted`,AssertionError 从 schedule() 抛出并终止 engine core 与所有在途请求;同块还存在 `state_advancements += 1` 无条件自增(python -O 下多回滚)与缺少 try/finally 的问题。

结论:作者在 `9bd40d7` 中把断言降级为 `failed` 标志 + `logger.error`,剩余行不再约束(fail-closed);FINISHED_ERROR 需要新返回通道,作者声明留作后续。 · 已解决

is_reasoning_end_streaming 的纯度 / 幂等契约无法被代码强制 设计

abmfy 建议用 `copy.copy(reasoner)` 做探针、或持久化绝对边界索引;作者认为这是 parser 自身缺陷,应在 #53174 中修 Step3p5,并拒绝 `__debug__` 双重探针(性能 + 不一定能抓到状态变更)。最终把契约写进 abstract_parser 文档。

结论:契约文档化 + 拆出 #53174;abmfy approve 时要求「#53174 should land before this PR」,并建议在 PR 描述里声明依赖。有状态 parser 的静默丢约束与 off-by-one 在本分支仍可复现。 · 部分解决,依赖 PR#53174

结构化输出请求每步付出 O(context) 拷贝 性能

abmfy 指出 `request.all_token_ids` 在 `_get_constraint_start` 中被无条件切片 / 拼接,空窗口也会拷贝整段历史(~355µs @200k ctx,属于 #50886 刚消除的回归类型)。

结论:作者在 `32a5a63` 把切片移到早退之后并加空窗口提前返回,`ee9e30f` 用 `delta_ids = spec_tokens[:num_spec_tokens]` 替代 islice/ 星号解包;实测 ~56µs vs main ~141µs per request per step @8k。只读 Sequence 视图留作 TODO。 · 已解决

首个 -1 之后是否仍真实填充 bitmask 行 正确性

旧实现在第一个 -1 后翻转为 memset;重构版对所有行 real-fill,AR 下纯属浪费,扩散模型 + 异步调度下会把 frontier 之后的行约束到错误的 token 集合。

结论:作者在 `cba6819` 恢复 `seen_padding` 逻辑并覆盖 bonus row;同时说明 #44006 的 bonus row 期望被有意翻转,安全性依赖 rejection sampler 自动拒绝 PLACEHOLDER 草案这一未写明的约定。 · 已解决

新 flow suite 缺少有状态 reasoner 与多 token marker 覆盖 测试

abmfy 指出 suite 唯一 reasoner 是无状态 delta-only mock,崩溃 / 静默丢约束的 repro 都能让测试全绿;被删的 test_bitmask_post_reasoning_end_drafts_skip_grammar_advance 恰好守护新断言会死的形状。

结论:作者以「reasoning-end 检查应无状态」与「parser 层测试不应混入 manager 测试」为由不采纳,仅补文档与清理失效 mock;abmfy 在 approve 中再次提示可加 `__debug__` 双重探针。 · 未采纳,存在争议

len + 1 哨兵与 re-open 窗口语义 设计

abmfy 指出哨兵值在三个消费点分别用 `>=`、`>`、`<=` 比较,只有 accept 的严格 `>` 是 load-bearing,容易被后续统一化改坏,建议返回 `int | None`;mgoin 随后给出 Kimi K3 场景(close → 非法 token → reopen)整窗不被 mask 的复现,指出降序扫描只认最后一次边界。

结论:作者保留哨兵(计划缓存 _get_constraint_start,None 留给 cache miss)并补注释;对 re-open 场景确认 main 同样未处理,属于既有空白而非本次回归,未在本 PR 内修复。 · 部分记录,行为未改

风险与影响

  1. 核心路径回归面广:grammar_bitmask() 串行分支被整段重写,同时承载 AR、扩散模型(is_diffusion + canvas_length)、投机解码 padding、异步调度四种组合。seen_padding / failed / constraint_start 三个条件的组合语义(如 bonus row 在 constraint_start == len(req_tokens) 时仍要 real-fill)没有不变式测试兜底,active_later_token_invalid 等 FlowCase 的期望值已与删除的 #44006 回归用例相反,安全性依赖「rejection sampler 会拒绝 PLACEHOLDER(-1)」这一未写进代码的约定。
  2. 隐性契约:reasoning parser 必须是纯函数。三个入口(validate_tokens / grammar_bitmask / accept_tokens)每 step 都会重走同一窗口,任何持有跨调用状态的 parser(当前树内的 Step3p5ReasoningParser._end_token_pending)都会出现「静默丢约束」或 off-by-one,且 i + 1 >= 1 使漏检不可恢复、请求会以无约束输出继续服务。本 PR 只把致命崩溃降级为日志,根因留给 #53174 与文档契约。
  3. 未决的 re-open 窗口:mgoin 指出 close → 中间 token → reopen 的窗口在三个阶段都不再被约束;虽然 main 的处理本身也不一致,但这一路径在 Kimi K3 + XGrammar 上可复现,若上游再次出现类似 #43388 的调度变化,风险会被放大。
  4. 性能未完全收敛:_get_constraint_start() 每 step 最多被调用三次,request.all_token_ids.copy() 仍在 no-fire 路径上物化;作者的实测显示合并 #55223 后 TTFT / 吞吐与 main 持平,但 TODO: Build a read-only Sequence view 尚未落地,长上下文(108k+)下每步仍有 ~28µs/请求量级的额外拷贝。
  5. API 兼容性:StructuredOutputRequest.reasoning_end_token_index 被删除,should_advance / trim_reasoning_for_advance 从 manager 公共接口消失;任何下游 fork 或插件式 reasoning parser 若引用这些符号会直接 ImportError / AttributeError。

用户侧:reasoning 模型(Qwen / DeepSeek / GLM 等)+ JSON/STRUCTURAL_TAG 约束 + 投机解码的组合从「部分请求静默不约束」变为契约统一;PR body 的 50 次请求对比中 JSON 约束成功率从 68% 提升到 100%,作者在 MTP + reasoning 场景下实测吞吐 +19%、Mean ITL −16%。系统侧:调度器热路径少了两层间接调用与一次 grammar 依赖,Scheduler 不再 import StructuredOutputGrammar;grammar 推进的职责边界从「调度器 + manager 协商」变为「manager 单点决策」,后续新增约束类型或推理后端只需遵循一个 helper。团队侧:这是尚未落地的 RFC 的第一次主干实现,#53174(Step3p5 parser 无状态化)成为事实上的后续依赖;同时删除了 900+ 行旧测试,测试守护从「按方法逐个 mock」转为「按真实后端全流程」的范式,后续扩展 reasoning 场景需要补 FlowCase 而非改 mock。

核心路径变更 隐性纯度契约 依赖 PR#53174 先落地 哨兵值语义易踩坑 测试覆盖缺口 长上下文拷贝未收敛

关联 Issue

#48197 [RFC]: StructuredOutputManager x Speculative Decoding Refactor

完整报告

参与讨论