最近后台至少有四五位朋友问过我同一个报错:[error] unexpected endpoint or method. (post /chat/completions). returning 2。有人明明用的是最新的 OpenAI SDK,代码却还在写client.chat.completions.create,结果网关直接甩了这么一句不明不白的话回来。更常见的情况是本地接了一个开源兼容服务,路径、SDK、base_url 三者之间互相打架,最后卡在一个小小的端点配置上。
这件事往小了说是配置问题,往大了说其实是 OpenAI 接口规范正处在一个微妙的分水岭。老牌的 Completions 已经退场,Chat Completions 统治了几乎整个开源生态,而官方又在全力推新一代 Responses API。问题是,社区并没有跟着官方一起转身,大量模型服务、代理网关、开源框架都还虔诚地盯着/chat/completions。这篇文章我就把这次接口演进的前因后果、字段差异、开源兼容的真实情况,以及我在迁移过程中踩过的坑一次讲清楚,希望对正在做模型接入的同学有帮助。
1. 接口演进的底层逻辑:从文本补全到对话,再到 Agent 执行层
1.1 初代 Completions 接口解决了什么,又留下了什么坑
OpenAI 最早的商用接口就是 Completions,路径是POST /v1/completions,请求体里核心参数只有一个prompt,模型拿到这段文本,然后续写下去。那个年代最有代表性的模型是text-davinci-003,用法也直白:
import openai openai.Completion.create( model="text-davinci-003", prompt="写一句关于上海的文案", max_tokens=100 )返回结构里嵌着一个choices数组,每个元素里有text字段,那就是续写出来的内容。这套设计在 2022 年前后是非常自然的,因为 GPT-3 系列本质上就是一个"文本续写器",你给前缀,它补后缀。
但用着用着问题就出来了。最典型的是多轮对话,如果你想实现一个聊天机器人,就必须把整个对话历史手动拼进prompt里。今天的系统提示、几轮一问一答、用户当前问题,全用换行符和特殊标记串在一起。这会导致几个实际痛点:
第一,Token 消耗非常浪费。每一轮都要把全部历史塞进去,语义没有结构化,很多 token 浪费在重复的边界标记上。第二,上下文管理全靠手写,用户说一句"刚才说的那个方案改一下",程序得自己在字符串里找"刚才"指代什么,非常痛苦。第三,模型的角色区分只能靠文字暗示,比如在 prompt 里写"你是客服"和"用户说",一旦历史长了模型很容易跟丢角色。
所以 Completions 接口本质上是为"续写"设计的,不是为"对话"设计的。当 ChatGPT 这样的产品引爆市场后,这套以字符串拼接为核心的接口已经撑不起更复杂的交互范式。OpenAI 自己也清楚,与其让开发者继续在字符串里打滚,不如把对话结构内置到接口里,于是 Chat Completions 来了。
1.2 Chat Completions 如何成为事实标准
Chat Completions 的路径是POST /v1/chat/completions,核心是把一段文本prompt换成了结构化数组messages。每个消息带role和content,角色分为system、user、assistant,规则也清晰:system 负责设定人设和边界,user 是用户输入,assistant 是模型回复。
我第一次从 Completions 迁到 Chat Completions 的时候,最大的感受不是参数变了,而是"语义终于对上号了"。多轮对话不再需要手工拼字符串,代码里维护一个数组,对话结束就把新消息 push 进去,然后整体发过去。这套设计直接降低了接入门槛,也让函数调用(Function Calling)有了安放的位置:模型可以输出结构化的工具调用指令,而不是在文本里瞎猜。
后来开源社区大规模跟进,几乎所有的本地推理框架、模型网关、SDK 都默认兼容/v1/chat/completions。OpenAI 官方文档更新了一版又一版,Chat Completions 依然是全世界接入语言模型最常见的姿势。这很容易给人一个错觉:这就是不可动摇的事实标准了。当然,从结果看,OpenAI 自己并不打算让它永垂不朽。
1.3 Responses API 到底新在哪
2024 年底,OpenAI 推出了 Responses API,路径是POST /v1/responses。官方口径很明确:这是面向 Agent 场景的统一入口,把对话、工具调用、联网搜索、文件检索、记忆等多模态能力收编到一个执行管道里。你要是只做一次普通的聊天,Chat Completions 仍然能跑,但你要是做一个会反复调用工具、需要维护多步上下文的 Agent,Responses 的设计才更贴合。
Responses API 引入了两个我很在意的概念。一个是input,它取代了messages,既可以传字符串,也可以传消息数组;另一个是previous_response_id,用它把上一次 response 的 ID 传进来,就能延续上下文,不用再把历史消息一股脑重发一遍。这种"服务端管理上下文"的思路,和之前每次请求都要带上完整历史完全不同,设计上更接近数据库的游标,而不是邮件全文转发。
另一个新点是输出结构。Chat Completions 返回一个choices数组,每个 choice 里嵌 message 和 finish_reason。Responses 的返回顶层的output是一个数组,里面每一项都带type,可能是message、function_call、web_search_call、file_search_call等。也就是说,响应被做成了"事件流",不同的工具调用是不同类型的事件,模型的一次执行过程可以被拆成多个可观测的步骤。这个变化对调试 Agent 非常有用,你能清楚地看到模型先调了什么函数、拿到了什么结果、下一步做了什么,而不用从一段文本里猜它的内部推理。
2. 从请求字段到返回结构:Chat Completions 与 Responses 的逐项对比
2.1 请求体的差异,远不止换名字
很多人以为 Responses 只是把messages改成了input,其实差异远不止如此。我整理了一张对比表,方便你一眼看出核心字段的变化:
| 维度 | Chat Completions | Responses API |
|---|---|---|
| 请求路径 | POST /v1/chat/completions | POST /v1/responses |
| 对话历史 | messages数组,每次都全量提交 | input字符串/数组,可配合previous_response_id增量提交 |
| 系统提示 | messages中 role=system 的消息 | 独立字段instructions |
| 工具定义 | tools,类型包含 function | tools,类型包含 function、web_search、file_search 等 |
| 上下文交接 | 无标准方案,需要自己拼历史 | previous_response_id直接引用上一条执行结果 |
| 输出项 | choices[].message | output数组,按类型区分事件 |
第一眼就能看出,Responses 把"系统提示"提升为独立字段instructions,这算是对 chat 时代习惯的一种纠偏。以前写 system 消息,总有人忘记它的优先级,搞不清楚它和 user 消息的边界。现在变成了显式指令,语义清楚,甚至支持用数组传入多段指令,对复杂 Agent 编排更友好。
previous_response_id是我觉得最核心的改动。Chat Completions 时代,每轮对话必须把全部历史消息发过去,一旦历史很长,token 消耗直线上升,而且可能被上下文长度限制卡住。Responses 只靠一个 ID 就能串联前后两次执行,服务端替你保管上下文,客户端只需要维护最近一次 response 的 ID。这种设计对多轮 Agent 场景是颠覆性的,交互次数降了一个数量级,请求体体积也小了很多。
还有一个字段值得注意:store。Responses API 支持把 response 存在服务端,后续可以通过 ID 查询和复用。默认是true,如果不希望服务端存,可以显式传store=false。这个选项在 Chat Completions 里是没有的,背后是 OpenAI 在把接口往"执行记录"的方向推,让你能追踪模型做了哪些操作。
2.2 返回结构的变化,直接影响可观测性
先说 Chat Completions 的返回结构,典型长这样:
{ "choices": [ { "message": { "role": "assistant", "content": "你好,有什么可以帮你?" }, "finish_reason": "stop" } ] }简单直接,一个完整回复块。Responses API 的返回就完全不一样了。我刚切换的时候看到output数组里的type字段,第一反应是:这不就是消息事件流吗?它的最小结构类似这样:
{ "output": [ { "type": "message", "role": "assistant", "content": [ { "type": "output_text", "text": "你好,有什么可以帮你?", "annotations": [] } ] } ] }output里的每一项都有一个type。如果模型调用了工具,会出现type: "function_call"的事件,里面带着函数名和参数;如果模型触发了联网搜索,会出现type: "web_search_call"。这带来的好处是:你的程序不需要再把模型回复的文本强行分词、正则提取工具调用意图,因为工具调用已经被结构化成了独立事件。
调试体验也随之变化。以前排查 Agent 问题时,要打印完整响应、手动找tool_calls,再去关联工具返回结果,链路又长又容易断。现在 Responses 把每次执行的中间产物都结构化地暴露出来,你可以按type过滤、跟踪每一步。如果你在做复杂 Agent,这种可观测性会让你少掉很多头发。
2.3 官方迁移工具与 SDK 支持
面对这么大改动,OpenAI 也给了迁移路径。官方 SDK 从某个版本开始同时支持client.chat.completions.create和client.responses.create,并且提供了一套迁移工具,可以帮你把旧代码中的chat.completions替身切换到responses。这个迁移工具本身并不神秘,核心是做了字段映射:
messages拆成input和instructionstemperature、top_p等采样参数直接对应tools中 type=function 的工具基本可以平移max_tokens改叫max_output_tokens
我在一个 CRUD 机器人项目上试过官方迁移提示,大部分基础调用改起来很快,但涉及流式、异步、工具调用和系统消息拼接的地方要格外小心,表面上是同名迁移,实际行为有一些细微差异。我的建议是:不要盲目相信自动迁移,先把迁移后的请求体和响应体打印出来,逐个字段核对,尤其是边界情况。
3. 开源兼容的真相:为什么大家还在折腾 /chat/completions
3.1 生态的现实:Chat Completions 是开源模型的通用语言
OpenAI 高调推 Responses,但开源世界并没有立刻跟上。今天你打开任意一个主流本地推理服务的文档,几乎清一色写着"兼容 OpenAI Chat Completions API"。vLLM、Ollama、LocalAI、FastChat、LiteLLM、One API 这些项目,优先支持的都是/v1/chat/completions,而支持/v1/responses的屈指可数。
这不是这些开源项目技术实力不够,而是生态惯性太强。过去两年里,围绕着 Chat Completions 长出了海量的第三方工具、教程、SDK、示例代码。开发者应聘一个岗位,面试官看的是你会不会调chat.completions;一个开源项目要快速扩张用户群,最简单的手段就是兼容chat.completions。当一套接口成了社区默认通信协议,它的价值就已经超出了 OpenAI 官方文档的边界。
更现实的原因是,很多开源模型框架本身并不提供"Agent 原生"的能力。Responses API 里的web_search_call、file_search_call依赖 OpenAI 服务端的托管工具,而本地部署的模型服务往往只提供基础推理能力,真正跑搜索和检索需要自己挂外部服务,所以它们选择继续用 Chat Completions 作为统一出口,反而是更务实的设计。
3.2 兼容层是怎么工作的
所谓"开源兼容",本质上就是做一个协议翻译器。客户端还是用 OpenAI SDK 的经典姿势发POST /chat/completions,网关或推理框架收到请求后,把它翻译成内部数据结构,丢给模型引擎,再把模型输出恢复成标准的choices[].message格式。如果是本地推理引擎,这个过程中还需要完成 prompt 模板拼接、角色映射、停止词处理等工作。
我在本地用 Ollama 接入过 LangChain 和自写 Python 脚本,它提供的/v1/chat/completions端点体感非常接近官方接口。当时我检查了一下它的兼容细节,发现messages全部转成了对话模板,system角色也会被正确处理,返回里的finish_reason也模拟得很像。不过要达到这种兼容性,不是简单的字符串转发,而是要在框架层面实现对 OpenAI 协议子集的精确理解。
但兼容层也有边界。很多开源实现只做到"能用",做不到"完全一致"。比如流式输出的 chunk 格式、usage字段的完整性、logprobs支持、tools的写法差异,这些细节经常是埋雷的地方。所以如果你要在开源兼容服务上跑生产任务,最好先做一轮冒烟测试,把下面几项逐个验证:单轮对话、多轮对话、流式输出、工具调用、异常输入。
3.3 开源项目支持状态速查
我自己维护过几个调用 OpenAPI 风格接口的小工具,也测过不少开源兼容方案,给你一张通用的支持状态参考表(具体请以各项目最新文档为准):
| 项目 | 兼容端点 | 备注 |
|---|---|---|
| vLLM | /v1/chat/completions | 主流开源模型服务,覆盖较全 |
| Ollama | /v1/chat/completions | 自带 OpenAI 兼容层,适合本地快速验证 |
| LocalAI | /v1/chat/completions | 本地多模型支持,需注意版本差异 |
| LiteLLM | /chat/completions + 其他模型供应商 | 偏向多厂商路由,映射规则更丰富 |
| One API | /v1/chat/completions | 国内常见的聚合网关,走转发和计费 |
| OpenRouter | /v1/chat/completions | 云端聚合,兼容层稳定 |
这张表想说明一件事:现在开源世界的主流策略仍然是"统一兼容旧标准"。Responses API 想要在开源生态里普及,还有一段很长的路。这也意味着,你如果今天开发一个面向企业的中间层,最好同时保留两套出口:对外继续广播 Chat Completions 的兼容能力,对内允许新项目逐步试用 Responses。两边都接得住,才不会在接口交替期被生态淘汰。
4. 迁移实操:代码级对比与决策指南
4.1 同一功能的两种写法
为了让你直观感受差异,我拿最简单的"带系统提示的单轮对话"举例。用 Chat Completions 的经典写法:
from openai import OpenAI client = OpenAI() resp = client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": "你是一个简洁的助手。"}, {"role": "user", "content": "用一句话介绍 Responses API"} ] ) print(resp.choices[0].message.content)用 Responses API 的写法变成了:
from openai import OpenAI client = OpenAI() resp = client.responses.create( model="gpt-4o", instructions="你是一个简洁的助手。", input="用一句话介绍 Responses API" ) print(resp.output_text)注意第二个示例里我直接用了resp.output_text。这是官方 SDK 为 Responses 提供的一个便捷属性,它会把output中的文本内容拼起来返回,省去手工遍历output数组。如果你是刚接触新接口,这个属性用起来非常顺手。
再对比一个多轮对话场景。Chat Completions 要维护一个 messages 数组:
messages = [ {"role": "system", "content": "你是资深技术顾问。"}, {"role": "user", "content": "我需要了解接口迁移。"}, ] resp = client.chat.completions.create(model="gpt-4o", messages=messages) messages.append(resp.choices[0].message) messages.append({"role": "user", "content": "继续说说风险"}) resp = client.chat.completions.create(model="gpt-4o", messages=messages)Responses API 可以这样写:
resp1 = client.responses.create( model="gpt-4o", instructions="你是资深技术顾问。", input="我需要了解接口迁移。" ) resp2 = client.responses.create( model="gpt-4o", previous_response_id=resp1.id, input="继续说说风险" ) print(resp2.output_text)看到区别了吗?第二次调用只需要传previous_response_id,不需要把对话历史完整重发一遍。在长对话场景里,这种写法的请求体积会小很多,网络传输和上下文处理压力都显著下降。
4.2 迁移时的几个关键注意事项
迁移不是把chat.completions四个字改成responses就完事,有几个坑我在项目里真实遇到过。
第一个是instructions的优先级。Responses 里的instructions字段等价于 Chat Completions 的 system message,但如果你在同一请求里既传了instructions,又在一个input数组里塞了 role 为system的消息,行为可能会让你意外。我测试下来,input里的 system 消息会被视为普通用户会话里的历史消息,而不是指令,所以必要的时候把系统提示统一放到instructions,避免歧义。
第二个是工具调用的结构。Chat Completions 的 function calling 可以这么写:
tools = [ { "type": "function", "function": { "name": "get_weather", "description": "获取指定城市天气", "parameters": { "type": "object", "properties": { "city": {"type": "string"} }, "required": ["city"] } } } ]Responses API 也接受类似的 tools 定义,但事件输出变了。在 Chat Completions 里,模型会输出message.tool_calls;在 Responses 里,工具调用变成了output数组中的function_call事件。处理逻辑要相应调整,不能再用老代码去读choices[0].message.tool_calls。
第三个是流式输出差异。Chat Completions 的流式 chunk 每一片可能只包含一个字节增量,Response 的流式结构也类似,但字段名和事件类型不同。如果你以前用for chunk in client.chat.completions.create(...):处理流式,现在切到 Responses 需要改用响应事件迭代的方式。最好在正式迁移前写一个小 demo 验证流式协议是否满足你的业务需求。
第四个是max_tokens改名。Responses 把输出 token 上限改成了max_output_tokens,语义更明确,但初迁移的人很容易忘记改,结果报参数错误的尴尬我经历过不止一次。
4.3 新项目用 Responses,存量项目怎么平滑过渡
我的建议是:新项目如果有 Agent 化趋势,直接用 Responses;存量项目如果只是简单对话、兼容性和稳定优先,暂时留在 Chat Completions 完全没问题,不用为了追新而强行迁移。过度迁移本身是一种风险:工具调用、流式、Prompts 模板都要跟着改,回归测试成本很高。
如果实在要过渡,可以采用"双客户端"策略。代码里同时创建使用不同接口的调用方法,老接口保持原样,新功能模块走 Responses,通过配置开关切换默认行为。我在一个客服机器人项目上就是这么干的:核心对话链路保持 Chat Completions,新增的联网检索与多步工具操作走 Responses。这样既不影响现有用户,又能逐步积累新接口的使用经验。
另外要注意,Responses API 的计费、上下文存储、数据留存策略和 Chat Completions 不一定完全相同。涉及企业合规场景,务必先核对官方文档中关于数据存储和隐私的说明,再决定是否开启store=true。
5. 常见错误与报错排查实录
5.1 unexpected endpoint or method 的完整排查路径
回到文章开头那个报错:[error] unexpected endpoint or method. (post /chat/completions). returning 2。这句话本身不是 OpenAI 官方 API 的标准报错,更多是网关、反向代理或兼容服务返回的。
我见过好几次这种报错,基本都是三个原因。第一个是 base_url 配错了。比如你本地跑了一个开源网关,它只暴露了/v1/responses端点,但 SDK 默认在 base_url 后面追加/chat/completions,于是请求打到不存在的路径,网关就抛了这个错误。第二个是路径重复拼接。假设 base_url 已经写成了https://api.example.com/v1,SDK 又在内部拼出/v1/chat/completions,最终实际请求会变成/v1/v1/chat/completions,各种网关都会直接拒掉。第三个是兼容服务本身没有启用 Chat Completions 路由,只支持自家格式,客户端却按 OpenAI 协议发请求。
排查顺序我建议这样来:
- 确认你调用的服务到底支持哪些端点。看官方文档或直接
curl试一下,比如:
curl -X POST https://api.example.com/v1/chat/completions \ -H "Authorization: Bearer YOUR_KEY" \ -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}'- 检查 base_url 是否包含
/v1。如果包含,确保 SDK 配置里不要再重复追加版本路径。 - 在你自己的服务端日志里查看实际收到的请求路径和 method,确认是不是多了
/v1或少了/chat/completions。 - 如果用的是内网网关,确认网关路由规则有没有把
/chat/completions转发到对应的上游服务。
5.2 一个典型线上问题的排查过程
我有一次帮朋友排查一个内部 API 代理的报错,现象是任何请求都会返回unexpected endpoint or method. (post /chat/completions)。一开始我以为是路径拼接问题,后来发现 base_url 配的是https://gateway.internal/v1,SDK 发的请求是https://gateway.internal/v1/chat/completions,从路径看完全正常。
接着我去看网关的配置文件,发现这个网关只注册了一个路由:/v1/responses,没有注册/v1/chat/completions。也就是说,它压根就没打算兼容旧接口。问题根源不是 SDK,而是网关能力集与客户端协议不匹配。
最后的解决办法是在网关前再加一层轻量适配,或者让团队统一改用client.responses.create并配置 base_url 指向正确的响应端点。这个案例给了我很深的印象:接口兼容是一个双向工程,客户端要按协议走,服务端也要明确自己支持哪些端点。两边只要有一边不动,报错就是家常便饭。
5.3 其他高频问题速查表
| 问题 | 典型原因 | 处理建议 |
|---|---|---|
| 401 unauthorized | API Key 未配置或已失效 | 检查环境变量、密钥权限 |
| 404 endpoint not found | base_url 多带/漏带 /v1 | 用 curl 验证完整路径 |
| unexpected endpoint or method | 网关未启用对应路由 | 检查网关路由配置,开启兼容路由 |
| tool_calls 读取不到 | 响应结构仍为 Chat Completions | 确认是否切到了 Responses API |
| max_tokens 不被识别 | 新接口参数名变了 | 改用 max_output_tokens |
| 上下文不连续 | 忘记传 previous_response_id | 记录上一条 response.id 并回传 |
| 流式返回格式异常 | 对 Responses 流式结构不熟悉 | 打印原始 chunk,按输出类型解析 |
另外提一嘴,OpenAI 官方最近大力推广的 Codex CLI 其实已经走的是 Responses API 风格,它作为一个命令行编码代理,需要反复调用模型、工具和文件操作,这种场景恰好是 Chat Completions 最吃力的地方。如果你要在自己的工具链里接入类似 agent 能力,尽早理解 Responses 的执行模型会很有帮助。
我个人的真实感受是:接口规范演进从来不是单纯的技术升级,Chat Completions 的普及让整套生态围绕对话格式形成了深度绑定,而 Responses API 更像是 OpenAI 为下一代 Agent 应用铺的路。现阶段完全没必要把存量代码急着重写,但新项目、新能力,完全值得在 Responses 上投入。
最后分享一个很实用的小技巧:无论你用的是哪种接口,务必在项目入口处集中封装一个"模型网关模块",把请求构造、响应解析、错误重试全部收口。这样将来接口迁移、模型切换,你只需要改一个模块,而不是满项目搜chat.completions.create。我在多个项目上吃过程序里散落几百处 chat 调用的亏,后来花了整整两天统一封装。早封装早省心,这比纠结选哪个接口重要得多。