Prhub

#46437 [Frontend][Gpt-oss] Use `process_eos()` to flush Harmony Parser outputs.

原始 PR 作者 yzong-rh 合并时间 2026-06-27 05:18 文件变更 2 提交数 4 评论 5 代码增减 +91 / -61

执行摘要

为 HarmonyParser 添加 flush() 方法,调用 process_eos() 刷新并重置。

关联 Issue #45736 报告:vLLM 在 Harmony 解析器处于非终止状态时,仍返回 HTTP 200 且 content: nullfinish_reason="stop",静默丢弃已生成 token。此 PR 是修复该 bug 的第一步,通过 flush() 明确调用 process_eos() 促使消息提交,并为后续 serving 层修复(#46102)奠定基础。

此 PR 值得技术负责人和 parser 模块维护者精读,特别是 flush() 中异常处理和惰性初始化的设计决策。它展示了如何在不破坏现有接口的前提下,为下游修复铺平道路。后续 PR #46102 将进一步完善 serving 层集成,建议关注。

讨论亮点

Review 中 bbrowning 指出异常捕获应缩小到 HarmonyError 而非捕获所有异常,并质疑是否应围绕整个 flush() 逻辑捕获。作者 yzong-rh 同意只捕获 HarmonyError,并解释 _poll_completed_message 不需包围在 try 中。同时讨论到旧 parser 中的模式(静默返回部分内容)可能掩盖错误,作者认为应避免静默失败,优先暴露问题。最终达成一致。

实现拆解

  1. 惰性初始化 _harmony_parser:将 self._harmony_parser__init__ 中直接创建改为 @property 惰性初始化(_parser: StreamableParser | None),避免重复构造。
  2. 新增 _poll_completed_message() 方法:从 _harmony_parser.messages 中拉取新完成的 Message,避免直接读取内部状态。
  3. 新增 flush() 方法:调用 process_eos() 触发 Harmony 内部消息提交,捕获 HarmonyError 后仍尝试获取消息;随后重置 _parser_num_processed_messages 为初始状态,返回 Segment(含 completed_message)或 None
  4. 修改 parse() 方法:在 process_chunk() 后立即调用 flush(),若返回 Segment 则追加到 result.segments 列表中。
  5. 删除冗余属性:移除 statecurrent_rolecurrent_channelcurrent_recipientcurrent_contentcurrent_content_type 等属性,统一通过 _harmony_parser_poll_completed_message 访问。
  6. 测试配套:新增 TestFlush 类覆盖正常 flush 和异常后重置;在现有中断场景的测试中增加 assert harmony_parser._parser is None 验证正确重置。
文件 模块 状态 重要度
vllm/parser/harmony.py 解析器 modified 8.6
tests/parser/test_harmony.py 测试 modified 6.44

关键符号

flush _poll_completed_message _harmony_parser parse

关键源码片段

vllm/parser/harmony.py core-logic

核心实现:添加 `flush()`、`_poll_completed_message()`,改为惰性初始化,删除冗余属性,修改 `parse()` 以调用 `flush()`。

# vllm/parser/harmony.pyclass HarmonyParser(DelegatingParser):
    def __init__(self, tokenizer, tools=None, *args, **kwargs):
        super().__init__(tokenizer, tools, *args, **kwargs)
        # ... 校验 reasoning_parser 和 tool_parser ...
        self._parser: StreamableParser | None = None # 惰性初始化
        self._next_tool_call_index = 0
        self._num_processed_messages = 0
​
    @property
    def _harmony_parser(self) -> StreamableParser:
        """惰性初始化底层 Harmony 解析器,避免重复构造。"""
        if self._parser is None:
            self._parser = get_streamable_parser_for_assistant()
        return self._parser
​
    def _poll_completed_message(self) -> Message | None:
        """从已完成的消息列表中拉取下一条未处理的消息。"""
        messages = self._harmony_parser.messages
        if len(messages) <= self._num_processed_messages:
            return None
        msg = messages[self._num_processed_messages]
        self._num_processed_messages += 1
        return msg
