如果 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_call的item.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_choice、strict或某种状态路径,应先记录为兼容性差异,再按对方文档建立单独基线。
协议验证与行为验证也要分开:
- 协议验证:在端点支持时,用
tool_choice={"type": "function", "name": "get_status"}强制首轮调用指定函数,避免把“模型没有自主选择工具”误判为协议故障。 - 行为验证:改用
tool_choice="auto"和自然提示,评估模型在真实任务中是否自主调用工具。
下面代码采用协议验证模式。当前tool_choice结构可在 Function calling 指南中复核。
公共辅助代码:只记结构,不打印业务参数
importhashlibimportjsonimportosfromimportlib.metadataimportversionfromopenaiimportOpenAI MODEL=os.environ["TEST_MODEL_ID"]MAX_TOOL_ROUNDS=4client=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"}defshort_hash(value):ifnotvalue:returnNonereturnhashlib.sha256(value.encode("utf-8")).hexdigest()[:12]deflog_response(label,response):print({"label":label,"sdk_version":version("openai"),"response_id_hash":short_hash(response.id),"item_types":[item.typeforiteminresponse.output],"call_id_hashes":[short_hash(item.call_id)foriteminresponse.outputifitem.type=="function_call"],})defcreate_response(label,**request):try:returnclient.responses.create(**request)exceptExceptionasexc:# 不直接打印异常正文,避免兼容端点把请求片段带入错误信息。print({"label":label,"error":"api_request_failed","type":type(exc).__name__})raisedefrun_tool(name,arguments):ifname!="get_status":raiseValueError(f"unknown tool:{name}")return{"task_id":arguments["task_id"],"status":"running"}defbuild_tool_outputs(response):outputs=[]foriteminresponse.output:ifitem.type!="function_call":continuetry:arguments=json.loads(item.arguments)exceptjson.JSONDecodeErrorasexc: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),})raiseRuntimeError("工具参数不是合法 JSON")fromexctry:result=run_tool(item.name,arguments)exceptExceptionasexc:# 工具失败是应用状态,不要复用旧 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),})returnoutputs日志只保留 SDK 版本、Item 类型序列以及 response/call ID 的短哈希,不输出原始 ID、工具参数或工具结果。真实项目还应通过环境变量或密钥管理系统提供凭证,不要把 API Key 写进代码。
示例把受控工具异常作为结果回传,让模型有机会解释失败。若错误属于权限、数据完整性或不可安全恢复的异常,也可以明确终止链路;关键是不要拿旧call_id重新绑定一条新响应链。生产代码还应细分超时、业务错误和系统错误,并避免向模型泄露内部堆栈。
路径一:显式重放完整 Items
这一方式由应用维护history。每轮都把响应中的全部 output Items 加入历史,再追加本轮工具结果:
defverify_with_explicit_replay():history=[{"role":"user","content":"查询任务 demo-001 的状态"}]forround_indexinrange(MAX_TOOL_ROUNDS):response=create_response(f"explicit_round_{round_index+1}",model=MODEL,instructions="根据工具结果回答;需要时可以继续调用工具。",tools=tools,tool_choice=forced_toolifround_index==0else"auto",input=history,)log_response(f"explicit_round_{round_index+1}",response)tool_outputs=build_tool_outputs(response)ifnottool_outputs:ifround_index==0:raiseRuntimeError("首轮未产生 function_call,协议验证无效")ifnotresponse.output_text:raiseRuntimeError("工具链结束,但没有最终文本")returnresponse.output_text# 必须保留完整 output,而不是只复制文本或 function_call。history.extend(response.output)history.extend(tool_outputs)raiseRuntimeError("超过最大工具轮数,主动终止")# 单独运行本路径时再取消下一行注释:# 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:
defverify_with_previous_response_id():previous_id=Nonepending_input=[{"role":"user","content":"查询任务 demo-001 的状态"}]forround_indexinrange(MAX_TOOL_ROUNDS):request={"model":MODEL,"instructions":"根据工具结果回答;需要时可以继续调用工具。","tools":tools,"tool_choice":forced_toolifround_index==0else"auto","input":pending_input,}ifprevious_idisnotNone: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)ifnottool_outputs:ifround_index==0:raiseRuntimeError("首轮未产生 function_call,协议验证无效")ifnotresponse.output_text:raiseRuntimeError("工具链结束,但没有最终文本")returnresponse.output_text previous_id=response.idpending_input=tool_outputsraiseRuntimeError("超过最大工具轮数,主动终止")# 单独运行本路径时再取消下一行注释:# 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自动继承,所以模板在每轮都显式传入指令。
怎样让兼容端点验证可复现?
如果条件和权限允许,建立两个基线:
- 在官方原生端点运行同一最小脚本;
- 只替换 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,先把脚本变成可重复的协议实验:固定版本和端点,强制首轮工具调用,分别验证两种状态路径,再对照网关转换、并发与重试。只有完成这些步骤后,才有条件讨论路由架构;否则修改负载均衡只是把一个可验证的配对问题换成新的猜测。