Prhub

#44105 [BugFix] Omit empty tool_calls from OpenAI chat responses

原始 PR 作者 QwertyJack 合并时间 2026-06-23 13:43 文件变更 5 提交数 5 评论 5 代码增减 +134 / -5

执行摘要

修复空 tool_calls 数组导致 OpenAI SDK 失败

修复 Issue #44104 中描述的 OpenAI 兼容性 bug:当聊天补全 API 在工具调用结果后返回普通文本响应时,序列化输出中包含空的 tool_calls 数组。OpenAI SDK 客户端将 tool_calls 非 None 视为工具调用路径,从而在访问空列表时索引失败。需要对齐 OpenAI 规范,仅在确实存在工具调用时才包含 tool_calls 字段。

该 PR 值得精读,原因如下:

  1. 展示了如何利用 Pydantic V2 的 @model_serializer 优雅地调整序列化输出,同时保持模型定义不变。
  2. 测试覆盖设计良好,既验证了 model_dump 又验证了 model_dump_json,并验证了 OpenAI SDK 模型解析。
  3. 修复了一个真实影响用户的兼容性 bug,展现了良好的用户同理心。
  4. review 中关于条件判断方式的讨论体现了对代码质量的关注。
讨论亮点
  • Reviewer Chauncey Jiang 在审查时建议使用 len(data.get("tool_calls", [])) == 0 替代 data.get("tool_calls") == [],以提高代码清晰度和避免潜在的空列表比较问题。作者采纳了该建议。
  • Issue 评论者 hclsys 指出该变更可能影响内部通过 message["tool_calls"] 读取空数组的消费者(如回调、日志)。作者通过 git grep 检查确认 vLLM 内部无此类依赖,风险可控。

实现拆解

  1. 识别问题:在 ChatMessage(非流式)和 DeltaMessage(流式)的 Pydantic 序列化中,默认的 tool_calls 字段由 Field(default_factory=list) 产生空列表,序列化后输出 tool_calls: [],与 OpenAI 规范不一致。
  2. 添加序列化钩子:分别在 vllm/entrypoints/openai/chat_completion/protocol.py 的 ChatMessage 类和 vllm/entrypoints/openai/engine/protocol.py 的 DeltaMessage 类上,使用 @model_serializer(mode="wrap") 装饰器添加 _serialize 方法。该方法先调用默认序列化 handler,然后检查 data.get("tool_calls", []) 是否为空,若是则移除该键。使用 len(...) == 0 而非直接比较 == [] 以提高稳健性。
  3. 更新现有测试:修改 test_completion_with_function_calling.py 和 test_serving_chat.py 中的断言,将 assert len(choice.message.tool_calls) == 0 改为 assert choice.message.tool_calls is None,以反映新行为。
  4. 新增单元测试:在 test_tool_choice_content_none.py 中新增辅助函数 _chat_response 和 _stream_response,以及 4 个测试函数,验证 JSON payload 中 tool_calls 字段在空时被省略、非空时保留,并验证通过 OpenAI Python SDK 模型解析时 tool_calls 为 None。
  5. 验证无回归:通过 git grep 确认 vLLM 内部代码无直接依赖 message["tool_calls"] 键;运行所有相关测试通过,ruff 检查和 pytest 通过。
文件 模块 状态 重要度
vllm/entrypoints/openai/chat_completion/protocol.py 聊天补全 modified 6.49
vllm/entrypoints/openai/engine/protocol.py 引擎协议 modified 6.7
tests/entrypoints/openai/test_tool_choice_content_none.py 工具测试 modified 6.98
tests/entrypoints/openai/chat_completion/test_completion_with_function_calling.py 函数测试 modified 3.43
tests/entrypoints/openai/chat_completion/test_serving_chat.py 服务测试 modified 3.25

关键符号

ChatMessage._serialize DeltaMessage._serialize

关键源码片段

vllm/entrypoints/openai/chat_completion/protocol.py core-logic

核心变更点:为 ChatMessage 类添加 _serialize 序列化方法,当 tool_calls 为空时从输出中移除

# vllm/entrypoints/openai/chat_completion/protocol.py
class ChatMessage(OpenAIBaseModel):
    role: str
    content: str | None = None
    refusal: str | None = None
    # ... 其他字段 ...
    tool_calls: list[ToolCall] = Field(default_factory=list)
​
    # 添加序列化钩子,在序列化输出时若 tool_calls 为 [] 则移除该字段
    @model_serializer(mode="wrap")
    def _serialize(self, handler):
        data = handler(self)
        # 当 tool_calls 为空列表时,从 dict 中弹出该键,使输出中不包含该字段
        # 使用 len() 检查而非直接比较 == [],提高代码健壮性
        if len(data.get("tool_calls", [])) == 0:
            data.pop("tool_calls", None)
        return data
vllm/entrypoints/openai/engine/protocol.py core-logic