​
    def flush(self) -> Segment | None:
        """
        调用 `process_eos()` 提交未完成消息,
        然后拉取新完成的 `Message`,重置解析器状态,
        返回 `Segment`(delta 为空)或 `None`。
        """
        msg = None
        with contextlib.suppress(HarmonyError):
            self._harmony_parser.process_eos()
            # TODO: 考虑重新抛出,避免静默失败
​
        msg = self._poll_completed_message()
​
        # 重置到初始助理解析器状态,为下一轮对话准备
        self._parser = None
        self._num_processed_messages = 0
​
        if msg is None:
            return None
​
        return Segment(
            channel=msg.channel,
            recipient=msg.recipient,
            delta="",
            completed_message=msg,
        )
​
    def parse(
        self,
        model_output: str,
        request: ChatCompletionRequest | ResponsesRequest,
        enable_auto_tools: bool = False,
        model_output_token_ids: Sequence[int] = (),
    ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
        """解析 Harmony 输出,返回 reasoning、content 和 tool_calls。"""
        result = self.process_chunk(model_output_token_ids)
        flushed_segment = self.flush() # 确保所有消息被提交
        if flushed_segment is not None:
            result.segments.append(flushed_segment)
        # 后续遍历 segments 提取 reasoning、content、tool_calls ...
tests/parser/test_harmony.py test-coverage

测试覆盖:新增 `TestFlush` 类测试正常刷新和 EOS 错误后的重置,并在现有中断测试中增加重置断言。

# tests/parser/test_harmony.pyclass TestFlush:
    def test_flush(self, harmony_parser):
        # 模拟部分输出:只写了 analysis 信道头,未结束
        harmony_parser.process_chunk(
            encode_output("<|channel|>analysis<|message|>Think")
        )
​
        flushed = harmony_parser.flush()
​
        assert flushed is not None
        assert flushed.channel == "analysis"
        assert flushed.recipient is None
        assert flushed.delta == ""
        assert flushed.completed_message is not None
        assert get_text(flushed.completed_message) == "Think"
        assert harmony_parser._parser is None # 确保重置
​
    def test_flush_resets_after_eos_error(self, harmony_parser):
        # 模拟无法解析的残缺输出
        harmony_parser.process_chunk(encode_output("<|channel|>analysis"))
​
        flushed = harmony_parser.flush()
​
        assert flushed is None
        assert harmony_parser._parser is None # 异常后仍重置

评论区精华

异常捕获范围 正确性

bbrowning 建议将 `except Exception` 收窄为 `except HarmonyError`,并确认 try 的覆盖范围应只包含 `process_eos()`。yzong-rh 同意,并补充说明旧 parser 中静默返回的模式可能掩盖错误,应避免。

结论:改为只捕获 `HarmonyError`,`_poll_completed_message` 移至 try 外部。 · 已解决

风险与影响

  1. 功能回归flush()parse() 末尾被调用,可能改变已有解析行为(如之前未提交的消息现在被提交),但测试覆盖表明行为一致。
  2. 异常处理flush() 中捕获 HarmonyError 后仍尝试获取消息,若 HarmonyError 发生在 process_eos() 但仍有部分消息可能被提交,_poll_completed_message 可能返回 None 或部分消息,调用方需处理 None
  3. 惰性初始化_harmony_parser 属性首次访问时创建,若后续 flush()process_eos() 异常,重置 _parser = None 可能导致下次调用重新创建,状态丢失。
  4. 测试覆盖:新增测试覆盖正常和 EOS 错误场景,但未覆盖多轮对话中多次 flush 的交互。

影响范围限于 GPT-OSS Harmony 解析器下游的 chat completions 和 responses API 调用。修正了在非终止状态下返回空内容的静默失败,提升了工具调用和推理结果的完整性。负面影响极小,因为新增的 flush() 仅主动提交消息,未改变已有接口契约。需要 upstream 依赖(openai-harmony)的 process_eos() 行为稳定。

核心路径变更 异常处理待完善 惰性初始化状态丢失 依赖外部库行为

关联 Issue

#45736 [Bug]: GPT-OSS Harmony: vLLM silently returns `content: null` with `finish_reason="stop"` when its parser ends in a non-terminal state

完整报告

参与讨论