从Chat Completions到Responses API:迁移踩坑与开源兼容真相
发布时间:2026/10/31 9:09:00 锦皓数字建站

最近在评估一个批量文本总结服务的迁移方案把项目从 Chat Completions 切到 OpenAI 的 Responses API。改的过程比预想中顺利但真正让我花时间的是原来那套工具调用逻辑要重写以及不同开源框架对 Responses 的“兼容”方式差别极大。有的只是把新接口的参数悄悄映射回老接口有的则是完整实现了新协议。这篇就把我从 Completions 时代一路用过来的观察、迁移时踩的坑以及开源生态里那些“挂着羊头卖狗肉”的兼容层一次性说清楚。先交代一下背景。Completions 系列接口统治了 OpenAI 生态很长一段时间最开始是纯粹的文本补全接口POST /v1/completions那时候你传一个 prompt它给你补一段文字。后来 2023 年出了 Chat Completions本质还是补全但把输入换成了messages数组带上了 system、user、assistant、tool 这些角色概念。再后来就是 2025 年 OpenAI 力推的 Responses API。如果你现在打开 OpenAI 的文档会看到新项目几乎都默认从/v1/responses起步。那问题来了老接口用了这么多年社区里积累了那么多工具和教程为什么要换换完之后开源生态到底跟进到什么程度这篇文章的结论我先放在这里——接口演进的方向是对的但所谓“开源兼容”四个字水分比很多人想象的要多。1. 为什么 Chat Completions 统治了这么久又为什么要被替换1.1 从 Completions 到 Chat Completions不是改名是换了一种交互模型很多人会把 Completions 和 Chat Completions 当成“旧版”和“新版”的关系其实它们在相当长时间里是并存的两个端点。Legacy Completions 的请求体极其简单一个prompt字段加上max_tokens就能跑curl https://api.openai.com/v1/completions \ -H Authorization: Bearer $OPENAI_API_KEY \ -H Content-Type: application/json \ -d { model: gpt-3.5-turbo-instruct, prompt: 写一首关于秋天的诗, max_tokens: 100 }它的问题是“无状态、无角色”。你想做多轮对话就得手动把历史对话拼进 prompt系统提示词和用户输入混在一起模型经常分不清哪些是背景、哪些是当前问题。Chat Completions 解决了这个基本痛点把“对话补全”这个概念正式引入了接口层。请求从prompt变成了messages每个消息带role服务端不再认为你在做文本续写而是在做“下一轮对话预测”。这套设计在 2023-2024 年非常成功可以说整个大模型应用生态都是围着它长出来的。OpenAI 自己的 Python SDK、LangChain、LlamaIndex以及后来的各种开源 Agent 框架底层核心调用无一例外都是chat/completions。为什么它能统治这么久核心原因是简单。开发者只需要拼一个消息数组就能获得稳定的文本输出工具调用Function Calling也通过tools字段加进来不需要了解模型内部是怎么把“调用某个函数”编码成 token 的。说白了Chat Completions 的接口设计做到了“够用”而“够用”在工程上往往是最大的优势。但“够用”不等于“正确”。当应用场景从简单聊天扩展到多模态、多工具编排、长周期任务时Chat Completions 那种“一次性拼消息、一次性拿结果”的无状态模型就明显吃力了。1.2 Chat Completions 的历史包袱无状态、工具调用别扭、多模态别扭我提三个具体的痛点都是实际开发中能感受到的。第一无状态带来的上下文管理成本。Chat Completions 本身不维护对话状态多轮对话要自己把历史消息全部塞进messages。短对话还好一旦对话超过几十轮或者中间插入工具执行结果消息数组会越来越长token 消耗越来越高。很多团队不得不自己做截断、摘要、向量检索只为了维护一个实际上“每次都是全新请求”的假对话。第二工具调用的表达太“绕”。Chat Completions 的工具调用流程是这样的你声明tools模型返回一个tool_calls数组里面每个元素包含function.name和arguments。你执行完函数后还要把结果作为role: tool的消息塞回messages再发一次请求让模型继续。这套机制看着合理但问题在于所有的“工具选择”和“工具结果”都被拍平成了消息数组里的普通条目流式输出时delta.tool_calls还会被拆成碎片前后端要自己做增量拼接。一旦工具数量多了整个消息数组会变得极其混乱调试的时候满屏都是 JSON非常痛苦。第三多模态只是“内容数组”的扩展。Chat Completions 的多模态输入是靠content数组来硬撑的比如图片变成 URL 或 base64 塞进去音频也类似。这导致图片、音频、文本混在一个 channel 里模型内部的注意力机制和输出格式都受限于这个“先序列化成文本 token”的路径。这三个痛点叠加起来就出现了一个很尴尬的局面开发者为了做一个稍微复杂一点的 Agent要在应用层自己写状态机、自己拼上下文、自己处理工具调用协议。OpenAI 显然也意识到了这一点Responses API 就是冲着“把状态、工具、多模态收编进协议层”这个目标去的。2. Responses API 到底改了什么一次“状态化的重新设计”2.1 请求体messages 数组变成 input instructionsChat Completions 的请求体核心是messagesResponses API 把它拆成了两个更语义化的字段input和instructions。instructions其实就是系统提示词的升级版官方建议把全局性指令放在这里比如“你是客服助手”“回答不超过 200 字”。input则是用户输入它可以是一个字符串也可以是一个消息数组也可以是一个 item 数组。举个例子最简单的调用变成了这样from openai import OpenAI client OpenAI() # Chat Completions 时代 resp_old client.chat.completions.create( modelgpt-4o, messages[ {role: system, content: 你是一个严谨的技术写手。}, {role: user, content: 解释一下依赖注入是什么。}, ], ) print(resp_old.choices[0].message.content) # Responses 时代 resp_new client.responses.create( modelgpt-4o, instructions你是一个严谨的技术写手。, input解释一下依赖注入是什么。, ) print(resp_new.output_text)注意两个变化一是input可以直接传字符串不需要再包一层{role: user, content: ...}二是最终输出可以直接用output_text拿到文本而不是去choices[0].message.content里翻。这不是简单的字段改名它意味着接口把“对话状态”这个概念正式引入了协议层。你不需要每次手动把历史消息拼成数组而是可以借助previous_response_id把前一次对话的响应 ID 直接丢给下一次请求。这个设计让多轮对话的状态管理从“应用层手动拼数组”变成了“协议层维护引用”。我实测下来连续多轮对话只需要维护最后一个response.id请求体长度几乎恒定token 消耗也更好预期。2.2 响应体choices 变成 output 条目流output_text 一行拿结果Chat Completions 的响应结构是个典型的消息对象choices数组里只有一个元素里面是message。Responses API 的响应结构完全不一样它是事件流和状态机的结合体。一次响应的顶层结构里核心字段是output数组里面的每一项都是一个 item常见的 item 类型有message正常的文本回复。function_call一个函数调用意图不再包在tool_calls里。function_call_output函数执行完的结果。web_search_call、file_search_call内置工具的调用记录。这个结构最大的改变是“文本回复”和“工具调用”不再是同一层级的两个字段而是输出列表里的不同条目。你可以在一次请求里连续触发多个函数调用响应体自然地反映这个“调用链”的序列调试信息一目了然。给你一个直观对比对比项Chat CompletionsResponses API请求端点/v1/chat/completions/v1/responses系统提示messages里塞 rolesysteminstructions字段用户输入messages数组input字段字符串或数组上下文状态手动维护完整消息数组previous_response_id引用工具声明tools字段tools字段工具调用结果message.tool_callsoutput里的function_callitem工具结果回传追加 roletool 消息追加function_call_outputitem文本输出位置choices[0].message.contentoutput_text快捷字段流式事件名chat.completion.chunk与deltaresponse.output_text.delta等细粒度事件对这个表格不一定要全记住但建议你记住一个核心区别Chat Completions 的响应是一个“消息包”而 Responses API 的响应是一个“条目序列”。消息包适合简单的问答条目序列适合复杂的 Agent 编排。2.3 内置工具与状态管理文件搜索、网页搜索、previous_response_idResponses API 另一个让我觉得值得迁移的原因是它引入了多个内置工具。以前你想让模型联网搜索得自己接搜索引擎、自己抓网页、自己把结果拼进上下文想让模型检索知识库得自己搭向量数据库和 RAG 管线。现在你在tools里直接声明一个web_search或file_search就行{ model: gpt-4o, input: 帮我搜索一下今年的 AI 圈大事件, tools: [ { type: web_search } ] }模型会在内部调用搜索工具把搜索结果纳入上下文再生成回复。这个能力在 Chat Completions 里是没有的。对于很多中小企业来说这意味着“搜索增强”这个功能的开发门槛大幅降低。当然它本身也要额外计费我看到账单的时候心里也咯噔了一下但比起自己维护抓取服务的成本这个价钱在某些场景下是可以接受的。previous_response_id前面提了一嘴这里展开说。这个字段本质上就是状态引用的句柄。你第一次调用的响应会得到一个id下一次调用带上这个id服务端会自动把上一次的上下文拼进来。这比你自己维护一个越来越长的messages数组要干净得多。唯一要留意的是这个状态是有时效和大小限制的不是无限保存长时间不活动的会话过期后还是得回退到手动拼上下文。3. 一次真实迁移的详细记录协议差异、流式事件与计费变化3.1 迁移前的技术债盘点我这边的老项目是一个内容归档助手用户丢进来一篇文章链接Agent 去抓取、总结、提炼要点、再通过工具写入数据库。老代码用的是chat/completions带两个工具一个是summarize另一个是save_to_db。整体流程是典型的“模型决定调用哪个函数应用执行函数再回传结果”。迁移之前我盘了一下技术债主要有三块第一多轮工具调用时messages数组越来越长中期甚至出现过 token 超限第二流式输出时delta.tool_calls的碎片拼接逻辑很脆弱偶发工具参数丢字第三想让模型访问知识库时没有内置方案只能额外拉一套 RAG 服务。Responses API 显然针对这三个痛点做了设计但我心里清楚迁移不是换个端点那么简单工具调用的协议变了流式事件变了连计费统计都变了。3.2 代码层面逐字段对照先看工具调用的老写法。Chat Completions 里工具调用的回传是“往消息数组里追加一个 tool 消息”# Chat Completions模型想调用工具时返回 tool_calls response client.chat.completions.create( modelgpt-4o, messages[ {role: system, content: 你是归档助手。}, {role: user, content: 把这篇关于 Rust 的文章总结后存入数据库。}, ], tools[ { type: function, function: { name: save_to_db, description: 把内容存入数据库, parameters: { type: object, properties: { title: {type: string}, summary: {type: string} }, required: [title, summary] } } } ] ) # 拿到 tool_calls 后执行函数再把结果塞回 messages tool_result save_to_db(title..., summary...) messages.append({ role: tool, tool_call_id: response.choices[0].message.tool_calls[0].id, content: json.dumps(tool_result) })Responses API 的写法是模型把函数调用作为一个独立的输出条目返回应用执行后把function_call_output加进input# Responses模型返回 output 列表其中一项是 function_call response client.responses.create( modelgpt-4o, instructions你是归档助手。, input把这篇关于 Rust 的文章总结后存入数据库。, tools[ { type: function, name: save_to_db, description: 把内容存入数据库, parameters: { type: object, properties: { title: {type: string}, summary: {type: string} }, required: [title, summary] } } ] ) # 检查输出里有没有 function_call for item in response.output: if item.type function_call: args json.loads(item.arguments) result save_to_db(**args) # 把执行结果作为 function_call_output 继续对话 response client.responses.create( modelgpt-4o, instructions你是归档助手。, input[ {type: message, role: user, content: 把这篇关于 Rust 的文章总结后存入数据库。}, item, { type: function_call_output, call_id: item.call_id, output: json.dumps(result) } ] )或者更简单一点用多轮引用的方式把上一次的response.id传进来response client.responses.create( modelgpt-4o, instructions你是归档助手。, input把这篇关于 Rust 的文章总结后存入数据库。, tools[...] ) # 假设上一轮返回了 function_call执行后回传 response client.responses.create( modelgpt-4o, instructions你是归档助手。, previous_response_idresponse.id, input[ { type: function_call_output, call_id: last_function_call.call_id, output: json.dumps(result) } ] )两种方式都行第一种适合你手上有完整 input 数组的场景第二种适合你把状态交给服务端管理的场景。我的建议是能上previous_response_id就上它能帮你少拼一半的 JSON。3.3 流式模式的变化从 delta 到事件流这是我迁移过程中最想骂人的地方。Chat Completions 的流式输出是chat.completion.chunk事件里面带delta你只需要不断把delta.content拼起来就行。Responses API 的流式输出是一套全新的事件序列常见的事件类型有response.created响应初始化。response.output_item.added一个新的输出条目创建。response.output_text.delta文本增量。response.function_call_arguments.delta函数参数的增量。response.completed整个响应完成。如果你的代码是从 Chat Completions 迁移过来只改端点名是不够的事件解析几乎要重写。举一个最简单的例子Prompts 里两个事件类型在协议里大小写规则不同、嵌套层级不同老的解析器直接崩。我最终的做法是写了一个事件分发器把response.output_text.delta的内容增量喂给前端展示把response.function_call_arguments.delta累加出来喂给参数解析器。整体比 Chat Completions 复杂但换来的是事件语义更清晰——你能准确知道“模型开始输出哪一条 tool call”以及“函数参数到底收没收完”这对长任务的进度展示很有帮助。3.4 计费与元信息统计token_details 带来的差异计费这块的变化比较容易忽略但对成本敏感的项目很重要。Chat Completions 的 usage 字段是prompt_tokens和completion_tokens两块。Responses API 引入了更细的token_details会把输入拆成文本、图像、音频等不同模态的 token 分别统计输出里也能看到推理 token 的消耗。我第一次看到账单时发现同样一个带图片输入的任务Responses API 的计费明细比 Chat Completions 更透明你能清楚知道图片吃掉了多少 token文本又占了多少。对于想优化 prompt 的人来说这是个不小的利好。另外Responses API 在新模型上的推理能力比如 reasoning是作为一项独立的 token 消耗显示的如果你开了推理模式建议把这个字段单独接进监控不然月底对账的时候容易吓一跳。4. 开源兼容真相谁真的实现了谁只是套了层壳4.1 OpenAI SDK 的官方适配程度先别急着骂开源生态OpenAI 自家的 SDK 反而是最靠谱的。官方 Python SDK 和 Node SDK 都直接新增了client.responses.create和client.responses.stream方法这些都是原生实现不是参数映射。你可以在同一个客户端里同时调用client.chat.completions.create和client.responses.create互不影响。这也意味着如果你是直接用官方 SDK 写的代码迁移难度并不高。真正的问题出在那些“基于 OpenAI 接口协议开发的开源框架”上——OpenAI 自己升级了它们有没有跟上4.2 主流开源框架的兼容分层我把主流开源项目对 Responses API 的兼容分成了三层。第一层是“官方 API 的薄封装”。LangChain 里的ChatOpenAI搭配create_responses参数LlamaIndex 里的OpenAIResponses底子还是官方 SDK只是帮你把提示词工程和记忆管理包了一层。这类框架兼容性相对可靠因为它们没做二次协议转换只是把“用 Responses 调用”这件事藏了起来。第二层是“模型抽象层的参数映射”。很多 Agent 框架保留了自己的messages抽象内部再把messages转换成 Responses 的input。这类兼容最危险因为它的转换逻辑往往只能覆盖“纯文本多轮对话”的场景。一旦你用了图片输入或者依赖output里的function_call条目做工具调度转换层可能直接丢掉这些结构。我自己见过一个框架在转function_call时把call_id丢了导致后一轮function_call_output回传时无法关联原调用。第三层是“代理网关的透传或降级”。许多 LLM 网关项目如 one-api、new-api、LiteLLM 等为了统一管理多个模型供应商会在自己的服务里把/v1/responses映射成内部的模型调用。这里有一个值得注意的“兼容真相”很多网关所谓的“支持 Responses”只是把 Responses 风格的请求翻译回 Chat Completions 格式再转发给背后的模型服务。「新协议」只是在入口做了适配内部管道完全没有改变。做一个简单的判断标准你往一个自称兼容 Responses 的网关发带web_search工具的请求如果它直接返回“不支持该工具类型”那它多半是参数映射。如果它能真的完成联网搜索并把结果带进上下文那才是真的实现了协议。4.3 第三方网关的翻译本质翻译这件事本身不丢人网关存在的意义就是协议适配。问题在于“翻译质量”。我见过一个网关的兼容实现上游模型是 GPT-4o客户端发来一个 Responses 请求网关把input数组里的字符串直接塞给messages[0].content把instructions塞给messages[0].system然后就完事了。表面上看功能没毛病但你仔细想Responses 里input是可以带 item 列表的input里的消息有role标识有的还是function_call_output。网关只把字符串输入转过去了遇到function_call_output就得当场爆炸。也就是说这类“兼容”只能用在你恰好只发纯文本消息的场景。用的时候怎么避坑我建议你把测试用例分成三档纯文本、多模态输入、工具调用。三档全部通过才能宣称某个网关“兼容 Responses”。如果只测第一档踩坑是迟早的事。4.4 一个真实样本Codex CLI 的 Windows 安装报错聊到这里我可以顺带提一个很多人遇到过的怪现象在 Windows 上安装 Codex CLI 的时候终端会报一句Missing optional dependency: openai/codex-win32-x64. Reinstall Codex: npm install。网上不少人对这句话一头雾水以为是网速问题或 npm 源问题。实际上Codex CLI 把不同平台的二进制包拆成了独立的 optional dependenciesopenai/codex-win32-x64、openai/codex-linux-x64、openai/codex-darwin-arm64等npm 在某些环境下没有正确拉取对应平台包就会出这句提示。官方建议的做法就是重装npm install -g openai/codex或者清一下 npm 缓存再装npm cache clean --force npm install -g openai/codex这事儿看着跟接口演进没关系但它背后有个信号OpenAI 自己的 CLI 工具链已经在用 Responses API 了而且它的安装包结构都为了跨平台做成了按平台拆分的 optional dependency。这从侧面说明Responses API 不是实验性功能而是现在官方工具链的默认基线。你再回到开源兼容的话题如果一个框架连 npm 的跨平台依赖都处理不好那它内部的 Responses 兼容大概率也是“能跑通 hello world跑不通生产流程”。5. 迁移决策哪些场景值得动哪些场景先别动5.1 值得立即迁移的场景如果你手头的项目属于下面三类我建议现在就开始迁第一新项目尤其是还没定型、正在写第一版 Agent 逻辑的。直接用 Responses API少走“写完再改协议”的弯路。第二重度使用多模态、搜索、文件检索的项目。内置工具的增量价值非常大省去的不是几行代码而是一整套外部服务。第三工具编排复杂的 Agent。Responses 的output条目流对调试、日志、链路追踪都更友好模型每一次工具调用的意图都清晰可见。5.2 建议按兵不动的场景如果你的项目已经稳定跑了很久而且没有复杂工具链就是简单的文本问答或内容生成我建议别急着动。Chat Completions 的存量生态太庞大了你的第三方库、监控脚本、日志解析器可能都是按choices[0].message.content写的。为了一个“接口更干净”迁过去却要把所有下游逻辑重新验证一遍投入产出比不高。另外如果你依赖某些非 OpenAI 的模型服务并使用了它们的 Chat Completions 兼容端点那也要先确认对方是否同时提供了 Responses 兼容端点。很多开源模型服务只维护了/chat/completionsResponses 请求砸过去大概率 404。这种场景下强行迁移等于把所有请求都卡在网关翻译层上。5.3 迁移验收清单我自己总结了一份清单每次迁完一个项目就照着过一遍纯文本多轮对话是否正常用previous_response_id连续问三轮确认状态不丢。工具调用的参数是否完整让模型调用一个带 5 个参数的工具检查arguments是否逐个字段都能正确解析。流式事件是否全部处理至少跑一遍response.output_text.delta、response.function_call_arguments.delta、response.completed三条路径。计费明细是否接入监控确认token_details字段能正常读取并落库。网络与超时是否有兜底Responses API 在长任务上的响应时间可能比 Chat Completions 更久因为内置搜索和文件检索都要时间客户端超时设置要留足余量。另外还有一个容易被忽略的点如果你的代码里大量使用finish_reason tool_calls来判断是否要继续工具调用切到 Responses 之后要改成检查response.output里有没有function_call条目。这个判断逻辑跟老接口完全不一样别只改端点忘了改终止条件。结尾最后分享一个小技巧。判断一个开源网关到底是不是真的实现了 Responses最直接的办法不是看 README而是给它的服务端点抓个包看它发往上游模型的请求 URL 到底是/v1/responses还是/v1/chat/completions。如果上游请求还停留在 chat/completions那不管入口做得再怎么像它的 Responses 支持都只是壳。我在自己的网关里就抓到过这样的例子说是支持新协议底层全给翻译回补全接口了。把这层遮羞布揭开之后你对所谓“开源兼容”的判断就不会只停留在文档层面了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。