资讯动态

OpenAI接口演进:从Chat Completions到Responses的兼容与迁移实战

发布时间:2026/10/2 19:58:07 来源:尧图企业网站定制
1. 接口演进背后的真实驱动力1.1 从补全到对话一次范式转移如果你在两年前问我怎么调 OpenAI 的接口我会直接甩给你一段openai.Completion.create()的代码参数里塞个prompt模型吐一段续写完事。那时候的接口设计逻辑非常朴素——你给一段文本模型接着往下写本质上就是个高级版的自动补全。text-davinci-003那个年代大家写的代码几乎都长一个样temperature、max_tokens、top_p这几个参数翻来覆去地调谁也没觉得有什么不对劲。但问题很快就暴露了。当你试图用补全接口做一个多轮对话系统时你会发现自己在做一件极其别扭的事情手动拼接历史消息。你得自己维护一个字符串数组把用户说的话、AI 回的话按顺序拼成一大段文本还要小心翼翼地处理角色标记、分隔符、换行格式。更麻烦的是不同模型对格式的敏感度不一样今天调好的拼接模板明天换个模型就崩了。这种把对话硬塞进补全框架的做法本质上是用错误的抽象层去解决一个结构化的问题。Chat Completions的出现就是对这个痛点的直接回应。它把消息变成了结构化的数组每条消息有明确的role字段——system、user、assistant、tool各司其职。这个设计看起来只是换了个数据格式但它带来的连锁反应是巨大的多轮对话的状态管理变得清晰工具调用的结果可以自然地插入对话流系统提示词有了独立的承载位置。你可以理解为补全接口是给模型一张白纸让它接着写而对话接口是给模型一个结构化的会议记录让它参与讨论。再往后看Responses接口的推出其实是这条演进路线的自然延伸。当模型不再只是生成文本而是要执行任务——调用工具、检索知识、分步推理、返回结构化结果——对话接口的那套消息数组也开始显得不够用了。Responses试图把输入和输出都统一成事件流的概念每一次交互都是一个有类型、有顺序的事件模型可以在一次请求中完成多轮内部推理和工具调用最终返回一个聚合的结果。这不是简单的 API 改名而是对模型能做什么这个问题的重新定义。1.2 为什么接口规范一变再变很多人抱怨 OpenAI 的接口老是变今天Chat Completions还没用熟明天又冒出个Responses。但如果你站在 API 设计者的角度想这种演进其实是被能力倒逼的。模型的能力边界在扩张接口作为能力的暴露层不可能一成不变。早期的补全接口假设了一个前提模型的输出就是文本你拿到文本自己去解析。这个假设在纯生成场景下没问题但一旦涉及工具调用你就得在文本里约定一套解析规则——比如让模型输出 JSON然后你自己去json.loads()。这种做法极其脆弱模型稍微不听话你的解析就炸了。Chat Completions引入的tool_calls字段本质上是把模型要调工具这件事从自由文本提升为结构化协议让调用方不用再猜。而Responses走得更远。它不再把一次请求看作一问一答而是看作一个任务的生命周期。在这个生命周期里模型可能先思考、再调工具、拿到结果后继续思考、最后给出答案。这些中间步骤在Chat Completions里是隐式的——你只能看到最终的message中间的推理过程要么被藏在content里要么根本不可见。Responses把这些中间事件显式地暴露出来让开发者能观察到模型怎么想的也能在必要时介入。从工程角度看这种演进解决了一个核心矛盾接口的稳定性和能力的扩展性之间的张力。如果接口永远不变新能力就没法优雅地暴露如果接口频繁大改存量代码就遭殃。OpenAI 的策略是保留Chat Completions作为兼容层同时用Responses承载新能力让开发者按需迁移。这个策略是否成功另说但逻辑上是说得通的。1.3 开源兼容层的真实处境标题里提到开源兼容真相这个词用得很准。市面上有大量项目声称兼容 OpenAI 接口但兼容这两个字的含金量差异极大。有的只是把Chat Completions的请求格式翻译成自己的后端格式有的连tool_calls都不支持还有的虽然支持但行为细节和官方不一致。我实际测过不少号称兼容 OpenAI 的服务踩过的坑包括stream模式下delta的字段结构不对、finish_reason永远返回stop、tool_calls的id不唯一导致后续消息关联失败、system角色被静默忽略等等。这些问题的根源在于很多兼容层只看了官方文档的请求示例没有深入研究响应语义和边界行为。更微妙的是Responses接口的兼容。由于Responses的事件流模型比Chat Completions复杂得多目前真正完整兼容的开源实现屈指可数。大部分项目要么只做Chat Completions要么对Responses的支持停留在能返回文本的层面事件类型、工具调用循环、状态管理这些核心机制根本没有对齐。所以当你在选型时看到兼容 OpenAI的宣传一定要问清楚兼容的是哪个接口兼容到什么程度边界行为是否一致2. 核心接口的细节拆解与实操要点2.1 Chat Completions 的消息结构设计Chat Completions的核心是messages数组每条消息的role决定了它的语义。system消息用于设定模型的整体行为user消息代表用户输入assistant消息是模型的回复tool消息承载工具调用的结果。这个设计看起来简单但实际使用中有很多细节需要注意。首先是system消息的位置。官方文档说system消息应该放在最前面但实际测试中如果你把system消息放在中间模型有时也会遵循但行为不稳定。我的做法是永远把system放在数组第一个位置不给自己找麻烦。另外多个system消息的行为在不同模型上不一致有的模型会合并处理有的只认第一条所以最好只放一条。其次是assistant消息中的tool_calls字段。当模型决定调用工具时它会返回一个assistant消息其中content可能为null但tool_calls数组非空。每个tool_call包含id、type和function三个字段function里又有name和arguments。这里的关键是你必须把这条assistant消息原样放回对话历史然后用tool角色消息返回结果tool_call_id必须和之前的id对应。我见过太多人直接把工具结果作为user消息塞回去结果模型完全懵了。# 正确的工具调用消息流 messages [ {role: system, content: 你是一个天气助手}, {role: user, content: 北京今天天气怎么样}, # 模型返回的 assistant 消息包含 tool_calls {role: assistant, content: None, tool_calls: [ {id: call_abc123, type: function, function: {name: get_weather, arguments: {city: 北京}}} ]}, # 工具执行结果tool_call_id 必须对应 {role: tool, tool_call_id: call_abc123, content: {temp: 25, condition: 晴}} ]还有一个容易忽略的点是content的类型。早期content只能是字符串后来支持了数组形式可以混合文本和图片。如果你要传图片content就变成[{type: text, text: ...}, {type: image_url, image_url: {url: ...}}]这样的结构。这个变化导致一些老代码在遇到多模态输入时会报错因为它们的解析逻辑假设content永远是字符串。2.2 Responses 接口的事件流模型Responses接口最让人不适应的地方是它不再返回一个简单的message对象而是返回一个事件流。每个事件有type字段比如response.created、response.output_text.delta、response.function_call_arguments.done、response.completed等等。这种设计的好处是你可以实时处理模型的每一步输出而不必等整个响应完成。但这也带来了新的复杂度。在Chat Completions里你只需要处理choices[0].delta.content和choices[0].delta.tool_calls两种增量。在Responses里你需要处理的事件类型多得多而且事件之间有顺序依赖。比如response.output_item.added事件会告诉你一个新的输出项开始了后续的response.content_part.added和response.output_text.delta都属于这个输出项。如果你不维护这个状态机解析就会乱套。我实际写过一个Responses的流式解析器核心逻辑是维护一个current_item和current_content_part的引用根据事件类型更新状态。代码大概长这样current_item None current_part None for event in stream: if event.type response.output_item.added: current_item event.item elif event.type response.content_part.added: current_part event.part elif event.type response.output_text.delta: print(event.delta, end) elif event.type response.function_call_arguments.delta: # 累积工具调用参数 pass elif event.type response.completed: break这里的关键经验是不要试图把所有事件都处理一遍先搞清楚你真正需要哪些事件。如果你只是要文本输出那response.output_text.delta就够了如果你要工具调用那需要关注response.function_call_arguments.delta和response.output_item.done。事件类型太多全处理只会增加出错概率。2.3 工具调用的参数传递陷阱工具调用是这两个接口里最容易出问题的部分。Chat Completions的tools参数是一个数组每个工具定义包含type: function和function对象function里有name、description和parameters。parameters是 JSON Schema 格式描述工具接受的参数。我踩过的最大的坑是parameters的required字段。如果你不指定required模型可能会省略某些参数然后你的工具函数就会因为缺少参数而报错。更隐蔽的是即使你指定了required模型有时也会返回不符合 schema 的参数——比如该传数字的地方传了字符串。所以工具函数的入口一定要做参数校验不能信任模型的输出。另一个坑是strict模式。OpenAI 后来引入了strict: true选项要求模型严格按照 schema 输出参数。这个选项确实能减少参数错误但它对 schema 的要求也更严格——比如additionalProperties必须显式设为false所有字段都必须在properties里声明。如果你开了strict但 schema 不符合要求请求会直接报错。在Responses接口里工具调用的机制又变了。工具定义放在tools参数里但格式和Chat Completions不完全一样。Responses支持更多工具类型比如file_search、web_search、computer_use等内置工具也支持自定义函数。自定义函数的定义方式和Chat Completions类似但调用结果的返回方式不同——在Responses里工具调用结果是通过response.function_call_output事件返回的而不是像Chat Completions那样作为一条tool消息。这个差异导致的一个实际问题是如果你在Chat Completions和Responses之间切换工具调用的代码几乎要重写。我建议的做法是抽象一层工具调用的适配器把接口差异封装起来业务逻辑只关心调用了什么工具、传了什么参数、返回了什么结果。3. 实操过程与核心环节实现3.1 从零搭建一个兼容双接口的调用层假设你要写一个库同时支持Chat Completions和Responses并且能在两者之间切换。我的做法是定义一个统一的请求模型然后在内部做转换。首先定义统一的消息格式from dataclasses import dataclass, field from typing import Optional, List, Dict, Any dataclass class UnifiedMessage: role: str # system, user, assistant, tool content: Optional[str] None tool_calls: Optional[List[Dict]] None tool_call_id: Optional[str] None name: Optional[str] None dataclass class UnifiedRequest: messages: List[UnifiedMessage] model: str tools: Optional[List[Dict]] None stream: bool False temperature: float 1.0 max_tokens: Optional[int] None然后写两个转换函数一个把UnifiedRequest转成Chat Completions的格式一个转成Responses的格式。Chat Completions的转换比较直接Responses的转换需要把messages数组拆成input数组并且把system消息提取到instructions参数里。def to_chat_completions(req: UnifiedRequest) - Dict: return { model: req.model, messages: [ {k: v for k, v in { role: m.role, content: m.content, tool_calls: m.tool_calls, tool_call_id: m.tool_call_id, name: m.name, }.items() if v is not None} for m in req.messages ], tools: req.tools, stream: req.stream, temperature: req.temperature, max_tokens: req.max_tokens, } def to_responses(req: UnifiedRequest) - Dict: instructions None input_items [] for m in req.messages: if m.role system: instructions m.content elif m.role tool: input_items.append({ type: function_call_output, call_id: m.tool_call_id, output: m.content, }) else: input_items.append({ type: message, role: m.role, content: m.content, }) return { model: req.model, instructions: instructions, input: input_items, tools: req.tools, stream: req.stream, temperature: req.temperature, max_output_tokens: req.max_tokens, }这个转换层的价值在于业务代码只需要构造UnifiedRequest不用关心底层用的是哪个接口。切换接口只需要改一个配置项。当然这个转换层不可能覆盖所有差异比如Responses的内置工具在Chat Completions里没有对应物这种就需要在业务层做条件判断。3.2 流式响应的统一处理流式响应是另一个需要适配的地方。Chat Completions的流式返回是 SSE 格式每个 chunk 是一个 JSON包含choices[0].delta。Responses的流式返回也是 SSE但事件类型更多。我写了一个统一的流式处理器把两种流都转换成统一的StreamEventdataclass class StreamEvent: type: str # text_delta, tool_call_start, tool_call_delta, done text: Optional[str] None tool_call_id: Optional[str] None tool_name: Optional[str] None tool_arguments: Optional[str] None def parse_chat_completions_stream(chunk: Dict) - Optional[StreamEvent]: choice chunk[choices][0] delta choice.get(delta, {}) if delta.get(content): return StreamEvent(typetext_delta, textdelta[content]) if delta.get(tool_calls): tc delta[tool_calls][0] if tc.get(function, {}).get(name): return StreamEvent( typetool_call_start, tool_call_idtc.get(id), tool_nametc[function][name], ) if tc.get(function, {}).get(arguments): return StreamEvent( typetool_call_delta, tool_argumentstc[function][arguments], ) if choice.get(finish_reason): return StreamEvent(typedone) return None def parse_responses_stream(event: Dict) - Optional[StreamEvent]: etype event.get(type) if etype response.output_text.delta: return StreamEvent(typetext_delta, textevent[delta]) if etype response.output_item.added: item event[item] if item.get(type) function_call: return StreamEvent( typetool_call_start, tool_call_iditem.get(call_id), tool_nameitem.get(name), ) if etype response.function_call_arguments.delta: return StreamEvent(typetool_call_delta, tool_argumentsevent[delta]) if etype response.completed: return StreamEvent(typedone) return None这个统一层的好处是上层的业务逻辑只需要处理StreamEvent不用关心底层是哪个接口。实测下来这套抽象在大多数场景下都能正常工作唯一的例外是Responses的reasoning事件——如果你用的是推理模型Responses会返回推理过程的 delta而Chat Completions不暴露这个。如果你需要展示推理过程就得在统一层里加一个reasoning_delta事件类型。3.3 工具调用的完整闭环实现工具调用的完整流程包括定义工具、发送请求、解析工具调用、执行工具、返回结果、获取最终回复。这个流程在两个接口里的实现差异较大我分别说一下。Chat Completions的流程在请求里带上tools参数模型返回assistant消息tool_calls非空把这条assistant消息加入历史执行工具把结果作为tool消息加入历史tool_call_id对应再次发送请求模型基于工具结果生成最终回复Responses的流程在请求里带上tools参数模型返回的事件流里包含function_call类型的输出项执行工具把结果作为function_call_output输入项再次发送请求模型基于工具结果生成最终回复注意Responses的第二步和第三步之间你不需要手动维护消息历史——Responses接口本身是有状态的你可以用previous_response_id来关联上一次的响应。这是Responses和Chat Completions的一个根本区别Chat Completions是无状态的每次请求都要带上完整历史Responses可以是有状态的服务端帮你维护上下文。这个差异对代码结构影响很大。用Chat Completions时你需要自己管理一个messages列表每次请求都把它传进去。用Responses时你可以只传新的输入然后用previous_response_id关联。但这也带来了新的问题如果服务端的状态丢了你的对话就断了。所以我的做法是即使Responses支持有状态我也在本地维护一份完整历史必要时可以重建。4. 常见问题与排查技巧实录4.1 兼容层的典型故障速查在实际使用各种兼容 OpenAI 接口的服务时我整理了一份常见问题速查表问题现象可能原因排查方法解决方案流式响应中delta为空兼容层没有正确透传增量打印原始 SSE 数据检查兼容层是否解析了delta字段tool_calls的id重复兼容层没有生成唯一 ID连续调用两次工具对比 ID在客户端重新生成 IDfinish_reason永远是stop兼容层没有映射结束原因触发max_tokens限制观察返回值手动根据usage判断是否截断system消息被忽略兼容层不支持system角色发一条强约束的system消息看模型是否遵循把system内容合并到第一条user消息图片输入报错兼容层不支持多模态content发送数组格式的content降级为纯文本或换支持多模态的服务Responses事件类型缺失兼容层只实现了部分事件打印所有收到的事件类型补全事件处理逻辑或改用Chat Completions这张表里的每一条都是我实际踩过的坑。比如finish_reason那个问题我在一个兼容服务上跑了很久才发现模型明明被max_tokens截断了但finish_reason还是返回stop导致我的重试逻辑完全失效。后来我改成检查usage.completion_tokens是否等于max_tokens如果是就认为被截断了。4.2 参数传递的隐蔽错误工具调用的参数传递是最容易出隐蔽错误的地方。我遇到过几种典型情况第一种是参数类型不匹配。模型返回的arguments是一个 JSON 字符串你需要json.loads()之后才能用。但有些兼容层会直接返回一个 dict如果你不检查类型json.loads()就会报错。我的做法是先判断类型如果是字符串就解析如果是 dict 就直接用。第二种是参数缺失。即使你在 schema 里标了required模型有时也会漏掉某些参数。特别是当参数很多的时候模型容易忘记中间的某个。我的做法是在工具函数入口做参数校验缺失的参数用默认值填充或者直接返回错误让模型重试。第三种是参数名大小写不一致。JSON Schema 是大小写敏感的但模型有时会把userName写成username。这个问题在strict模式下会好很多但如果你没开strict就得自己处理。我的做法是在 schema 里尽量用全小写的参数名减少大小写出错的概率。4.3 流式解析的状态管理流式解析最容易出的问题是状态管理混乱。特别是在Responses接口里事件之间有复杂的嵌套关系如果你不维护状态解析出来的内容就会错位。我总结了一个原则每个事件类型只做一件事状态更新和内容输出分离。比如response.output_item.added事件只负责更新current_item不输出任何内容response.output_text.delta事件只负责输出文本不更新状态。这样即使事件顺序有轻微异常也不会导致内容错乱。另一个经验是永远不要假设事件的顺序。虽然官方文档描述了事件的典型顺序但实际使用中由于网络延迟或服务端实现差异事件顺序可能会有变化。我的做法是在解析器里维护一个状态机根据当前状态决定如何处理事件而不是根据事件顺序硬编码逻辑。还有一个实际问题是流式响应的中断处理。如果网络断了或者用户取消了请求你需要正确地清理状态。我见过一些代码在流式响应中断后current_item还停留在旧值导致下一次请求的内容混入了上一次的残留。我的做法是在每次请求开始时重置所有状态变量确保干净起步。4.4 模型选择与接口匹配不同的模型对接口的支持程度不一样。比如gpt-4o系列同时支持Chat Completions和Responses但一些老模型只支持Chat Completions。推理模型如o1、o3系列在Responses接口下的表现和Chat Completions下差异很大特别是在工具调用和推理过程暴露方面。我的建议是如果你要用推理模型优先用Responses接口因为它能更好地暴露推理过程也支持更多的工具类型。如果你用的是普通对话模型Chat Completions就够用了没必要为了新接口而新接口。如果你需要兼容多个模型那就用我前面说的统一调用层把接口差异封装起来。还有一个实际问题是max_tokens的默认值。Chat Completions的max_tokens默认值在不同模型上不一样有的模型默认值很小导致回复被截断。Responses的max_output_tokens也有类似问题。我的做法是永远显式设置这个参数不依赖默认值。具体设多少根据你的场景来——如果是短回复设 256 就够了如果是长文生成设 4096 或更高。5. 开源兼容的选型与自建策略5.1 如何评估一个兼容层的质量评估一个兼容层是否靠谱不能只看它能不能返回结果。我通常会做几个测试第一个测试是边界行为测试。发一个max_tokens设为 1 的请求看finish_reason是否正确返回length。发一个包含tool_calls的请求看id是否唯一、arguments是否是合法的 JSON 字符串。发一个包含图片的请求看是否正确处理多模态content。第二个测试是流式一致性测试。用同样的请求分别走流式和非流式对比最终结果是否一致。我遇到过一些兼容层非流式返回正常但流式返回的内容缺斤少两。第三个测试是错误处理测试。发一个故意错误的请求比如model字段填一个不存在的模型看返回的错误格式是否和官方一致。官方返回的错误是一个 JSON包含error对象里面有message、type、code等字段。如果兼容层返回一个纯文本错误或者格式不对你的错误处理逻辑就会失效。第四个测试是并发测试。同时发多个请求看是否有竞态条件。我遇到过一些兼容层在并发时会把不同请求的响应搞混特别是流式响应。5.2 自建兼容层的核心考量如果你决定自建一个兼容层有几个核心问题需要想清楚。首先是状态管理。Chat Completions是无状态的每次请求都要带上完整历史。如果你的后端是有状态的比如某些自研模型服务你需要决定是在兼容层里维护会话状态还是要求客户端每次传完整历史。我的建议是兼容层保持无状态和官方行为一致这样客户端的代码不用改。其次是工具调用的实现。如果你的后端模型不支持原生工具调用你需要在兼容层里用提示词工程模拟。具体做法是在system消息里注入工具定义的描述要求模型以特定格式输出工具调用。然后兼容层解析模型的输出转换成标准的tool_calls格式。这种做法的问题是不稳定模型可能不按格式输出。我的做法是加一层重试机制如果解析失败就重新请求。第三是流式响应的实现。如果你的后端不支持流式你需要在兼容层里把完整响应拆成多个 chunk模拟流式输出。这个模拟的质量直接影响用户体验。我的做法是按标点符号或固定长度拆分每个 chunk 之间加一个小延迟让输出看起来更自然。第四是错误映射。不同后端的错误格式不一样你需要把它们映射成 OpenAI 的标准错误格式。这个映射表需要覆盖常见的错误类型认证失败、配额不足、模型不存在、请求格式错误、服务端错误等。5.3 迁移策略与回退方案如果你现在用的是Chat Completions想迁移到Responses我的建议是分步走。第一步是抽象调用层。把现有的Chat Completions调用封装到一个统一的接口后面业务代码不直接调 SDK。这样后续切换接口时只需要改封装层。第二步是并行测试。在封装层里同时支持两个接口用配置项控制走哪个。然后在测试环境里并行跑一段时间对比两个接口的输出差异。重点关注工具调用、流式输出、错误处理这几个方面。第三步是灰度切换。在生产环境里先切一小部分流量到Responses观察稳定性和性能。如果没问题再逐步扩大比例。第四步是保留回退能力。即使全量切到Responses也要保留切回Chat Completions的能力。因为新接口可能有未知的坑保留回退路径能让你在出问题时快速恢复。我实际做迁移时最大的坑是Responses的previous_response_id机制。这个机制在单轮对话里很好用但在多轮对话里如果中间有一次请求失败后续的previous_response_id就断了。我的解决方案是在本地维护一个响应 ID 的映射表每次请求成功后更新失败时回退到上一个成功的 ID。5.4 性能与成本的实测对比最后说一下性能和成本。我用同样的模型gpt-4o和同样的请求分别走Chat Completions和Responses测了几组数据。在延迟方面Responses的首 token 延迟略高于Chat Completions大概高 10% 到 20%。我猜测是因为Responses的事件流模型需要更多的服务端处理。但在总响应时间上两者差异不大因为Responses的后续 token 生成速度略快。在 token 消耗方面同样的对话内容Responses的输入 token 数通常比Chat Completions少因为Responses不需要重复传完整历史。但在工具调用场景下Responses的输出 token 数可能更多因为它会暴露推理过程。在成本方面如果你的对话轮次很多Responses的有状态机制能省不少输入 token。但如果你的对话轮次很少两者的成本差异可以忽略。我的建议是如果你的场景是多轮对话为主且对话轮次较多可以考虑迁移到Responses如果你的场景是单轮生成或工具调用为主Chat Completions的成熟度和兼容性更好没必要急着迁移。我个人在实际操作中的体会是接口的选择不应该由哪个更新来决定而应该由哪个更适合你的场景来决定。Responses不是Chat Completions的替代品而是补充。两者会在很长一段时间内共存就像Completions和Chat Completions共存了很长时间一样。与其纠结用哪个不如把调用层抽象好让切换成本降到最低。这样无论接口怎么变你的业务代码都能稳如泰山。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