核心变更点:为 DeltaMessage 类添加 _serialize 序列化方法,使流式响应中的空 tool_calls 被省略

# vllm/entrypoints/openai/engine/protocol.py
class DeltaMessage(OpenAIBaseModel):
    role: str | None = None
    content: str | None = None
    reasoning: str | None = None
    tool_calls: list[DeltaToolCall] = Field(default_factory=list)
​
    # 序列化时若 tool_calls 为 [] 则移除,与 ChatMessage 行为一致
    @model_serializer(mode="wrap")
    def _serialize(self, handler):
        data = handler(self)
        if len(data.get("tool_calls", [])) == 0:
            data.pop("tool_calls", None)
        return data
tests/entrypoints/openai/test_tool_choice_content_none.py test-coverage

新增大量单元测试,覆盖非流式和流式两种模式下的空 / 非空 tool_calls 场景,是验证修复正确性的核心测试文件

# tests/entrypoints/openai/test_tool_choice_content_none.py# 辅助函数:构造非流式响应
def _chat_response(message: ChatMessage) -> ChatCompletionResponse:
    return ChatCompletionResponse(
        model="test-model",
        choices=[ChatCompletionResponseChoice(
            index=0, message=message, finish_reason="stop")],
        usage=UsageInfo(prompt_tokens=1, completion_tokens=1, total_tokens=2),
    )# 测试空 tool_calls 被省略
def test_chat_completion_response_omits_empty_tool_calls_payload():
    # 构造只有 content 的 ChatMessage(tool_calls 使用默认空列表)
    response = _chat_response(ChatMessage(role="assistant", content="done"))
    payload = response.model_dump()
    # 验证序列化后 choices[0].message 中不包含 tool_calls 键
    assert "tool_calls" not in payload["choices"][0]["message"]
    # 同时验证 exclude_unset=True 模式下也省略
    payload_exclude_unset = response.model_dump(exclude_unset=True)
    assert "tool_calls" not in payload_exclude_unset["choices"][0]["message"]
    # 使用 OpenAI SDK 模型解析,验证 tool_calls 属性为 None
    parsed = OpenAIChatCompletion.model_validate(payload)
    assert parsed.choices[0].message.tool_calls is None# 测试非空 tool_calls 被保留
def test_chat_completion_response_keeps_non_empty_tool_calls_payload():
    # 构造包含一个 tool_call 的 ChatMessage
    response = _chat_response(ChatMessage(
        role="assistant", content="",
        tool_calls=[ToolCall(
            function=FunctionCall(name="get_weather",
                                  arguments='{"city": "Beijing"}'))]))
    message = response.model_dump()["choices"][0]["message"]
    assert len(message["tool_calls"]) == 1
    assert message["tool_calls"][0]["function"]["name"] == "get_weather"

评论区精华

序列化条件判断改进 style

Reviewer chaunceyjiang 建议将 data.get("tool_calls") == [] 替换为 len(data.get("tool_calls", [])) == 0,以避免使用空列表直接比较,提高代码清晰度和安全性。

结论:作者采纳建议,在最终代码中使用 len(...) == 0。 · 已解决

对内部消费者的影响 question

Issue 评论者 hclsys 指出该变更可能影响通过 message["tool_calls"] 访问空数组的内部消费者(如回调、日志记录器)。作者回应通过 git grep 检查确认 vLLM 内部无此类依赖,风险可控。

结论:确认无内部消费者依赖,无需额外处理。 · 已解决

风险与影响

主要风险包括:

  • 兼容性:对于依赖 tool_calls 键存在的下游工具或自定义中间件,省略该键可能导致 KeyError。但经过 grep 内部代码,未发现 vLLM 内部有此类依赖;外部用户通常使用 OpenAI SDK,该 SDK 会处理 None 字段。
  • 回归风险:改动集中在两个类的方法,每个方法只有 7 行,测试新增 4 个单元测试并更新 2 个现有测试,覆盖空和非空场景,回归概率低。
  • 序列化性能:model_serializer 模式会增加微小的函数调用开销,但影响可忽略。
  • 多格式兼容:变更同时影响 model_dump() 和 model_dump_json(),行为一致。未修改 model_dump(by_alias=True) 等特殊模式,但当前字段无别名,无影响。
  • 用户影响:使用 OpenAI SDK 或其他基于 OpenAI 规范解析响应的客户端将不再因空 tool_calls 而崩溃。这是正向影响。
  • 系统影响:序列化输出格式变化,但非空 tool_calls 行为不变。API 响应体稍微变小(少一个字段)。需确保监控和日志系统不会因缺少字段而出错(但日志通常序列化后记录,不会受影响)。
  • 团队影响:无直接运维影响,更新部署后即可。
序列化行为变更

关联 Issue

#44104 [Bug]: Chat completions emits empty tool_calls arrays after tool results

完整报告

参与讨论