1. 接口演进背后的真实驱动力
1.1 从补全到响应,不只是改个名字
OpenAI 的接口体系这几年变化挺大,最早大家接触的都是/v1/completions,那个年代做文本生成基本就是给它一段 prompt,它续写一段内容回来。后来 chat 场景爆发,/v1/chat/completions成了主流,消息数组、角色区分、多轮对话这些概念逐渐被大家接受。再往后,/v1/responses这个新端点开始出现在视野里,很多人第一反应是“又换接口了?”,但实际上这次变化跟之前几次有本质区别。
Completions 的核心逻辑是“补全”——你给前缀,模型接龙。Chat Completions 的核心逻辑是“对话”——你给消息列表,模型扮演助手回一条。而 Responses 的核心逻辑是“响应”——你给一个请求,模型返回一个结构化的响应对象,里面可能包含文本、工具调用、推理过程、引用来源等多种内容块。这个转变不是简单的字段调整,而是把模型输出从“一段文本”升级成了“一个可编程的响应结构”。
我刚开始接触 Responses 接口的时候,最直观的感受是返回体变复杂了。以前choices[0].message.content一把梭,现在得从output数组里遍历不同类型的 item,文本在message类型的 item 里,工具调用在function_call类型的 item 里,推理摘要在reasoning类型的 item 里。刚开始觉得麻烦,用久了发现这种结构反而更清晰,尤其是做 Agent 类应用的时候,不用再靠解析文本来判断模型到底想干嘛。
1.2 为什么 OpenAI 要推 Responses
从工程角度看,Chat Completions 有个先天不足:它把“对话”和“工具调用”混在同一个 message 对象里。模型返回的 message 可能带tool_calls字段,你得判断这个字段存不存在,再决定是展示文本还是执行工具。这种设计在简单场景下够用,但一旦涉及多工具并行、推理链展示、多模态混合输出,message 这个容器就撑不住了。
Responses 接口的设计思路是把输出拆成独立的 item,每个 item 有自己的 type,文本是文本,工具调用是工具调用,推理是推理。这样做的好处是前端渲染和后端处理都可以按 type 分派,不用写一堆 if-else 去猜。另一个好处是状态管理更明确,Responses 支持previous_response_id来串联多轮,不用像 Chat Completions 那样每次把完整历史消息重新传一遍。
还有个容易被忽略的点:Responses 对内置工具的支持更原生。比如 web search、file search、code interpreter 这些,在 Chat Completions 里要么不支持,要么得通过插件机制绕,而 Responses 直接把工具定义在请求里,模型自己决定调不调。这对做 RAG 和 Agent 的开发者来说,省了不少胶水代码。
1.3 开源兼容的真实现状
热词里有个词叫“openai compatible”,这其实是很多开源模型服务端的卖点。但实际情况是,大部分号称兼容 OpenAI 的服务,兼容的是 Chat Completions,不是 Responses。你拿一个只实现了/v1/chat/completions的服务去调/v1/responses,大概率会收到 404 或者 502。
我实测过几个主流的开源推理框架,Ollama 早期只支持 completions,后来加了 chat,Responses 基本没影。vLLM 的 OpenAI 兼容层也是以 chat 为主,Responses 的支持还在社区讨论阶段。LocalAI 稍微好一点,但 Responses 的完整语义——比如 reasoning item、内置工具调用——基本没法还原。
这就导致一个很尴尬的局面:你按 Responses 的规范写了一套代码,想切到本地模型跑,发现接口对不上,得改回 Chat Completions 的写法。或者你用一个中间层做协议转换,把 Responses 请求翻译成 Chat Completions 请求,再把返回体包装成 Responses 格式。这个转换层写起来不难,但细节坑很多,后面会专门讲。
提示:如果你现在要选型,先确认你的目标服务端到底支持哪个端点。别看着文档写着“OpenAI compatible”就以为全兼容,一定要实际发请求验证。
2. 核心字段拆解与实操要点
2.1 请求体结构对比
先看 Chat Completions 的典型请求:
{ "model": "gpt-4o", "messages": [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "帮我写一段 Python 快速排序"} ], "temperature": 0.7, "max_tokens": 1024 }再看 Responses 的等价请求:
{ "model": "gpt-4o", "instructions": "You are a helpful assistant.", "input": "帮我写一段 Python 快速排序", "temperature": 0.7, "max_output_tokens": 1024 }几个关键差异:messages变成了input,system 角色变成了顶层instructions字段,max_tokens改名成了max_output_tokens。这些改动看起来小,但如果你做协议转换,每个字段都得映射对,漏一个就可能导致行为不一致。
input字段比messages灵活,它可以是字符串,也可以是消息数组,还可以是包含多模态内容的数组。这种灵活性带来的代价是解析逻辑变复杂,你得先判断 input 的类型再决定怎么处理。
2.2 返回体结构差异
Chat Completions 的返回:
{ "choices": [ { "message": { "role": "assistant", "content": "快速排序的 Python 实现如下..." }, "finish_reason": "stop" } ], "usage": {"prompt_tokens": 20, "completion_tokens": 150, "total_tokens": 170} }Responses 的返回:
{ "id": "resp_abc123", "output": [ { "type": "reasoning", "summary": [{"type": "summary_text", "text": "用户需要一段快速排序代码"}] }, { "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "快速排序的 Python 实现如下..."}] } ], "usage": {"input_tokens": 20, "output_tokens": 150, "total_tokens": 170} }最明显的区别是output是个数组,里面每个元素都有type。文本内容藏在message类型的content数组里,而且 content 本身也是带 type 的对象。这种嵌套结构第一次看会有点晕,但写个递归遍历函数就能搞定。
usage字段的命名也变了,prompt_tokens变成input_tokens,completion_tokens变成output_tokens。如果你做计费统计,这个映射别忘了。
2.3 工具调用的写法变化
Chat Completions 里定义工具:
{ "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "查询天气", "parameters": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] } } } ] }Responses 里定义工具:
{ "tools": [ { "type": "function", "name": "get_weather", "description": "查询天气", "parameters": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] } } ] }注意 Responses 把function这一层去掉了,name、description、parameters 直接平铺在 tool 对象上。这个改动减少了嵌套层级,但如果你从 Chat Completions 迁移过来,得记得把function这层剥掉。
工具调用的返回也不一样。Chat Completions 是在 message 里带tool_calls数组,Responses 是在 output 里出现function_call类型的 item。处理逻辑得跟着改。
2.4 内置工具的请求方式
Responses 支持一些内置工具,比如 web search:
{ "model": "gpt-4o", "input": "今天有什么科技新闻", "tools": [{"type": "web_search_preview"}] }这种内置工具在 Chat Completions 里是没有原生支持的,你得自己实现搜索逻辑再塞回消息里。Responses 把这个过程内置了,模型自己决定搜不搜、搜什么。但这也带来一个问题:开源兼容层基本没法还原这个能力,因为搜索后端是 OpenAI 自己的。
注意:如果你的应用依赖内置工具,迁移到开源模型时这部分功能会直接丢失,需要自己补一套搜索或检索逻辑。
3. 协议转换层的实现细节
3.1 为什么需要转换层
现实情况是,很多团队的生产代码已经基于 Chat Completions 写好了,但想试试 Responses 的新特性,或者反过来,想用 Responses 的写法但后端只支持 Chat Completions。这时候就需要一个转换层,把一种协议的请求翻译成另一种,再把返回体包装回去。
我见过几种做法:一种是在应用层写适配器,根据配置决定走哪个端点;另一种是起一个本地代理服务,对外暴露 Responses 接口,内部转发到 Chat Completions。第二种更通用,因为对上层应用完全透明。
3.2 请求转换的关键映射
请求转换的核心是把 Responses 的字段映射到 Chat Completions:
| Responses 字段 | Chat Completions 字段 | 说明 |
|---|---|---|
| instructions | messages[0] with role=system | 顶层指令转成 system 消息 |
| input (string) | messages 追加 user 消息 | 字符串输入转成 user 消息 |
| input (array) | messages 直接映射 | 消息数组逐条转换 |
| max_output_tokens | max_tokens | 字段改名 |
| tools[].name | tools[].function.name | 补上 function 层级 |
| previous_response_id | 需要查缓存拼历史 | 无直接对应,需自己维护 |
previous_response_id是最麻烦的,Chat Completions 没有这个概念,你得在转换层维护一个 response_id 到消息历史的映射表,每次请求带上 previous_response_id 时,把之前的历史消息拼到 messages 前面。
3.3 返回体包装的坑
返回体包装是把 Chat Completions 的返回转成 Responses 格式。基本思路是:把choices[0].message.content包装成output数组里的一个 message item,把tool_calls包装成 function_call item。
但有几个细节容易出错。第一,finish_reason的映射,Chat Completions 的stop对应 Responses 的completed,tool_calls对应requires_action,这个映射表得写对。第二,usage 字段的改名,别忘了。第三,如果 Chat Completions 返回的是流式响应,包装逻辑会更复杂,因为 Responses 的流式事件类型跟 Chat Completions 的 delta 结构完全不同。
我踩过的一个坑是:Chat Completions 的流式返回里,content 是逐 token 给的,而 Responses 的流式事件是按 item 和 delta 分层的。你得把 token 级别的 delta 聚合成 item 级别的输出,这个聚合逻辑如果写不好,会出现文本重复或丢失。
3.4 流式响应的处理
Responses 的流式事件类型比较多,常见的有response.created、response.output_item.added、response.output_text.delta、response.completed等。Chat Completions 的流式就是一堆data: {...}的 chunk,每个 chunk 带choices[0].delta。
转换层需要把 Chat Completions 的 chunk 序列转换成 Responses 的事件序列。基本做法是:收到第一个 chunk 时发response.created,收到 content delta 时发response.output_text.delta,收到结束标志时发response.completed。工具调用的 delta 处理更麻烦,因为 Chat Completions 的 tool_calls 是分片传输的,你得攒齐了再发response.output_item.added。
# 简化的流式转换伪代码 def convert_stream(chat_stream): yield {"type": "response.created", "response": {"id": "resp_xxx"}} item_added = False for chunk in chat_stream: delta = chunk["choices"][0]["delta"] if "content" in delta and delta["content"]: if not item_added: yield {"type": "response.output_item.added", "item": {"type": "message"}} item_added = True yield {"type": "response.output_text.delta", "delta": delta["content"]} yield {"type": "response.completed"}这段代码只是示意,实际实现要考虑工具调用、多 item、错误处理等情况。
4. 常见报错与排查实录
4.1 502 Bad Gateway 的典型原因
热词里有个unexpected status 502 bad gateway: unknown error, url: http://127.0.0.1:15721/v1/responses,这个报错很典型。502 说明你的请求到了某个中间层,但中间层转发到上游时失败了。结合 URL 是本地地址,大概率是你本地起了一个代理或转换服务,它把请求转发到真正的 OpenAI 端点时出了问题。
排查思路:先确认本地服务是否正常运行,再看它的上游配置是否正确。常见原因包括上游地址写错、API key 没配、网络不通、上游返回了非 200 但被包装成了 502。我遇到过一种情况是转换层把 Responses 请求转成 Chat Completions 时,字段映射出错导致上游返回 400,但转换层没处理好错误码,统一报成了 502。
提示:遇到 502 先看转换层或代理层的日志,别只看客户端报错。上游的真实错误信息通常藏在中间层日志里。
4.2 工具调用相关的报错
热词里有个custom tools require mimo freeform responses lite mode,这看起来是某个特定平台或框架的报错。大意是自定义工具需要某种特定的响应模式。这类报错的通用排查思路是:确认你的工具定义格式是否符合目标端点的要求,确认模型是否支持工具调用,确认请求里 tools 字段的结构是否正确。
Responses 的工具定义去掉了 function 层级,如果你从 Chat Completions 迁移过来忘了改,就会报格式错误。反过来,如果你用 Responses 的格式去调只支持 Chat Completions 的服务,也会报错。
4.3 常见问题速查表
| 报错现象 | 可能原因 | 排查方向 |
|---|---|---|
| 404 on /v1/responses | 服务端不支持该端点 | 确认服务端支持的端点列表 |
| 502 Bad Gateway | 中间层转发失败 | 查中间层日志和上游配置 |
| 400 invalid tools format | 工具定义格式不匹配 | 检查是否需要 function 层级 |
| 401 unauthorized | API key 无效或未传 | 检查认证头 |
| 429 rate limit | 请求频率超限 | 降低频率或升级配额 |
| 流式响应文本重复 | delta 聚合逻辑有误 | 检查 item 边界处理 |
| previous_response_id 无效 | 历史缓存丢失或过期 | 检查缓存实现和 TTL |
4.4 实操避坑经验
第一个坑是别假设所有 OpenAI compatible 的服务都支持 Responses。我见过太多人看着文档写着兼容就以为全兼容,结果调 Responses 直接 404。选型阶段一定要实际发请求验证,别只看文档。
第二个坑是流式响应的 item 边界。Responses 的流式输出里,一个 response 可能包含多个 output item,每个 item 有自己的生命周期。如果你在转换层没处理好 item 的 added 和 done 事件,前端渲染会出现内容错位。
第三个坑是 usage 统计的字段映射。Chat Completions 的 prompt_tokens 对应 Responses 的 input_tokens,completion_tokens 对应 output_tokens。如果你做计费或配额统计,这个映射错了会导致数据对不上。
第四个坑是 previous_response_id 的缓存策略。这个字段依赖服务端保存历史,如果你用的是转换层,得自己实现历史存储。缓存过期时间、存储容量、并发读写都是要考虑的问题。我一般用 Redis 存,TTL 设个几小时,够大多数场景用。
第五个坑是错误码的透传。转换层如果把上游的错误统一包装成 500 或 502,排查起来会很痛苦。尽量把上游的原始错误码和错误信息透传出来,哪怕包装一层也要保留原始信息。
4.5 开源兼容的选型建议
如果你现在要选一个开源方案来跑 Responses 接口,我的建议是:先明确你的核心需求是什么。如果只是文本生成,Chat Completions 足够了,没必要追 Responses。如果需要工具调用和推理链展示,Responses 的结构更合适,但开源支持有限,可能得自己写转换层。
vLLM 和 Ollama 目前对 Responses 的支持都不完整,LocalAI 稍微好一点但也没法还原内置工具。如果你非要用 Responses 的写法,最稳妥的方案是:上层用 Responses 规范写应用,中间加一个转换层,底层用 Chat Completions 的服务。转换层自己写,别指望现成的轮子能完美适配。
这个转换层的工作量大概在两三天,主要时间花在流式处理和工具调用映射上。写完之后记得做充分的回归测试,尤其是多轮对话和工具调用的场景,这两块最容易出问题。
我在实际项目里用这套方案跑了几个月,整体稳定,但偶尔会遇到流式输出在弱网环境下 item 边界错乱的情况。后来加了一个缓冲机制,把 delta 攒一小段再发,问题就少了。这个经验分享出来,希望对做类似事情的同行有点参考价值。