Prhub

#39091 [Bugfix][Reasoning] Nemotron V3: surface reasoning as content when thinking is unterminated

原始 PR 作者 askliar 合并时间 2026-06-10 20:58 文件变更 3 提交数 1 评论 15 代码增减 +163 / -10

执行摘要

Nemotron V3 未闭合 thinking 块时内容丢失修复

Nemotron V3 模型在使用 enable_thinking=Falseforce_nonempty_content=True 配置时,有时会生成整个答案在 <think> 块内且不输出 </think>,导致只读取 content 的客户端得不到任何内容(null)。为了在流式结束时也能提供内容,需要将推理内容兜底放入 content 字段。

建议相关开发者仔细阅读,特别是 finalize_generation 的设计体现了延迟处理未完成块的思想。设计上采用 opt-in,行为清晰,值得借鉴。

讨论亮点

Review 中主要讨论了流式模式下是否应在每个 chunk 复制推理到 content。tomeras91 最初要求更改,认为这会导致正常闭合思考块时内容重复。最终实现改为仅在 finalize_generation 时处理,避免了重复。另外讨论了解析器状态管理和工具调用提升的必要性,最终简化实现。

实现拆解

步骤 1:提取检查条件

NemotronV3ReasoningParser 中新增 _should_force_content 方法,统一检查 chat_template_kwargs 中的 enable_thinking is Falseforce_nonempty_content is True

步骤 2:新增流式兜底方法

新增 get_streaming_fallback_content 方法,在流式结束时根据 _should_force_content 判断是否提取推理内容并返回。

步骤 3:重构非流式提取

修改 extract_reasoning,使用 _should_force_content 决定是否在推理内容为空时交换 reasoning 和 content,保持原有非流式行为。

步骤 4:添加 finalize_generation 钩子

DelegatingParser 基类中新增 finalize_generation 方法,在 parse_delta 的 finished 分支中调用。如果 reasoning 未结束且 reasoning parser 提供了 get_streaming_fallback_content,则将返回的内容追加到当前 delta_message 的 content 中。同时保留原有的工具参数追加逻辑。

步骤 5:测试覆盖

添加单元测试覆盖以下场景:未闭合 thinking 时内容被提升、force_nonempty_content 时 whitespace 也可被覆盖、真实内容不被覆盖、以及通过 DelegatingParser 的 delta 测试验证最终行为。

文件 模块 状态 重要度
vllm/reasoning/nemotron_v3_reasoning_parser.py 推理解析器 modified 7.52
vllm/parser/abstract_parser.py 解析器基础 modified 6.88
tests/reasoning/test_nemotron_v3_reasoning_parser.py 测试 modified 6.9

关键符号

extract_reasoning _should_force_content get_streaming_fallback_content finalize_generation

关键源码片段

vllm/reasoning/nemotron_v3_reasoning_parser.py core-logic

核心推理解析器,新增 _should_force_content 和 get_streaming_fallback_content 方法,重构 extract_reasoning。

# nemotron_v3_reasoning_parser.py - 核心逻辑
class NemotronV3ReasoningParser(DeepSeekR1ReasoningParser):
    def _should_force_content(self, request):
        # 检查 chat_template_kwargs 中是否启用了强制内容
        chat_template_kwargs = getattr(request, "chat_template_kwargs", None)
        return bool(
            chat_template_kwargs
            and (
                chat_template_kwargs.get("enable_thinking") is False
                or chat_template_kwargs.get("force_nonempty_content") is True
            )
        )
​
    def extract_reasoning(self, model_output, request):
        reasoning, final_content = super().extract_reasoning(model_output, request)
        # 非流式场景:如果内容为空且启用了强制内容,则交换
        if self._should_force_content(request) and (
            final_content is None or not final_content.strip()
        ):
            reasoning, final_content = final_content, reasoning
        return reasoning, final_content
​
    def get_streaming_fallback_content(self, text, request):
        # 流式结束时提取推理内容作为兜底
        if not self._should_force_content(request):
            return None
        reasoning, _ = super().extract_reasoning(text, request)
        return reasoning
vllm/parser/abstract_parser.py core-logic

基类解析器,新增 finalize_generation 钩子,在流式结束时处理未完成思考的推理提升。

# abstract_parser.py - finalize_generation 钩子
def finalize_generation(self, delta_message, request, state):
    # 如果推理未结束,尝试将推理内容提升到 content
    fallback_fn = getattr(self._reasoning_parser, "get_streaming_fallback_content", None)
    if fallback_fn is not None and not state.reasoning_ended:
        promoted = fallback_fn(state.previous_text, request)
        if promoted:
            if delta_message is None:
                delta_message = DeltaMessage()
            delta_message.content = (delta_message.content or "") + promoted
    # 追加未流式传输的工具参数
    self._append_unstreamed_tool_args(delta_message)
    return delta_message
tests/reasoning/test_nemotron_v3_reasoning_parser.py test-coverage

单元测试,覆盖未闭合 thinking 和 force_nonempty_content 的各种边界情况。

def test_nemotron_v3_force_nonempty_keeps_real_content(
    tokenizer: FakeNemotronTokenizer,
):
    # 当真实内容跟随在 </think> 之后,不应提升推理到 content
    parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
    parser = parser_cls(tokenizer)
    request = ChatCompletionRequest(
        model="test-model",
        messages=[],
        chat_template_kwargs={"force_nonempty_content": True},
    )
    reasoning, content = run_reasoning_extraction(
        parser,
        ["<think>reasoning here</think>real answer"],
        request=request,
        streaming=False,
    )
    assert reasoning == "reasoning here"
    assert content == "real answer"def _make_reasoning_parser(tokenizer):
    # 创建用于 delta 测试的解析器
    class _NemotronParser(DelegatingParser):
        reasoning_parser_cls = NemotronV3ReasoningParser
        tool_parser_cls = None
    return _NemotronParser(tokenizer)

评论区精华

流式模式下推理内容重复复制到 content 设计

tomeras91 指出,在流式模式下,当 _promote_reasoning_to_content 为 True 时,每个推理 chunk 都被复制到 content,导致常见情况(正常闭合 </think>)下内容重复。建议只在未完成思考时处理。

结论:最终实现改为只在 finalize_generation(流式结束时)复制,避免了每个 chunk 复制。 · 已解决

解析器状态管理 vs. 请求参数 设计

tomeras91 指出 self._chat_template_kwargs 可能优先级高于请求参数,且导致状态管理。建议移除。

结论:移除了 self._chat_template_kwargs,改为在每次调用时从请求中获取 chat_template_kwargs。 · 已解决

风险与影响

风险较低:仅在使用 Nemotron V3 且启用 force_nonempty_contentdisable_thinking 时才生效。主要风险是依赖 content 为 null 检测的客户端可能因修复而得到内容,但这是预期修复。无性能、安全或兼容性风险。

影响特定模型(Nemotron V3)和特定配置下的流式输出。解决了内容丢失的缺陷,使客户端始终获得非空 content。对其他模型无影响。

流式路径变更 特定模型影响 opt-in 行为变化

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论