如果 Codex 经 Responses API 兼容接口回传工具结果时出现:

No tool call found for function call output with call_id ...

这条 400 能直接说明的是:当前处理 function_call_output 的系统,无法在本次请求关联的上下文中找到对应 call_id。工程排查应先核对 ID 和状态续接,再检查兼容层转换、并发与重试;多上游切换只是后续需要受控验证的假设。

本文提供两个相互独立的 Python 协议验证模板:一个显式重放完整 Items,一个使用 previous_response_id。它们用于验证目标端点的工具调用链,不是在复刻 Codex 内部实现,也不是无需适配即可投入生产的 Agent。

先分清四种 ID

标识 来源 用途
item.id 模型返回的 output Item 标识一条输出 Item
item.call_id 模型返回的 function_call 将工具结果与具体工具调用配对
response.id 一次 Responses 响应 previous_response_id 续接响应链
Conversation ID Conversation 对象 标识对应 API 中的持久会话

回传 function_call_output 时必须使用对应 function_callitem.call_id。它不是函数名、数组下标或 item.id,也不应由客户端重新生成。OpenAI 官方示例同样把 response.output 加回输入,并使用 item.call_id 回传结果,见 Function calling 指南(查阅日期:2026-08-03)。

错误出现在 Codex 终端,不代表错误一定由 Codex 或 OpenAI 原生服务生成。Codex 支持配置自定义 provider 和 Base URL,当前 provider 协议为 Responses;兼容网关是否完整实现工具和状态语义仍需实测。参见 Codex 配置参考(查阅日期:2026-08-03)。

运行代码前,先固定验证环境

不要只保存一段脚本。每次实验至少记录:

openai Python SDK:pip show openai 的实际版本,或锁文件版本
模型:目标端点明确支持 Responses function calling 的模型 ID
入口:SDK 使用的 Base URL,以及最终请求的完整 /v1/responses URL
工具能力:是否支持 function、strict、tool_choice 和连续工具调用
状态路径:显式 Items 重放,或 previous_response_id;每次只测一种
数据设置:store、ZDR 以及 reasoning encrypted content 的要求

不同兼容端点支持的字段并不相同,因此示例使用环境变量,不提供“万能模型 ID”。若目标端点不支持指定函数的 tool_choicestrict 或某种状态路径,应先记录为兼容性差异,再按对方文档建立单独基线。

协议验证与行为验证也要分开:

  • 协议验证:在端点支持时,用 tool_choice={"type": "function", "name": "get_status"} 强制首轮调用指定函数,避免把“模型没有自主选择工具”误判为协议故障。
  • 行为验证:改用 tool_choice="auto" 和自然提示,评估模型在真实任务中是否自主调用工具。

下面代码采用协议验证模式。当前 tool_choice 结构可在 Function calling 指南中复核。

公共辅助代码:只记结构,不打印业务参数

import hashlib
import json
import os
from importlib.metadata import version

from openai import OpenAI


MODEL = os.environ["TEST_MODEL_ID"]
MAX_TOOL_ROUNDS = 4

client = OpenAI(
    api_key=os.environ["OPENAI_API_KEY"],
    base_url=os.environ["OPENAI_BASE_URL"],
)

tools = [
    {
        "type": "function",
        "name": "get_status",
        "description": "Return the current status of a task.",
        "parameters": {
            "type": "object",
            "properties": {"task_id": {"type": "string"}},
            "required": ["task_id"],
            "additionalProperties": False,
        },
        "strict": True,
    }
]

forced_tool = {"type": "function", "name": "get_status"}


def short_hash(value):
    if not value:
        return None
    return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]


def log_response(label, response):
    print(
        {
            "label": label,
            "sdk_version": version("openai"),
            "response_id_hash": short_hash(response.id),
            "item_types": [item.type for item in response.output],
            "call_id_hashes": [
                short_hash(item.call_id)
                for item in response.output
                if item.type == "function_call"
            ],
        }
    )


def create_response(label, **request):
    try:
        return client.responses.create(**request)
    except Exception as exc:
        # 不直接打印异常正文,避免兼容端点把请求片段带入错误信息。
        print({"label": label, "error": "api_request_failed", "type": type(exc).__name__})
        raise


def run_tool(name, arguments):
    if name != "get_status":
        raise ValueError(f"unknown tool: {name}")
    return {"task_id": arguments["task_id"], "status": "running"}


