Prhub

#53444 [Bugfix] Handle bare and malformed tool call openers in Gemma4 parser

原始 PR 作者 wuhangxian 合并时间 2026-09-15 10:52 文件变更 2 提交数 3 评论 10 代码增减 +116 / -0

执行摘要

修复 Gemma4 解析器静默丢弃裸 tool_call

关联 Issue #53431 报告:gemma-4-26b-a4b-it 会输出两种 tool call opener 形式——文档化的 <|tool_call>call:name{...}<tool_call|> 和裸形式 <|tool_call>:name{...}<tool_call|>。前者可被解析,后者被 FSM 静默丢弃,streaming 与非 streaming 均复现;由于 TOOL_PREAMBLE 状态没有 content_events 条目,整个 span 既不产生 tool call 也不产生 content,turn 直接丢失且无任何错误提示。Issue 讨论中 @chriskosys 进一步补充:无花括号或使用圆括号的畸形调用会让 function.name 吸收其后所有内容,包括关闭 token 和后续规范调用。

值得精读。该 PR 展示了 FSM 解析器应对模型非规范输出的一种最小干预模式:单个终端 + 单条转移即可修复整类静默丢失问题,且依赖 lexer 最长匹配优先级保证零回归。建议关注两点:一是 TOOL_PREAMBLE -> TOOL_NAME 转移与 #53657 逃逸舱口的协同设计,二是测试对"畸形+规范混合序列"的覆盖方式,这种场景最容易暴露 FSM 状态泄漏。

讨论亮点
  • 补测试的要求(已解决):审核人 chaunceyjiang 在 Review 中留言 "Could you add some test code?",作者随即新增 TestBareAndMalformedOpeners 9 个测试,并列出覆盖场景(裸 opener、文档化格式回归、无花括号边界、连续调用恢复、内容冒号等)。
  • PR 范围收敛(已解决):作者在评论区说明 rebase 后发现 TOOL_NAME, TOOL_END 逃逸舱口已由 #53657 合入 main,本 PR 相应收敛为只新增 COLON 终端与对应转移,避免重复实现。
  • pre-commit 合规(已解决):mergify bot 两次提示 pre-commit 检查失败,作者运行格式化并提交 0d7e929 后 CI 通过。
  • 最终批准:chaunceyjiang 在补充测试后给出 APPROVED("LGTM")。

实现拆解

  1. 新增 COLON 终端:在 vllm/parser/gemma4.py 的 gemma4_config() 的 terminals 字典中新增 "COLON": ":" 字面量,与已有的 CALL_PREFIX: "call:"、OPEN_BRACE、OPEN_PAREN 并列,供 FSM 在 TOOL_PREAMBLE 状态识别裸 opener 的冒号。
  2. 新增状态转移:添加 (ParserState.TOOL_PREAMBLE, "COLON") -> TOOL_NAME 转移(无事件输出)。这样 <|tool_call>:get_weather{...} 能像文档化格式一样进入 TOOL_NAME 并走原有 OPEN_BRACE 转移进入 TOOL_ARGS,工具调用不再被静默丢弃。
  3. 依赖 lexer 最长字面量优先:FSM 词法层按字面量长度优先匹配,"call:"(5 字符)仍优先于 ":"(1 字符),因此文档化格式 call:name{...} 行为完全不变;COLON 只在 TOOL_PREAMBLE 状态下定义转移,普通文本和参数值中的冒号不参与该状态转移,不受影响。
  4. 与逃逸舱口协同:(TOOL_NAME, "TOOL_END") -> CONTENT 转移已在先前的 #53657 中合入 main,本 PR rebase 后与之协同:若模型从未输出 {(无花括号畸形调用),FSM 停在 TOOL_NAME 时遇到 TOOL_END 即返回 CONTENT 并发出 TOOL_CALL_END,畸形调用被自身边界截断,后续内容和工具调用恢复正常解析,不会再把关闭 token 和后续调用吸进 function.name。
  5. 测试配套:tests/tool_parsers/test_gemma4_tool_parser.py 新增 TestBareAndMalformedOpeners 类共 9 个测试,覆盖裸 opener 非流式解析、文档化格式回归、多参数、畸形无花括号边界、畸形+规范连续调用、裸畸形+规范混合、三个混合调用、内容中冒号不被破坏、空参数花括号。现有 54 个测试全部通过,无回归。
