Prhub

#45104 [Refactor] Chat Completions Streaming Harmony Refactor and Bugfixes

原始 PR 作者 yzong-rh 合并时间 2026-06-12 09:09 文件变更 5 提交数 1 评论 6 代码增减 +394 / -733

执行摘要

重构流式路径统一到 HarmonyParser,删除专用流式模块

重构 Chat Completions Harmony 路径以使用统一的 Parser。此前 Harmony 绕过了统一 Parser 路径,非流式通过 harmony_utils.pyopenai_tool_parser.py 流向,流式通过 stream_harmony.py 流向,导致双重解析和流式 bug(关联 issue #37070:流式工具调用崩溃和参数分裂)。本 PR 更新了统一 Parser 接口以接受 token ID,创建 HarmonyParser 适配 StreamableParser,并删除 Harmony 专用的流式服务路径,使两种模式都流经 parser.parse()/parse_delta()

值得精读。展示了如何通过统一接口消除重复代码和修复顽固 bug。设计决策(将流式 delta 提取集成到 parser 内部)值得借鉴。

讨论亮点

无实质 review 评论,作者 yzong-rh 在 PR 评论中解释将非流式重构提取为 #45171,本 PR 专做流式重构。最终得到 sfeng33 的 LGTM 批准。

实现拆解

  1. 删除旧流式模块:移除 vllm/entrypoints/openai/chat_completion/stream_harmony.py 及其测试 tests/entrypoints/openai/chat_completion/test_serving_chat_stream_harmony.py,其中的 TokenStateextract_harmony_streaming_delta 函数不再使用。

  2. 增强 HarmonyParser:在 vllm/parser/harmony.py 中,HarmonyParser 类新增 parse_delta() 方法实现,该方法通过 process_chunk() 处理 token ID 序列,遍历生成的 Segment 列表并按照通道(channel)和接收者(recipient)分类组装 DeltaMessage。同时添加了 _next_tool_call_index_num_processed_messages 状态跟踪,用于正确分配工具调用索引。删除了原有的 messages 属性(不再需要暴露原始消息列表)。

  3. 统一服务调用路径:在 vllm/entrypoints/openai/chat_completion/serving.py 中,移除 use_harmony 标志和对应的 Harmony 专用分支代码,流式生成时不再额外创建 StreamableParser,而是直接使用 parser.parse_delta() 处理每个输出 token 序列。非流式路径保持不变(仍走 parser.parse())。

  4. 更新测试覆盖:在 tests/parser/test_harmony.py 中新增 TestParseDelta 测试类,覆盖基本流式场景、多 token 场景、工具调用跨 delta 分割场景等,确保 parse_delta 行为正确。删除了原有的 visible_segments 辅助函数,替换为 tool_call_headerstool_call_payloadscombined_tool_arguments 等更贴合 delta 消息检验的辅助函数。

文件 模块 状态 重要度
vllm/entrypoints/openai/chat_completion/stream_harmony.py 流式模块 removed 8.3
tests/entrypoints/openai/chat_completion/test_serving_chat_stream_harmony.py 测试 removed 7.76
vllm/parser/harmony.py 解析器 modified 7.78
vllm/entrypoints/openai/chat_completion/serving.py 服务层 modified 7.32
tests/parser/test_harmony.py 测试 modified 7.52

关键符号

HarmonyParser.parse_delta HarmonyParser.process_chunk HarmonyParser.parse HarmonyParser.__init__ extract_harmony_streaming_delta (deleted) TokenState (deleted)

关键源码片段

vllm/parser/harmony.py dependency-wiring

核心解析器文件,新增了 `parse_delta()` 方法实现流式 delta 提取,并移除了 `messages` 属性。

    def parse_delta(
        self,
        delta_text: str,
        delta_token_ids: list[int],
        request: ChatCompletionRequest | ResponsesRequest,
        prompt_token_ids: list[int] | None = None,
        *,
        finished: bool,
    ) -> DeltaMessage | None:
        # 记录当前 recipient,用于检测工具调用边界
        prev_recipient = self.current_recipient
        # 通过 process_chunk 将 token ID 序列解析为 Segment 列表
        result = self.process_chunk(delta_token_ids)
        combined_content = ""
        combined_reasoning = ""
        tool_messages: list[DeltaToolCall] = []
​
        for segment in result.segments:
            # 跳过已完成的消息边界(boundary 段)
            if segment.completed_message is not None:
                prev_recipient = None
                continue
​
            # 判断段类型:推理、内容或工具调用
            segment_type = _SegmentType.from_channel_and_recipient(
                segment.channel, segment.recipient
            )
            match segment_type:
                case _SegmentType.REASONING:
                    combined_reasoning += segment.delta
                case _SegmentType.CONTENT:
                    combined_content += segment.delta
                case _SegmentType.TOOL:
                    assert segment.recipient is not None
                    if prev_recipient != segment.recipient:
                        # 新工具调用开始:生成工具调用头部(含名称)
                        tool_name = extract_function_from_recipient(segment.recipient)
                        tool_messages.append(
                            DeltaToolCall(
                                id=make_tool_call_id(),
                                type="function",
                                function=DeltaFunctionCall(
                                    name=tool_name,
                                    arguments=segment.delta,
                                ),
                                index=self._next_tool_call_index,
                            )
                        )
                        self._next_tool_call_index += 1
                        prev_recipient = segment.recipient
                    elif segment.delta:
                        # 已有工具调用的参数流:仅传递 arguments
                        tool_call_index = self._next_tool_call_index - 1
                        tool_messages.append(
                            DeltaToolCall(
                                index=tool_call_index,
                                function=DeltaFunctionCall(arguments=segment.delta),
                            )
                        )
​
        # 无任何内容则返回 None
        if not combined_content and not combined_reasoning and not tool_messages:
            return None
​
        # 组装最终 DeltaMessage
        delta_message = DeltaMessage()
        if combined_content:
            delta_message.content = combined_content
        if combined_reasoning:
            delta_message.reasoning = combined_reasoning
        if tool_messages:
            delta_message.tool_calls = tool_messages
        return delta_message
tests/parser/test_harmony.py test-coverage

测试文件,新增 `TestParseDelta` 测试类,覆盖流式场景。同时新增辅助函数 `tool_call_headers`、`tool_call_payloads`、`combined_tool_arguments` 用于验证 delta 消息。

class TestParseDelta:
    """
    测试 HarmonyParser.parse_delta 的流式 delta 提取逻辑。
    每个测试使用 gpt_oss_tokenizer 构造真实的 token ID 序列。
    """
​
    def test_basic(self, gpt_oss_tokenizer, chat_request):
        """基本流式场景:先分析通道,再最终通道。"""
        parser = HarmonyParser(gpt_oss_tokenizer)
        # 第一个 token 序列:分析通道的推理内容
        first_delta = parser.parse_delta(
            delta_text="",
            delta_token_ids=encode_output("<|channel|>analysis<|message|>Thinking"),
            request=chat_request,
            finished=False,
        )
        # 第二个 token 序列:最终通道的回答内容
        second_delta = parser.parse_delta(
            delta_text="",
            delta_token_ids=encode_output(
                "<|end|><|start|>assistant<|channel|>final<|message|>Answer"
            ),
            request=chat_request,
            finished=False,
        )
        assert first_delta is not None
        assert first_delta.reasoning == "Thinking"
        assert first_delta.content is None
        assert second_delta is not None
        assert second_delta.content == "Answer"
        assert second_delta.reasoning is None
​
    def test_multi_token(self, gpt_oss_tokenizer, chat_request):
        """单次调用包含多个 token 的正确组装。"""
        parser = HarmonyParser(gpt_oss_tokenizer)
        delta = parser.parse_delta(
            delta_text="",
            delta_token_ids=encode_output("<|channel|>final<|message|>Hello, world!"),
            request=chat_request,
            finished=False,
        )
        assert delta is not None
        assert delta.content == "Hello, world!"
        assert delta.reasoning is None
        assert not delta.tool_calls
​
    @pytest.mark.parametrize("tool_channel", ["commentary", "analysis"])
    def test_tool_call_split_across_deltas(
        self, gpt_oss_tokenizer, chat_request, tool_channel
    ):
        """工具调用参数分布在多个 delta 中时的合并行为。"""
        parser = HarmonyParser(gpt_oss_tokenizer)
        # 第一次 delta:开始工具调用
        first_delta = parser.parse_delta(
            delta_text="",
            delta_token_ids=encode_output(
                f"<|channel|>{tool_channel}<|message|>"
            ),
            request=chat_request,
            finished=False,
        )
        # ... 后续 delta 包含参数
        # 此测试验证工具调用的 index 和 arguments 跨 delta 连续

评论区精华

拆分非流式与流式重构为两个 PR 设计

作者 yzong-rh 在评论中说明:应 reviewer 要求将非流式部分提取为单独 PR(#45171),本 PR 专注于流式路径。他先将两个 commit 拆开,第一个 commit 是非流式,第二个是流式,然后提取前者为 #45171,本 PR 仅保留流式变更。

结论:接受拆分,两个 PR 先后合并。 · 已解决

风险与影响

风险较低。原流式路径被完全替换,新路径通过 BFCL 和单元测试验证无回归。但若 harmony 模型(gpt-oss)有非预期行为,可能需要快速修复。删除的模块和测试不再可用,需保证新测试覆盖完整。

对使用 harmony 模型(如 gpt-oss)的用户,流式行为将改善(修复了之前的 bug),非流式行为不变。对系统无性能影响。开发团队可获得更清晰的代码结构,便于后续维护。

核心路径变更 删除旧模块 流式路径重写 无实时 review

关联 Issue

#37070 [Bugfix] Fix harmony streaming tool call crash and argument splitting

完整报告

参与讨论