Prhub

#45852 [Bugfix][Gemma4] Pre-initialise streaming reasoning state when prompt ends inside an open `<|channel>` (fixes #45834)

原始 PR 作者 nikhilesh-csa 合并时间 2026-06-17 18:16 文件变更 6 提交数 7 评论 9 代码增减 +270 / -0

执行摘要

修复 Gemma4 post-tool 推理泄露到 content

问题源于issue #45834报告:Gemma4工具调用开启thinking后,最后工具响应后模板会插入<|channel>thought\n,导致prompt结束在开放推理channel中。is_reasoning_end(prompt_token_ids)已正确返回False,但该信号未被传播到引擎初始状态。PR body指出需要在解析器层修复,不修改chat template。

值得精读,尤其关注:

  • 如何在不破坏现有流程的前提下引入状态调整钩子(通过标志 _prompt_streaming_prepared 保证只执行一次)。
  • 设计决策:复用 is_reasoning_end 避免重复扫描逻辑,no-op 过渡处理边界情况。
  • 命名哲学:adjust_initial_state_from_prompt 准确表达了意图。
  • 测试设计:同时覆盖回归用例和鲁棒性用例,以及确保新回合不受影响。
讨论亮点

Reviewer bbrowning 提出数项改进建议,贡献者全部采纳:

  • 命名prepare_streaming_for_prompt 改名为 adjust_initial_state_from_prompt,准确反映语义(调整初始状态)。
  • 实现简化:使用 self._engine.reset(initial_state=ParserState.REASONING) 代替 initialize_streaming,避免额外副作用。
  • 复用现有方法:利用 is_reasoning_end 检测 prompt 末端,消除独立的 _prompt_ends_in_open_reasoning 和 token ID 扫描,隐式处理多轮对话和隐式推理开始/结束。
  • Flag 顺序修复:纠正 parse_delta 中设置标志与调用 hook 的顺序,防止 hook 内调用 reset 清除标志后重复扫描。
    bbrowning 最终批准前手工验证了修复在一台具有多轮工具调用和 thinking 的服务器上有效。

实现拆解

  1. 基类添加钩子:在vllm/reasoning/abs_reasoning_parsers.pyReasoningParser中添加空方法adjust_initial_state_from_prompt(prompt_token_ids),默认无操作。在vllm/parser/engine/parser_engine.pyParserEngine添加同名空方法和_prompt_streaming_prepared标志。vllm/parser/engine/adapters.pyParserEngineReasoningAdapter转发调用。

  2. 触发时机:修改ParserEngine.parse_delta(parser_engine.py),在首次delta处理且prompt_token_ids非空时调用钩子(注意flag顺序:调用前不设置标志,因为钩子可能调用reset清标志)。同样在AbstractParser.parse_delta(abstract_parser.py)中,当推理未结束时调用推理解析器的钩子。

  3. Gemma4实现:在vllm/parser/gemma4.pyGemma4Parser重写钩子:调用is_reasoning_end(prompt_token_ids)检测prompt是否已闭合;若未闭合则self._engine.reset(initial_state=ParserState.REASONING)并标记_streaming_initialized=True。同时添加(REASONING, THINK_START) -> REASONING no-op过渡,防止模型自身发出的<|channel>被当作TEXT_CHUNK泄露。

  4. 测试:新增tests/parser/engine/test_gemma4_streaming_reasoning.py中的TestGemma4PromptOpenReasoning(回归)和TestGemma4PreInitReasoningRobustness(鲁棒性),覆盖开放推理的检测、post-reasoning内容完整性、以及普通新回合场景不变。

  5. 配套:仅修改6个文件,全增量无删除,pre-commit通过,回归测试1724 passed(11个预失败无关)。

文件 模块 状态 重要度
tests/parser/engine/test_gemma4_streaming_reasoning.py 解析器测试 modified 7.67
vllm/parser/gemma4.py 解析器 modified 7.22
vllm/parser/engine/parser_engine.py 解析器引擎 modified 6.58
vllm/reasoning/abs_reasoning_parsers.py 推理解析器 modified 5.63
vllm/parser/engine/adapters.py 适配器 modified 5.03
vllm/parser/abstract_parser.py 解析器 modified 5.0

关键符号

adjust_initial_state_from_prompt is_reasoning_end TestGemma4PromptOpenReasoning TestGemma4PreInitReasoningRobustness

关键源码片段

tests/parser/engine/test_gemma4_streaming_reasoning.py test-coverage

新增 208 行测试,覆盖 open reasoning prompt 场景和普通场景,确保修复的正确性和回归预防。

# tests/parser/engine/test_gemma4_streaming_reasoning.py
# —— 新增测试类:验证 prompt 结束在开放推理 channel 时的行为 ——class TestGemma4PromptOpenReasoning:
    """当 add_generation_prompt=True 且 enable_thinking=True 时,
    模板留下 prompt 以 <|channel>thought\n 结尾,
    即位于开放推理 channel 内。生成的 token 在 <channel|> 之前
    必须被分类为 reasoning,而非可见 content。
    """
​
    @pytest.fixture
    def open_reasoning_tokenizer(self):
        # 模拟 <channel|> 后仍有 token 的场景
        return _make_tokenizer(_OPEN_REASONING_GEN_SEQUENCE)
