Prhub

#40148 fix(openai): tolerate empty content in forced tool choice

原始 PR 作者 QwertyJack 合并时间 2026-05-06 22:16 文件变更 5 提交数 6 评论 11 代码增减 +109 / -7

执行摘要

修复 forced tool_choice 在 content=None 时的断言崩溃

Issue #40147 报告:推理解析器返回 content=None 时,forced tool_choice 触发 AssertionError,导致服务器错误而非有效的空函数调用。需要使服务器能够优雅处理 content 为 None 的情况,符合 OpenAI 行为。

值得精读。该 PR 展示了如何正确修复推理解析与工具调用交互的边界情况,并包含 review 中设计决策的来回迭代,对理解工具调用流程有参考价值。

讨论亮点

Reviewer chaunceyjiang 最初建议在 DeepSeek 解析器中修复("Perhaps it would be better to modify the corresponding parser"),但作者 QwertyJack 重写后,reviewer 纠正了自己的看法("I realized I was wrong earlier"),并指出更正确的修复是当 content 为 None 时返回空工具调用列表([]),而不是拼接空的参数字符串。最终方案采纳了这一建议,并移除了之前尝试的 content = content or '' 逻辑。讨论还涉及 arguments 应使用 "{}" 还是 "",但最终决定直接返回空列表,符合 OpenAI 行为。

实现拆解

  1. 修改 OpenAIServing._parse_tool_calls_from_content(vllm/entrypoints/openai/engine/serving.py):在 Responses API 的 ToolChoiceFunction 分支和 ChatCompletion 的 ChatCompletionNamedToolChoiceParam 分支中,将 assert content is not None 替换为 if content is None: return [], None,确保 content 为空时不构造 FunctionCall。
  2. 修改 DelegatingParser._parse_tool_calls(vllm/parser/abstract_parser.py):同样将 forced function call 分支的断言改为条件返回空列表。
  3. 调整 chat_completion/serving.py 中 tool_calls 的断言:将 assert tool_calls is not None and len(tool_calls) > 0 简化为 tool_calls = tool_calls or [],避免 tool_calls 为 None 时崩溃。
  4. 新增单元测试文件 tests/entrypoints/openai/test_tool_choice_content_none.py,覆盖 ChatCompletion 和 Responses 路径中 named tool_choice 传递 content=None 的场景。
  5. 扩展端到端测试 test_max_tokens_with_tool_choice_required(tests/entrypoints/openai/chat_completion/test_completion_with_function_calling.py),通过参数化同时测试 'required' 和 named function 两种 tool_choice。
文件 模块 状态 重要度
vllm/entrypoints/openai/engine/serving.py 服务层 modified 5.8
vllm/parser/abstract_parser.py 解析器 modified 4.99
tests/entrypoints/openai/test_tool_choice_content_none.py 测试 added 7.09
tests/entrypoints/openai/chat_completion/test_completion_with_function_calling.py 函数调用测试 modified 5.1
vllm/entrypoints/openai/chat_completion/serving.py 服务层 modified 4.49

关键符号

_parse_tool_calls_from_content _parse_tool_calls chat_completion_full_generator

关键源码片段

vllm/entrypoints/openai/engine/serving.py core-logic

核心修复文件,在 _parse_tool_calls_from_content 中添加 content 为 None 的守卫条件。

# 在 _parse_tool_calls_from_content 方法中处理强制工具调用的两个分支:
# 1. ToolChoiceFunction(Responses API)
# 2. ChatCompletionNamedToolChoiceParam(Chat API)
# 此前使用 assert content is not None 会在此处抛出异常,
# 现在改为守卫条件,当 content 为 None 时直接返回空列表。# 分支 1: Responses API forced function call
if (
    not use_mistral_tool_parser
    and request.tool_choice
    and isinstance(request.tool_choice, ToolChoiceFunction)
):
    # 当推理解析器(如 DeepSeekV3ReasoningParser)返回 content=None 时,
    # 返回空列表,避免 assert 崩溃
    if content is None:
        return [], None
    function_calls.append(
        FunctionCall(name=request.tool_choice.name, arguments=content)
    )
    content = None # 标记 content 已用于工具调用# 分支 2: ChatCompletion named tool choice
