Prhub

#51391 [Bugfix][Parser] Prevent Inkling block-end leakage with tools

原始 PR 作者 taking-lying-flat 合并时间 2026-08-09 00:34 文件变更 3 提交数 2 评论 3 代码增减 +352 / -30

执行摘要

修复 Inkling 工具启用时块结束 token 泄漏进 content

issue #51387 报告:启用 --enable-auto-tool-choice --tool-call-parser inkling --reasoning-parser inkling 后,模型在 tool result 之后用纯文本回答(无 thinking 块、无 tool call)时,响应 content 以字面量 <|end_message|> 结尾,agent 框架在每一轮工具调用后的 synthesis 回合都会命中。issue 定位到两处互配根因:一是 StreamingParserEngine._on_terminal 的 skip_tool_parsing 分支刻意把块结束终端透传为 content 供下游 tool pass 消费,但无工具块时 tool pass 不启动,标记直达客户端;二是 DelegatingParser._extract_tool_calls 无工具调用分支返回 pre-tool-pass content,丢弃了 tool parser 已消费掉标记的版本。PR body 还明确本 PR 不重复 #49876(no-tools 纯文本模式)和 #50528(无 thinking 块时的 tool-call 交接)。

值得精读。核心设计决策是:语义事件(TOOL_CALL_START / TOOL_CALL_END)不能作为词法工具区域的边界依据,应改由状态转移结构推导;同时 transition is None 分支也要同步清理转发状态。这对任何使用共享 parser engine 新增 grammar 的开发者都有直接借鉴意义,也建议后续为嵌套/多 wrapper 工具区域补充更细粒度的测试。

讨论亮点
  • 词法区域 vs 语义事件(bbrowning,CHANGES_REQUESTED):TOOL_CALL_START / TOOL_CALL_END 描述的是语义调用,不一定是 skip pass 必须保留的词法 wrapper。MiniMax 是典型案例:<minimax:tool_call> 打开词法区域时没有 TOOL_CALL_START</minimax:tool_call> 关闭时也没有 TOOL_CALL_END,因此建议边界改为结构转移推导。
  • transitionless closer 的 stale 状态(bbrowning):Qwen3、DeepSeek v3.2/v4、GLM 4.7 MoE、Nemotron v3 的 wrapper closer 只在工具状态定义,skip pass 位于 CONTENT 时 transition is None 提前返回,会让 passthrough 标志在请求剩余阶段保持 True,普通输出测试可能漏掉。
  • 独立验证(bbrowning,APPROVED):跑了 parser engine 全量单测 + deepseek/gemma4/glm4_moe/minimax_m2/qwen3 等模型的 reasoning/tool parser 测试,4064 passed;live server 复现脚本从 2 pass/4 fail/2 inconclusive 提升到 5 pass/1 fail/2 inconclusive,剩余失败与 #49876、#50528 相关,由作者们协调落地。

实现拆解

  1. 变更入口vllm/parser/engine/streaming_parser_engine.pyStreamingParserEngine 是 reasoning/tool 双 pass 共用的底层引擎;skip_tool_parsing=True 时由 reasoning pass 转发工具语法、tool pass 在下一阶段消费,泄漏发生在这条转发路径上。
  2. 新增结构推导出口集合:在 __init__ 中新增 _tool_exit_terminals,从 config.transitions 提取所有“工具状态 → 非工具状态”转移对应的 terminal。相比用 TOOL_CALL_END 事件识别,结构推导能覆盖 MiniMax 这类语义事件与词法 wrapper 不一致的 grammar。
  3. 新增转发 span 状态_in_skipped_tool_span 表示 reasoning pass 当前是否处于需要逐字转发的词法工具区域;reset 时清零,保证 parser 跨请求复用不串扰。
  4. 改造 _on_terminal 分支:对共享块结束 token,用 is_opener / is_exit / used_as_plain_closer 三段判断——工具区域之外作为普通文本/推理结束标记走正常 transition 被消费;区域之内打开 span、完整转发工具语法;对 transition is None 的 wrapper closer 也显式清除 span,避免 stale 状态使后续文本 closer 误走转发路径。
  5. 测试与验证配套tests/parser/engine/test_inkling.py 新增 TestDelegatingTwoPass(served 双 pass 路径,覆盖 streaming/non-streaming、纯文本、推理后文本、多工具往返、parser 复用、两种结束 token、无转移 closer);tests/parser/engine/test_parser_engine.py 新增 TestSkipToolSpanForwarding(共享引擎层验证 span 转发与清理)。bbrowning 另行跑了 parser engine 全量单测与多个模型的 reasoning/tool parser 测试,共 4064 passed。
