Prhub

#49876 [Bugfix][Parser] Confirm reasoning end when an Inkling content block opens

原始 PR 作者 Vegetog 合并时间 2026-08-09 06:07 文件变更 2 提交数 1 评论 39 代码增减 +57 / -11

执行摘要

修复 Inkling 无思考块时文本后接工具调用解析失败

Issue #49865 报告:使用 --tool-call-parser inkling --reasoning-parser inkling --tokenizer-mode inkling 且 reasoning_effort=none/minimal 时输出会带上 <|end_message|>。PR body 复现了更精确的失败:若响应以可见文本开头且无 thinking block,DelegatingParser 的 reasoning pass 从不发出 REASONING_END,于是工具 pass 永远不执行,<|content_invoke_tool_json|>{...}<|end_message|> 整段作为 content 返回且解析不出工具调用。PR body 明确:#51391 修复了 reasoning pass 内的 marker 归属,但未修复这个 phase gating。

值得精读:变更虽小,但清楚展示了共享 parser engine 中 phase gating 的设计约束,以及 maintainer 如何通过 review 把 PR 收窄到单一 bug。关注 inkling_config() 的六条 transition 与 test_visible_text_then_tool_streaming 的断言方式。后续处理 #50528 或重构 DelegatingParser 时可复用此测试模式。

讨论亮点
  • bbrowning 在 CHANGES_REQUESTED 中要求:“I verified the following still fails on current main… Adding REASONING_END to the visible-content opener transitions fixes it.” 并要求 rebase、只保留六条 transition、新增 DelegatingParser.parse_delta 回归测试,同时删掉 passthrough 逻辑。作者照做后最终获得 approved。
  • Codex 在 vllm/parser/inkling.py:199 指出 TOOL_TEXT / TOOL_ERROR 同样打开可见块,若只给 TEXT_STARTREASONING_END,块结束时 skip_tool_parsing 会把结束标记重发为文本导致泄漏;最终实现覆盖全部六条转换。
  • bbrowning 的 pre-commit 反馈:tests/parser/engine/test_inkling.py:173: error: Need type annotation for "args",作者补充类型注解后通过。
  • bbrowning 合入前验证:parser/reasoning/tool 全量单测 4073 passed,并复用 #51391 的 live model 复现脚本确认无回归。
  • 早期多轮 Codex review 曾把改动扩展到 numeric reasoning_effort、effective tools、Responses API 等更广范围,最终按 bbrowning 要求全部拆出,只保留 streaming text→tool 这一个 bug。

实现拆解

实现拆解如下:

  1. 定位入口与根因:入口是 vllm/parser/inkling.pyinkling_config() 状态机配置。DelegatingParser 先运行 reasoning pass,再运行 tool pass;reasoning pass 只在收到 EventType.REASONING_END 时离开推理阶段。无 thinking block 的响应没有任何显式结束事件,因此后续工具块全部留在 reasoning pass 内被当作普通文本输出。

  2. 核心状态机修改:为以下六条“开启可见内容块”的 transition 增加 EventType.REASONING_END 事件:

    • (CONTENT, TEXT_START)
    • (CONTENT, TOOL_TEXT)
    • (CONTENT, TOOL_ERROR)
    • (MESSAGE_HEADER, TEXT_START)
    • (MESSAGE_HEADER, TOOL_TEXT)
    • (MESSAGE_HEADER, TOOL_ERROR)

原因是这些 opener 都证明思考阶段已结束,显式确认边界后,后续 block-end 到达时 tool pass 才能接管并剥离工具标记。TOOL_START 不在此列,因为它是真正的工具调用终点,已有独立事件;这与 #51391 的 shared block-closer 处理不冲突。

  1. 测试配套:在 tests/parser/engine/test_inkling.py 中扩展 _stream_delegating,使其额外按工具 index 聚合流式参数 fragments 并返回工具参数列表;新增 test_visible_text_then_tool_streaming,参数化 opener(TEXT_START / TOOL_TEXT / TOOL_ERROR)与 chunk_size(1 / 3 / 64),共 9 个用例;同时更新两个既有调用点以适配新的四元组返回值。

  2. 范围收窄:按 bbrowning 的意见删除 passthrough_terminal_when_skipping_tools 及其 engine/config 管线、通用 _extract_tool_calls fallback;无工具纯文本的流式延迟问题拆到 follow-up;tool-first / no-thinking 场景留给 #50528。最终只保留 vllm/parser/inkling.py 与对应测试文件。

