Codex 工具调用报 400,问题到底出在哪?
如果 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 指南中复核。
公共辅助代码:只记结构,不打印业务参数
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 自动继承,所以模板在每轮都显式传入指令。
怎样让兼容端点验证可复现?
如果条件和权限允许,建立两个基线:
- 在官方原生端点运行同一最小脚本;
- 只替换 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,先把脚本变成可重复的协议实验:固定版本和端点,强制首轮工具调用,分别验证两种状态路径,再对照网关转换、并发与重试。只有完成这些步骤后,才有条件讨论路由架构;否则修改负载均衡只是把一个可验证的配对问题换成新的猜测。
更多推荐



所有评论(0)