Prhub

#50334 [Bugfix][Responses] Add tests for Chat Completions Responses API Render Parity

原始 PR 作者 yzong-rh 合并时间 2026-08-01 03:45 文件变更 8 提交数 4 评论 7 代码增减 +521 / -43

执行摘要

新增双 API 渲染一致性测试并修复工具渲染差异

PR body 声明目标是为 Chat Completions 与 Responses 之间的渲染一致性添加测试,覆盖多轮对话、tools(strict、defer_loading、extra 字段)、tool_choice、reasoning_effort/enable_thinking 以及 template kwargs。渲染入口的分叉会直接影响提示词模板输出,进而影响工具调用正确性与响应质量。测试驱动下发现并修复两个不一致:一是 Responses 的 FunctionTool 与 ChatCompletionToolsParam 渲染结果不一致(该问题源于上游 PR#49824 的讨论),二是 Responses 的 tool_choice='none' 未像 Chat Completions 那样尊重 --exclude-tools-when-tool-choice-none。行为变化随之产生:工具中的 extra 自定义字段被忽略、tool_choice='none' 时默认把工具注入 prompt。

值得精读,重点是 test_render_parity.py 的捕获-对比测试设计:它没有 mock 掉整个预处理链路,只在 HF 渲染边界安装桩,保留了两条 API 各自预处理逻辑的真实性,这种"边界打桩"思路对其他跨 API 一致性测试有借鉴价值。也值得关注两个序列化器的白名单过滤与 construct_tool_dicts 的参数化设计。对后续要扩展 Responses API 能力的开发者,这套测试应作为默认配套。

讨论亮点

核心讨论围绕 vllm/tool_parsers/utils.py 中 iter_response_function_tool_dicts 由 else 改为 elif isinstance(tool, FunctionTool) 引发的回归:code_interpreter 等 MCP 工具不再被注入,导致 tests/entrypoints/openai/responses/test_parsable_context.py::test_mcp_tool_call 断言失败(期望 mcp_call 出现在输出中,实际只有 reasoning 与 message)。yzong-rh 说明 MCP 工具在 ParsableContext 路径上从未被正确支持:旧路径只是注入一个无 name/parameters 的非法 function tool,新路径则完全不注入,属于"从坏的变成没有";他提出两个方案——xfail 该测试,或继续 dump 非 function 工具以保留旧行为。bbrowning 赞同 xfail 是正确做法,并指出 xfail 有助于未来在 ParsableContext 完善时清理。此外 bbrowning 在 approve 评论中认可测试套件对拦截这类回归的价值,并补充运行了 tool_parsers、responses 等一组周边单测确认无回归。

实现拆解

变更入口是新增的 tests/entrypoints/openai/test_render_parity.py:它通过 RenderCapture 在 render_chat_async 这一 HF 渲染边界安装桩函数,记录两套 API 各自预处理后产出的 messages 与 ChatParams,再由 _assert_parity 以配对请求做逐字段断言。关键设计是没有 mock 掉 OnlineRenderer.preprocess_chat 链路,只替换最末端的渲染函数,因此 Chat Completions 与 Responses 各自的工具转换、消息构造逻辑都真实执行。

实现过程按以下步骤展开:

  1. 建立跨 API 渲染捕获基础设施:新增 test_render_parity.py(479 行),MockModelConfig/MockHFConfig 提供构造 OnlineRenderer 与 OpenAIServingResponses 所需的完整配置;_capture_chat 走 online_renderer.render_chat,_capture_responses 走 serving._make_request,两者最终汇聚到同一个桩函数,保证观测点在渲染边界完全一致。
  2. 修复 FunctionTool 渲染不一致:vllm/entrypoints/openai/responses/utils.py 的 convert_tool_responses_to_completions_format 从返回裸 dict 改为构造 ChatCompletionToolsParam(type="function", function=FunctionDefinition.model_validate(...)),使 Responses 工具 schema 复用 Chat Completions 的字段校验与序列化;construct_tool_dicts 对应改为对转换结果调用 .model_dump()。配套在 vllm/entrypoints/openai/engine/protocol.py 的 FunctionDefinition._serialize 与 vllm/entrypoints/openai/chat_completion/protocol.py 的 ChatCompletionToolsParam._serialize 中加入按 model_fields 白名单过滤的逻辑,strict、defer_loading 为 None 时仍按原逻辑剔除——这是 extra 字段被统一忽略的实现来源。
  3. 对齐 tool_choice='none' 语义:vllm/entrypoints/openai/responses/serving.py::_make_request 调用 construct_tool_dicts 时新增 exclude_tools_when_tool_choice_none=self.online_renderer.exclude_tools_when_tool_choice_none 透传,construct_tool_dicts 的判定条件由 tool_choice == "none" 改为 tool_choice == "none" and exclude_tools_when_tool_choice_none,默认行为随之变为把工具写进 prompt。
  4. 收敛非 function 工具并标记缺口:vllm/tool_parsers/utils.py::iter_response_function_tool_dicts 由无条件的 else 分支收紧为 elif isinstance(tool, FunctionTool),type="code_interpreter" 等非 function 工具不再被注入;该收紧暴露了 MCP 工具在 ParsableContext 路径从未被正确渲染的既有缺口,最终对 tests/entrypoints/openai/responses/test_parsable_context.py::test_mcp_tool_call 加 xfail 并注明原因。
  5. 测试配套收尾:tests/entrypoints/openai/responses/test_responses_utils.py 删除旧的裸 dict 断言用例(test_convert_tool_responses_to_completions_format),覆盖职责移交给新的 parity 测试;test_render_parity.py 共 15 个用例,覆盖多轮对话、tools(strict、defer_loading、extra)、tool_choice(auto/none/required)、reasoning_effort、enable_thinking 与 template kwargs。
文件 模块 状态 重要度
tests/entrypoints/openai/test_render_parity.py 渲染测试 added 7.76
vllm/entrypoints/openai/responses/utils.py 响应处理 modified 7.09
tests/entrypoints/openai/responses/test_responses_utils.py 响应测试 modified 5.34
vllm/entrypoints/openai/responses/serving.py 响应服务 modified 5.27
vllm/entrypoints/openai/engine/protocol.py 协议模型 modified 5.13
vllm/entrypoints/openai/chat_completion/protocol.py 对话协议 modified 4.52
vllm/tool_parsers/utils.py 工具解析 modified 5.06
tests/entrypoints/openai/responses/test_parsable_context.py 上下文测试 modified 4.11

关键符号

convert_tool_responses_to_completions_format construct_tool_dicts iter_response_function_tool_dicts FunctionDefinition._serialize ChatCompletionToolsParam._serialize _make_request _assert_parity RenderCapture.take test_multiturn_tool_calling

关键源码片段

tests/entrypoints/openai/test_render_parity.py test-coverage

新增 479 行跨 API 渲染一致性测试,是本次变更的核心资产:RenderCapture 在 render_chat_async 边界捕获输入,_assert_parity 对两套 API 的 messages 与 ChatParams 逐字段断言,驱动了全部 bugfix。

class RenderCapture:
    """在 render_chat_async(HF 渲染边界)安装桩,记录其收到的输入。"""
​
    def __init__(self, online_renderer: OnlineRenderer) -> None:
        self.online_renderer = online_renderer
        self.captured: CapturedRenderInputs | None = None
​
        async def fake_render_chat_async(
            conversations, chat_params, tok_params=None, *,
            prompt_extras=None, skip_mm_cache=False,
        ):
            # 两条 API 路径最终都应只产生一组对话;
            # 记录消息与 ChatParams,再返回一个假 token 输入即可走完流程。
            assert len(conversations) == 1
            self.captured = CapturedRenderInputs(
                messages=list(conversations[0]),
                chat_params=chat_params,
            )
            return [list(conversations[0])], [tokens_input(prompt_token_ids=[0])]
​
        online_renderer.renderer.render_chat_async = fake_render_chat_async
​
    def take(self) -> CapturedRenderInputs:
        # 取走当前捕获并复位,保证每次调用只消费一次观测结果。
        assert self.captured is not None
        captured = self.captured
        self.captured = None
        return captured
​
​
async def _assert_parity(
    online_renderer: OnlineRenderer,
    serving: OpenAIServingResponses,
    *,
    chat_kwargs: dict[str, Any],
    responses_kwargs: dict[str, Any],
) -> None:
    """构造配对请求,分别捕获两个 API 的 HF 渲染输入,再断言完全相等。"""
    chat_req = ChatCompletionRequest(model=_MODEL, **chat_kwargs)
    responses_req = ResponsesRequest(model=_MODEL, **responses_kwargs)
    chat = await _capture_chat(online_renderer, chat_req)
    responses = await _capture_responses(serving, responses_req)
​
    # 消息本身必须一致,工具转换的差异会在这里直接暴露。
    assert chat.messages == responses.messages
​
    # 模板相关字段逐一对比:template、content format、媒体参数、
    # template kwargs(含 tools),全部相等才算渲染一致。
    chat_params = chat.chat_params
    responses_params = responses.chat_params
    assert chat_params.chat_template == responses_params.chat_template
    assert (
        chat_params.chat_template_content_format
        == responses_params.chat_template_content_format
    )
    assert chat_params.media_io_kwargs == responses_params.media_io_kwargs
    assert chat_params.mm_processor_kwargs == responses_params.mm_processor_kwargs
    assert dict(chat_params.chat_template_kwargs) == dict(
        responses_params.chat_template_kwargs
    )
vllm/entrypoints/openai/responses/utils.py core-logic

核心修复所在:convert_tool_responses_to_completions_format 改为构造 ChatCompletionToolsParam,construct_tool_dicts 增加 exclude_tools_when_tool_choice_none 参数,直接决定工具 schema 如何进入渲染管线。

def convert_tool_responses_to_completions_format(
    tool: dict,
) -> ChatCompletionToolsParam:
    """
    将 Responses API 的扁平工具 schema:
        {"type": "function", "name": "...", "description": "...", "parameters": {...}}
    转换为 Chat Completions 工具参数,供 chat-template 渲染使用。    关键点:直接构造 ChatCompletionToolsParam 而不是返回裸 dict,
    从而复用 Chat Completions 侧的字段校验与序列化逻辑,
    保证两种 API 渲染时工具 schema 完全一致。
    """
    return ChatCompletionToolsParam(
        type="function",
        # 丢弃 `type` 键,其余字段交给 FunctionDefinition 严格校验,
        # 未知的 extra 字段会被 pydantic 白名单序列化过滤,
        # 与 Chat Completions 的渲染行为保持一致。
        function=FunctionDefinition.model_validate(
            {k: v for k, v in tool.items() if k != "type"}
        ),
    )
​
​
def construct_tool_dicts(
    tools: list[Tool],
    tool_choice: ToolChoice,
    exclude_tools_when_tool_choice_none: bool = False,
) -> list[dict[str, Any]] | None:
    # tool_choice="none" 时是否仍把工具注入 prompt 取决于服务端参数,
    # 与 Chat Completions 的 --exclude-tools-when-tool-choice-none 对齐。
    if not tools or (tool_choice == "none" and exclude_tools_when_tool_choice_none):
        return None
    return [
        convert_tool_responses_to_completions_format(tool).model_dump()
        for tool in iter_response_function_tool_dicts(tools)
    ]

评论区精华

非 function 工具注入回归与 MCP 工具缺口 正确性

iter_response_function_tool_dicts 从无条件 else 改为 elif isinstance(tool, FunctionTool) 后,code_interpreter 等 MCP 工具不再注入;test_parsable_context.py::test_mcp_tool_call 断言 mcp_call 出现在输出中而失败。yzong-rh 说明旧路径只是注入无 name/parameters 的非法 function tool,ParsableContext 路径从未真正支持 MCP 工具渲染,提出 xfail 或保留旧行为两个方案。

结论:采纳 xfail 方案:对 test_mcp_tool_call 加 xfail 并在 reason 中注明 MCP 工具名与参数未被提取,留待 ParsableContext 完善后再清理。 · 已解决

渲染一致性测试作为回归护栏 测试

bbrowning 在 approve 评论中评价测试套件是 a great addition to catch these types of issues and prevent regressions,并在本地跑了 tool_parsers、responses 等一组相关单测确认无回归。

结论:无条件合入,无未解决疑虑。 · 已解决

风险与影响

1) 行为变更风险:FunctionTool 与 ChatCompletionToolsParam 的 extra 字段现在会被 pydantic 静默丢弃(engine/protocol.py 与 chat_completion/protocol.py 的序列化器新增 model_fields 白名单过滤),依赖自定义扩展字段的工具方可能收到与之前不同的渲染结果,且是静默丢弃而非报错。
2) tool_choice='none' 语义变化:Responses API 默认将全部工具注入 prompt,prompt 变长会推高 token 消耗与 TTFT,依赖 --exclude-tools-when-tool-choice-none 才能恢复旧行为。
3) MCP 工具缺口只是被 xfail 掩蔽:ParsableContext 路径下 MCP 工具仍无法在 prompt 中渲染,真实场景会静默缺少 mcp_call 输出,存在回归风险。
4) 测试机制局限:RenderCapture 拦截的是 render_chat_async 边界,真实 HF 模板渲染、TokenizeParams、截断等不在覆盖范围,parity 测试全绿不代表端到端输出逐字节一致。

影响范围集中在 OpenAI 前端 API(Responses 与 Chat Completions 的工具渲染路径),不涉及推理内核与调度器。对用户:Responses API 的 tool_choice='none' 默认行为变化、extra 字段被忽略,属于可感知的行为变更;对团队:新增的 parity 测试为两个 API 的后续演进提供回归护栏,避免渲染再次分叉;对 API 实现者:FunctionDefinition 序列化现在按 model_fields 白名单输出,限制未知字段传导到模板,工具 schema 传递路径被显著收紧。

行为变更:extra 字段静默丢弃 行为变更:tool_choice=none 默认注入工具 MCP 工具缺口仅 xfail 掩蔽 测试覆盖渲染边界而非端到端输出

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论