Prhub

#46091 [Bugfix] [Parser] Fix empty tool block silently dropping subsequent content

原始 PR 作者 bbrowning 合并时间 2026-06-19 07:17 文件变更 4 提交数 2 评论 0 代码增减 +26 / -3

执行摘要

修复空 tool block 导致后续内容被丢弃的 bug

作者在日常代码审查中,通过多种编码助手(Codex CLI + Qwen 3.6-27B、Claude Code + Nemotron 3 Super)实际触发了该 bug。模型输出类似 <tool_call>func_name<arg_key>key</arg_key><arg_value>value</arg_value></tool_call> 的内容时,解析器检测到开始和结束的 tool_call 特殊 token 但中间无有效内容,导致卡在 TOOL_PREAMBLE 状态,后续所有输出被丢弃。本 PR 旨在修复此问题,确保空 tool block 后正常恢复内容输出。

建议立即合并。该 PR 修复了在实际使用中已触发的严重 bug(输出截断),变更范围小,测试充分。关注其修复模式(状态机增加边缘 case 转移 + 引擎层条件放宽)值得在类似解析器中推广。

讨论亮点

该 PR 没有 review 评论,只获得了一个批准(sfeng33: "LGTM, thanks for the work!")。

实现拆解

  1. Qwen3 解析器状态转移补充vllm/parser/qwen3.py):在 TOOL_PREAMBLE 状态下新增针对 TOOL_END 标记的转移规则:(ParserState.TOOL_PREAMBLE, "TOOL_END")Transition(ParserState.CONTENT, (EventType.TOOL_CALL_END,))。当解析器处于 TOOL_PREAMBLE 状态且立即遇到结束标记时,正确回到 CONTENT 状态并发射结束事件。

  2. Gemma4 解析器状态转移补充vllm/parser/gemma4.py):与 Qwen3 相同,在 TOOL_PREAMBLE 状态下新增针对 TOOL_END 的转移规则,处理空 tool block 场景。

  3. Parser Engine 延迟内容释放修正vllm/parser/engine/parser_engine.py):在 _events_to_delta 方法中,原本的释放条件是 if self._deferred_content and not seen_tool_event:,这会在遇到工具事件(seen_tool_event=True)时阻止延迟内容的释放。但空 tool block 会导致 seen_tool_event 为 True 而 tool_call_deltas 为空,此时应该释放内容。修改为 if self._deferred_content and (not seen_tool_event or not tool_call_deltas):

  4. 测试配套tests/parser/engine/trace_builder.py):新增 empty-tool-block 场景,模拟空 tool block 后跟正常内容。同时为 Qwen3、MiniMax M2、Gemma4 的 segments 生成函数增加了处理 tool_calls=[] 的分支,生成相应的 <tool_call></tool_call> 特殊 token 序列,使得测试能够覆盖空 tool block 的端到端行为。

文件 模块 状态 重要度
vllm/parser/qwen3.py 解析器 modified 5.59
vllm/parser/gemma4.py 解析器 modified 5.59
vllm/parser/engine/parser_engine.py 解析器 modified 5.5
tests/parser/engine/trace_builder.py 测试 modified 5.26

关键源码片段

vllm/parser/qwen3.py core-logic

核心修复文件之一:为 Qwen3 解析器添加 TOOL_PREAMBLE → TOOL_END 转移,使空 tool block 能正确恢复状态。

# vllm/parser/qwen3.py 关键片段
# 在 qwen3_config() 的 transitions 字典中新增以下条目:# 新增:当 TOOL_PREAMBLE 状态直接遇到 TOOL_END 标记时(即空 tool block),
# 正确回到 CONTENT 状态并发射 TOOL_CALL_END 事件。
(ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition(
    ParserState.CONTENT,
    (EventType.TOOL_CALL_END,),
),# 原有的 TOOL_PREAMBLE → FUNC_PREFIX 转移保持不变,处理正常工具调用。
vllm/parser/gemma4.py core-logic

核心修复文件之一:为 Gemma4 解析器添加与 Qwen3 相同的 TOOL_PREAMBLE → TOOL_END 转移。

# vllm/parser/gemma4.py 关键片段
# 在 gemma4_config() 的 transitions 字典中,原有 (TOOL_PREAMBLE, "CALL_PREFIX") 之前新增:(ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition(
    ParserState.CONTENT,
    (EventType.TOOL_CALL_END,),
),# 注意插入位置:该转移必须排在 CALL_PREFIX 之前,
# 因为如果同时匹配,先定义的优先;空 tool block 应优先匹配 TOOL_END。
vllm/parser/engine/parser_engine.py core-logic

引擎层修复:修正 deferred content 的释放条件,确保空 tool block 场景下缓存内容能被正常释放到输出流。

# vllm/parser/engine/parser_engine.py _events_to_delta 方法
# 第 675 行:修改条件,从 "not seen_tool_event" 变为 "not seen_tool_event or not tool_call_deltas"if self._deferred_content and (not seen_tool_event or not tool_call_deltas):
    # 释放缓存内容:当没有工具事件,或者有工具事件但没有实际工具 delta 时(空 tool block),
    # 都应该把之前因等待工具而延迟的内容释放到 content_parts 开头。
    content_parts.insert(0, self._deferred_content)
    self._deferred_content = ""

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

低风险。变更集中在小范围的状态转移规则和单一条件判断上。Qwen3 和 Gemma4 解析器的新增转移仅在 TOOL_PREAMBLE 状态下对 TOOL_END 生效,不影响正常工具调用流程。parser_engine 的修改在逻辑上是正确的,不会导致已存在的工具 delta 被意外跳过。测试覆盖了新增的空 tool block 场景,且通过了 2333 个测试用例。

直接影响使用 Qwen3 和 Gemma4 模型且启用工具调用的用户,修复了空 tool block 导致输出截断的问题。间接影响所有使用 parser_engine 的解析器,使 deferred content 释放逻辑更健壮。无性能影响。

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论