Prhub

#47606 [Bugfix][Frontend] Flush engine reasoning parser at engine-reasoning → tool streaming boundary

原始 PR 作者 akii96 合并时间 2026-07-14 02:06 文件变更 2 提交数 2 评论 4 代码增减 +298 / -11

执行摘要

修复推理 - 工具边界缓冲丢失问题

根据 PR body,当 </think><tool_call> 出现在同一流式 delta 中时,引擎推理解析器确认推理结束但将 < 缓冲在内部,仅通过 finish_streaming() 暴露。但 DelegatingParser.parse_delta 中转换逻辑只检查 self._engine_based(即两个解析器都是 engine-based),导致文本工具解析器从未看到 <tool_call>,工具调用泄漏到 content 中且 tool_calls 为空。该问题在异步调度或 stream_interval > 1 时复现,表现为随机失败(如 test_named_tool_use 在 AMD/NVIDIA CI 中失败)。

建议前端及解析器模块的开发者阅读此 PR,学习流式转换中 flush 设计的选择,以及如何通过真实解析器组合编写可靠的回归测试。修复方法本身简洁且安全,值得在类似跨引擎边界中推广。

讨论亮点

在 Review 中,bbrowning 指出了初版修复(解码 raw token ids)对多 token 字符不安全,并给出了闪回式提交示例,建议改用引擎推理解析器的 flush 机制。akii96 接受了该建议,重新实现了基于 flush 的修复,确保 detokenizer 正确处理多 token 序列。最终 bbrowning 批准了变更,并确认测试覆盖了失败场景。

实现拆解

  1. 修改转换逻辑:在 vllm/parser/abstract_parser.pyDelegatingParser.parse_delta 方法中,当推理阶段检测到转换时,无条件对 engine-based 推理解析器调用 finish_streaming(),合并其缓冲内容到 current_text,确保多 token 字符安全解码。
  2. 调整引擎路径与文本路径:保留 self._engine_based 分支以置空 delta_message.content,避免工具解析器重复显示;对非 engine-based 路径,将 current_text 赋给 delta_text,传递给工具解析器进行流式解析。
  3. 添加回归测试:在 tests/parser/test_streaming.py 中新增 Qwen3ReasoningHermesToolParser 混合解析器类,以及 test_engine_reasoning_hermes_tool_* 系列测试,覆盖逐令牌、边界块、文本保持(多字节 emoji)等场景;同时添加 Qwen3ReasoningNoToolParser 测试无工具解析器时的内容传递。
文件 模块 状态 重要度
vllm/parser/abstract_parser.py 解析器 modified 6.73
tests/parser/test_streaming.py 流式测试 modified 7.52

关键符号

DelegatingParser.parse_delta Qwen3ReasoningHermesToolParser

关键源码片段

vllm/parser/abstract_parser.py core-logic

核心修复:修改 parse_delta 方法中推理 - 工具转换逻辑,确保 engine-based 推理解析器在转换时总是刷新缓冲文本,避免工具调用丢失。

# vllm/parser/abstract_parser.py ( 关键片段 )
if should_transition:
    state.reasoning_ended = True
    reasoning_transitioned = True
    current_token_ids = self.extract_content_ids(delta_token_ids)
​
    # 无论 tool parser 是否 engine-based,只要 reasoning parser 是
    # engine-based,就调用 finish_streaming() 获取其缓冲的文本
    # (例如 "</think>" 之后的 "<"),以确保多 token 安全解码。
    flush_delta = (
        reasoning_parser.finish_streaming()
        if reasoning_parser is not None
        and reasoning_parser.engine_based_streaming
        else None
    )
    # 合并来自 reasoning 的 delta_message.content 和 flush_delta.content
    current_text = (
        (delta_message.content if delta_message else None) or ""
    ) + ((flush_delta.content if flush_delta else None) or "")
​
    if self._engine_based:
        if delta_message and self._tool_parser is not None:
            delta_message.content = None
    else:
        # 非 engine-based 路径:将 current_text 赋给 delta_text,
        # 传递给后续的 tool parser 进行流式解析。
        delta_text = current_text
tests/parser/test_streaming.py test-coverage

添加全面的回归测试,使用真实 Qwen3 推理解析器和 Hermes 工具解析器,覆盖逐令牌、边界块、多字节字符保持等场景,确保修复正确且防止回归。

# tests/parser/test_streaming.py ( 新增测试片段 )
class Qwen3ReasoningHermesToolParser(DelegatingParser):
    # 混合引擎推理解析器与文本工具解析器,触发边界 bug
    reasoning_parser_cls = Qwen3ParserReasoningAdapter
    tool_parser_cls = Hermes2ProToolParser
​
​
def _decode_stream_deltas(parser, tokenizer, text: str, request_obj):
    """模拟 DecodeStream 真实行为:逐 token 解码并保持文本完整性。"""
    token_ids = tokenizer.encode(text, add_special_tokens=False)
    results: list[DeltaMessage | None] = []
    for i, tid in enumerate(token_ids):
        chunk = [tid] if i < len(token_ids) - 1 else token_ids[i:]
        delta_text = tokenizer.decode(chunk)
        result = parser.parse_delta(
            delta_text,
            chunk,
            request_obj,
            prompt_token_ids=None,
            finished=(i == len(token_ids) - 1),
        )
        results.append(result)
    return results
​
​
def test_engine_reasoning_hermes_tool_text_holdback(tokenizer, request_obj):
    """多字节字符保持测试:工具参数包含 ZWJ emoji,确保边界安全。"""
    text = (
        "<think>ok</think>"
        '<tool_call>{"name":"x","arguments":{"msg":"😊"}}</tool_call>'
    )
    parser = Qwen3ReasoningHermesToolParser(tokenizer)
    results = _decode_stream_deltas(parser, tokenizer, text, request_obj)
    _, _, tool_calls = collect_fields(results)
    assert len(tool_calls) > 0
    args = "".join(tc.function.arguments for tc in tool_calls if tc.function.arguments)
    assert "😊" in json.loads(args)["msg"]

评论区精华

推理 - 工具边界 flush 策略设计 正确性

bbrowning 指出初版解码 raw token ids 的方式对多 token 字符不安全,建议改为使用引擎推理解析器的 flush_delta 机制。akii96 采纳建议,使用 flush 方式确保 detokenizer 正确工作。bbrowning 还提供了一个示例 commit 辅助实现。

结论:采用 flush 方式:在转换时无条件调用 reasoning_parser.finish_streaming() 合并缓冲文本,避免直接解码 token ids。确保了多 token 字符的安全性。 · 已解决

风险与影响

核心风险在于 parse_delta 是流式解析的关键路径,本次修改改变了转换时的 flush 条件,可能影响其他引擎/非引擎解析器组合。但由于 flush 仅当 reasoning_parser.engine_based_streaming 为 True 时执行,且原逻辑中 engine-based 路径已有类似 flush,影响范围可控。测试已覆盖主流组合(Qwen3 + Hermes),但未覆盖所有第三方解析器。另一个风险是测试使用了真实 tokenizer(Qwen3-32B),可能导致环境依赖或测试速度较慢,但这是回归测试的必要成本。

直接修复了使用引擎推理解析器+文本工具解析器组合(如 Qwen3 + Hermes)时工具调用丢失的 bug。影响用户包括所有多模型推理与工具调用结合的场景,尤其当流式间隔大于 1 时。修复后工具调用正确性得到保证,且对单引擎路径无副作用。测试增强了项目对该边界条件的防御能力。

核心路径变更 流式边界条件

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论