我是一名在大模型平台层摸爬滚打八年多的工程师日常要对接 OpenAI、Anthropic、Gemini、Azure、Bedrock 五类主流后端维护过 17 个不同厂商/网关的 adapter亲手写过 3 套统一 API 抽象层从最简版 JSON Schema 映射到带状态缓存的 runtime bridge再到支持 tool calling trace 的 agent-aware gateway。今天这篇不是教程也不是文档翻译是我把过去两年踩过的坑、被客户问到哑口无言的瞬间、深夜 debug 时突然顿悟的逻辑断层全盘托出的一次复盘。你可能刚接触 LLM API看到/v1/chat/completions和/v1/responses两个路径就懵了这不就是换了个 URL是不是 OpenAI 又在搞“接口升级焦虑”又或者你已经上线了基于messages的对话系统突然发现要加图像理解、要接函数调用、要让模型返回 JSON Schema 校验过的结构体——结果发现老接口越塞越臃肿SDK 更新一次就崩一次第三方网关适配表里你的服务标着“partial support”。这些都不是错觉而是真实存在的抽象层撕裂。而我要说的就是这场撕裂的来龙去脉、技术动因以及——更重要的是——你在选型、设计、演进时到底该信什么、该押什么、该防什么。核心关键词其实就三个OpenAI-compatible Chat Completions、Responses API、协议抽象层级。它们不是并列的三种选项而是一条演进链上的三个坐标点起点是“能跑通”中间是“能兼容”终点是“能生长”。如果你只记住一句话那就记这个messages是对历史的封装input是对能力的声明choices是对输出的采样output是对结果的承诺。这句话背后藏着所有协议差异的根因也决定了你未来半年的开发效率、三个月的联调成本、甚至整个系统的可扩展寿命。接下来我会用一个真实项目贯穿始终——我们为某金融 SaaS 客户做的智能投研助手它既要解析 PDF 报告多模态、又要调用内部风控 APItool calling、还要按监管要求返回带字段级 schema 的 JSONstructured output同时支持长周期策略推演stateful workflow。正是这个项目让我彻底看清了为什么chat/completions在 2023 年还很顺手到了 2024 年中却成了技术债的温床而responses又为何不是“另一个接口”而是整套运行时范式的重置。1. 协议的本质不是 URL而是四层契约很多人一上来就翻文档看 endpoint这是最危险的起点。就像你买一辆车只看车标和轮毂尺寸却不去查发动机型号、变速箱协议、ECU 通信标准——等真要换轮胎、刷程序、连诊断仪时才发现根本不是一回事。LLM API 的“协议”从来就不是某个 URL 路径而是一组隐性但强约束的工程契约它由四个不可分割的层次共同构成认证方式、请求体结构、响应体结构、流式事件模型。漏掉任何一层接入成本都会指数级上升只盯住其中一层比如只看messages字段则必然在后续迭代中反复返工。1.1 认证方式第一道门禁也是最易被低估的兼容雷区表面上看Authorization: Bearer xxx和x-api-key: xxx就是 header 名字不同实则背后是三套完全不同的安全治理逻辑。OpenAI-styleAuthorization: Bearer这是 OAuth2 风格的 token 认证token 本身携带 scope如read:models,write:runs服务端可做细粒度权限控制。它的优势是标准化程度高几乎所有 HTTP client 库都原生支持劣势是 token 生命周期管理复杂需要 refresh flow且无法与云平台 IAM 体系天然打通。Anthropic 的x-api-keyanthropic-version这里x-api-key看似简单但它本质是静态密钥static key不带 scope权限由密钥创建时绑定。而anthropic-version头部才是关键——它强制要求客户端声明所依赖的 API 版本语义如2023-06-01服务端据此决定是否启用新字段、是否兼容旧行为。这意味着Anthropic 不是“向后兼容”而是“版本契约锁定”。你用2023-06-01调用哪怕服务端已升级到 v2.5它仍会按旧版规则解析messages结构但若你漏传此头请求直接 400。我在给客户做 Anthropic adapter 时就因忘记在 SDK 初始化时注入 version header导致所有流式请求失败debug 两小时才发现问题不在 SSE 解析而在 header 缺失。Gemini 的x-goog-api-keyGoogle 选择将 API key 直接暴露在 query string 或 header 中这是其早期云服务的设计惯性。它不带任何权限上下文完全依赖 key 本身的白名单配置。好处是极简curl 一把梭坏处是无法做动态权限升降级且 key 泄露风险更高。更隐蔽的坑在于Gemini 的 key 实际上是“project-level”而非“model-level”同一个 key 可调用 Gemini Pro、Flash、Ultra但不同 model 对contents.parts的字段校验严格度不同——Pro 接受纯文本Ultra 却强制要求parts数组中每个元素必须带type字段。这导致我们最初用 Pro 测试通过的请求在切换到 Ultra 时批量 400原因竟是parts里少了一个{type: text}包裹。Azure OpenAI 的api-keyapi-version表面看和 Anthropic 类似但api-version在 Azure 里是资源级概念而非 API 功能级。它绑定的是整个 Azure Resource Provider 的 REST API 版本如2023-12-01-preview影响的是 URL 路径拼接逻辑/subscriptions/{id}/providers/Microsoft.CognitiveServices/accounts/{name}/deployments/{deployment}/...而不是模型输入语义。这意味着你用2023-12-01-preview调用服务端可能返回choices[0].message.content但用2024-05-01调用同一模型却可能返回response.choices[0].message.content多了一层responsewrapper。这不是 bug而是 Azure 的资源管理范式使然。AWS Bedrock 的 SigV4 签名这才是真正的云原生范式。它不依赖静态 key而是用 AWS IAM Role 的临时凭证AccessKeyId SecretAccessKey SessionToken对整个 HTTP request包括 method、path、headers、body hash进行 HMAC-SHA256 签名。签名过程需精确计算 canonical request任何 header 大小写、空格、换行错误都会导致SignatureDoesNotMatch。我们曾因 Python SDK 默认在Content-Typeheader 后多加了一个空格导致连续三天请求失败日志里只显示403 Forbidden没有任何具体错误码。直到用 AWS CLI 的--debug模式比对签名字符串才定位到问题。SigV4 的代价是接入复杂但收益是零信任架构下的强身份绑定——你的请求不仅是“有 key”更是“由指定 IAM Role 在指定时间、用指定参数发起”。提示不要在 SDK 层硬编码认证逻辑。我们最终在统一网关里抽象出AuthStrategy接口每个 provider 实现自己的sign(request)方法。OpenAI 实现为inject_bearer_header()Anthropic 为inject_api_key_and_version()Bedrock 则完整嵌入botocore.auth.SigV4Auth。这样当 Bedrock 新增invocationRoleArn参数时只需更新 Bedrock strategy不影响其他 provider。1.2 请求体结构字段名之下是世界观的分野messages、input、contents.parts这些字段名绝非随意命名而是各自代表一套对“模型如何被使用”的底层假设。messagesOpenAI Chat Completions / Anthropic Messages这是一个对话历史快照模型。它的设计哲学是“模型的每一次推理都是对一段完整对话历史的延续”。因此messages必须是数组且顺序敏感role字段system/user/assistant定义了每条消息的语义角色content字段承载消息主体。这种结构天然适合聊天场景但带来三个硬伤多模态表达笨重要在content里塞图像OpenAI 要求你写成{type: image_url, image_url: {url: data:image/png;base64,...}}而 Anthropic 要求{type: image, source: {type: base64, media_type: image/png, data: ...}}。两者字段名、嵌套深度、base64 编码位置全不同。我们的网关曾为此写了 80 行转换逻辑只为把用户传来的image_base64字段映射到不同 provider 的正确位置。工具调用语义模糊messages里怎么表示“请调用 weather_api 工具”OpenAI 用tool_choicetools数组 assistant消息里返回tool_callsAnthropic 则用tool_useblock user消息里嵌入{type: tool_use, id: toolu_01, name: weather_api, input: {...}}。前者是“模型建议调用”后者是“用户指令调用”语义鸿沟极大。结构化输出被迫妥协你想让模型返回 JSONOpenAI 要求你写 system prompt “返回严格 JSON”Anthropic 要求你用tool_resultblock 包裹Gemini 则直接支持response_mime_type: application/json。messages本身不提供 schema 声明能力一切靠 prompt 工程 hack。inputOpenAI Responses这是一个模型执行单元模型。它的设计哲学是“模型是一台可编程机器input是喂给它的指令包output是它执行后的产物”。因此input可以是 string最简 case也可以是 object结构化指令甚至可以是 array多任务并行。OpenAI Responses 的input支持两种形态简单形态input: 帮我总结—— 语义等同于messages: [{role: user, content: 帮我总结}]但去除了“对话”包袱结构形态input: [{role: user, content: [{type: text, text: 帮我总结}]}]—— 这里content变成了数组每个元素是带type的块为多模态type: image_url、工具调用type: tool_use、结构化输出type: json_schema留出了干净的扩展槽位。关键突破在于input不再预设“这是第几轮对话”而是让平台侧通过previous_response_id或session_id来管理状态。这使得input成为真正意义上的“能力声明载体”。contents.partsGemini Native这是一个内容原子模型。contents是数组代表多轮交互parts是每轮里的内容片段数组。parts中每个元素是一个part必须带type字段text/inline_data/file_data且inline_data里mime_type和data字段严格分离。这种设计让多模态成为一等公民一张图、一段音频、一段文本都是平等的part没有主次之分。但代价是它彻底放弃了messages的 role 语义system指令只能塞进contents[0].parts[0].text且无法指定作用域是全局 system 还是仅对下一轮生效。我们在做 Gemini adapter 时不得不自己实现system_prompt的注入逻辑——把它拆成textpart 插入contents[0]并确保后续user消息不覆盖它。注意input和contents.parts都支持多模态但哲学不同。input是“模型执行指令”所以input里可以有{type: tool_use, name: search, input: {q: LLM protocol}}contents.parts是“内容片段”所以 Gemini 里调用工具要用{type: function_call, name: search, args: {q: LLM protocol}}且必须放在contents数组的特定位置。前者是声明式后者是命令式。1.3 返回体结构choices与output的范式之争读取响应是接入中最容易被忽略的环节却是线上故障的高发区。choices[0].message.contentChat Completions这是一个采样结果模型。choices数组的存在意味着模型可能返回多个候选n 1message是其中一条生成结果content是这条结果的文本主体。这种结构暗示模型输出是“随机采样”的content是概率分布的一个 realization。它天然适合“生成式问答”但带来两个问题结构化输出解析脆弱你想让模型返回 JSON它却可能返回{status: success, data: [...]}带引号的字符串或json\n{...}\n带代码块包裹甚至{status: success}单引号。因为content字段设计初衷就是文本没有 schema 约束。我们曾为金融客户做财报分析要求模型返回{revenue: number, profit: number}结果 30% 的响应因格式不规范导致 JSON.parse() 报错不得不加 5 层 fallback正则提取、trim 代码块、replace 单引号、try-catch、最后人工兜底。工具调用结果混杂当模型调用工具后choices[0].message.content可能为空而tool_calls字段在message下tool_results又在下一轮messages里。你需要维护一个跨请求的状态机来拼接完整链路。这对长流程 agent 几乎是灾难。output_text/outputResponses这是一个确定性结果模型。output_text是最简输出保证是纯文本output是结构化输出数组每个元素是{type: message | tool_result | json_schema}。关键区别在于output不再是“采样结果”而是“执行产物”。当你声明input里包含{type: json_schema, schema: {...}}output里就会出现{type: json_schema, value: {...}}且value字段保证是合法 JSON object无需额外 parse。我们在投研助手项目中将监管要求的字段 schema 直接作为input的一部分传入output返回的value可直接序列化入库错误率从 30% 降至 0.2%。更重要的是output支持类型化区分{type: tool_result, tool_use_id: toolu_01, content: 25°C, sunny}明确标识这是工具调用结果而非模型生成文本。这让我们在 agent orchestrator 里可以精准路由tool_result→ 调用下游 API → 生成新inputmessage→ 渲染给用户json_schema→ 写入数据库。无需再靠正则匹配content字段里的关键词。Gemini 的candidates[0].content.parts这是内容片段模型。candidates是模型生成的多个候选content是其中一条的完整内容parts是内容的原子切片。一个part可以是{text: 温度是}也可以是{function_call: {name: get_weather, args: {city: Beijing}}}。这种设计让 Gemini 天然支持“混合输出”——一段文本 一个函数调用 一张图。但问题在于parts顺序即渲染顺序而function_call的执行结果不会自动插入parts需要你手动 fetch 并 merge。我们在做实时天气播报时发现 Gemini 返回的parts里function_call在前文本描述在后但实际执行完工具后需要把结果插回parts的对应位置否则前端渲染错乱。这迫使我们在网关里实现了一套part的 patching 机制。1.4 流式事件模型SSE 不是银弹WebSocket 才是未来流式响应常被简化为“SSE vs WebSocket”但真实世界远比这复杂。OpenAI Chat Completions 的 SSEServer-Sent Events这是最成熟的流式方案。每个 event 是data: {...}\n\nchoices[0].delta.content是增量文本。优点是浏览器原生支持Nginx/Apache 可直接代理缺点是单向通信无法从客户端向服务端发送控制指令如“暂停”、“跳过当前 token”、“注入新 context”。我们在做实时代码补全时用户敲击速度远超模型生成速度想实现“按键即中断上一轮、启动新请求”SSE 无法做到只能靠客户端 cancel 上一个请求再发新请求造成大量无效 token 浪费。OpenAI Responses 的 SSE WebSocket 双模式OpenAI Responses 明确支持两种流式通道。SSE 用于简单文本流WebSocket 则用于双向状态流。通过 WebSocket客户端可以发送{type: input, content: 继续分析}或{type: control, action: pause}服务端也能实时推送{type: tool_result, id: t1, content: done}。这才是真正支持 agent 交互的流式模型。我们在投研助手的“多轮策略推演”功能中用 WebSocket 实现了用户说“基于刚才的结论模拟加息 25bp 的影响”客户端发input事件模型调用利率预测工具后服务端推tool_result客户端收到后自动触发下一轮input全程无页面刷新延迟 200ms。Anthropic 的 SSE withmessage_stopeventAnthropic SSE 在末尾会发送一个event: message_stop明确标识本次响应结束。这比 OpenAI 的data: [DONE]更语义化便于客户端做精准的 loading 状态控制。但 Anthropic 不支持 WebSocket所有控制指令如stop_sequences必须在初始请求体里声明无法运行时动态调整。Gemini 的 SSE withfinish_reasonGemini SSE 的finish_reason字段STOP/MAX_TOKENS/SAFETY提供了更丰富的终止原因便于客户端做差异化处理如SAFETY触发时显示“内容受限”提示。但 Gemini 的流式parts是逐个推送的一个part可能包含多个 token也可能只含一个客户端需自行 buffer 合并。实操心得不要在应用层直接解析 raw SSE stream。我们封装了StreamParser类统一处理event type 识别、data 解析、JSON parse 错误恢复、[DONE]/message_stop识别、finish_reason映射。对于 WebSocket我们实现了ResponseChannel类封装连接管理、心跳保活、reconnect 逻辑、message type router。这些抽象让上层业务代码完全不用关心底层传输细节。2. OpenAI Responses API不是接口升级而是运行时重构很多团队把responses当作chat/completions的“v2 版本”这是最大的认知偏差。chat/completions是一个功能接口feature interface解决“如何让模型生成文本”responses是一个运行时接口runtime interface解决“如何让模型作为一个可编排、可观察、可扩展的计算单元运行”。理解这一点是判断何时该迁、如何迁移的前提。2.1 为什么chat/completions的抽象已到极限chat/completions的设计基因刻在 2022 年GPT-3.5 Turbo 刚发布市场焦点是“聊天机器人”。它的核心假设非常清晰输入 一段对话历史messages输出 一条新的 assistant 消息choices[0].message能力 文本生成content这个假设在单一文本问答场景下坚如磐石。但当产品形态进化它开始处处掣肘多模态场景你要传一张财报截图messages里塞{type: image_url, ...}是 workaround不是 first-class 支持。messages的content字段本意是文本硬塞二进制数据违背设计直觉。更糟的是messages数组的每个元素都必须有role而图像本身没有“角色”强行赋予user角色语义失真。工具调用场景chat/completions的tool_calls是模型“建议”调用tool_results是用户“提供”结果二者割裂。模型无法知道工具调用是否成功用户也无法告诉模型“这个工具结果不可信换一个”。整个链路是开环的。结构化输出场景chat/completions没有 schema 声明机制。你只能靠 prompt engineering 诱导模型输出 JSON但模型不保证格式也不校验字段。当金融客户要求revenue字段必须是 number 类型chat/completions给你返回revenue: 123.45字符串你就得在应用层做类型转换一旦转换失败整个流程中断。状态化工作流场景chat/completions要求你把全部历史messages传上去10 轮对话就是 10 个对象token 开销巨大。更致命的是历史是“只读快照”模型无法修改历史中的某一条消息比如修正上一轮的错误事实只能生成新消息覆盖。这导致长链路推理中错误会像滚雪球一样累积。这些不是小问题而是chat/completions的抽象模型与新需求之间的范式冲突。OpenAI 没有选择在老接口上打补丁比如加multimodal_input字段、structured_output_schema字段而是另起炉灶用responses构建一个全新的运行时契约。2.2responses的三大运行时支柱responses不是堆砌新功能而是重建三个底层支柱输入声明、输出承诺、状态契约。支柱一输入声明Input Declarationresponses的input字段本质是一个能力声明 DSLDomain Specific Language。它不再问“你有什么历史”而是问“你希望模型执行什么任务”。这个 DSL 支持四种原语文本任务input: 帮我总结—— 最简声明等价于messages: [{role: user, content: ...}]多模态任务input: [{role: user, content: [{type: text, text: 分析这张图}, {type: image_url, image_url: {url: ...}}]}]——content是part数组每个part是独立的能力单元type字段是能力类型声明。工具调用任务input: [{role: user, content: [{type: tool_use, id: t1, name: search, input: {q: LLM protocol}}]}]——tool_use是一个声明告诉模型“请调用 search 工具”id是本次调用的唯一标识用于后续tool_result关联。结构化输出任务input: [{role: user, content: [{type: json_schema, schema: {type: object, properties: {revenue: {type: number}}}}]}]——json_schema是一个声明告诉模型“请输出符合此 schema 的 JSON”服务端会强制校验不合规则报错不返回垃圾数据。这种声明式设计让input成为真正的“能力蓝图”。你在写代码时不再是拼接messages数组而是构造一个InputTask对象它的parts属性是一个Part[]每个Part是TextPart、ImagePart、ToolUsePart、JsonSchemaPart的实例。这种面向对象的建模让代码可读性、可测试性、可扩展性大幅提升。支柱二输出承诺Output Contractresponses的output字段是一个类型化结果契约。它不承诺“返回一段文本”而是承诺“返回一个由若干确定类型部分组成的数组”。每个output元素都有type字段目前支持message纯文本输出value字段是 stringtool_result工具调用结果tool_use_id字段关联input中的idcontent字段是工具返回的原始数据json_schema结构化输出value字段是严格符合 schema 的 JSON objecterror执行错误code和message字段提供调试信息这个契约的关键在于类型安全。当你在 TypeScript 中定义OutputItemunion typetype OutputItem | { type: message; value: string } | { type: tool_result; tool_use_id: string; content: any } | { type: json_schema; value: Recordstring, any };你的 IDE 就能根据item.type自动推导item.value或item.content的类型编译期就能捕获item.value.length当type是tool_result时这类错误。这在chat/completions时代是不可想象的——choices[0].message.content永远是 string但你永远不知道它里面是纯文本、JSON 字符串、还是代码块。支柱三状态契约State Contractresponses引入了previous_response_id和session_id两个字段构建了一个平台侧状态管理契约。previous_response_id指向上一次responses请求的id字段。服务端可以用它检索之前的output从而实现“基于上一轮结果的续写”。例如上一轮output里有{type: tool_result, tool_use_id: t1, content: 25°C}这一轮input就可以直接引用t1的结果无需客户端再传一遍。session_id一个客户端生成的 UUID用于标识一个长期会话。服务端可以基于session_id维护会话状态如用户偏好、上下文缓存、工具调用历史而无需客户端每次传全量messages。这对于移动端尤其重要——网络不稳定时messages数组太大容易丢包而session_id很小重传成本低。这个契约把状态管理从“应用侧负担”变成了“平台侧服务”。你在写投研助手时不再需要在 Redis 里存一个巨大的messages数组只需存一个session_id和几个关键response_id状态同步开销降低 90%。2.3responses如何解决chat/completions的经典痛点用投研助手的真实案例对比两个接口的实现差异场景用户上传一份 PDF 财报要求“提取营收、净利润、毛利率并用表格展示同时调用内部风控 API 校验数据异常”chat/completions实现第一步messages: [{role: user, content: [{type: file_url, url: pdf_url}]}]→ 模型返回content: 已解析营收 100M净利润 20M毛利率 30%第二步客户端从content里用正则提取数字构造新messages[{role: user, content: 调用风控 API 校验revenue100000000, profit20000000, gross_margin0.3}]第三步模型返回tool_calls: [{id: t1, function: {name: risk_check, arguments: {...}}}]第四步客户端执行risk_check拿到结果再构造新messages[{role: tool, tool_call_id: t1, content: {result: true}}]第五步模型返回最终表格。总耗时5 轮 API 调用平均延迟 2s/轮总延迟 10s错误点步骤 1 的正则可能失效步骤 2 的数字格式可能错步骤 4 的 tool result 可能丢失。responses实现单次请求input: [{role: user, content: [ {type: file_url, url: pdf_url}, {type: json_schema, schema: {type: object, properties: {revenue: {type: number}, profit: {type: number}, gross_margin: {type: number}}}}, {type: tool_use, id: t1, name: risk_check, input: {}} ]}]服务端自动解析 PDF → 提取结构化数据 → 校验 schema → 调用risk_check工具 → 合并结果 → 返回output: [ {type: json_schema, value: {revenue: 100000000, profit: 20000000, gross_margin: 0.3}}, {type: tool_result, tool_use_id: t1, content: {risk_level: low}} ]总耗时1 轮 API 调用延迟 3s错误点0 —— schema 校验在服务端完成tool_result与tool_use由服务端自动关联。这就是responses的威力它把原本需要客户端 orchestrate 的 5 步压缩成服务端 atomic execution 的 1 步。input是声明output是承诺session_id是状态锚点。这不是接口变短了而是抽象层级变高了。2.4 为什么responses不是“为了流式”一个被严重误解的点网上充斥着“responses是流式接口chat/completions是非流式”的说法这是彻头彻尾的误读。真相是流式能力与 endpoint 路径无关只与stream参数和底层传输协议有关。chat/completions支持stream: true返回 SSEchoices[0].delta.content是增量文本。responses同样支持stream: true返回 SSEoutput数组是增量推送的先推{type: message, value: 正在}再推{type: message, value: 分析}。两者也都支持stream: false返回完整 JSON。那么区别在哪在于流式内容的语义粒度。chat/completions的流式是token 粒度每个 event 是一个或几个 tokendelta.content是字符串增量。你无法知道这个 token 是属于message还是tool_calls因为delta只有content字段。responses的流式是output item 粒度每个 event 是一个完整的output元素。第一个 event 可能是{type: message, value: 正在分析}第二个是{type: tool_result, tool_use_id: t1, content: 25°C}。客户端可以精准地按type分发message→ 渲染到 UItool_result→ 调用下游json_schema→ 更新 state。这才是responses流式的价值语义化流式Semantic Streaming。它让流式不再只是“更快看到开头”而是“更早获得可执行结果”。在投研助手里用户上传 PDF 后UI 立即显示正在解析文档...messageevent1 秒后显示风控校验通过tool_resultevent3 秒后渲染出结构化表格json_schemaevent。整个过程是渐进式、可感知、可交互的而不是等待 3 秒后一次性弹出所有内容。2.5 为什么