LLM API 的变迁:OpenAI-compatible 为什么不等于兼容
Posted on 一 07 9月 2026 in Tech
| Abstract | LLM API 的变迁:OpenAI-compatible 为什么不等于兼容 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Status | v1.1 |
| Updated | 2026-09-09 |
| License | CC-BY-NC-ND 4.0 |
大纲
展开看看
- 三代接口:从单个
prompt,到角色化messages,再到 typed items、事件流和工具循环。 - Chat Completions:简单、成熟、生态最广,仍受 OpenAI 支持;它也是许多兼容网关选择的最小公分母。
- Responses API:OpenAI 为新项目推荐的接口,输出不再只有 message,而是一组带类型的 Item;内置工具和多步 Agent 是它的主场。
- Anthropic Messages:表面同样是 messages,细节却不同:
system在顶层、响应是 content blocks、工具结果作为user内容块回传。 - OpenAPI 对照:用 schema 把三家的请求/响应摊开,工具配对键、参数形态、结果回传方式三处差异,一眼看清为什么靠改字段名兼容不了。
- Gemini:原生接口以
contents/parts组织多模态内容;兼容层方便迁移,但官方仍把它标为 beta。 - 核心判断:OpenAI-compatible 通常只兼容了 HTTP 路径和基础 JSON 外形,无法自动抹平行为、能力与生命周期语义。
- 工程做法:在业务层定义窄而明确的内部协议,保留 provider escape hatch,用能力矩阵和契约测试证明兼容。
把一段 OpenAI SDK 代码里的 base_url 和 model 换掉,请求返回了 200,文本也能打印出来。很多团队到这一步就宣布:供应商切换完成。
真正麻烦的部分通常还没开始。第二天加上 tool calling,参数被忽略;第三天打开 streaming,事件解析器读不到结束信号;换成长对话后,推理块和工具结果被适配层悄悄丢掉。接口看起来兼容,系统却在语义上慢慢走样。
OpenAI-compatible 更像“能说几句共同语言”,不是“双方拥有同一套法律、习惯和基础设施”。 对简单文本生成,它很实用;对 Agent、工具调用、结构化输出和多模态任务,只改 base_url 往往不够。
LLM API 的三次换挡
LLM API 的变化,可以压缩成三代数据模型:
prompt string -> messages -> typed items + events + tool loop
一段文本 一段对话 一次持续演化的任务
第一代:Completions,把模型当文本续写机
早期接口接收一个 prompt 字符串,返回一个或多个文本 completion。对话角色、工具结果、图片和控制指令都得由调用方自己拼进字符串。
这套形式直白,却把结构藏进了 prompt。谁负责区分 system instruction 和用户输入?哪里是工具输出?多轮对话怎么防止角色边界被拼坏?答案通常是“靠模板约定”。Anthropic 早期的 Text Completions 也要求开发者手工组织 Human: 与 Assistant: 标记。
OpenAI 当前文档把 Completions 列为 legacy API,并注明它最后一次更新在 2023 年 7 月。它没有立刻消失,但已经不是新能力落脚的地方。
第二代:Chat Completions,让角色成为协议的一部分
2023 年 3 月,OpenAI 发布 GPT-3.5 Turbo API,POST /v1/chat/completions 用结构化 message 代替自由文本 prompt:
{
"model": "gpt-5.6",
"messages": [
{"role": "system", "content": "回答要简洁。"},
{"role": "user", "content": "解释一下幂等。"}
]
}
响应的经典读取路径也被无数程序写死了:
choices[0].message.content
Chat Completions 的贡献不只是多了一个 messages 数组。它把角色边界、对话顺序和后来的 tool call 放进了机器可检查的协议。供应商和开源模型服务想接入现成生态,最省事的办法便是模仿这套请求格式。于是,“OpenAI-compatible”逐渐成了 LLM 世界事实上的通用插头。
插头统一了,电压、频率和电器功能并没有统一。
第三代:Responses 与 content blocks,把一次回答拆成动作
2023 年 12 月,Anthropic 推出 Messages API beta,把旧的 prompt 字符串换成 messages。2025 年 3 月,OpenAI 发布 Responses API,将 Chat Completions 的简单调用与 Assistants API 的工具能力合在一起。
两家公司没有采用同一份 schema,却做出了相似判断:模型输出已经不只是一段文本。 它可能先思考,再调用两个工具,接收工具结果,生成引用,最后才写答案。若还把所有东西塞进一个 message.content 字符串,协议很快会变成杂物间。
所以 Responses 用 Item,Anthropic 用 content block。名称不同,方向一致:把文本、工具调用、工具结果、推理内容和多模态内容做成有类型的对象。
四种接口,差别不在字段名
| 维度 | OpenAI Chat Completions | OpenAI Responses | Anthropic Messages | Gemini 原生 API |
|---|---|---|---|---|
| 主要输入 | messages |
input 字符串或 Item 列表 |
messages,system 单列 |
contents[].parts[],另有 system instruction |
| 主要输出 | choices[].message |
output[] typed Items |
content[] blocks |
candidates[].content.parts[] |
| 多轮上下文 | 通常由客户端重放 messages | 可重放 Items,也可用 previous_response_id 等机制 |
客户端在后续请求中提交历史 messages | 客户端提交 contents 历史或使用 SDK 会话封装 |
| 自定义工具调用 | tool_calls |
function_call Item + function_call_output |
tool_use block + tool_result block |
functionCall part + functionResponse part |
| 托管工具 | 能力有限且依模型而异 | web/file search、code interpreter、computer、MCP 等是核心方向 | web search、code execution 等按模型与版本提供 | search、code execution 等走 Gemini 自身能力 |
| 流式输出 | chat completion chunks | 多种带类型的 response events | message_* / content_block_* events |
generateContent 流式分块 |
| 结构化输出入口 | response_format |
text.format |
原生格式或 tool/schema 能力,依当前模型功能 | response schema,原生与兼容入口细节不同 |
| 适合场景 | 文本、简单多轮、广泛兼容 | OpenAI 原生 Agent 和复杂工具链 | Claude 原生能力与精细 content blocks | 深用 Gemini 多模态与原生工具 |
这张表里最容易被忽略的是“输出单位”。如果内部代码只接受一个 string answer,四家都能接;代价是工具调用、引用、推理摘要、拒答、音视频内容和细粒度 usage 全被压扁了。
抽象层越窄,兼容越容易;业务想保留的能力越多,供应商差异越藏不住。
用 OpenAPI 把三种格式摊开看
字段名、嵌套层级、必填项,光靠散落的 JSON 片段很难对齐。下面用 OpenAPI 3.1 的方式,把 Chat Completions、Responses、Anthropic Messages 三种格式的请求路径、请求体和响应体写成可对照的 schema。看 schema 比看散文更能暴露差异:同样一件事(发一句话、调一个工具、拿回一段答案),三家的形状根本不一样。
为可读性,schema 只保留能说明差异的核心字段,省略了大量可选参数和错误响应。完整定义以各家官方 OpenAPI/文档为准。
路径与鉴权
openapi: 3.1.0
info:
title: 三种 LLM API 的对照 schema
version: "1.0"
paths:
/v1/chat/completions: # OpenAI Chat Completions
post:
security: [{ bearerAuth: [] }] # Authorization: Bearer <OPENAI_API_KEY>
requestBody:
content: { application/json: { schema: { $ref: "#/components/schemas/ChatCompletionsRequest" } } }
responses:
"200": { content: { application/json: { schema: { $ref: "#/components/schemas/ChatCompletionsResponse" } } } }
/v1/responses: # OpenAI Responses
post:
security: [{ bearerAuth: [] }] # Authorization: Bearer <OPENAI_API_KEY>
requestBody:
content: { application/json: { schema: { $ref: "#/components/schemas/ResponsesRequest" } } }
responses:
"200": { content: { application/json: { schema: { $ref: "#/components/schemas/ResponsesResponse" } } } }
/v1/messages: # Anthropic Messages
post:
security: [{ apiKeyAuth: [] }] # x-api-key: <ANTHROPIC_API_KEY> + anthropic-version 头
requestBody:
content: { application/json: { schema: { $ref: "#/components/schemas/MessagesRequest" } } }
responses:
"200": { content: { application/json: { schema: { $ref: "#/components/schemas/MessagesResponse" } } } }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer }
apiKeyAuth: { type: apiKey, in: header, name: x-api-key }
第一个差异在这里就露头了:OpenAI 用 Authorization: Bearer,Anthropic 用 x-api-key,而且 Anthropic 还要求带 anthropic-version 头。这层最好做——但也别忘了做。
请求体:一句话、一个 system、一个工具
三家发同样一段话,请求 schema 长这样:
components:
schemas:
# ---------- OpenAI Chat Completions ----------
ChatCompletionsRequest:
type: object
required: [model, messages]
properties:
model: { type: string, example: "gpt-5.6" }
messages: # system 是 messages 里的一条
type: array
items: { $ref: "#/components/schemas/ChatMessage" }
tools:
type: array
items: { $ref: "#/components/schemas/ChatTool" }
response_format: { type: object } # 结构化输出入口
stream: { type: boolean, default: false }
ChatMessage:
type: object
required: [role]
properties:
role: { type: string, enum: [system, user, assistant, tool] }
content: { type: string } # 经典写法:content 是字符串
tool_calls: # assistant 发起的工具调用挂在这里
type: array
items: { $ref: "#/components/schemas/ChatToolCall" }
tool_call_id: { type: string } # role=tool 时用来配对
ChatTool:
type: object
properties:
type: { const: function }
function:
type: object
required: [name, parameters]
properties:
name: { type: string }
description: { type: string }
parameters: { type: object } # JSON Schema,工具描述再包一层 function
# ---------- OpenAI Responses ----------
ResponsesRequest:
type: object
required: [model, input]
properties:
model: { type: string, example: "gpt-5.6" }
input: # 可以是字符串,也可以是 Item 列表
oneOf:
- type: string
- type: array
items: { $ref: "#/components/schemas/InputItem" }
instructions: { type: string } # system 语义单独出来,不进 input
tools:
type: array
items: { $ref: "#/components/schemas/ResponsesTool" }
text: { type: object } # text.format 是结构化输出入口
previous_response_id: { type: string } # 服务端串上下文
store: { type: boolean } # 是否让服务端保存这次 response
stream: { type: boolean, default: false }
InputItem: # 输入也是 typed Item
oneOf:
- $ref: "#/components/schemas/EasyMessage"
- $ref: "#/components/schemas/FunctionCallOutput"
EasyMessage:
type: object
properties:
role: { type: string, enum: [user, assistant, system, developer] }
content: { type: string }
ResponsesTool: # 工具描述是平的,没有 function 包装
type: object
properties:
type: { const: function }
name: { type: string }
description: { type: string }
parameters: { type: object }
strict: { type: boolean }
# ---------- Anthropic Messages ----------
MessagesRequest:
type: object
required: [model, max_tokens, messages]
properties:
model: { type: string, example: "claude-sonnet-4-6" }
max_tokens: { type: integer, example: 1024 } # 必填,OpenAI 里是可选
system: { type: string } # system 是顶层参数,不进 messages
messages:
type: array
items: { $ref: "#/components/schemas/AnthropicMessage" }
tools:
type: array
items: { $ref: "#/components/schemas/AnthropicTool" }
stream: { type: boolean, default: false }
AnthropicMessage:
type: object
required: [role, content]
properties:
role: { type: string, enum: [user, assistant] } # 没有 system/tool 角色
content: # content 可以是字符串,也可以是 block 数组
oneOf:
- type: string
- type: array
items: { $ref: "#/components/schemas/ContentBlock" }
AnthropicTool: # 工具描述又是另一种形状
type: object
required: [name, input_schema]
properties:
name: { type: string }
description: { type: string }
input_schema: { type: object } # 注意字段名叫 input_schema
把三个 Tool 定义并排看,同一个"描述一个工具"的需求,字段名分别是 function.parameters、parameters、input_schema。仅这一点,任何声称"改个 base_url 就兼容"的网关都得在中间做真正的翻译,而不是透传。
响应体:文本、工具调用、停止原因
响应端的分歧更大,因为"输出单位"根本不同:
components:
schemas:
# ---------- Chat Completions 响应 ----------
ChatCompletionsResponse:
type: object
properties:
choices: # 经典读取路径 choices[0].message.content
type: array
items:
type: object
properties:
message:
type: object
properties:
role: { const: assistant }
content: { type: [string, "null"] }
tool_calls:
type: array
items: { $ref: "#/components/schemas/ChatToolCall" }
finish_reason: # stop / length / tool_calls / content_filter
type: string
usage: { $ref: "#/components/schemas/Usage" }
ChatToolCall:
type: object
properties:
id: { type: string } # 用来和后续 role=tool 消息配对
type: { const: function }
function:
type: object
properties:
name: { type: string }
arguments: { type: string } # 注意是 JSON 字符串,不是对象
# ---------- Responses 响应 ----------
ResponsesResponse:
type: object
properties:
id: { type: string } # 这就是可用于 previous_response_id 的值
output: # 一组 typed Item,不是单个 message
type: array
items: { $ref: "#/components/schemas/OutputItem" }
output_text: { type: string } # SDK 便利视图,生产代码别硬依赖
usage: { $ref: "#/components/schemas/Usage" }
OutputItem: # 可能是 reasoning / function_call / message
oneOf:
- $ref: "#/components/schemas/ReasoningItem"
- $ref: "#/components/schemas/FunctionCallItem"
- $ref: "#/components/schemas/OutputMessageItem"
FunctionCallItem:
type: object
properties:
type: { const: function_call }
call_id: { type: string } # 用 call_id 配对(不是 id/tool_use_id)
name: { type: string }
arguments: { type: string }
FunctionCallOutput: # 回传工具结果:独立 Item
type: object
properties:
type: { const: function_call_output }
call_id: { type: string }
output: { type: string }
# ---------- Anthropic Messages 响应 ----------
MessagesResponse:
type: object
properties:
role: { const: assistant }
content: # content 永远是 block 数组
type: array
items: { $ref: "#/components/schemas/ContentBlock" }
stop_reason: # end_turn / max_tokens / tool_use / stop_sequence
type: string
usage: { $ref: "#/components/schemas/Usage" }
ContentBlock:
oneOf:
- type: object # 文本块
properties: { type: { const: text }, text: { type: string } }
- type: object # 模型发起的工具调用
properties:
type: { const: tool_use }
id: { type: string } # 用 id
name: { type: string }
input: { type: object } # 注意是对象,不是 JSON 字符串
- type: object # 回传工具结果:塞进 user 的 content block
properties:
type: { const: tool_result }
tool_use_id: { type: string } # 用 tool_use_id 配对
content: { type: string }
Usage: # 三家字段名各不相同,这里只给形状
type: object
description: >
Chat/Responses 用 prompt_tokens/completion_tokens(Responses 叫 input/output_tokens),
Anthropic 用 input_tokens/output_tokens,还有 cache_creation/cache_read 相关字段。
一张对照表把最容易踩坑的差异收拢:
| 关注点 | Chat Completions | Responses | Anthropic Messages |
|---|---|---|---|
| system 放哪 | messages 里 role=system |
顶层 instructions |
顶层 system 参数 |
max_tokens |
可选 | 可选(max_output_tokens) |
必填 |
| 输出单位 | choices[].message |
output[] typed Item |
content[] block |
| 工具调用载体 | message.tool_calls[] |
function_call Item |
tool_use block |
| 工具参数类型 | arguments 是 JSON 字符串 |
arguments 是 JSON 字符串 |
input 是 对象 |
| 工具结果配对键 | tool_call_id |
call_id |
tool_use_id |
| 工具结果怎么回传 | role=tool 的独立消息 |
function_call_output 独立 Item |
user 消息里的 tool_result block |
| 停止字段 | finish_reason |
Item 状态 + response 状态 | stop_reason |
看完这张表就明白:三家在"发消息—收消息"上勉强能对齐,但一旦进入工具循环,tool_call_id / call_id / tool_use_id 三个配对键、字符串 vs 对象两种参数形态、独立消息 vs 内容块两种回传方式,没有一处是能靠改字段名糊弄过去的。这正是"兼容"最容易在工具调用上悄悄崩掉的原因。
Chat Completions:老接口没有死,也没有停在 2023 年
OpenAI 的迁移文档写得很明确:Chat Completions 仍受支持,Responses 是新项目的推荐选择。把前者直接说成“已废弃”,会误导选型。
Chat Completions 仍有三项现实优势:
- 生态覆盖广。 开源推理服务、云厂商、网关和 SDK 大多先实现它。
- 心智负担低。 一问一答、分类、改写、摘要,用 messages 足够清楚。
- 方便做多供应商最小公分母。 文本、图片输入、基础 tool calling 和 JSON 输出,通常最先获得兼容支持。
它的短板也来自这份简单。客户端要管理历史消息;工具循环通常由应用自己驱动;复杂动作被塞进 assistant message 的附属字段;流式处理围绕 delta chunk 展开。需求停在“生成一段内容”时,这不算问题。需求变成“完成一项任务”后,应用层会长出越来越多编排代码。
选择 Chat Completions 不等于落后。明知要做复杂 Agent,却只因为兼容网关都支持它而强行压回 Chat,才是在预支技术债。
Responses API:从“返回一句话”改成“返回一串事件”
Responses 最关键的变化不是 endpoint 从 /chat/completions 变成 /responses,而是上下文的基本单位从 message 变成 Item。
{
"model": "gpt-5.6",
"input": "查一下明天上海的天气,再给出骑行建议。",
"tools": [
{
"type": "function",
"name": "get_weather",
"description": "查询指定城市和日期的天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"date": {"type": "string"}
},
"required": ["city", "date"],
"additionalProperties": false
},
"strict": true
}
]
}
模型可能返回 reasoning、function_call、message 等多个 Item。应用执行函数后,用同一个 call_id 回传:
{
"type": "function_call_output",
"call_id": "call_abc123",
"output": "小雨,12-17°C,东北风4级"
}
简单文本可以读 response.output_text,但它只是 SDK 提供的便利视图。生产代码若假定 output[0] 必然是最终 message,碰到 reasoning 或 tool call 就会读错。
Responses 还允许用 previous_response_id 串接上下文,也能由客户端重放完整 Items。这里有两个常见误解:
- 传了
previous_response_id不等于旧上下文免费。 OpenAI 的迁移文档明确提醒,链上的旧输入 token 仍按输入计费。 - 只保存最终文本不等于保存完整上下文。 手工重放时漏掉 reasoning、function call 或 function output Item,后续回合可能退化或失去工具对应关系。
Responses 的流式协议也是 typed events,不能拿 Chat Completions 的 delta parser 原样套上去。response.output_text.delta、工具参数 delta、Item 完成和整个 response 完成,是不同事件。
如果项目要深用 OpenAI 的托管工具、后台任务、MCP、长时 Agent 和后续模型能力,Responses 是自然选择。若只是每天批量改写几万条短文,用它也行,但不会因为换了 endpoint 就自动获得收益。
Anthropic Messages:看着像 messages,骨子里是 blocks
Anthropic Messages 最容易让 OpenAI 用户踩的第一个坑,是把 system prompt 放进 messages:
{
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"system": "回答要简洁。",
"messages": [
{"role": "user", "content": "解释一下幂等。"}
]
}
system 是顶层参数,max_tokens 也是必填预算。响应的 content 是 block 数组,文本只是其中一种:
{
"role": "assistant",
"content": [
{"type": "text", "text": "我需要先查询订单状态。"},
{
"type": "tool_use",
"id": "toolu_01A",
"name": "get_order",
"input": {"order_id": "A-1024"}
}
],
"stop_reason": "tool_use"
}
工具执行结果要放进后续的 user message,并通过 tool_use_id 配对:
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01A",
"content": "订单已发货"
}
]
}
这和 Responses 的 function_call_output 很像,却不是同一个协议:
- OpenAI Responses 用
call_id;Anthropic 用tool_use_id。 - Responses 的工具结果是独立 Item;Anthropic 的
tool_result是user内容块。 - 两边的停止原因、流式事件名称、usage 字段和错误响应也不同。
- Anthropic 的 prompt caching 可以在内容块上用
cache_control标记缓存边界;这不是把 OpenAI 的缓存参数改个名字就能复刻的行为。 - 开启 extended thinking 后,还要正确保留 thinking blocks 及其签名;把它们压成普通文本会破坏后续工具回合。
所以,Anthropic Messages 并非“OpenAI Chat Completions 换了几个字段”。它把内容块当一等公民,而且对工具循环、缓存和 thinking 有自己的生命周期约束。
Gemini:兼容入口是迁移桥,不是原生能力说明书
Gemini 原生接口以 contents 和 parts 组织输入。文字、图片、音频、函数调用与函数结果都可以成为 part;assistant 对应的角色名是 model。这套模型更接近 Anthropic content blocks 和 Responses Items,而不是早期只有字符串的 chat message。
Google 同时提供 OpenAI compatibility endpoint。已有 OpenAI SDK 的程序改 API key、base_url 和 model,便可调用 Gemini;这对验证模型和迁移简单文本任务很省时间。
但 Google 官方页面也同时写了两句话:如果没有使用 OpenAI libraries,建议直接调用 Gemini API;OpenAI libraries 支持仍是 beta。原生能力还可能需要通过 extra_body 传 Gemini 特有参数。
这恰好说明兼容层的定位:它解决接线问题,不承诺隐藏供应商的全部能力。 项目若大量使用 Gemini 的多模态、thinking、grounding 或原生工具,直接采用 Gemini SDK 往往更清楚。
“OpenAI-compatible”到底兼容了哪一层
我更愿意把兼容拆成四层:
| 层次 | 要回答的问题 | 常见现状 |
|---|---|---|
| 传输兼容 | endpoint、鉴权头、HTTP 状态码是否相似? | 最容易做到 |
| 结构兼容 | messages、tools、stream chunk 能否解析? |
基础文本常见,高级字段参差不齐 |
| 行为兼容 | system 优先级、tool choice、JSON schema、stop 条件是否一致? | 很少完全一致 |
| 能力兼容 | 推理、缓存、内置搜索、多模态、长任务、MCP 是否等价? | 基本不可能靠同一 schema 自动抹平 |
只通过下面这个 smoke test,最多证明前两层的一小部分:
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "reply OK"}],
)
assert response.choices[0].message.content
它没有检查工具参数是否遵守 schema,没有检查两个并行 tool calls,没看 usage 和 finish reason,也不知道中途断流后能否安全重试。
最危险的兼容问题通常不报错。供应商忽略了一个不认识的参数,仍然返回 200;网关把 strict: true 吞掉,模型还是给出一份“像 JSON”的结果;适配器只保留文本,引用和拒答原因静悄悄地没了。失败得太安静,比直接 400 更难查。
常见误用:代码能跑,契约已经坏了
1. 把 base_url 当成完整适配层
换地址只解决路由。model 命名、鉴权、超时、rate limit headers、错误结构、流式结束事件、重试建议都可能不同。
2. 内部只保留一个 text 字段
做文本改写可以。做 Agent 时,这会丢掉 tool call、citation、refusal、reasoning summary、音视频 part 和逐项 usage。等业务需要其中一个能力,只能推倒重来。
3. 把未知字段“宽容地忽略”当成兼容
请求成功不代表配置生效。关键参数应采用 fail closed:适配器不支持 strict schema、parallel_tool_calls 或指定缓存策略,就明确报错或降级告警,不能装作执行了。
4. 用同一个流式解析器处理所有供应商
Chat Completions 的 chunk、Responses 的 typed event、Anthropic 的 content_block_delta 不是一回事。统一的应该是内部事件语义,不是外部 JSON 路径。
5. 盲目重试整个工具循环
读取天气重试几次问题不大;“退款”“发邮件”“创建工单”重试一次就可能多做一次。工具调用要带业务幂等键,网络重试与业务动作重试分开。
6. 把 server-side state 当业务数据库
previous_response_id 能省客户端拼装工作,却不该成为订单、审批或审计记录的唯一来源。供应商状态适合模型上下文,业务事实仍要落在自己的系统里。
7. 追求一个覆盖所有供应商全部能力的“完美接口”
最后通常得到两种结果:要么内部 schema 变成所有供应商字段的并集,要么为了整齐,只剩最低能力。前者是假抽象,后者是自废武功。
适配层应该长什么样
一个实用的多供应商架构,不从复制 OpenAI schema 开始,而从业务真正需要的语义开始:
flowchart LR
B[业务用例] --> C[内部窄协议]
C --> R[能力注册表]
C --> OA[OpenAI Responses Adapter]
C --> OC[OpenAI Chat Adapter]
C --> AN[Anthropic Adapter]
C --> GE[Gemini Adapter]
OA --> P1[Provider API]
OC --> P2[Provider API]
AN --> P3[Provider API]
GE --> P4[Provider API]
C -.显式逃生口.-> X[provider_options]
R -.启动时校验.-> OA
R -.启动时校验.-> OC
R -.启动时校验.-> AN
R -.启动时校验.-> GE
内部协议不必很大,但要保住业务关心的类型:
InputPart = Text | Image | File | ToolResult
OutputEvent = TextDelta | ToolCallDelta | ToolCallDone | Citation | Usage | Done | Error
FinishReason = Stop | Length | ToolUse | Refusal | ContentFilter | Error | Unknown(raw)
Capability = Streaming | Tools | ParallelTools | StrictSchema | Vision | HostedSearch | State
这里有三条纪律。
第一,归一化语义,不伪造等价。 Anthropic 的 tool_use 和 Responses 的 function_call 可以归一成内部 ToolCall;thinking block 和 reasoning Item 若语义不等价,就分别保留在 provider metadata,不能硬塞成同一种“思维链”。
第二,保留 escape hatch。 provider_options.openai.reasoning、provider_options.anthropic.cache_control 这类显式扩展,会让抽象不那么漂亮,却能避免为了统一接口放弃原生能力。逃生口必须命名空间隔离,不能让供应商字段散落到业务代码。
第三,能力在调用前检查。 路由器选择模型之后,先验证请求需要的 capabilities。目标不支持 strict schema,就换模型、走降级路径或报错;不要等请求发出去再猜供应商做了什么。
选型:按任务的复杂度选,不按接口的新旧选
| 场景 | 建议起点 | 理由 |
|---|---|---|
| 摘要、分类、改写、简单问答,且要切多家模型 | Chat Completions 风格的内部窄协议 | 生态广,最小公分母够用 |
| 新建 OpenAI-only 项目 | Responses API | 官方推荐,后续能力和托管工具优先落在这里 |
| 复杂 OpenAI Agent、内置搜索、MCP、后台长任务 | Responses API | typed Items 和 Agent loop 更贴合任务 |
| Claude 为主,重度使用 prompt caching、thinking、tool use | Anthropic Messages 原生接口 | 少做有损翻译,保留 block 语义 |
| Gemini 为主,重度多模态或原生工具 | Gemini 原生 API | 兼容入口是 beta,原生参数表达更完整 |
| 既要跨供应商,又要使用各家特色能力 | 内部窄协议 + provider adapters + escape hatch | 统一公共语义,同时允许显式差异 |
我的取舍很简单:边缘能力可以兼容,核心能力必须原生。 如果供应商切换只是成本和容灾选项,统一层值得做;如果产品卖点正是某家的 Agent、缓存或多模态能力,就别为了“可移植”把它削平。
上线前的兼容性契约测试
别在 README 里写“兼容”,让测试说话。至少覆盖这些用例:
- [ ] 基础文本:system instruction 是否生效,Unicode 和长文本是否完整。
- [ ] 多轮对话:重放历史后语义是否连续,token 统计是否符合预期。
- [ ] 单工具调用:参数能通过 JSON Schema 校验,ID 能正确配对。
- [ ] 并行工具调用:顺序不同、两个结果回传后仍能生成最终答案。
- [ ] 工具失败:超时、业务错误、错误结果块是否能被模型理解。
- [ ] 结构化输出:合法 JSON 之外,再做业务 schema 与语义校验。
- [ ] Streaming:中文字符边界、工具参数增量、取消、断流、最终 usage。
- [ ] 停止原因:length、tool use、refusal、content filter 和未知值不能都映射成 success。
- [ ] Rate limit:读取各家 headers,指数退避加 jitter,遵守
Retry-After。 - [ ] 幂等性:有副作用的工具在重试、断流恢复和超时后不会重复执行。
- [ ] 可观测性:记录 provider、model snapshot、request ID、latency、usage、finish reason 与工具耗时。
- [ ] 隐私:明确
store、日志脱敏、缓存和数据保留策略,不依赖默认值。
迁移时别一刀切。先挑一个简单文本流量做 shadow test,再迁工具调用,最后处理 streaming 和长对话。比较的不只是“答案看起来差不多”,还要看 p95 latency、输入输出 token、工具成功率、结构化解析失败率和重试次数。
总结:兼容不是一个布尔值
LLM API 从 prompt 走到 messages,再走到 Items、blocks 和 events,背后的变化是模型角色变了:它不再只续写文本,也开始选择工具、维护任务状态、处理多模态内容。
Chat Completions 仍是好用的通用插头;Responses 是 OpenAI 面向 Agent 的主接口;Anthropic Messages 和 Gemini 原生 API 各有自己的内容模型。它们可以被适配,但不能靠改几个字段就变成同一种东西。
下一次看到“OpenAI-compatible”,不妨追问一句:兼容的是 endpoint、JSON 外形、运行行为,还是完整能力? 答不出来,就把它当成迁移起点,别当成交付结论。
参考资料
- OpenAI:Introducing APIs for GPT-3.5 Turbo and Whisper
- OpenAI:Completions API(Legacy)
- OpenAI:Migrate to the Responses API
- OpenAI:Conversation state
- OpenAI:Function calling
- OpenAI:New tools for building agents
- Anthropic:Messages API Reference
- Anthropic:Messages API beta announcement
- Google:OpenAI compatibility for Gemini API
- Google:Gemini API function calling
全文思维导图