文件 模块 状态 重要度
vllm/parser/inkling.py 解析引擎 modified 5.89
tests/parser/engine/test_inkling.py 回归测试 modified 6.22

关键符号

inkling_config test_visible_text_then_tool_streaming _stream_delegating

关键源码片段

vllm/parser/inkling.py core-logic

核心修复文件:在 inkling_config() 状态机中为六条可见内容块开启 transition 补发 REASONING_END 事件,使 DelegatingParser 能正确进入 tool pass。

# vllm/parser/inkling.py — inkling_config() 中可见内容块开启相关 transition 片段
terminals = {
    'MSG_MODEL': MESSAGE_MODEL,
    'TEXT_START': CONTENT_TEXT,
    'THINK_START': CONTENT_THINKING,
    'THINK_END': END_MESSAGE,
    'END_SAMPLING': CONTENT_MODEL_END_SAMPLING,
    'TOOL_START': CONTENT_INVOKE_TOOL_JSON,
    'TOOL_TEXT': CONTENT_INVOKE_TOOL_TEXT,
    'TOOL_ERROR': CONTENT_TOOL_ERROR,
}
transitions: dict[tuple[ParserState, str], Transition] = {
    # 从 CONTENT 态打开可见内容块:说明思考阶段必然已经结束。
    # 只有显式发出 `REASONING_END` 事件,`DelegatingParser` 才会在后续
    # block-end 到达时把块交给 tool pass 而不是当作普通文本输出。
    (ParserState.CONTENT, 'TEXT_START'): Transition(
        ParserState.CONTENT,
        (EventType.REASONING_END,),
    ),
    # 原始 / 错误工具块同样渲染为可见文本,与 TEXT_START 需要相同的边界确认;
    # 否则它们结束后 `END_MESSAGE` 会以文本形式泄漏进 content。
    (ParserState.CONTENT, 'TOOL_TEXT'): Transition(
        ParserState.CONTENT,
        (EventType.REASONING_END,),
    ),
    (ParserState.CONTENT, 'TOOL_ERROR'): Transition(
        ParserState.CONTENT,
        (EventType.REASONING_END,),
    ),
    # MESSAGE_HEADER 态下(MSG_MODEL 之后的角色 / 函数名元数据段)同样适用
    (ParserState.MESSAGE_HEADER, 'TEXT_START'): Transition(
        ParserState.CONTENT,
        (EventType.REASONING_END,),
    ),
    (ParserState.MESSAGE_HEADER, 'TOOL_TEXT'): Transition(
        ParserState.CONTENT,
        (EventType.REASONING_END,),
    ),
    (ParserState.MESSAGE_HEADER, 'TOOL_ERROR'): Transition(
        ParserState.CONTENT,
        (EventType.REASONING_END,),
    ),
}
tests/parser/engine/test_inkling.py test-coverage

新增回归测试:复现无 thinking block 时文本后接工具调用的流式泄漏,参数化三种 opener 与三种 chunk size,并扩展 _stream_delegating 以断言工具参数。

# tests/parser/engine/test_inkling.py — 新增回归:无 thinking block 时
# 可见文本之后紧跟工具调用,流式 chunk 必须正确切到 tool pass
@pytest.mark.parametrize('opener', [TEXT_START, TOOL_TEXT, TOOL_ERROR])
@pytest.mark.parametrize('chunk_size', [1, 3, 64])
def test_visible_text_then_tool_streaming(
    self, mock_tokenizer, mock_request, chunk_size, opener
):
    """可见内容先于工具调用,且没有 thinking block。    旧行为:reasoning pass 始终停在 reasoning 阶段,整个工具块被当作
    assistant 文本返回,工具调用完全丢失。三种 opener 都会开启可见内容
    块,所以必须全部覆盖。
    """
    tools = [_function_tool()]
    mock_request.tools = tools
    content, _, names, args = _stream_delegating(
        _delegating(mock_tokenizer, tools),
        mock_request,
        f'{opener}let me check{END_MESSAGE}{MSG_MODEL}'
        + _tool_block('get_weather', '{"city":"SF"}'),
        chunk_size,
        self.GEN_PROMPT,
    )
    # 可见内容必须精确等于文本本身,工具块与结束标记不得泄漏
    assert content == 'let me check'
    assert names == ['get_weather']
    assert json.loads(args[0]) == {"city": "SF"}
    assert TOOL_JSON not in content
    assert END_MESSAGE not in content

