
先简单交代下背景。上个月我把一个内部工具从chat/completions迁到responses本来以为就是换换参数名结果第一轮联调就撞上一面墙。服务端直接甩回来[error] unexpected endpoint or method. (post /chat/completions). returning 2排查到最后发现不是我的请求格式写错了而是请求根本就没到达我以为的那个端点。从那会儿开始我才认真把 Completions 到 Responses 这条规范演进路线从头捋了一遍也顺带把开源兼容这层窗户纸捅破了。这篇文章打算把这些梳理结果、踩坑过程和排查思路完整记录下来给同样在做接口迁移或者自建兼容层的朋友做个参考。1. 这次报错迁移是怎么被引爆的先说当时的具体场景。我维护的那个内部工具集成了 OpenAI 的 Chat Completions 接口用来做多轮对话补全。因为项目迭代需要支持更精细的意图追踪和更稳定的输出控制官方文档里主推的已经是 Responses API所以决定把调用层整体切过去。改动本身不算复杂我也照常建了个新函数指向POST /v1/responses把messages数组改成input参数模型名加上版本后缀。但跑测试的时候日志里反复出现上面那条报错。一开始以为是 SDK 缓存了旧端点把 Python 和 Node 的 SDK 都升到最新版问题还在。后来用裸curl直接打https://api.openai.com/v1/responses居然同样报错。那一刻我就知道锅不在我这边。仔细对比之后发现当时调用的域名走的是一个自建的网关层网关基于白名单做路由只把chat/completions转发到了上游新端点responses压根不在转发规则里请求被网关直接拦下来返回了那段unexpected endpoint or method。这个经历典型的点在于每次官方新端点发布最大的坑往往不是接口格式变动而是中间链路——包括网关、SDK、代理层、缓存策略——没有跟着更新。很多人抱怨接口规范演进不兼容开源实现根源往往就在这里。2. 从 Chat Completions 到 Responses 到底改了哪几层2.1 请求结构的变化从 messages 到 input早期用 Chat Completions 的时候每次请求大概长这样{ model: gpt-4o, messages: [ {role: system, content: 你是一个耐心的助手}, {role: user, content: 帮我解释一下量子纠缠} ], temperature: 0.7, max_tokens: 500 }这套设计得非常线性客户端把上下文一股脑传给模型模型一次性吐回结果。简单场景下够用可一旦有工具调用、多轮检索、结果修正这些复杂动作状态就分散在不同请求里服务端也没法统一管理。Responses API 把入口改成了input{ model: gpt-4o, input: [ { role: developer, content: 你是一个耐心的助手 }, { role: user, content: 帮我解释一下量子纠缠 } ], tools: [], store: true }光看形式好像只是字段名变了但背后的思路变化值得展开讲。input已经不只是一串聊天记录它被设计成一种统一的输入事件流可以把用户消息、前一步工具返回的结果、甚至系统级指令都塞进去。同时新增的store、previous_response_id这些参数把上下文管理这件事从客户端挪到了服务端。也就是说原来你要自己把全量对话历史反复传给接口现在可以拿previous_response_id串联多轮调用。服务端记住上一次的状态新一轮直接基于它继续推演。这对复杂 Agent 应用有根本性影响——省掉了大量上下文重复传输也避免了手工组装历史时容易漏消息的问题。2.2 输出结构的变化流式事件取代单一回包Completions 时代的输出就是一个干净的 JSON{ id: chatcmpl-xxx, choices: [ { message: { role: assistant, content: 量子纠缠是一种…… }, finish_reason: stop } ], usage: { prompt_tokens: 20, completion_tokens: 80, total_tokens: 100 } }应用层拿到choices[0].message.content就能直接用。到了 Responses API非流式返回的结构大致是这样{ id: resp_xxx, output: [ { type: message, role: assistant, content: [ { type: output_text, text: 量子纠缠是…… } ] } ], usage: { input_tokens: 20, output_tokens: 80, total_tokens: 100 } }差异点集中在两处。output是一个数组而数组元素并不保证只有message一种 type。它可能是function_call、web_search_call、reasoning甚至以后会出现新的类型。这意味着客户端不能再假设返回一个字符串就完事而是要遍历output根据type分发处理。首次迁移的时候这块代码改动量最大也最容易踩坑——很多人直接从choices[0].message.content改成output[0].content[0].text结果遇到函数调用就直接取空值。另一点是usage字段新版把prompt_tokens拆成更细粒度的input_tokens并且单列了reasoning_tokens。别小看这一个拆分的语义变化它对成本核算有直接影响。如果你在做一个计费系统老接口只需要统计两个数新接口你得清楚哪些 token 属于推理思维链、哪些属于实际输出因为官方部分模型对思维链 token 计费规则不一样。不拆分的话后面对账会非常痛苦。2.3 可解释性与调试体验的差异Responses API 还有一个值得单独拿出来说的设计它把推理过程外显成了结构化事件。在使用 Completions 接口时如果你用流式模式最多能看到一系列delta增量片段模型内部到底经历了什么对开发者是一团黑盒。而 Responses API 流式时可能出现response.reasoning_summary_text、response.failed_agent_turn、response.incomplete这类事件。我当时第一次看到reasoning_summary_text这个事件类型的时候第一反应是这不就是官方把思维链的摘要下放给开发者了吗虽然只是摘要而非完整思维链但排障时非常有用。比如面对同一个问题模型输出两个不同版本的答案你至少能从摘要里看出来模型在哪个环节做了关键判断而不至于一脸懵。跳过这段为什么改结构的铺垫下面说说作为普通开发者最关心的老代码到底还能不能继续用。3. Chat Completions 为什么没被一刀切以及什么时候还应该用它3.1 官方保留它的真实原因官方没有立刻下线 Chat Completions核心原因用一个词概括是存量生态。从 2023 年初到现在Chat Completions 这个接口名几乎成了 LLM 应用的默认协议。LangChain 早期版本、LlamaIndex、各种开源机器人框架底层全部按它的格式封装了一遍。如果直接下线等于把整个开源生态的轮子全拆了。更实际的原因是Responses API 的设计目标并不是取代 Chat Completions 在简单对话场景里的位置而是给需要复杂编排的应用提供一个更高层入口。OpenAI 自己给的定位很明确Chat Completions 是直接的、无状态的补全调用Responses 是有状态、可组合的智能体协议。对很多场景来说无状态补全反而是一种优势。举个例子你只想让模型做一次文本分类、关键词抽取或者一次性的翻译任务开一个 Responses 会话存储状态完全是浪费。请求少、上下文短、不需要工具调用这时候 Chat Completions 依然是最省事的选择。3.2 一张表看清迁移取舍我给团队整理过一张迁移决策表先直接列出来对比维度Chat CompletionsResponses API请求入口POST /v1/chat/completionsPOST /v1/responses消息字段messagesinput输出字段choices[0].messageoutput[]可多类型上下文管理客户端自行拼接storeprevious_response_id工具调用function calling 参数声明tools function_call 输出类型内置检索/网页搜索不直接支持原生支持web_search流式事件choices[].deltaresponse.output_text.delta等适合场景轻量无状态调用多轮 Agent、工具编排、复杂状态这张表在团队内部文档里一直保留到现在。明显能看出来如果你做的是给我一段话还我一个结果这种一锤子买卖留在 Completions 完全没有问题如果你要搞多步推理、让模型自己决定调用哪些工具、并且需要让服务端帮你保存中间状态那应该尽早迁移到 Responses。3.3 迁移时老参数怎么映射很多文章只讲两个接口请求字段不同了没细讲参数映射关系。这里把我实际迁移用到的映射表也放出来messages→input基本等价注意角色名。system在 Responses 里通常用developer代替某些模型同时接受system但官方向导明确建议迁移到developer。max_tokens→max_output_tokens语义更明确明确是输出侧上限避免误解。function_call→toolstool_choice声明方式基本一致但返回结构变了函数名和参数从message.tool_calls移到了output[].type function_call的arguments/name字段。stream→stream参数名没变但流式事件名和结构需要重写解析逻辑。stop→stop兼容保留。temperature/top_p兼容保留个别模型不支持时会忽略或报错需要注意模型文档。迁移过程中最隐蔽的是max_tokens与max_output_tokens。两个接口在同一个模型上如果max_tokens被超范围赋值有的模型会直接拒绝请求有的模型虽然接受但长度截断行为不一样。这不是 Response 接口特有的问题但迁移过程中不改这个名字后面很容易跟计费和截断问题搅在一起。4. 开源兼容的真相为什么支持 OpenAI 兼容不能直接信4.1 OpenAI 兼容通常指的是什么开源生态里几乎所有 LLM 网关、本地推理框架比如 vLLM、llama.cpp 的 server 模式、各类代理项目都宣称支持 OpenAI API。很多人一听支持 OpenAI API就下意识觉得那 Chat Completions 和 Responses 都该支持——这是最普遍的误解。实际情况是到目前为止市面上绝大多数兼容 OpenAI的实现兼容的是 Chat Completions 协议也就是POST /chat/completions这一套。原因很简单Chat Completions 的格式是先发、先普及、先形成事实标准的。Responses API 因为引入状态管理和事件流实现成本高很多开源项目的跟进明显滞后。这不只是少支持一个新端点的问题。如果你用 Responses API 语法去调一个只实现了 Chat Completions 的网关大多数情况下不会收到功能不支持这样的明确提示而是直接报开头那种unexpected endpoint or method错误或者返回 404。从排障角度看这种反馈方式相当不友好。4.2 自建兼容层时我在 routing 规则上总结的教训我自己维护过一个小型路由层专门给团队内部的不同模型品牌做统一入口。当时犯过的错误和很多网关项目的通病一模一样路由规则按 path 精确匹配新端点一出现旧配置就漏了。正确做法是把路由设计成二维判断先按 path 判断端点类型再按模型名判断上游支持而不是只匹配 path。我后来改造后的伪规则是这样的function routeRequest(path, model) { if (path /v1/chat/completions) { return handleChatCompletion(model); } if (path /v1/responses) { if (supportsResponses(model)) { return handleResponses(model); } else { return emitUnsupportedEndpoint(model); } } return 404; }看起来很简单但真的把supportsResponses(model)这个判断落地的项目少之又少。很多网关直接把所有模型都当作支持全部端点来处理结果就是请求到了上游上游返回不支持网关再把上游的错误原样抛给用户用户一脸茫然。如果你自己搭建的就是这种兼容网关我最实在的建议是加一个能力矩阵配置明确每个上游模型支持哪些端点、哪些参数、哪些功能类型。宁可多写几百行配置也别让用户在报错堆里猜。4.3 开源客户端库的真实跟进情况再说回开源客户端库。以我实际用过的几个为例情况可以分为几档官方 Python SDK新端点支持最及时client.responses.create(...)早就稳了。官方 Node SDK同步更新openai.responses.create(...)可用但流式事件类型定义有一点滞后。社区维护的多品牌 SDK差异非常大。有的主推自家抽象协议底层只映射了 Chat Completions即使接口文档写明支持 Responses API内部实现也是把请求翻译回 chat 格式再转发。第三档是最容易产生假兼容的地方。比如某些支持多种模型的框架对外统一暴露一套方法你传modelgpt-4o时走 Responses传modelanother-model时自动退回 Chat Completions。这种设计本身没问题但如果你拿它做全量迁移验证就会发现不同模型在工具调用返回结构上存在细微差别导致解析层逻辑没法统一。这也是开源兼容最容易被忽略的一层兼容的不只是 HTTP 端点还包括输出结构、事件类型、错误语义。5. 从踩坑到稳定我建议的迁移路径和验收标准5.1 先切一个功能跑通而不是全量搬迁拿我自己的项目来说最开始的迁移诉求是多轮对话 工具调用 服务端存储上下文。如果一次性把所有对话场景都迁过去排查问题的面就太大了。我建议按下面的节奏推进第一阶段选一个低风险功能比如单轮问答 固定工具切到 Responses。验证基础请求、输出解析、计费字段正确。第二阶段把多轮逻辑从客户端拼接改为服务端store模式重点测previous_response_id的有效性和上下文隔离。第三阶段再把动态工具注册、流式输出、异常重试逐步搬过来。每个阶段验收的时候都明确记录旧接口行为 vs 新接口行为的差异特别是超时、断连重试时返回的事件结构差异。这是我踩过的一个大坑Responses API 流式模式下网络断开会多出一个response.incomplete事件老代码按 Chat Completions 的流式逻辑解析会把这种事件当成未知字段丢掉导致 UI 上已经断了但进度条还在转。5.2 用契约测试而不是联调测试来保护兼容层所谓契约测试简单说就是把接口的行为固化下来每次改动后自动验证。我的做法是针对 Responses API 单独跑一份测试集核心断言包括请求POST /v1/responses返回 200id以resp_开头。output数组里消息元素的content[0].type是output_text不是老的text。工具调用场景中output里至少有一个type function_call的元素且name、arguments非空。流式模式下能收到response.completed事件且其中包含response.usage。错误场景发送max_output_tokens为 0 的请求应返回明确错误而不是静默截断。这套测试集我把它们固化在 CI 里每次升级 SDK、换网关配置、新增模型品牌都自动跑一遍。几次小版本更新都因为它拦下了不该上线的改动。如果你是刚接触这个接口我的建议是先照着官方文档写一组简单的契约用例再开始功能开发别功能写完了才发现有的行为跟你预期完全不同。5.3 调参和计费核对上的几个关键点最后聊几个实操里容易忽略的点。第一storetrue时服务端会保留会话内容。这个配置在同一组织下的权限管控里非常重要。如果你在一台共享机器上调试开了 store 又不清理别人可能通过GET /v1/responses/{id}看到你的历史输入输出。我的做法是非必要不开 store开 store 的话单独用一个隔离的 API key。第二流式响应里 token 计费以response.completed事件中的 usage 为准。有些人图省事直接数output_text.delta里的字符长度然后自己估算 token这在中文场景下误差非常大。别自己算直接用服务端给的 usage。第三reasoning_tokens这个字段不要忽略。从 Completions 迁移过来的时候如果按照老的prompt_tokens completion_tokens逻辑去统计成本你会发现账单对不上因为推理 token 可能单独计费或者被合并到 output 计费里。每款模型规则不一样项目里最好有一张本地映射表按模型维护计费规则。6. 复盘这次迁移让我对接口规范演进有了哪些新认识整个过程走完之后我对接口规范演进这件事的认识比单纯看文档要深得多。接口规范从来不只是请求长什么样的问题它牵着数据模型、状态管理、错误语义、计费口径甚至兼容层的路由逻辑。Responses API 和 Chat Completions 的关系类似从无状态函数调用逐步演化到有状态会话协议。判断一个项目要不要跟着迁移核心标准不是官方主推什么而是你的应用到底需不需要服务端状态、复杂工具编排和统一事件流。如果不需要留在 Chat Completions 一点问题没有。如果确实需要尽早看清楚它和你中间链路的关系尤其是自建网关和开源 SDK 的真实能力能少走不少弯路。我在迁移过程中有一个强烈的体会排查这类问题最好的工具不是盲改代码而是先确认请求到底到了哪一层、是哪一层拒绝了它。上文那个报错用一句话总结就是看起来是接口问题实际是路由器没跟上。把这一层想通之后后面所有参数映射、结构解析、计费核对都是在确认已知规则而不是在黑暗中摸索。最后再分享一个小技巧当你切换接口版本时在客户端代码里显式打印一下完整请求 URL 和响应头特别是server字段。很多时候请求到底打到了官方网关、自建网关、还是某个本地缓存服务看一眼响应头就能定位比在一堆堆栈日志里翻找快得多。这条经验我已经在团队内部安利过好几轮了。