Codex 间歇性 400 之谜:一条对话里,它为什么有时候用 chat/completions,有时候切到 responses?
一、问题描述
我的 Codex 接了第三方模型网关(如 Agnes AI、OneAPI、OpenRouter),大部分时候能正常对话,但时不时突然报
code: 400错误,重试一次可能又好了。报错看起来像是"入参格式不对",但明明同一段对话、同一个模型,什么都没改。
如果你遇到过这个现象,你不是一个人。这篇文章就来拆解这个"间歇性 400"的真相。
二、快速结论
Codex 内部不是只用一种 API 协议,而是按功能场景动态切换。 它在两种协议之间跳跃:
| 协议 | 什么时候用 |
|---|---|
/v1/chat/completions |
普通对话、Agent 工具调用等绝大多数场景 |
/v1/responses |
调推理模型、传 thinking 参数、文件上传、状态恢复等部分高级功能 |
而第三方网关(包括你的 Agnes / OneAPI)通常只实现了 chat/completions 协议 。当 Codex 一跳到 responses 协议,网关收到不认识格式的请求,直接返回 400。
"间歇性"的原因是:你并不是每次对话都会触发这些"高级功能"。
三、为什么 Codex 不只用一套协议?
要理解这个问题,先看 Codex 的架构设计:
bash
┌─────────────────────────────────────┐
│ Codex │
│ ┌─────────────────────────────┐ │
│ │ 功能模块调度层 │ │
│ │ - 普通对话 → chat/completions │ │
│ │ - 推理模型 → responses │ │
│ │ - 文件分析 → responses │ │
│ │ - 工具调用 → chat/completions │ │
│ └──────────┬──────────────────┘ │
│ │ │
│ ┌──────────▼──────────────────┐ │
│ │ 底层 HTTP 客户端 │ │
│ │ (根据功能模块决定协议和格式) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
Codex(以及其他现代 AI 工具如 Cursor、Windsurf)需要同时支持:
- 传统模型 :通过
chat/completions协议调用 - 推理模型(o1/o3) :需要
responses协议才能开启thinking机制 - 多模态能力:文件上传、图片理解,不同协议对 content 格式要求不同
- 状态管理 :
responses协议内置了previous_response_id,Codex 可以用它做会话恢复
问题在于:Codex 没有一个统一的"协议抽象层"------它没有在底层把两种协议归一化,而是在上层功能模块里直接写死了各自用的协议。
四、协议对比:差异到底有多大?
输入格式
jsonc
// chat/completions(第三方网关通常只实现了这个)
{
"model": "claude-sonnet-4-6",
"messages": [
{ "role": "system", "content": "你是一个助手" },
{ "role": "user", "content": "你好" }
],
"tools": [...],
"stream": true,
"max_tokens": 4096
}
jsonc
// responses(Codex 在某些分支下切过去)
{
"model": "claude-sonnet-4-6",
"input": "你好",
// ^^^ 注意这层:没有 messages,是 input
// 可以传字符串或消息数组,但格式和 messages 不同
"tools": [...],
"stream": true,
"max_tokens": 4096,
"previous_response_id": "resp_xxx",
// ^^^ 服务端管理历史,不需要客户端传历史消息
"stream_options": {
"include_usage": true
}
}
看明白了吗?这两个协议从顶层的字段名到底层的数据结构都不同,不是简单的"字段改名"能解决的。
流式输出格式
| 阶段 | chat/completions | responses |
|---|---|---|
| SSE 事件名 | chat.completion.chunk |
response.output_item.added |
| 内容路径 | choices[0].delta.content |
output[0].content[0].text |
| 工具调用 | delta.tool_calls |
output[0].tool_use |
| 用量信息 | 单独一个 chunk | response.completed 事件内嵌 |
一个网关如果只实现了 chat/completions 的 SSE 解析流,碰到 responses 的流事件结构,就会直接解析失败。
五、Codex 什么时候会触发 responses 协议?(诊断表)
这是最关键的排查对照表:
| 你在 Codex 里的操作 | Codex 用的协议 | 网关只支持 chat/completions 时 |
|---|---|---|
| 普通对话、问问题 | chat/completions |
✅ 正常 |
| Agent 调工具、读写文件 | chat/completions |
✅ 正常 |
| 代码审查、编辑 | chat/completions |
✅ 正常 |
调用了 thinking / 推理模式 |
responses |
❌ 400 |
| 上传文件让 AI 分析 | responses |
❌ 400 |
| 流式输出 + 实时用量统计 | responses (stream_options) |
❌ 400 |
| 恢复上一次对话上下文 | responses (previous_response_id) |
❌ 400 |
| 调用了 o1/o3 系列模型 | responses |
❌ 400 |
| 非流式批量调用 | chat/completions |
✅ 正常 |
这就是"间歇性"的根源: 你在这次对话中,可能前 10 轮都是普通问答 → 一直用 chat/completions → 第 11 轮你上传了一张截图 → Codex 切到 responses → 报 400。
并不是网络不稳定,也不是模型挂了,而是 Codex 换了一个你的网关不认识的"方言"。
六、如何验证?
方法一:抓请求体
在 Codex 日志中搜索发送给模型的实际请求:
bash
# Codex 日志位置因版本而异,一般在
~/.codex/logs/
# 或者直接在网关侧看日志
# Agnes / OneAPI 等通常有请求日志,看收到的 body
正常请求的关键特征 :有 messages 字段 触发了 responses 的请求特征 :有 input 字段,或 previous_response_id
方法二:复现触发条件
尝试以下操作顺序,看 400 是否出现:
- ✅ 普通对话 → 正常
- ✅ 让 AI 读代码 → 正常
- ❌ 上传一张图片 → 可能 400
- ❌ 打开"推理模式" / "深度思考" → 可能 400
- ❌ 恢复一个历史会话 → 可能 400
方法三:看错误信息细节
400 返回的 body 里通常会写明哪个字段不合法:
json
{
"error": {
"message": "Unknown field: 'input'",
"type": "invalid_request_error"
}
}
看到 Unknown field: 'input' → 100% 是 Codex 切到了 responses。
七、怎么修?(分角色)
🧑 如果你是 Codex 用户(最终用户)
方案 A(推荐):强制 Codex 使用 OpenAI 兼容模式
在 Codex 配置中明确指定 provider 类型:
jsonc
// ~/.codex/config.json 或 settings.json
{
"provider": "openai-compatible",
// 不要设为 openai 或 auto
"baseUrl": "https://your-gateway.com/v1"
}
当 Codex 认到是 openai-compatible 时,它会保守地只使用 chat/completions 协议 ,不会切到 responses。
代价 :thinking、o1/o3 推理模型、previous_response_id 等功能不可用。
方案 B:在网关和 Codex 之间架一个翻译代理
写一个轻量中间件(约 100-300 行代码),把 Codex 发出的 responses 请求翻译成 chat/completions 再发给网关,反向再把响应翻译回去。
但这不是长久之计------随着
responses生态扩大,翻译层会越来越复杂。
🛠️ 如果你是网关开发者(提供第三方模型接入)
长期方案:在网关上实现 responses 协议
需要改造三个模块:
python
# 1. 新增路由
router.add("/v1/responses", handle_responses)
# 2. 输入翻译层
def responses_to_messages(body):
"""把 responses 格式转成内部统一的 messages 格式"""
if isinstance(body.get("input"), str):
msgs = [{"role": "user", "content": body["input"]}]
else:
msgs = body["input"] # 数组形式
# 处理 previous_response_id
if body.get("previous_response_id"):
history = session_store.get(body["previous_response_id"])
msgs = history + msgs
# 处理 stream_options
return {
"messages": msgs,
"stream": body.get("stream", False),
"include_usage": body.get("stream_options", {}).get("include_usage", False)
}
# 3. 输出翻译层
def messages_to_responses(model_output, response_id):
"""把模型的标准输出转成 responses 格式"""
return {
"id": response_id,
"object": "response",
"output": [{
"type": "message",
"role": "assistant",
"content": [{
"type": "output_text",
"text": model_output["content"]
}]
}],
"usage": model_output.get("usage")
}
# 4. 状态管理(新增!)
session_store = {} # response_id → message_history
这意味着你的网关从"单纯转发"变成了"有状态代理"。
八、延伸思考:为什么会有这种割裂?
历史原因
chat/completions是 GPT-3.5 时代的协议(2022 年),那时 LLM 只有"文本聊天"一种场景responses是 OpenAI 在 2025 年推出的新协议,为了统一聊天、推理、多模态、文件处理等能力- 两个协议共存不是设计好的,而是演进过程中留下的历史包袱
生态原因
Model provider(模型厂商)为什么不用自定义协议?
| 如果 DeepSeek 用自定义协议 | 如果 DeepSeek 兼容 OpenAI 格式 |
|---|---|
| 所有框架得单独适配 | 一行代码换 model 名就能用 |
| 用户接入成本高 | 生态工具天然兼容 |
| 开发者获取慢 | 门槛极低,拿来就用 |
自定义协议 ≠ 技术能力强,而是意味着你要重建整个开发者生态。 这是为什么即使各家模型架构差异很大,都优先兼容 OpenAI 格式------网络效应决定了接口标准。
工具责任
那 Codex 在这方面做得怎么样?坦白说,不够好。
理想中的 Codex 应该在底层做一个统一的协议抽象层:
markdown
Codex 功能模块
↓
┌───────────────┐
│ 协议抽象层 │ ← 在这里归一化:不管上层用哪种协议,底层都
│ (Protocol Adapter)│ 翻译成统一的内部格式,再按目标端点输出
└───────┬───────┘
↓
第三方网关 / 官方 API
但现实是 Codex 在上层各个功能模块里直接写死了协议选择,导致:
- 零散:协议选择逻辑散布在各模块中,没有中心化管控
- 不可预测:用户没法通过配置固定用一种协议
- 对第三方不友好:似乎默认了用户会用官方 API
九、总结
bash
间歇性 400 的本质
不是: 网络问题 / 模型挂了 / 网关不稳定
而是: Codex 在 chat/completions 和 responses 之间动态切换
网关不认识 responses 格式,返回 400
"间歇性"的原因:
只有特定操作(thinking、文件上传等)会触发切换
大部分时候你碰不到这些操作,所以时好时坏
给你的建议
| 你的角色 | 做什么 |
|---|---|
| 普通用户 | 在 Codex 配置里把 provider 设为 openai-compatible,牺牲高级功能换稳定 |
| 网关开发者 | 在网关上实现 responses 协议翻译层,跟上生态演进 |
| 框架使用者 | 了解这个机制后,排查问题时直接看请求 body 里是 messages 还是 input,两秒定位 |
最后说一句: 这个问题不是"谁的锅"的问题,而是 AI 工具生态快速演进带来的兼容阵痛 。chat/completions 还没死,responses 也没完全铺开,两边共存期造成的摩擦,就是我们这些踩坑的人要买单的。
希望这篇文章帮你看清了问题的全貌。下次遇到 400,你至少知道不是玄学,而是 Codex 在你看不到的地方切了个协议