​
    @staticmethod
    def _prompt_ids_open_channel() -> list[int]:
        # 模拟 prompt 以 <|channel> + 任意 token 结尾
        return [CHANNEL_START_ID, 3000, 3001]
​
    def test_reasoning_not_leaked_into_content(
        self, open_reasoning_parser, open_reasoning_tokenizer, request_obj
    ):
        results = _stream_tokens_batched(
            open_reasoning_parser, open_reasoning_tokenizer, request_obj,
            batch_size=1, prompt_token_ids=self._prompt_ids_open_channel(),
        )
        reasoning, content, _ = _collect_fields(results)
        assert "Sure, the answer is 42" in reasoning, \
            f"Expected pre-<channel|> tokens in reasoning, got reasoning={reasoning!r} content={content!r}"
        # 验证推理内容没有泄露到 content 中
        for leaked in ("Sure", "answer", "42"):
            assert leaked not in content, \
                f"Reasoning text leaked into content: {content!r}"
vllm/parser/gemma4.py core-logic

核心逻辑变更:新增 adjust_initial_state_from_prompt 方法预初始化引擎状态,以及 no-op 过渡处理边界情况。

# vllm/parser/gemma4.py (Gemma4Parser.adjust_initial_state_from_prompt)
def adjust_initial_state_from_prompt(self, prompt_token_ids: Sequence[int]) -> None:
    """预初始化引擎状态为 REASONING,当 prompt 结束在开放推理 channel 时。    原理:利用现有的 is_reasoning_end 方法向后扫描 prompt,
    若未发现 THINK_END (且前有 THINK_START),则说明 channel 未关闭。
    覆盖 issue #45834 描述的场景。
    """
    # 如果 prompt 本身已经结束了推理(例如包含 <channel|>),无需调整
    if self.is_reasoning_end(list(prompt_token_ids)):
        return
    # 将底层引擎状态从默认 CONTENT 切换为 REASONING
    self._engine.reset(initial_state=ParserState.REASONING)
    # 阻止后续默认的 initialize_streaming() 用 CONTENT 覆盖此状态
    self._streaming_initialized = True# 同时在 gemma4_config() 中新增了一个 no-op 过渡,处理模型自身
# 也可能输出 <|channel> 的情况:
(ParserState.REASONING, "THINK_START"): Transition(
    ParserState.REASONING, # 状态不变,不产生事件
    (), # 不触发任何语义事件,从而避免泄露 TEXT_CHUNK
)

评论区精华

Flag 顺序修复:钩子调用前不应设置标志 正确性

bbrowning 指出在 `parse_delta` 中如果先设置 `_prompt_streaming_prepared = True` 再调用钩子,钩子内部可能调用 `reset` 清标志,导致重复扫描。

结论:修复为先调用钩子再设置标志,并在注释中说明原因。 · 已解决

用 _engine.reset 代替 initialize_streaming 设计

bbrowning 建议使用 `self._engine.reset(initial_state=ParserState.REASONING)` 而非 `initialize_streaming`,因为后者有额外副作用且可能被外部调用覆盖。

结论:采纳,直接 reset 引擎状态并设置 `_streaming_initialized = True` 防止后续覆盖。 · 已解决

方法命名:prepare_streaming_for_prompt → adjust_initial_state_from_prompt 设计

bbrowning 认为原命名未能表达调整初始状态的核心意图。

结论:重命名,更准确反映语义。 · 已解决

复用 is_reasoning_end 消除独立 token 扫描 正确性

bbrowning 提议利用已有的 `is_reasoning_end` 方法检测 prompt 末端,避免自己实现 token 扫描漏掉隐式开始 / 结束场景。

结论:完全移除独立的 `_prompt_ends_in_open_reasoning`,直接调用 `is_reasoning_end`。 · 已解决

添加 no-op 过渡 (REASONING, THINK_START) 设计

bbrowning 提醒若引擎被预初始化为 REASONING,而模型随后自身输出 <|channel>,则会导致 TEXT_CHUNK 泄露。需要类似 Qwen3 的 no-op 过渡。

结论:添加 `(ParserState.REASONING, "THINK_START") -> ParserState.REASONING` 不产生事件。 · 已解决

风险与影响

风险较低:

  1. 新增 no-op 过渡 (REASONING, THINK_START) 目前无害,但若未来 Gemma4 模型行为变化(如需要双重 <|channel> 输出),可能需要调整。
  2. 对 prompt 全序列的 is_reasoning_end 线性扫描在长对话中可能产生性能影响,但扫描仅一次且 token 量有限。
  3. 钩子框架可能被其他解析器误用,但默认空实现保证安全。
  4. 测试覆盖了开放推理和常见场景,但未覆盖极端长多轮混合 tool_response 与 reasoning 的情况(但 is_reasoning_end 本身已覆盖)。

影响范围:Gemma4用户是直接受益者,post-tool推理不再泄露。其他模型用户无影响。团队获得了可扩展的解析器初始状态调整机制,Qwen3、DeepSeek等解析器未来可利用此钩子处理类似需求。兼容性:所有变更均为新增,无删除,向后兼容。性能无退化。

新状态机过渡 线性扫描 prompt 性能 钩子框架误用风险

关联 Issue

#45834 [Bug]: [Gemma4] Post-tool reasoning can leak after final tool response with enable_thinking=true

完整报告

参与讨论