elif (
    not use_mistral_tool_parser
    and request.tool_choice
    and isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam)
    and (tool_parser_cls is None or tool_parser_cls.supports_required_and_named)
):
    # 同样,content 为 None 时返回空列表
    if content is None:
        return [], None
    function_calls.append(
        FunctionCall(name=request.tool_choice.function.name, arguments=content)
    )
    content = None
tests/entrypoints/openai/test_tool_choice_content_none.py test-coverage

新增回归测试,验证 content=None 时返回空工具调用。

class _DummyDelegatingParser(DelegatingParser):
    """模拟一个始终返回 None 的推理解析器"""
    def extract_reasoning(self, model_output: str, request):
        # 返回 (None, model_output) 触发 content=None 场景
        return None, model_output
    # 其他方法省略 ...def test_parse_tool_calls_from_content_allows_named_tool_choice_with_none_content():
    """验证 ChatCompletion named tool_choice 在 content=None 时返回空列表"""
    request = ChatCompletionRequest.model_validate({
        "model": "test-model",
        "messages": [{"role": "user", "content": "test"}],
        "tools": [{
            "type": "function",
            "function": {"name": "get_weather", "parameters": {}},
        }],
        "tool_choice": {"type": "function", "function": {"name": "get_weather"}},
    })
    tool_calls, content = OpenAIServing._parse_tool_calls_from_content(
        request=request, tokenizer=None, enable_auto_tools=True,
        tool_parser_cls=None, content=None,
    )
    assert content is None
    assert tool_calls is not None
    assert tool_calls == [] # 现在是空列表,而非崩溃

评论区精华

修复位置:改 parser 还是改入口? 设计

Reviewer 最初建议修改 DeepSeek parser,作者照做后 reviewer 意识到错误,指出正确的做法是在入口处处理 content=None 并返回空工具调用。

结论:最终在 shared forced-tool handling 路径中返回空列表,不修改 DeepSeek parser。 · 已解决

arguments 格式:应使用 "{}" 还是 ""? 设计

Reviewer chaunceyjiang 评论:当 content 为 None 时,arguments 应为 '{}' 而非 '',因为 '' 不是 OpenAI 定义的标准语义。

结论:最终决定不合成函数调用,而是返回空列表 [],避免 arguments 格式争议。 · 已解决

是否需要合成空的 FunctionCall? 设计

在第二次迭代中,作者尝试返回空 FunctionCall 列表而非空列表,但 reviewer 指出 function_calls 应为 [],因为无法确定函数名。

结论:采纳空列表,与 OpenAI 行为一致。 · 已解决

风险与影响

风险极低:改动仅限于 forced tool_choice 路径中 content 为 None 的边界情况,替换断言为条件返回,不会影响其他 tool_choice 模式(auto, required, none)。新增单元测试覆盖 ChatCompletion 和 Responses 两种入口,扩展的端到端测试覆盖了 max_tokens 触发 content 为 None 的场景。唯一潜在风险是如果调用方依赖原先的断言行为(即期望服务器报错),但这是从内部错误恢复为正常响应,更符合预期。

对用户:修复了 DeepSeek V3 等带推理的模型在使用 forced tool_choice 时可能返回 500 服务器错误的问题,现在会正确返回空工具调用。对系统:无性能开销,分支判断仅在 content 为 None 时执行。对团队:代码更健壮,减少了类似异常的排查成本。

边界条件处理 测试覆盖新增

关联 Issue

#40147 [Bug]: forced tool_choice asserts when reasoning extraction returns content=None

完整报告

参与讨论