如果你的项目最近突然抛出unexpected endpoint or method (POST /chat/completions)这类报错,先别急着怀疑代码写错了。大概率你是在不知不觉间,从 Completions 那一代摸到了 Responses 这一代。OpenAI 的接口规范正在经历一次明显但很多人还没反应过来的演进:从早期的文本补全,到 Chat Completions 时代,再到以 Agent 场景为核心的 Responses API。而“开源兼容”这四个字,在过渡期里往往不是开箱即用,而是需要自己动手缝补的真相。
这篇文章我想把这条演进线完整梳理一遍:为什么要从 Completions 迁到 Responses,两个规范在请求、响应、多轮、工具调用上到底差在哪里,以及最容易被忽略的——开源项目和新版 SDK 之间存在哪些兼容断层,遇到unexpected endpoint、Codex 安装失败这类报错该怎么定位。如果你正在用 OpenAI API 做应用,或者维护开源轮子,又或者纠结要不要现在迁移,这篇应该能帮你省不少排查时间。
1. 为什么要从 Completions 迁到 Responses:一次接口规范的范式调整
1.1 Completions 时代的缝补史:从“补全”到“对话”再到“工具”
最早 OpenAI 对外提供的接口是POST /v1/completions,客户端传入prompt,模型返回一段completion,本质是文本续写。那时候没有 system、user、assistant 的角色概念,你给一段话,它往后接一段话,简单粗暴。后来 ChatGPT 火了,OpenAI 才发现“对话”才是绝大多数应用的真正形态,于是补了POST /v1/chat/completions,引入了messages数组,用 role 区分系统指令、用户输入和模型回复。
问题在于,这套体系是“缝补式”演进的。对话刚做出来时没考虑工具调用,后来 Agent 概念兴起,OpenAI 又往 Chat Completions 里塞了tools和tool_calls;结构化输出需求来了,又追加了response_format;多轮对话的上下文太大,又只能靠业务方自己裁剪历史消息。结果是:接口本身变得越来越重,但语义始终停留在“一次问答”的层面。开发者在聊天气泡之上自己搭状态机、自己维护历史、自己处理工具调用循环,接口只是被当作一个文本生成器使用。这种模式不是不能跑,只是越跑越别扭。
1.2 Responses 接口的设计动机:Agent 场景成了第一公民
Responses API(端点POST /v1/responses)的定位,从一开始就不是 Chat Completions 的简单改名,而是把“完成任务”这件事作为核心语义。它把请求入口从messages改成了input,input既可以直接传字符串,也可以传结构化的消息列表;多轮上下文用previous_response_id引用上一次响应,避免每次都重放完整 history;工具调用、推理过程、搜索结果都作为不同类型的输出事件放在output数组里,客户端可以按需消费,而不是每次都要自己解析choices[0].message.tool_calls。
我用一个例子帮你建立直观感受。Chat Completions 时代,一个带工具的多轮请求,messages 里会堆满 system 指令、用户消息、带 tool_calls 的 assistant 消息、tool 角色的函数结果,整个上下文越长越大;到了 Responses,你只需要把当前任务写在input里,通过previous_response_id让服务端记住上一轮的状态,上下文管理对业务方来说几乎透明。这就是为什么 OpenAI 要把 Codex 这类命令行编程代理直接构建在 Responses 上——因为编程代理不是“问一句答一句”,而是“给一个任务,循环调用工具、读取结果、修正计划,直到完成”,接口如果不在语义层支持这种循环,上层实现就会非常痛苦。
1.3 兼容层的真实定位:新旧世界之间的“大坝”
官方并没有立刻砍掉 Chat Completions,新旧两个端点会并行存在很长一段时间。但这不代表它们等价。新版 SDK 里,client.chat.completions.create()和client.responses.create()走的是两套完全不同的请求构造、响应解析和事件流格式。很多开源中转项目、网关、SDK 适配层在这段时间做的,本质上是在中间加了一个“协议翻译层”——接收 Responses 格式的请求,内部转换成 Chat Completions 发给上游,或者反过来。
这个翻译层就是“开源兼容的真相”:它能把 80% 的常规请求翻译过去,但流式事件、推理级别、结构化输出、多轮引用这类细节,在不同实现里丢失程度完全不同。所以你会看到,同一个应用用官方端点正常,切到某些开源网关就报unexpected endpoint or method,这不是模型的问题,而是兼容层只做了一半。理解这层设计,是判断“要不要迁移、迁移后有没有坑”的前提。
2. 接口规范深度对比:请求体、响应体与关键参数的差异
2.1 端点与核心请求字段:从 messages 到 input 的语义切换
先看请求层面的对比。我用一张表格把高频差异列出来,后面逐项解释。
| 维度 | /v1/chat/completions | /v1/responses |
|---|---|---|
| 请求入口 | messages 数组 | input,可传字符串或消息数组 |
| 系统指令 | messages 中 role=system 的消息 | 独立字段 instructions |
| 多轮上下文 | 重放完整 messages 历史 | previous_response_id 引用上一次响应 |
| 结构化输出 | response_format 参数 | text.format 参数 |
| 推理过程控制 | 无统一参数 | reasoning.effort(low/medium/high) |
| 工具调用 | tools + tool_choice | tools + tool_choice,但响应结构不同 |
| 流式 | stream: true,输出 chat.completion.chunk | stream: true,输出语义化事件序列 |
最核心的变化是input替代了messages。过去你必须把 system、user、assistant 的历史消息全部拼好再发出去,现在大多数场景下直接给一句任务描述就行。如果确实需要多轮结构化信息,input依然接受消息数组,字段格式和 Chat Completions 的 messages 接近,但系统指令建议放到instructions里。这个拆分的意图很明确:指令是稳定不变的运行配置,输入是每次实际要处理的任务,两者不该混在一条历史消息流里。
2.2 响应结构变化:从 choices[0].message 到 output 数组
响应差异是迁移中体感最明显的地方。Chat Completions 的响应里,核心内容是choices[0].message,content是文本,tool_calls是可选数组;如果启用了流式,你要处理逐帧 chunk。Responses 的响应则是output数组,数组里的每个元素都带type字段,有的是message(最终文本回复),有的是function_call(模型决定调用哪个工具),有的是reasoning(推理过程摘要),还有搜索类调用之类的事件类型。
看一段最简对比。旧写法取文本:
text = resp.choices[0].message.content新写法取文本:
text = resp.output_textoutput_text是 SDK 提供的便捷属性,自动把 output 数组中所有 message 类型的文本内容拼接起来,90% 的应用用这一个属性就够了。真正需要遍历 output 的场景是工具调用和流式处理,这时候你要判断事件type,分别处理。Chat Completions 时代,工具调用结果埋在 message 的一个嵌套结构里;Responses 时代,function_call 是顶层事件,读取路径反而变短了。
2.3 工具调用与多轮会话:把复杂状态塞进一次请求的代价
工具调用的语义变化值得单独说。Chat Completions 的循环是这样的:请求 -> 模型返回 assistant 消息带 tool_calls -> 开发者把每条 tool_call 的执行结果拼成 role=tool 的消息 -> 塞进 messages 重新请求 -> 直到模型不再返回工具调用。所有状态都在 messages 里,所有历史都由业务方背着。
Responses 的模式是:请求 -> 响应里出现 function_call 事件 -> 开发者执行对应工具 -> 把工具结果作为新的 input 再次请求。看起来差别不大,但这个“把结果传回去”的动作不再需要重放全部历史,可以配合previous_response_id轻量化上下文。而且一次响应里可以包含多个不同的 output 事件,比如先有推理,再有工具调用,再有最终回复,开发者按顺序消费即可。
这里必须澄清一个容易误解的点:Responses API 不是 Agent 运行时,它不会替你去执行工具。它只是用一种更清晰的方式告诉你“该调用什么”“上下文应该怎么衔接”,真正的工具执行、循环控制、终止条件依然要自己写。它解决的问题是语义混乱,不是替你搭 Agent。
3. 开源兼容的真相:为什么很多开源项目还在报错
3.1 兼容的三种层次:官方 SDK、网关/中转、业务框架
聊开源兼容,要先分清三个层面,因为每一层的兼容状态完全不同。
第一层是官方 SDK。openaiPython 包从 1.60+ 开始原生支持client.responses.create(),你只要升级包版本就能用。这套 SDK 会同时暴露chat.completions和responses两套入口,不会互相干扰。第二层是网关/中转类项目,比如 LiteLLM、new-api、one-api 这类。它们为了兼容不同的模型提供商,通常会把请求规范的差异在自己这一层抹平。但抹平是需要逐个端点适配的,不少开源网关在 2025 年上半年仍然只做透了/v1/chat/completions,/v1/responses要么没有实现,要么只是简单入口,后续逻辑没跟全。第三层是业务框架,比如 LangChain、LlamaIndex,它们在各自版本里逐步增加了对 Responses 的支持,但如果你用的是老版本,调用responses就会触发各种异常。
所以你在社区里看到“开源兼容”的讨论,要习惯先问一句:兼容的是哪一层?是官方 SDK 认这个端点,还是中间网关会做协议翻译,还是业务框架已经适配了响应结构?很多报错恰恰是因为这三层之间版本错位。
3.2 “unexpected endpoint or method” 到底是谁的锅
回到文章开头那个报错:unexpected endpoint or method (POST /chat/completions)。这种情况十有八九不是 OpenAI 官方返回的,而是网关层的自定义错误信息。它出现的原因通常是:你的 SDK 用 Chat Completions 格式请求,但自建网关版本比较老,或者网关把 /chat/completions 这个端点归类为“未支持端点”直接拒了。还有一种变体是请求打到/v1/responses,网关还没有实现这个端点,返回 404,或者同样给一句unexpected endpoint or method。
排查思路很简单,按顺序来。第一步,确认你的 SDK 到底调的是哪个端点,在代码里打印client.base_url和实际请求日志,或者用抓包工具看一眼;第二步,确认这个 base_url 指向谁,如果是自建网关,就去网关日志里找对应请求的到达记录和路由结果;第三步,直接用 curl 分别打/v1/chat/completions和/v1/responses,看哪个端点通、哪个不通。大多数情况下,问题都能定位到“网关版本没跟上”或“base_url 拼写错误导致 URL 不是 /v1 路径”。别一上来就觉得是模型出问题了,分清 SDK 层、网关层、模型层,能省掉大把时间。
3.3 开源项目适配 Responses 的典型改法:协议翻译层怎么补
如果你维护网关类项目,想快速支持 Responses,最常见的方案不是从零实现,而是做协议翻译:把进来的/v1/responses请求转换成/v1/chat/completions请求转发给上游模型供应商。具体要做三件事。第一,把input里的字符串包装成[{"role": "user", "content": "..."}]格式,instructions映射成 system 消息;第二,把上游 Chat Completions 的choices[0].message转换成 Responses 的output数组结构,其中纯文本转成type=message的事件,tool_calls转成type=function_call的事件;第三,把流式 chunk 重新映射成 Responses 风格的流事件。
这套转换在常规场景下能跑通,但有两个容易翻车的点。一是previous_response_id,网关如果没实现多轮状态存储,这个字段就只能透传或者干瞪眼;二是 reasoning 事件,Chat Completions 在很多模型上不返回推理内容,转换层就没有数据可映射,下游如果依赖type=reasoning就会拿到空结果。所以我的建议是:如果网关只是为了兼容最普通的对话请求,翻译层足够;如果应用重度依赖工具调用、推理过程和多轮状态,最好还是直接让网关支持原生 Responses,不要走翻译层,否则后续排查成本很高。
4. 迁移实操:从 Chat Completions 到 Responses 的落地步骤
4.1 迁移前的判断标准:你的场景适合立刻迁吗
不是所有项目都该马上迁。我的判断标准分成四类。
如果你的场景是简单问答、单轮文本生成、聊天机器人界面,Chat Completions 完全够用,没必要为了追新而迁移,官方也不会短期内下线它。如果你的场景是多轮对话但历史不长,迁移收益也不大,因为 Responses 的previous_response_id优势在你这里体现不出来。如果你的场景是工具调用密集、多步推理、需要长时间上下文保持,比如编程代理、数据分析助手、客服工单处理,那迁移收益非常明显,建议尽早开始。最后一种情况要特别提一下:如果你依赖自建网关,先确认网关是否支持/v1/responses透传或翻译;网关不支持,本地代码改成 Responses 只会更难受。
同时要做一次 SDK 版本盘点。官方 Python 包最好升到 1.60 以上,Node.js 对应的openai包同理。如果业务框架里封装了自己的chat()方法,先看清楚底层调的是哪个入口,别换了一个入口返回值格式变了,业务代码跟着崩。
4.2 最小迁移示例:用 Python SDK 改写一个工具调用对话
一个最小迁移示例胜过千言万语。先看 Chat Completions 版本:
from openai import OpenAI client = OpenAI() tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的当前天气", "parameters": { "type": "object", "properties": { "city": {"type": "string"} }, "required": ["city"] } } } ] messages = [ {"role": "system", "content": "你是一个天气助手。"}, {"role": "user", "content": "北京今天天气怎么样?"} ] resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools, ) msg = resp.choices[0].message if msg.tool_calls: # 执行工具后,把 tool 结果追加进 messages,循环请求 for tc in msg.tool_calls: messages.append({"role": "assistant", "tool_calls": msg.tool_calls}) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": execute_tool(tc.function.name, tc.function.arguments), }) resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools, ) print(resp.choices[0].message.content)换成 Responses 版本,结构是这样的:
from openai import OpenAI client = OpenAI() tools = [ { "type": "function", "name": "get_weather", "description": "查询指定城市的当前天气", "parameters": { "type": "object", "properties": { "city": {"type": "string"} }, "required": ["city"] }, } ] resp = client.responses.create( model="gpt-4o-mini", instructions="你是一个天气助手。", input="北京今天天气怎么样?", tools=tools, ) for item in resp.output: if item.type == "function_call": result = execute_tool(item.name, item.arguments) resp = client.responses.create( model="gpt-4o-mini", instructions="你是一个天气助手。", input=[ {"role": "user", "content": "北京今天天气怎么样?"}, {"role": "assistant", "content": "", "tool_calls": [{"id": item.call_id, "type": "function", "name": item.name, "arguments": item.arguments}]}, {"role": "tool", "tool_call_id": item.call_id, "content": result}, ], ) print(resp.output_text)两个版本的差异很明显。旧版要靠开发者手动维护 messages 数组不断追加;新版的核心逻辑更直白,工具调用作为顶层事件出现,最后用output_text拿最终文本。注意工具定义里,新版把function包裹层去掉了,name、description、parameters直接平铺在工具对象里,这个细节容易踩坑。
4.3 流式输出、结构化输出与 Key 安全:迁移中的三个高频细节
迁移中三个高频细节,每一个都值得单独讲。
先说流式输出。Responses 的流式事件不再是逐帧choices[0].delta.content,而是一系列语义化事件,比如response.output_text.delta携带增量文本,response.completed表示整个响应结束,response.function_call_arguments.delta携带工具参数的增量。如果你之前的前端是基于 SSE chunk 渲染的,迁移后需要改事件解析逻辑:
stream = client.responses.create( model="gpt-4o-mini", input="讲个段子", stream=True, ) for event in stream: if event.type == "response.output_text.delta": print(event.delta, end="")再说结构化输出。Chat Completions 里你用response_format={"type": "json_object"},Responses 里换成了text参数:
resp = client.responses.create( model="gpt-4o-mini", input="返回一个 JSON,包含 name 和 age 字段", text={"format": {"type": "json_schema", "name": "user_info", "schema": {...}}}, )json_schema模式比json_object更严格,能少很多字段缺失的坑,但 schema 里每个字段都要写清楚,不然容易把自己绕进去。
最后说 Key 安全。热词里有人提到“openai api key 分享”,这里我明确说:永远不要分享 API Key,无论是发在 GitHub 还是发到群里。泄露一个 key 的代价不只是盗刷额度,还可能连累账号风控。正确做法是用环境变量加载,不要在代码里写死;给子账号分配最小权限;发现泄露立刻在后台吊销并轮换。
4.4 Codex CLI 安装与登录避坑:从 “missing optional dependency” 说起
Responses API 背后一个很重要的落地产品,就是 OpenAI 官方的命令行编程代理 Codex。安装时很多 Windows 用户会遇到missing optional dependency @openai/codex-win32-x64这个报错,提示重装:npm i -g @openai/codex。这个报错的本质是 npm 安装过程中平台相关的可选依赖没有被正确拉下来,原因通常有三种:npm 版本太老、缓存脏数据、网络中断导致平台包没下全。
处理办法按顺序尝试。先把 npm 升级到最新:npm install -g npm@latest,然后清缓存npm cache clean --force,最后重装npm i -g @openai/codex。如果还是失败,可以把node_modules里的@openai/codex-win32-x64目录手动删掉再触发重装。安装成功后,运行codex会看到welcome to codex, openai's command-line coding agent. sign in with chatgpt to continue之类的提示,说明需要登录。Codex 支持 ChatGPT 账号登录,也支持 API Key 模式,具体根据自己的账号类型选。
5. 常见问题与排查技巧实录
5.1 常见报错速查表
把文章里提到的、社区里高频出现的报错整理成表,方便排查时直接对号入座。
| 报错信息 | 常见原因 | 解决思路 |
|---|---|---|
unexpected endpoint or method (POST /chat/completions) | 自建网关版本不支持该端点,或 base_url 配置错误 | 确认 base_url 指向,检查网关日志,升级网关或换端点 |
missing optional dependency @openai/codex-win32-x64 | npm 平台可选依赖安装失败 | 升级 npm、清缓存、重装 codex |
Invalid API key/Authorization header missing | key 错误、环境变量未加载、网关透传丢弃了请求头 | 检查环境变量和 base_url,确认网关是否透传 Authorization 头 |
404 /v1/responses not found | 网关或兼容层不支持 Responses 端点 | 更换网关版本,或改用官方端点,或降级到 Chat Completions |
| 迁移后流式输出乱码或空内容 | 前端还在解析旧 chunk 格式,没有适配新事件类型 | 改成消费response.output_text.delta事件 |
5.2 排错方法论:先分清“SDK 层、网关层、模型层”
遇到问题别慌,先做三层定位。这是我在排接口问题时的固定套路。
第一层,SDK 层。确认当前openai包版本,确认调用的入口是chat.completions.create还是responses.create,确认base_url是官方地址还是自建网关地址。可以在代码里临时打印请求参数,或者在环境变量里开启 debug 日志。
第二层,网关层。如果你走的是自建网关,直接看网关的访问日志。重点关注请求是否到达、路由匹配到了哪个上游、上游返回的状态码是什么。很多网关的错误信息是自定义的,unexpected endpoint or method这种话一看就是网关在拦截,不是模型返回。
第三层,模型层。跳过所有中间层,直接用 curl 打官方端点验证模型本身是否正常。比如:
curl https://api.openai.com/v1/responses \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o-mini","input":"ping"}'如果官方端点正常,那问题基本锁定在网关兼容层;如果官方端点也报错,再回头看 key、账号和模型参数。按这个顺序排查,绝大多数问题十分钟内能找到根源。
5.3 我在实际迁移中踩过的几个坑
最后分享几个我自己的经验,可能比文档更实用。
第一个坑是协议翻译层的信息丢失。我一开始图省事,把responses请求统一走到网关的翻译层转成chat/completions,常规文本对话没问题,但一旦应用依赖reasoning事件,翻译层返回的结果里就没有这一块,下游逻辑拿不到推理内容,表现就是“模型偶尔抽风”。后来我看了原始请求日志才发现,不是模型问题,是翻译层没做事件映射。所以关键结论是:功能越复杂的应用,越要直接走原生 Responses 端点,不要依赖翻译。
第二个坑是 SDK 混用。项目里有老模块用chat.completions,新模块用responses,两者返回结构完全不同,如果业务代码里共用了一个统一的parse_response()函数,很容易把output_text当成choices[0].message.content来取,结果取到None还不报错。我的习惯是在工具函数层做隔离,分别封装parse_chat_response()和parse_response_response(),免得混用。
第三个坑是 API Key 的安全分散管理。以前总习惯在多个环境文件里各放一个 key,后来发现只要有一个环境文件被提交到 Git,泄露面就很大。现在我的做法是:所有 key 集中放到密钥管理服务里,应用运行时读取环境变量,本地开发用.env并且加入.gitignore。一旦发生泄露,只轮换那一把 key,而不是手忙脚乱地到处改。
第四个坑是关于迁移节奏。不要在生产环境里一把梭地把所有请求从 Chat Completions 切到 Responses。稳妥的做法是先跑 shadow 模式:新旧两套请求同时发,新链路只记录结果不接入业务,对比几天,观察流式、工具调用、结构化输出是否有差异,确认稳定后再切流量。接口规范的迁移从来不是换一个 URL 那么简单,参数语义、事件结构、错误处理都要跟着变,留出足够的验证时间,比赶版本更值得。
从我的角度看,OpenAI 推 Responses API 这件事,本质上是在把接口语义统一到一个更适合 Agent 的范式上——上下文状态、工具调用、事件流都被重排了一遍。Chat Completions 不会立刻消失,但新项目、重工具项目早一点迁过去,后面能少背很多历史包袱。这篇文章如果能把演进逻辑、差异对照和排查路径讲清楚,让你在切换的时候心里有数,就够了。