执行摘要
- 一句话:重构结构化输出与投机解码的约束起点契约
- 推荐动作:值得精读,且建议按「契约 → 消费方 → 测试」的顺序读:先看
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 与投机解码 + 结构化输出组合使用。
功能与动机
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 保持通过)。
实现拆解
- 新增统一契约 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 字段(旧的绝对索引方案被窗口内相对下标取代)。
- 重写 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。
- 新增两个公共入口:
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。
- 调度器侧收敛:
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。
- 契约文档化与解析器对齐:
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 直接得到约束起点。
- 测试整合(配套改动):删除
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(模块 结构化输出;类别 source;类型 core-logic;符号 _get_constraint_start, validate_tokens, accept_tokens, grammar_bitmask): 整个重构的语义中心:新增 _get_constraint_start() 统一约束起点,新增 validate_tokens()/accept_tokens() 两个公共入口,重写 grammar_bitmask() 的 padding 与失败降级逻辑,同时删除 should_advance()/trim_reasoning_for_advance()/should_fill_bitmask()。
vllm/v1/core/sched/scheduler.py(模块 调度器;类别 source;类型 dependency-wiring;符号 update_from_output, update_draft_token_ids, update_draft_token_ids_in_output): 消费新契约的三个调用点被收敛:不再 import grammar 与 padding 工具,update_from_output 直接依赖 manager.accept_tokens 的布尔返回,两个 draft 校验入口统一调用 manager.validate_tokens。
tests/v1/structured_output/test_structured_output_manager.py(模块 结构化输出;类别 test;类型 test-coverage;符号 FlowCase, MockReasoner, _run_real_flow, _row_pattern): 测试策略的载体:用 FlowCase 在真实 xgrammar/guidance 后端上跑 validate → bitmask → accept → 再取 bitmask 的完整时序,替代原先按方法逐个 mock 的测试。
tests/v1/spec_decode/test_mtp_structured_output.py(模块 投机解码;类别 test;类型 test-coverage;符号 _EngineReasonerStub, test_bitmask_engine_reasoner_ends_midwindow_with_padding, test_xgrammar_accept_tokens_stops_at_termination, test_xgrammar_validate_tokens_stops_at_termination): 保留并改写为「后端 + 引擎 reasoner」的边界用例:删除 357 行 manager 流程测试(迁往新 suite),新增 _EngineReasonerStub 验证引擎路径不会重扫草案前缀,并保留 xgrammar 终止语义测试。
vllm/parser/abstract_parser.py(模块 解析器契约;类别 source;类型 documentation;符号 is_reasoning_end, is_reasoning_end_streaming, find_reasoning_end_offset): 把本 PR 依赖的隐式假设写成显式契约:is_reasoning_end / is_reasoning_end_streaming 必须是纯函数,find_reasoning_end_offset 的「未发现」返回值语义由 None 改为 len(token_ids)。
vllm/v1/structured_output/request.py(模块 结构化输出;类别 source;类型 data-contract;符号 StructuredOutputRequest.reasoning_end_token_index): 删除 StructuredOutputRequest.reasoning_end_token_index,标志旧的「绝对索引」方案被窗口内相对下标方案取代,是本 PR 数据契约层面的关键清理。
vllm/parser/engine/parser_engine.py(模块 解析器引擎;类别 source;类型 core-logic;符号 find_reasoning_end_offset): find_reasoning_end_offset 的兜底返回值从 None 改为 len(token_ids),让上游可以无条件用 offset + 1 计算约束起点,是本 PR 快路径的前提。
tests/v1/core/test_scheduler.py(模块 调度器;类别 test;类型 test-coverage;符号 test_update_draft_token_ids_strips_ngram_padding, test_update_draft_token_ids_in_output_strips_padding, test_abort_request_when_structured_output_fsm_cannot_advance): 跟随 scheduler 接口变化改写:abort 用例改为 mock manager.accept_tokens,ngram padding 用例改为依赖 StructuredOutputRequest.reasoning_ended 而非 monkeypatch should_advance。
关键符号:_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
消费新契约的三个调用点被收敛:不再 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
测试策略的载体:用 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
评论区精华
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」。
- grammar_bitmask 断言可能打挂整个 engine core (correctness): 作者在
9bd40d7 中把断言降级为 failed 标志 + logger.error,剩余行不再约束(fail-closed);FINISHED_ERROR 需要新返回通道,作者声明留作后续。
- is_reasoning_end_streaming 的纯度/幂等契约无法被代码强制 (design): 契约文档化 + 拆出 #53174;abmfy approve 时要求「#53174 should land before this PR」,并建议在 PR 描述里声明依赖。有状态 parser 的静默丢约束与 off-by-one 在本分支仍可复现。
- 结构化输出请求每步付出 O(context) 拷贝 (performance): 作者在
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 行 (correctness): 作者在
cba6819 恢复 seen_padding 逻辑并覆盖 bonus row;同时说明 #44006 的 bonus row 期望被有意翻转,安全性依赖 rejection sampler 自动拒绝 PLACEHOLDER 草案这一未写明的约定。
- 新 flow suite 缺少有状态 reasoner 与多 token marker 覆盖 (testing): 作者以「reasoning-end 检查应无状态」与「parser 层测试不应混入 manager 测试」为由不采纳,仅补文档与清理失效 mock;abmfy 在 approve 中再次提示可加
__debug__ 双重探针。
- len + 1 哨兵与 re-open 窗口语义 (design): 作者保留哨兵(计划缓存 _get_constraint_start,None 留给 cache miss)并补注释;对 re-open 场景确认 main 同样未处理,属于既有空白而非本次回归,未在本 PR 内修复。
风险与影响
- 风险:
- 核心路径回归面广:
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)」这一未写进代码的约定。
- 隐性契约:reasoning parser 必须是纯函数。三个入口(
validate_tokens / grammar_bitmask / accept_tokens)每 step 都会重走同一窗口,任何持有跨调用状态的 parser(当前树内的 Step3p5ReasoningParser._end_token_pending)都会出现「静默丢约束」或 off-by-one,且 i + 1 >= 1 使漏检不可恢复、请求会以无约束输出继续服务。本 PR 只把致命崩溃降级为日志,根因留给 #53174 与文档契约。
- 未决的 re-open 窗口:mgoin 指出 close → 中间 token → reopen 的窗口在三个阶段都不再被约束;虽然 main 的处理本身也不一致,但这一路径在 Kimi K3 + XGrammar 上可复现,若上游再次出现类似 #43388 的调度变化,风险会被放大。
- 性能未完全收敛:
_get_constraint_start() 每 step 最多被调用三次,request.all_token_ids.copy() 仍在 no-fire 路径上物化;作者的实测显示合并 #55223 后 TTFT / 吞吐与 main 持平,但 TODO: Build a read-only Sequence view 尚未落地,长上下文(108k+)下每步仍有 ~28µs/请求量级的额外拷贝。
- 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 先落地, 哨兵值语义易踩坑, 测试覆盖缺口, 长上下文拷贝未收敛
关联脉络
- PR #56200 [Refactor] Derive is_reasoning_end from the engine grammar: 同一条功能线的前序重构,同样围绕 is_reasoning_end / find_reasoning_end_offset 与 vllm/parser/engine/parser_engine.py;本 PR 在其基础上把边界探测下沉到 _get_constraint_start 并删掉多模型手工覆写。
- PR #53174 Step3p5 reasoning parser 修复(讨论中给出的链接,上下文未提供正式标题): review 中确定的前置依赖:Step3p5ReasoningParser 的一次性 _end_token_pending latch 会让三次 walk 得到不同 constraint_start,造成引擎崩溃(已降级)与静默丢约束(未修);abmfy 要求其先于本 PR 合入。
- PR #44297 strict tool calling(STRUCTURAL_TAG)约束修复(PR body 引用,未提供正式标题): PR body 用其 repro gist 作为回归基准,RFC #48197 也把它列为修复仅覆盖 STRUCTURAL_TAG 场景、JSON/REGEX 仍受损的前序工作。
- PR #44993 JSON/REGEX 等非 STRUCTURAL_TAG 约束在投机解码下的修复(issue #48197 引用,未提供正式标题): RFC 中标记为修复 should_advance 差异与 num_output_placeholders 推断问题的 PR;PR body 的 JSON 约束对比数据(main 68% → 本 PR 100%)以它为基线。
参与讨论