文件 模块 状态 重要度
vllm/parser/gemma4.py 解析器 modified 5.67
tests/tool_parsers/test_gemma4_tool_parser.py 工具解析 modified 6.86

关键符号

gemma4_config TestBareAndMalformedOpeners test_bare_opener_non_streaming test_malformed_then_well_formed test_three_mixed_calls

关键源码片段

vllm/parser/gemma4.py core-logic

FSM 配置的源码主路径:新增 COLON 终端与 (TOOL_PREAMBLE, COLON) 转移,是修复裸 opener 静默丢弃的核心改动。

@functools.cache
def gemma4_config() -> ParserEngineConfig:
    """构造 Gemma4 工具调用解析器的 FSM 配置(terminals + transitions)。"""
    return ParserEngineConfig(
        name="gemma4",
        initial_state=ParserState.CONTENT,
        terminals={
            "THINK_START": CHANNEL_START,
            "THINK_END": CHANNEL_END,
            "TOOL_START": TOOL_CALL_START,
            "TOOL_END": TOOL_CALL_END,
            "CALL_PREFIX": "call:",
            # 本次新增:`<|tool_call>:name{...}` 裸 opener 直接匹配冒号字面量。
            # lexer 按字面量长度优先匹配,"call:"(5 字符)先于 ":"(1 字符)被消费,
            # 因此文档化格式行为不变;普通文本中的冒号不参与下方转移,同样不受影响。
            "COLON": ":",
            "OPEN_BRACE": "{",
            "OPEN_PAREN": "(",
        },
        transitions={
            # 文档化格式的既有转移:`<|tool_call>call:name{...}` 走 CALL_PREFIX。
            (ParserState.TOOL_PREAMBLE, "CALL_PREFIX"): Transition(
                ParserState.TOOL_NAME, ()
            ),
            # 本次新增的裸 opener 转移:TOOL_PREAMBLE 遇冒号直接进入 TOOL_NAME,
            # 与 CALL_PREFIX 一样不产生事件,后续由 OPEN_BRACE / OPEN_PAREN
            # 继续转入 TOOL_ARGS 解析参数。
            (ParserState.TOOL_PREAMBLE, "COLON"): Transition(
                ParserState.TOOL_NAME, ()
            ),
            (ParserState.TOOL_NAME, "OPEN_BRACE"): Transition(
                ParserState.TOOL_ARGS, ()
            ),
            (ParserState.TOOL_NAME, "OPEN_PAREN"): Transition(
                ParserState.TOOL_ARGS, ()
            ),
            # 逃逸舱口(由 PR #53657 合入 main,本 PR rebase 后与之协同):
            # 若模型从未输出 "{",TOOL_END 把 FSM 拉回 CONTENT 并发出
            # TOOL_CALL_END,畸形调用被自身边界截断,后续内容和工具调用
            # 恢复正常解析,不会再被吸进 function.name。
            (ParserState.TOOL_NAME, "TOOL_END"): Transition(
                ParserState.CONTENT, (EventType.TOOL_CALL_END,)
            ),
            (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
                ParserState.CONTENT, (EventType.TOOL_CALL_END,)
            ),
        },
        content_events={
            # TOOL_PREAMBLE 没有 content_events 条目:修复前 FSM 卡在此状态时,
            # 整个 `<|tool_call>:...<tool_call|>` 跨度不产生任何事件,
            # 这就是裸 opener 被静默丢弃(无 tool call 也无 content)的根因。
            ParserState.CONTENT: EventType.TEXT_CHUNK,
            ParserState.REASONING: EventType.REASONING_CHUNK,
        },
    )
tests/tool_parsers/test_gemma4_tool_parser.py test-coverage

新增 TestBareAndMalformedOpeners 9 个回归测试,覆盖裸 opener、畸形调用边界、混合序列与内容冒号场景,是本次修复的验证主体。

class TestBareAndMalformedOpeners:
    """裸 opener 与畸形调用边界回归测试,对应 issue #53431。"""
​
    def test_bare_opener_non_streaming(self, parser, mock_request):
        # 裸形式不带 "call" 前缀,修复前 FSM 卡在 TOOL_PREAMBLE,
        # 整段调用被静默丢弃;修复后应正常提取名称与参数。
        model_output = (
            '<|tool_call>:get_weather{location:<|"|>London<|"|>}<tool_call|>'
        )
        result = parser.extract_tool_calls(model_output, mock_request)