@startmindmap
<style>
mindmapDiagram {
node {
BackgroundColor #F8F9FA
RoundCorner 10
Padding 10
FontSize 13
}
:depth(0) {
BackgroundColor #1E3A5F
FontColor white
FontSize 18
FontStyle bold
}
:depth(1) {
FontSize 15
FontStyle bold
}
:depth(2) {
FontSize 13
}
}
</style>
* LLM API 的变迁\n兼容不是布尔值
** 三代数据模型
*** prompt string\n文本续写
*** messages\n角色化对话
*** typed items/events\nAgent 任务循环
** 四种主要接口
*** Chat Completions\n生态广、最小公分母
*** Responses API\nItems、状态、托管工具
*** Anthropic Messages\ncontent blocks
*** Gemini Native\ncontents / parts
** OpenAPI 对照
*** system 放哪不同
*** 工具参数\n字符串 vs 对象
*** 配对键\ncall_id/tool_use_id
*** 结果回传\n独立消息 vs block
** OpenAI-compatible 四层
*** 传输兼容
*** 结构兼容
*** 行为兼容
*** 能力兼容
** 七种常见误用
*** 只换 base_url
*** 输出压成 text
*** 忽略未知字段
*** 共用 stream parser
*** 盲目重试工具
*** 把供应商状态当数据库
*** 追求完美统一接口
** 正确的适配层
*** 内部窄协议
*** provider adapters
*** capability registry
*** provider escape hatch
** 选型与验证
*** 边缘能力可兼容
*** 核心能力用原生
*** 契约测试证明兼容
*** shadow test 渐进迁移
@endmindmap
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。