OpenAI接口从Completions到Responses的演进与开源兼容性解析
1. 接口换代这件事,为什么值得单独聊一次
如果你最近半年翻过 OpenAI 的官方文档,大概率会有一种"走错片场"的感觉:以前闭着眼睛都能写的chat.completions.create,现在文档首页推的却是responses.create。更让人犯嘀咕的是,社区里一边有人说"Completions 要废弃了赶紧迁移",一边又有人说"Responses 根本跑不通,还是老接口香"。信息互相打架,最后的结果就是——很多人干脆两个都不敢动,项目里那套老代码继续跑着,心里却一直悬着。
这篇东西就是想把这件事讲透。核心话题是OpenAI 接口规范从 Completions 到 Responses 的演进逻辑,以及围绕它衍生出来的一堆"开源兼容"问题。我会从接口设计的底层动机讲起,拆解 Responses 到底改了什么、为什么这么改,然后重点聊一个大家最关心也最容易被带偏的问题:那些号称"兼容 OpenAI"的开源实现、第三方网关、本地推理服务,到底兼容到了什么程度,哪些能无缝切换,哪些一碰就碎。
适合谁看?三类人。第一类是把 OpenAI 接口当成基础设施在用的应用开发者,你需要判断"要不要迁移、什么时候迁移、迁移成本多大"。第二类是在做 AI 网关、模型路由、多模型聚合的工程师,你需要搞清楚兼容层的边界在哪。第三类是对接口设计本身感兴趣的技术人,想理解一个被全球开发者依赖的 API 是怎么一步步演化的。不管你是哪一类,读完应该都能对"该用哪个接口、怎么用、坑在哪"有个清晰的判断。
先说结论性的判断,免得你看到一半才发现方向不对:Completions 不会明天就消失,但新能力基本只会往 Responses 上堆;开源兼容层对 Completions 的兼容度远高于 Responses,所以短期内"双接口并存"是常态。下面展开讲为什么。
2. Completions 接口的设计基因与历史包袱
2.1 从"补全一段文本"到"对话"的语义漂移
要理解 Responses 为什么出现,得先回到 Completions 的出身。最早的/v1/completions接口,本质是一个文本补全接口:你给它一段 prompt,它还你一段续写。这个设计非常纯粹,输入是字符串,输出是字符串,模型干的事就是"接着往下写"。那时候大家用它写代码、续写文章、做翻译,都是这个逻辑。
后来 ChatGPT 火了,对话成了主流用法,于是有了/v1/chat/completions。注意这个名字——它是在completions前面加了个chat,而不是重新设计一个接口。这个命名本身就暴露了它的历史包袱:它是在文本补全的骨架上,硬套了一层"消息数组"的皮。你传进去的不再是字符串,而是一个messages数组,每个元素有role和content。模型内部把这些消息拼成一个特殊格式的 prompt,再走一遍补全逻辑。
这个"套皮"设计在早期够用,但随着能力越来越复杂,问题就藏不住了。最典型的就是多轮对话的状态管理。在 Completions 里,模型是无状态的,每一轮你都得把完整的历史消息重新传一遍。对话越长,请求体越大,token 消耗越多,而且你还得自己维护这个历史数组,稍不注意就丢上下文或者重复发送。
2.2 那些年被"套皮"设计坑过的场景
我踩过最典型的一个坑是函数调用(Function Calling)。Completions 时代的工具调用,流程是这样的:你传tools参数,模型返回一个tool_calls,你执行完工具,再把结果以role: "tool"的消息塞回messages数组,重新发一次请求。整个链路里,工具调用的中间状态全靠你自己在客户端维护。
问题来了:如果模型一次要调三个工具,其中两个成功一个失败,你怎么把失败信息优雅地传回去?如果工具调用过程中你想插入一段系统提示,怎么保证不破坏消息顺序?这些在 Completions 里都没有原生支持,全靠开发者自己拼消息数组。我见过不少项目,光是为了处理工具调用的状态机,就写了上千行胶水代码。
再比如多模态输入。Completions 后来支持了图片,但格式是content数组里塞image_url对象。这个设计能用,但很别扭——文本和图片混在一个数组里,顺序敏感,解析起来容易出错。而且音频、文件这些新模态加进来的时候,接口结构越来越臃肿。
还有一个更隐蔽的问题:推理过程不可见。Completions 返回的就是最终答案,模型"想了什么"你完全看不到。对于需要调试、需要审计、需要展示推理链的场景,这是个硬伤。虽然后来有reasoning相关的字段陆续加进来,但都是在原有结构上打补丁,不够系统。
2.3 为什么不能直接在 Completions 上继续改
有人会问:既然问题这么多,为什么不直接在 Completions 上迭代,非要搞个新接口?
答案在于兼容性诅咒。Completions 已经被全球无数应用依赖,任何破坏性改动都会引发灾难。你没法把messages数组改成别的结构,没法把工具调用的返回格式换掉,没法重新定义多模态的输入方式——因为一改,所有现存代码全挂。这就导致 Completions 只能做加法:加字段、加参数、加能力,但底层结构动不了。
加法的结果是接口越来越复杂,语义越来越混乱。同一个功能可能有多种实现方式,文档越写越厚,新用户上手成本越来越高。这时候,与其在旧地基上盖危楼,不如另起炉灶。Responses 就是那个"另起炉灶"的产物。
3. Responses 接口到底重构了什么
3.1 从"消息数组"到"事件流"的思维转变
Responses 最根本的变化,是把交互模型从**"请求-响应"改成了"请求-事件流"。在 Completions 里,你发一个请求,等一个完整响应,中间过程是黑盒。在 Responses 里,整个交互过程被拆成了一串事件(events)**,每个事件代表状态的一次变化。
这个转变听起来抽象,但影响非常实际。举个例子:在 Completions 里做流式输出,你拿到的是一个个delta,每个 delta 里有一小段文本。你只能看到"文本在往外蹦",但不知道模型是在思考、在调工具、还是在生成最终答案。而在 Responses 里,事件类型是明确的:有response.created、有response.output_text.delta、有response.function_call_arguments.delta、有response.completed。你能精确知道当前处于哪个阶段。
这个设计的好处是状态可观测。对于需要展示"AI 正在思考"、需要区分"推理内容"和"最终答案"、需要在工具调用时给用户反馈的场景,事件流提供了原生支持。你不用再靠猜或者靠 hack 来实现这些效果。
3.2 有状态会话:把历史管理从客户端搬到服务端
Responses 另一个重量级变化是有状态会话。前面说过,Completions 是无状态的,每轮对话你都得重传历史。Responses 引入了previous_response_id这个概念:你第一次请求拿到一个 response id,下一轮请求带上这个 id,服务端自动帮你把历史接上。
这个改动直接解决了好几个痛点。第一,请求体变小了。不用每次重传几十轮对话,省 token 也省带宽。第二,历史管理交给服务端,客户端逻辑简化。第三,多轮一致性更好,不会因为客户端拼错历史导致模型行为异常。
但这里有个坑必须提醒:有状态意味着服务端要存东西。如果你用的是第三方兼容实现,它可能根本不支持previous_response_id,或者支持得很粗糙。我实测过几个开源网关,有的直接忽略这个参数,有的虽然接受了但内部还是无状态处理,结果就是你以为接上了历史,实际上模型啥都不记得。这个后面会详细讲。
3.3 工具调用与多模态的原生整合
Responses 在工具调用上的设计,比 Completions 干净太多。在 Completions 里,工具调用是"模型返回 tool_calls,你执行,再塞回去"的三段式。在 Responses 里,工具调用是事件流的一部分:模型决定调工具时,会发出response.function_call_arguments.done事件;你执行完,把结果作为新的 input 传回去,继续同一个 response。
更关键的是,Responses 支持内置工具。比如网页搜索、文件搜索、代码执行这些,在 Completions 里你得自己接第三方或者自己实现,在 Responses 里可以直接声明tools: [{type: "web_search"}]之类的,服务端帮你搞定。这个能力对做 Agent 的团队来说是质变——以前要搭一整套工具编排框架,现在接口层面就支持了。
多模态方面,Responses 把输入统一成了input数组,里面可以放文本、图片、文件,结构比 Completions 的content数组更清晰。而且输出侧也支持多模态,模型可以直接返回图片、文件,不用你再单独调别的接口。
3.4 一个对比表看清两代接口的差异
| 维度 | Completions | Responses |
|---|---|---|
| 交互模型 | 请求-响应 | 请求-事件流 |
| 状态管理 | 客户端维护历史 | 服务端维护(previous_response_id) |
| 工具调用 | 三段式,客户端编排 | 事件流原生支持,含内置工具 |
| 多模态输入 | content 数组混排 | input 数组,结构更清晰 |
| 推理可见性 | 有限,靠字段补丁 | 事件流区分推理与输出 |
| 流式输出 | delta 文本流 | 类型化事件流 |
| 兼容性 | 事实标准,广泛兼容 | 新接口,兼容层支持有限 |
这张表建议存下来,后面判断"某个开源实现能不能用"的时候,对着它逐项检查就行。
4. 开源兼容层的真相:兼容的是哪一层
4.1 "兼容 OpenAI"这句话的水分有多大
现在市面上大量开源项目、推理框架、API 网关都标榜"OpenAI Compatible"。但这句话的信息量其实极低,因为它没说兼容的是哪个 OpenAI 接口。是/v1/completions?/v1/chat/completions?还是/v1/responses?三者差别巨大。
我做过一轮实测,把常见的兼容实现分成三档:
- 第一档:只兼容
/v1/completions。这是最老的接口,实现最简单,很多轻量级推理服务只做到这一层。你用它做文本补全没问题,但对话、工具调用基本别想。 - 第二档:兼容
/v1/chat/completions。这是目前的主流,绝大多数开源网关、本地推理服务都停在这一层。对话、流式、基础工具调用能跑,但细节参差不齐。 - 第三档:兼容
/v1/responses。这一档目前很少,而且大多只实现了子集,事件流、有状态会话、内置工具这些核心特性往往缺失。
所以当你看到"OpenAI Compatible"时,第一件事就是问:兼容到哪一档?如果对方文档里只提 chat completions,那 Responses 相关的能力你就别指望了。
4.2 兼容层最容易"形似神不似"的几个点
即便都声称兼容 chat completions,实际表现也差很多。我踩过的坑主要集中在下面几个地方。
第一个是流式格式。OpenAI 的流式返回是 SSE(Server-Sent Events),每个 chunk 是data: {...}格式,最后以data: [DONE]结束。有些兼容实现虽然也返回 SSE,但 chunk 结构不对,比如缺少choices[0].delta的某些字段,或者finish_reason的时机不对。你的客户端如果严格按 OpenAI 的格式解析,就会出错。
第二个是工具调用的参数拼接。流式工具调用时,OpenAI 会把函数参数分多个 chunk 传,每个 chunk 是参数 JSON 的一个片段,你需要自己拼接。有些兼容实现直接一次性把完整参数传过来,或者拼接逻辑有 bug,导致你拿到的 JSON 不完整。这个坑很隐蔽,因为非流式下看不出来,一开流式就炸。
第三个是 token 计数和截断。OpenAI 的max_tokens、finish_reason: "length"这些行为,兼容实现经常对不齐。有的把max_tokens当摆设,有的截断后不返回正确的 finish_reason,导致你的重试逻辑失效。
第四个是错误码和错误结构。OpenAI 的错误返回有固定结构,包含error.type、error.code、error.message。兼容实现经常直接抛一个通用错误,你的错误处理代码就抓瞎了。
4.3 为什么 Responses 的兼容这么难做
Responses 兼容难,根本原因在于它的事件流模型。Completions 的兼容,本质是"把请求转成内部格式,把内部输出转成 OpenAI 格式",是个相对简单的适配层。而 Responses 的事件流,要求兼容实现真正理解并维护一套状态机:什么时候发response.created,什么时候发output_item.added,工具调用和文本输出怎么交错,previous_response_id怎么映射到内部会话……
这套东西不是简单转格式能搞定的,需要兼容层自己实现一套完整的事件编排逻辑。对于大多数开源项目来说,投入产出比太低——毕竟用 Responses 的人还不多,做深了也没多少人用。所以现状就是:Responses 的兼容层大多停留在"能返回个结果"的水平,事件流的精髓基本丢失。
5. 迁移决策:什么时候该动,什么时候该等
5.1 判断你的项目属于哪一类
要不要迁移到 Responses,不能一刀切,得看你的项目类型。我把它分成三类。
第一类:纯文本对话应用。比如客服机器人、写作助手、问答系统。这类应用 Completions 完全够用,迁移到 Responses 的收益主要是"历史管理简化"和"推理可见性",但如果你现在的历史管理没出问题,迁移的紧迫性不高。可以等 Responses 生态更成熟再说。
第二类:Agent / 工具调用密集型应用。比如自动化工作流、多工具编排、需要内置搜索或代码执行的场景。这类应用强烈建议尽早评估 Responses。因为工具调用的原生支持、内置工具、事件流的可观测性,对这类应用是实打实的效率提升。你在 Completions 上写的那些工具编排胶水代码,在 Responses 上可能直接省掉。
第三类:多模型 / 多供应商架构。如果你的系统要同时对接 OpenAI、开源模型、其他厂商,那迁移决策要更谨慎。因为 Responses 的兼容性差,你一旦深度绑定,切换成本会很高。这种情况下,保持 Completions 作为统一抽象层可能更稳妥,把 Responses 的特性作为可选增强。
5.2 迁移成本的真实构成
很多人低估了迁移成本,以为"改个接口名、调下参数"就完事。实际成本主要在三个地方。
一是数据结构重构。Completions 的messages数组和 Responses 的input数组,结构不一样。你的消息构造、历史管理、工具结果回传,这些逻辑都要重写。如果代码里到处散落着messages.push(...),那改动量不小。
二是流式处理重写。前面说过,Responses 是类型化事件流,你的流式解析逻辑要从"解析 delta 文本"改成"解析事件类型并分发处理"。这是个架构级改动,不是改几行能搞定的。
三是测试和回归。接口行为变了,你的测试用例、mock、断言都要跟着改。而且 Responses 的一些行为(比如事件顺序)可能和 Completions 不完全一致,需要重新验证。
我的建议是:先做一个小范围试点,挑一个非核心功能迁移到 Responses,跑通全链路,把坑摸清楚,再决定要不要全面铺开。别一上来就全量迁移,风险太大。
5.3 双接口并存的过渡策略
现实一点讲,未来一两年内,双接口并存会是大多数项目的常态。你需要一套策略来管理这种并存。
我的做法是在应用层做一层抽象,把"对话"、"工具调用"、"流式输出"这些概念抽象成内部接口,底层可以切换 Completions 或 Responses 实现。这样迁移的时候,只改底层适配器,上层业务代码不动。这层抽象会增加一些前期成本,但长期看非常值——尤其是当你还要对接多个供应商的时候。
具体实现上,可以定义一个LLMClient接口,包含chat()、stream()、call_tool()这些方法,然后写CompletionsClient和ResponsesClient两个实现。业务代码只依赖LLMClient。这样 Responses 成熟了,切过去就行;某个供应商只支持 Completions,也能兼容。
6. 实操中那些文档不会告诉你的细节
6.1 事件流解析的正确姿势
Responses 的事件流解析,有几个细节文档讲得不清楚,但实操中很关键。
第一,事件类型要穷举处理,但要有兜底。Responses 的事件类型会随版本增加,你不能只处理已知类型,遇到未知类型要能优雅忽略而不是崩溃。我的做法是维护一个已知类型集合,未知类型记日志但不中断。
第二,response.completed不一定是你以为的那个时机。在工具调用场景下,模型可能先发一个response.completed,然后你回传工具结果,又触发新一轮。所以不能简单地把response.completed当成"整个交互结束",要结合上下文判断。
第三,文本 delta 和推理 delta 要分开处理。如果你把推理内容也当最终答案展示给用户,体验会很怪。要区分output_text.delta和推理相关的事件,只把前者展示给用户。
6.2 previous_response_id 的隐藏限制
previous_response_id用起来很爽,但有几个限制要知道。
一是有效期。服务端存的历史不是永久的,超过一定时间或一定轮数可能就失效了。你不能假设一个 response id 永远有效,要有降级方案——比如失效时回退到手动传历史。
二是跨会话隔离。不同用户的 response id 不能混用,否则会串上下文。这个在多人应用里要特别注意,做好 id 和用户的绑定。
三是兼容层可能不支持。前面提过,很多兼容实现忽略这个参数。如果你依赖它做历史管理,换到兼容层就会出问题。所以要么确认兼容层支持,要么自己维护历史作为兜底。
6.3 工具调用结果回传的格式陷阱
Responses 里回传工具结果,格式和 Completions 不一样。Completions 是往messages里塞role: "tool"的消息,Responses 是往input里塞特定类型的 item。这个格式如果搞错,模型会"看不懂"工具结果,表现为重复调用工具或者胡言乱语。
我踩过一次坑:把工具结果按 Completions 的格式塞进 Responses 的 input,结果模型完全无视了工具返回的内容,继续按自己的想象回答。排查了半天才发现是格式问题。所以迁移时,工具结果回传这块一定要对照文档仔细改,别想当然。
7. 兼容性测试:怎么验证一个实现到底行不行
7.1 一套可复用的测试清单
与其听别人说"兼容",不如自己测。我整理了一套测试清单,覆盖从基础到进阶的能力,你可以拿它去验证任何声称兼容 OpenAI 的实现。
| 测试项 | 验证内容 | 通过标准 |
|---|---|---|
| 基础对话 | 单轮文本问答 | 返回结构正确,内容合理 |
| 多轮对话 | 历史传递 | 模型能引用前文 |
| 流式输出 | SSE 格式 | chunk 结构正确,以 [DONE] 结束 |
| 工具调用(非流式) | tools 参数 | 返回 tool_calls,结构正确 |
| 工具调用(流式) | 参数分片拼接 | 拼接后 JSON 完整可解析 |
| 多模态输入 | 图片理解 | 能正确描述图片内容 |
| 错误处理 | 非法参数 | 返回标准错误结构 |
| 截断行为 | max_tokens | finish_reason 正确 |
| 有状态会话 | previous_response_id | 历史正确接续 |
| 事件流类型 | Responses 事件 | 事件类型齐全,顺序合理 |
这套清单跑一遍,一个实现的真实兼容度就清楚了。别嫌麻烦,踩坑的成本比测试高得多。
7.2 遇到不兼容时的降级方案
测试发现不兼容怎么办?不是所有情况都要换实现,有些可以降级处理。
流式格式不对:如果只是 chunk 结构小差异,可以在客户端做一层适配,把对方的格式转成标准格式。这个适配层写一次,到处能用。
工具调用不支持:如果兼容层不支持工具调用,可以在应用层用 prompt 工程模拟——让模型输出特定格式的文本,你解析后执行。虽然不如原生优雅,但能用。
有状态会话不支持:这个最简单,回退到客户端维护历史,每次请求带上完整历史。功能上没损失,只是费点 token。
事件流不完整:如果兼容层只返回最终结果不返回事件流,那就当非流式用,或者用轮询模拟进度。体验差一些,但功能能保证。
降级方案的核心思路是:把 Responses 的高级特性当成"增强",而不是"必需"。这样即使兼容层拉胯,你的应用也能跑。
8. 我对这套接口演进的一点个人判断
聊了这么多技术细节,最后说点我自己的观察。
接口这东西,从来不是纯技术问题。Completions 能成为事实标准,不只是因为它设计得好,更因为它出现得早、生态大、大家都用。Responses 设计上确实更先进,但它要面对的是"如何让已经用惯 Completions 的人愿意迁移"这个非技术难题。从目前的开源兼容现状看,这个迁移会比我最初预想的慢。
我的实际做法是:新项目直接用 Responses,老项目保持 Completions 但做好抽象层。新项目没有历史包袱,直接用新接口能吃到工具调用、事件流这些红利。老项目迁移成本高,没必要为了迁移而迁移,等 Responses 生态更成熟、兼容层更完善了再动。
还有一个判断:未来一两年,"兼容 OpenAI"这个标签会从"兼容 chat completions"逐渐转向"兼容 responses"。现在做兼容层的团队,如果还没开始支持 Responses,建议尽早排期。因为一旦 Responses 成为主流,兼容层的竞争点就会从"能不能对话"变成"事件流完不完整、有状态会话支不支持"。这个窗口期不会太长。
至于那些还在纠结"要不要学 Responses"的开发者,我的建议很简单:先跑通一个最小 demo。不用管迁移,就写个几十行的脚本,调一次 Responses,看看事件流长什么样,工具调用怎么回传。跑通了,你自然就知道它适不适合你的场景了。光看文档是看不出门道的,动手跑一遍比什么都强。