def build_tool_outputs(response):
    outputs = []
    for item in response.output:
        if item.type != "function_call":
            continue

        try:
            arguments = json.loads(item.arguments)
        except json.JSONDecodeError as exc:
            print(
                {
                    "error": "invalid_tool_arguments_json",
                    "response_id_hash": short_hash(response.id),
                    "item_type": item.type,
                    "tool_name": item.name,
                    "call_id_hash": short_hash(item.call_id),
                }
            )
            raise RuntimeError("工具参数不是合法 JSON") from exc

        try:
            result = run_tool(item.name, arguments)
        except Exception as exc:
            # 工具失败是应用状态,不要复用旧 call_id 重建响应链。
            result = {
                "ok": False,
                "error_type": type(exc).__name__,
                "message": "tool execution failed",
            }

        outputs.append(
            {
                "type": "function_call_output",
                "call_id": item.call_id,
                "output": json.dumps(result, ensure_ascii=False),
            }
        )
    return outputs

日志只保留 SDK 版本、Item 类型序列以及 response/call ID 的短哈希,不输出原始 ID、工具参数或工具结果。真实项目还应通过环境变量或密钥管理系统提供凭证,不要把 API Key 写进代码。

示例把受控工具异常作为结果回传,让模型有机会解释失败。若错误属于权限、数据完整性或不可安全恢复的异常,也可以明确终止链路;关键是不要拿旧 call_id 重新绑定一条新响应链。生产代码还应细分超时、业务错误和系统错误,并避免向模型泄露内部堆栈。

路径一:显式重放完整 Items

这一方式由应用维护 history。每轮都把响应中的全部 output Items 加入历史,再追加本轮工具结果:

def verify_with_explicit_replay():
    history = [
        {"role": "user", "content": "查询任务 demo-001 的状态"}
    ]

    for round_index in range(MAX_TOOL_ROUNDS):
        response = create_response(
            f"explicit_round_{round_index + 1}",
            model=MODEL,
            instructions="根据工具结果回答;需要时可以继续调用工具。",
            tools=tools,
            tool_choice=forced_tool if round_index == 0 else "auto",
            input=history,
        )
        log_response(f"explicit_round_{round_index + 1}", response)

        tool_outputs = build_tool_outputs(response)
        if not tool_outputs:
            if round_index == 0:
                raise RuntimeError("首轮未产生 function_call,协议验证无效")
            if not response.output_text:
                raise RuntimeError("工具链结束,但没有最终文本")
            return response.output_text

        # 必须保留完整 output,而不是只复制文本或 function_call。
        history.extend(response.output)
        history.extend(tool_outputs)

    raise RuntimeError("超过最大工具轮数,主动终止")


# 单独运行本路径时再取消下一行注释:
# print(verify_with_explicit_replay())

这里不能只挑出 function_call。对于推理模型,首轮还可能包含下一轮需要的 reasoning Items。OpenAI 的 Conversation state 指南给出了保留完整输出的状态管理方式(查阅日期:2026-08-03)。

如果使用 store: false、ZDR 或其他无状态条件,还要按目标接口核对是否应通过 include=["reasoning.encrypted_content"] 获取并回传加密推理内容。OpenAI 的 Responses API Create 参考说明,该字段支持无状态多轮中的 reasoning Items(查阅日期:2026-08-03)。本文模板没有开启这类设置,不声称覆盖 ZDR,也不能假设兼容网关支持同样行为。

路径二:用 previous_response_id 续接

如果目标端点明确支持服务端响应链,可用一个完全独立的函数验证。该函数不复用上一节的 history

def verify_with_previous_response_id():
    previous_id = None
    pending_input = [
        {"role": "user", "content": "查询任务 demo-001 的状态"}
    ]

    for round_index in range(MAX_TOOL_ROUNDS):
        request = {
            "model": MODEL,
            "instructions": "根据工具结果回答;需要时可以继续调用工具。",
            "tools": tools,
            "tool_choice": forced_tool if round_index == 0 else "auto",
            "input": pending_input,
        }
        if previous_id is not None:
            request["previous_response_id"] = previous_id

        response = create_response(
            f"previous_id_round_{round_index + 1}", **request
        )
        log_response(f"previous_id_round_{round_index + 1}", response)

        tool_outputs = build_tool_outputs(response)
        if not tool_outputs:
            if round_index == 0:
                raise RuntimeError("首轮未产生 function_call,协议验证无效")
            if not response.output_text:
                raise RuntimeError("工具链结束,但没有最终文本")
            return response.output_text

        previous_id = response.id
        pending_input = tool_outputs

    raise RuntimeError("超过最大工具轮数,主动终止")