文件 模块 状态 重要度
vllm/parser/engine/streaming_parser_engine.py 解析引擎 modified 7.41
tests/parser/engine/test_inkling.py 解析器 modified 7.39
tests/parser/engine/test_parser_engine.py 引擎测试 modified 7.01

关键符号

StreamingParserEngine.__init__ StreamingParserEngine.reset StreamingParserEngine._on_terminal TestDelegatingTwoPass.test_plain_text_non_streaming TestSkipToolSpanForwarding.test_content_after_transitionless_closer_observable

关键源码片段

vllm/parser/engine/streaming_parser_engine.py core-logic

共享 parser engine 核心逻辑:新增 `_tool_exit_terminals` 结构推导、`_in_skipped_tool_span` 状态与 `_on_terminal` 分支改造,是本次修复的主体。

# 在 __init__ 中基于状态转移结构推导词法工具区域的出口集合。
# 不用 TOOL_CALL_END 语义事件的原因是:MiniMax 的 </invoke> 会触发
# TOOL_CALL_END,但外层 <minimax:tool_call> wrapper 的关闭没有任何事件;
# 因此统一用“工具状态 -> 非工具状态”的转移来识别出口。
self._tool_exit_terminals: frozenset[str] = frozenset(
    terminal
    for (state, terminal), tr in config.transitions.items()
    if state in self._TOOL_STATES and tr.next_state not in self._TOOL_STATES
)# reset 时清除上一请求遗留的转发 span,避免 parser 复用时状态串扰。
self._in_skipped_tool_span = False
​
​
def _on_terminal(self, terminal: str, value: str) -> list[SemanticEvent]:
    key = (self.state, terminal)
    transition = self.config.transitions.get(key)
​
    if transition is None:
        # skip pass 投影后的状态可能没有该 terminal 的转移
        # (Qwen3 / DeepSeek / GLM 4.7 MoE / Nemotron v3 的 wrapper closer
        # 都只在工具状态定义)。此时依然要清除 span,否则它会在请求
        # 剩余阶段保持 True,后续普通文本 closer 会被误转发并泄漏。
        if self.skip_tool_parsing and terminal in self._tool_exit_terminals:
            self._in_skipped_tool_span = False
        return self._emit_for_state(value)
​
    if self.skip_tool_parsing and terminal in self._tool_terminals:
        # Inkling 复用同一个块结束 token 作为工具、文本、推理三种出口,
        # 因此不能只按 terminal 身份分类。规则是:词法工具区域之外按正常
        # 转移消费 closer;区域之内则完整转发工具语法(含 wrapper closers)。
        is_opener = transition.next_state in self._TOOL_STATES
        is_exit = terminal in self._tool_exit_terminals
        used_as_plain_closer = (
            is_exit and not is_opener and not self._in_skipped_tool_span
        )
        if not used_as_plain_closer:
            if is_opener:
                self._in_skipped_tool_span = True
            elif is_exit:
                self._in_skipped_tool_span = False
            if self.state == ParserState.MESSAGE_HEADER:
                self.state = ParserState.CONTENT
                self._message_header_buffer = ""
                return [
                    SemanticEvent(
                        EventType.TEXT_CHUNK,
                        value=value,
                        tool_index=self.tool_index,
                    )
                ]
            if EventType.REASONING_END in transition.events:
                self.state = ParserState.CONTENT
                return [
                    SemanticEvent(
                        EventType.REASONING_END,
                        value=value,
                        tool_index=self.tool_index,
                    ),
                    SemanticEvent(
                        EventType.TEXT_CHUNK,
                        value=value,
                        tool_index=self.tool_index,
                    ),
                ]
            content_type = self.config.content_events.get(self.state)
            if content_type is not None:
                return [
                    SemanticEvent(
                        content_type,
                        value=value,
                        tool_index=self.tool_index,
                    )
                ]
            return []
        # used_as_plain_closer 为 True:这是纯文本 / 推理的结束标记,
        # 落到下方原有路径,按正常转移消费,不再透传给调用方。
    # 原有:普通转移、skip_in_token_id_mode 与 _apply_transition 处理保持不变。

