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_urlmodel 换掉,请求返回了 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 列表 messagessystem 单列 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.parametersparametersinput_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 放哪 messagesrole=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 仍有三项现实优势:

  1. 生态覆盖广。 开源推理服务、云厂商、网关和 SDK 大多先实现它。
  2. 心智负担低。 一问一答、分类、改写、摘要,用 messages 足够清楚。
  3. 方便做多供应商最小公分母。 文本、图片输入、基础 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
    }
  ]
}

模型可能返回 reasoningfunction_callmessage 等多个 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_resultuser 内容块。
  • 两边的停止原因、流式事件名称、usage 字段和错误响应也不同。
  • Anthropic 的 prompt caching 可以在内容块上用 cache_control 标记缓存边界;这不是把 OpenAI 的缓存参数改个名字就能复刻的行为。
  • 开启 extended thinking 后,还要正确保留 thinking blocks 及其签名;把它们压成普通文本会破坏后续工具回合。

所以,Anthropic Messages 并非“OpenAI Chat Completions 换了几个字段”。它把内容块当一等公民,而且对工具循环、缓存和 thinking 有自己的生命周期约束。


Gemini:兼容入口是迁移桥,不是原生能力说明书

Gemini 原生接口以 contentsparts 组织输入。文字、图片、音频、函数调用与函数结果都可以成为 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 状态码是否相似? 最容易做到
结构兼容 messagestools、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 schemaparallel_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.reasoningprovider_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 外形、运行行为,还是完整能力? 答不出来,就把它当成迁移起点,别当成交付结论。

参考资料

全文思维导图

LLM API 的变迁与兼容性 - 思维导图

@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 访问原文并评论。