# 单独运行本路径时再取消下一行注释:
# print(verify_with_previous_response_id())

两段代码的状态来源不同:显式重放由应用携带完整 Items;previous_response_id 引用服务端保存或可恢复的响应链。本教程为隔离变量,建议每次只运行其中一个函数。生产实现除非有明确协议依据和端到端测试,不要在引用 previous_response_id 的同时重复提交同一份完整历史,以免引入重复上下文;这是一项实现建议,不应包装成所有组合都被 API 绝对禁止。

当前明确的接口互斥是:previous_response_id 不能与 conversation 同时使用。具体字段见 Responses API Create 参考。此外,上一响应的 instructions 不会仅因引用 previous_response_id 自动继承,所以模板在每轮都显式传入指令。

怎样让兼容端点验证可复现?

如果条件和权限允许,建立两个基线:

  1. 在官方原生端点运行同一最小脚本;
  2. 只替换 Base URL、凭证和目标端点支持的模型,在兼容端点运行。

两边必须记录 SDK 版本、完整 endpoint、模型 ID、状态路径和 Item 类型序列。原生端点通过而兼容端点失败,只能把问题范围缩小到两者差异,不能自动证明是哪一个转换字段出错;还需对照兼容网关入口与出口的脱敏结构。

如果无法使用原生端点,至少保存兼容端点每轮的:

  • response ID 与前序 response ID 的受控哈希关系;
  • function_call -> function_call_output -> message 等 Item 类型序列;
  • 每个 call ID 的受控哈希和对应工具名;
  • 精确入口、模型、SDK/网关版本、时间与重试序号;
  • 转换前后字段是否从 Responses call_id 变成其他协议的工具调用 ID。

仍然报 400:按故障矩阵排查

位置 典型问题 验证动作
ID 配对 item.id、旧链 ID 或自建 ID 当成 call_id 按短哈希建立 function call 与 output 一一对应关系
参数解析 item.arguments 不是合法 JSON 单独捕获 JSONDecodeError,只记结构元数据
状态续接 漏传必要 Items,或 previous_response_id 指错链 分别运行两个独立模板,不共享可变输入
协议转换 call_id 与另一协议的工具 ID 映射错误,或丢 reasoning Item 对照网关入口、出口的脱敏 Item 序列
并发 工具完成顺序与返回顺序不同,结果按数组下标配对 每个任务携带自己的 call_id,以 ID 为键汇总
自动重试 新响应链收到旧链工具结果 记录重试序号和 response 关系,隔离后逐项恢复
工具执行 超时或业务失败被误当成协议失败 回传受控错误结果,或按策略明确终止
多上游 网关或上游状态无法跨节点恢复 前述项目通过后,再做固定与受控切换实验

本文不再展开“如何证明多上游是根因”的完整判断框架。工程上应记住:固定上游成功、切换上游失败只能增强假设;还要定位究竟是网关映射、上游 Response ID 作用域,还是转换链丢项。无法控制路由时,把结论保留为待验证,不要在生产流量上强制切换。

最终验收不能只看 HTTP 200

  • SDK、模型、Base URL、完整 endpoint 和状态路径已记录;
  • 协议测试使用目标端点支持的确定性 tool_choice
  • 每个 function_call_output.call_id 均来自对应 function_call
  • 参数 JSON 失败、工具失败和 API 失败能够分开识别;
  • 显式重放与 previous_response_id 在独立输入中分别验证;
  • 若使用 store: false 或 ZDR,已核对 encrypted reasoning 支持;
  • 网关转换前后的 Item 类型和 ID 关系能够对应;
  • 并发、重试和连续工具调用均在最大轮数保护下回归;
  • 模型最终消费工具结果并生成有效回答,而不只是第二次请求返回 200;
  • 日志、截图和公开文章不含真实凭证、完整 ID、业务参数或工具输出。

看到 No tool call found ... call_id,先把脚本变成可重复的协议实验:固定版本和端点,强制首轮工具调用,分别验证两种状态路径,再对照网关转换、并发与重试。只有完成这些步骤后,才有条件讨论路由架构;否则修改负载均衡只是把一个可验证的配对问题换成新的猜测。

更多推荐