评论区精华

词法工具区域边界应以结构转移而非语义事件定义 设计

bbrowning 在 CHANGES_REQUESTED 中指出 `TOOL_CALL_START` / `TOOL_CALL_END` 描述语义调用,不一定是 skip pass 需要保留的词法 wrapper;MiniMax `<minimax:tool_call>` 打开 / 关闭都没有对应语义事件,建议边界改为 tool-state → non-tool-state 的结构转移。

结论:最终实现采纳结构性边界,新增 `_tool_exit_terminals` 推导与 `_in_skipped_tool_span` 跟踪。 · 已解决

transitionless wrapper closer 会导致 span 状态 stale 正确性

bbrowning 指出 Qwen3、DeepSeek v3.2/v4、GLM 4.7 MoE、Nemotron v3 的 wrapper closer 只在工具状态定义,skip pass 位于 CONTENT 时 `transition is None` 提前返回,旧实现会让 passthrough 标志在请求剩余阶段保持 True,普通输出测试可能漏掉。

结论:在 `transition is None` 分支补上 `_tool_exit_terminals` 检查并清除 span,新增对应回归测试。 · 已解决

回归验证范围 测试

bbrowning APPROVED 时说明独立跑了 parser engine 全量单测与多个模型(deepseek/gemma4/glm4_moe/minimax_m2/qwen3 等)的 reasoning/tool parser 测试,共 4064 passed;live server 复现脚本从 2 pass/4 fail/2 inconclusive 提升至 5 pass/1 fail/2 inconclusive。

结论:已批准;剩余失败与 #49876、#50528 相关,由作者们协调后续落地。 · 已解决

风险与影响

streaming_parser_engine.py 是共享引擎,qwen3/deepseek/glm47_moe/nemotron_v3 等所有 engine-backed parser 都经过该路径,本次改动虽经 4064 个单测回归,但 live 环境仍存在 1 个失败用例(与 #49876/#50528 相关),说明 Inkling 整体解析尚不稳定。_in_skipped_tool_span 是新增可变状态,任何提前 return 路径若未正确清理,都可能造成同类泄漏或反向误消费;当前测试覆盖主要场景,但嵌套工具区域等边缘未穷尽。本 PR 未运行实时模型评估,仅 CPU-only 确定性测试,后续接入真实 Inkling 权重时建议再做一次端到端回归。

用户侧:启用 Inkling tool-calling 的请求(尤其是 agent 循环中 tool result 之后的 synthesis 回合)不再收到尾部脏标记,流式与非流式同时修复,contentreasoning 的划分更符合 OpenAI 兼容协议预期。系统侧:共享 parser engine 的行为变化影响所有 engine-backed parser,但工具区域内转发、区域外消费的语义被保留,回归面集中在新的 span 状态清理逻辑;对推理性能和 GPU 路径无影响。团队侧:与 #49876、#50528 构成 Inkling 解析修复序列,三个 PR 互补,需要协同合入并统一做一次带真实模型的端到端验证。

共享引擎核心路径变更 无实时模型验证 新增状态变量需跨请求清理

关联 Issue

#51387 [Bug]: Inkling: trailing <|end_message|> leaks into content on plain-text turns when tools are enabled (every post-tool-result synthesis turn)

完整报告

参与讨论