评论区精华

要求 rebase 并收窄范围到 streaming text→tool 设计

bbrowning 在 CHANGES_REQUESTED 中要求:将 PR rebase 到包含 #51391 的 main,收窄到剩余的 streaming text→tool 正确性 bug,并保留六条 `REASONING_END` 事件、新增 focused regression test、删除 passthrough 逻辑。

结论:作者删除 passthrough_terminal_when_skipping_tools、通用 _extract_tool_calls fallback,只保留六条 transition 与回归测试;bbrowning 最终 approved。 · 已解决

TOOL_TEXT / TOOL_ERROR 遗漏 REASONING_END 正确性

Codex P1 评论指出:流式场景下 `TOOL_TEXT` 和 `TOOL_ERROR` 也打开可见内容块,若只给 `TEXT_START` 发 `REASONING_END`,块结束时 `skip_tool_parsing` 会把结束标记重发为文本,泄漏 `<|end_message|>` 或 end-sampling marker。

结论:最终实现为六条可见块 opener 转换全部增加 `REASONING_END`,包括 `MESSAGE_HEADER` 变体。 · 已解决

回归测试设计 测试

bbrowning 要求 focused `DelegatingParser.parse_delta` 测试,断言 content 精确等于可见文本、工具名和参数正确、`CONTENT_INVOKE_TOOL_JSON` 与 `END_MESSAGE` 不出现;作者扩展 `_stream_delegating` 聚合工具参数,并用 3 opener × 3 chunk_size 参数化出 9 个用例。

结论:9 个用例在 main 上全部失败、本 PR 全部通过;两个既有调用点同步适配新返回值。 · 已解决

pre-commit mypy 类型注解 style

bbrowning 指出 pre-commit 抓到 `tests/parser/engine/test_inkling.py:173: error: Need type annotation for "args"`。

结论:作者补充 `args: dict[int, str] = {}` 类型注解后通过。 · 已解决

合入前 live model 验证 测试

bbrowning 使用 #51391 的同一份 edge-case 复现脚本对 Inkling live model 做回归,并运行 parser engine、reasoning、tool parser 全量单测。

结论:4073 passed,17 warnings;live model 无回归,bbrowning 给出 approved。 · 已解决

风险与影响

  1. 状态机语义风险:REASONING_END 是共享 ParserEngine 的事件,虽然改动只在一个 Inkling 专属配置函数中,但任何未覆盖的 transition 组合(如 REASONING 态下直接出现 TEXT_START)若存在,可能产生重复或缺失事件;当前测试覆盖三种 opener 与三种 chunk size,但未穷举所有状态组合。
  2. 回归风险:_stream_delegating 返回值从三元组变为四元组,若其他调用点未同步更新会破坏测试;PR 内两个既有调用点已更新,parser engine 全套 3767/4073 通过。
  3. 验证风险:PR 自身说明没有 live Inkling 模型端到端评测;bbrowning 合入前用 Inkling-Small-NVFP4 复现脚本做了 live 验证,但该验证不在 CI 中固化。
  4. 上下游依赖:修复依赖 #51391 的共享 block-closer 语义;若后续重构 ParserEngine 或 DelegatingParser 的 phase 切换逻辑,这六条 transition 需要同步回归。
  • 用户影响:开启 Inkling reasoning + tool 双解析器的流式请求(尤其 tool_choice=auto)不再把工具块泄漏进正文,能正确解析工具名和参数;同时避免在非 reasoning 模式看到 <|end_message|>
  • 系统影响:仅 parser 配置层状态机事件变化,不影响推理、KV cache、调度等路径;无性能开销。
  • 团队影响:为共享 parser engine 确立了“可见内容块 opener 需要显式确认 reasoning 边界”的约定;为 #50528 等后续 parser 修复提供了模式。影响范围为 Inkling 模型 + tool-calling 用户,规模中等。
状态机核心路径变更 无 CI 固化 live 模型验证 依赖 #51391 语义 测试 helper 返回值签名变化

关联 Issue

#49865 [Bug]: Inkling parser is wrong for non reasoning mode - outputs "<|end_message|>"

完整报告

参与讨论