​
        assert result.tools_called is True
        assert len(result.tool_calls) == 1
        assert result.tool_calls[0].function.name == "get_weather"
        args = json.loads(result.tool_calls[0].function.arguments)
        assert args == {"location": "London"}
​
    def test_malformed_then_well_formed(self, parser, mock_request):
        # 无花括号的畸形调用后紧跟规范调用:修复前关闭 token 和后续
        # 调用会被吸进 function.name;修复后靠 TOOL_END 逃逸舱口截断,
        # 第二个调用需要被正常恢复。
        model_output = (
            "<|tool_call>call:bad_func no brace<tool_call|>"
            '<|tool_call>call:get_weather{location:<|"|>London<|"|>}'
            "<tool_call|>"
        )
        result = parser.extract_tool_calls(model_output, mock_request)
​
        assert result.tools_called is True
        assert len(result.tool_calls) == 2
        assert result.tool_calls[0].function.name == "bad_func no brace"
        assert result.tool_calls[1].function.name == "get_weather"
        assert "London" in result.tool_calls[1].function.arguments

评论区精华

要求补充回归测试 测试

chaunceyjiang 在 Review 中留言 "Could you add some test code?",作者随后新增 TestBareAndMalformedOpeners 类并列出 9 个覆盖场景。

结论:作者补充 9 个回归测试(裸 opener、文档化格式回归、畸形边界、混合序列、内容冒号、空参数),现有 54 个测试也全部通过。 · 已解决

PR 范围在 rebase 后收敛 设计

作者说明 rebase main 后与 #53657 的冲突解决,`TOOL_NAME, TOOL_END` 逃逸舱口已在 main 上,本 PR 只保留 COLON 终端和 TOOL_PREAMBLE -> TOOL_NAME 转移。

结论:PR 收敛为最小改动,避免与 #53657 重复实现;两者共同构成完整修复。 · 已解决

pre-commit 格式检查 style

mergify bot 两次提示 pre-commit 失败,要求运行 `pre-commit run --all-files`。

结论:作者提交格式化 commit `0d7e929` 后通过,CI 全绿。 · 已解决

风险与影响

  • COLON 是新增的全局字面量终端,理论上参与 FSM 各状态的词法匹配;但转移仅在 TOOL_PREAMBLE 状态定义,且 lexer 最长匹配优先保证 "call:" 不被拆解,风险受控。测试 test_colon_in_content_not_broken 验证了普通文本冒号不受影响。
  • 修复效果依赖 #53657 的逃逸舱口:若该改动被回退,或本改动被 cherry-pick 到不含 #53657 的旧分支,无花括号畸形调用仍可能污染后续内容,需要注意合并顺序。
  • 新测试集中在非流式 extract_tool_calls 路径;流式路径依赖同一 FSM 配置,现有流式测试(如 test_streaming_trailing_bare_bool_not_duplicated)全部通过,但裸 opener 的流式场景没有直接覆盖。
  • _gemma4_arg_converter 对 ) 结尾的处理逻辑已有 OPEN_PAREN 转移配合,本次改动未触碰该逻辑,圆括号格式行为不变。
  • 用户:使用 gemma-4-26b-a4b-it 并开启 --enable-auto-tool-choice --tool-call-parser gemma4 的用户,此前会静默丢失裸 opener 工具调用(streaming 与非 streaming 均复现),修复后可正常提取;畸形调用不再污染后续内容和工具调用。
  • 系统:改动仅限 gemma4_config() 的 FSM 配置,mistral 等其他 parser 的 CALL_PREFIX 逻辑不受影响。
  • 团队/维护:源码 +8 行、测试 +108 行,改动面小;9 个新测试为后续 Gemma4 输出格式变化提供回归护栏,回滚成本低。
解析器 FSM 核心路径变更 依赖 lexer 最长匹配优先级 协同依赖 #53657 逃逸舱口 流式裸 opener 未直接覆盖

关联 Issue

#53431 [Bug]: Gemma4 tool parser silently drops the bare `<|tool_call>:name{...}` opener — no tool call, no content, on both streaming and non-streaming

完整报告

参与讨论