cd ../返回博客
$Guide//2026年6月4日//8 min read

「OpenAI 兼容」到底什么会匹配、什么会悄悄不一样

一份实战指南:流式、工具、视觉、JSON 都能干净移植,但分词器、嵌入、缓存与推理会漏。诚实的网关才值得信任。

「OpenAI 兼容」是 AI 基础设施市场里被滥用得最厉害的一个说法。它可以指 「你能把 OpenAI SDK 指向我们的 URL,发一个基本的对话调用能返回结果」 —— 这是容易做到的 80% —— 也可以指「每一个字段、每一个流式事件、每一次工具 调用的往返,以及每一个 usage 数字,行为都跟你代码已有的预期 完全一致」。这两者之间的落差,正是生产事故的栖身之地。这篇文章就是一份 实战指南:到底什么必须匹配,你已有的代码才能原样跑通;什么在不同 上游之间行为一致;以及当 OpenAI 外壳背后的模型其实是 Claude(Anthropic) 或 Gemini(Google)而非 GPT 时,什么会悄悄地不一样。

Brievio 是一个架在真正第一方模型前面的 OpenAI 兼容网关,所以这篇是从 「翻译者的座位」上写的 —— 正是要把 Anthropic 的 Messages API 和 Google 的 Vertex API,都从 OpenAI 形状的那根管子里送出来的那一层。我会明确指出 这层抽象在哪里干净、在哪里会漏,因为假装它从不漏水,正是你凌晨两点被 呼叫值班的原因。

「兼容」到底必须意味着什么

兼容不是一个营销勾选项;它是与你已经导入的那个 SDK 之间的契约。OpenAI 的 Python 和 Node 库,对线上传输格式有一套硬性假设。一个网关只有在尊重 全部这些假设时,才算兼容:

  • 请求 schema。带上 modelmessages(一个由 role/content 对象组成的列表),以及可选的 那些旋钮 —— temperaturemax_tokenstop_pstoptoolsresponse_format —— 的 POST /v1/chat/completions。 对未知参数应当接受并忽略,而不是返回 400。
  • 响应外壳。一个带有 idobject: "chat.completion"modelchoices[](每项带有 messageindexfinish_reason),以及一个usage 块的对象。SDK 会反序列化成带类型的对象;漏掉一个 字段,resp.choices[0].message.content 就会在别人的机器上 抛出异常。
  • 流式协议。带有 data: [DONE] 结束标记和 逐 token delta 对象的服务器推送事件(SSE)。这是「兼容」 网关最常见会在细节上搞错的一件事。
  • 错误结构与 HTTP 状态码。429 必须看起来像限流,400 必须携带一个带 typemessageerror 对象。SDK 里的重试与退避逻辑,就是靠这些来判断的。

这就是基准线 —— 人人都做对的那部分。改两行,这个调用就能返回一个正常的 completion 对象:

chat.py
# 重点就在这里:只改两行,代码原样不动。
# 同一个 SDK、同样的请求结构、同样的响应对象 —— 背后换了个模型而已。
from openai import OpenAI

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",   # 原本是 https://api.openai.com/v1
)

resp = client.chat.completions.create(
    model="claude-sonnet-4-6",               # 原本是 gpt-4o
    messages=[
        {"role": "system", "content": "You are concise."},
        {"role": "user", "content": "Summarize the CAP theorem in two sentences."},
    ],
    temperature=0.2,
    max_tokens=300,
)

print(resp.choices[0].message.content)
print(resp.usage)        # prompt_tokens / completion_tokens / total_tokens —— 字段完全一样
# resp.id、resp.model、resp.choices[0].finish_reason 都在,结构也跟 OpenAI 一致。

如果一个网关连这点都做不到,直接走人。但这只是入场门槛,不是终点线。 真正有意思的问题是:当你把真实应用会用到的那些功能打开时,会发生什么。

流式响应:兼容悄悄漏水的地方

流式是最有可能「技术上存在、实际上却坏掉」的功能。SDK 的流式迭代器 期待三样东西:一个 text/event-stream 的内容类型、增量地 到达 choices[0].delta.content 的增量内容,以及一行字面量data: [DONE] 来关闭数据流。任何一个搞错,症状都让人抓狂 —— 在你的 curl 测试里好好的,到了生产就卡死。

stream.py
# 流式响应是「兼容」最容易出漏子的地方。你代码依赖的契约是:
# - Content-Type: text/event-stream
# - 每个事件都是 "data: {json}\n\n",增量内容到达 choices[0].delta.content
# - 数据流以一行字面量 "data: [DONE]\n\n" 作为结束标记
stream = client.chat.completions.create(
    model="gemini-2-5-pro",
    messages=[{"role": "user", "content": "Explain B-trees in one paragraph."}],
    stream=True,
)

for chunk in stream:
    delta = chunk.choices[0].delta
    if delta.content:
        print(delta.content, end="", flush=True)

# 网关处理不当就会让客户端出问题的几件事:
#   - 把整个响应缓冲下来,再一次性吐成一个 chunk(这不是真正的流式)
#   - 漏掉 [DONE] 结束标记(有些 SDK 会一直等它而卡死)
#   - usage 只在最后才有 —— 传 stream_options={"include_usage": True} 才拿得到。

最常见的「假流式」失效,是网关去调用上游,等待整个响应,然后 把它当成一两个大 chunk 一次性吐出来。SDK 不会报错 —— 你只是白白失去了 流式的全部意义(首 token 时间依旧糟糕)。一个真正的网关会保持到上游的 连接敞开,并在每个 token 到达时就转发出去。对 Claude 来说,这意味着要把 Anthropic 的 content_block_delta 事件实时翻译成 OpenAI 的chat.completion.chunk 事件;对 Gemini 来说,则是针对 Vertex 的流式格式做同样的活儿。输出在你代码看来一模一样,但底下的机器 正在做实打实的逐事件翻译。

有一处真实的差异要知道:流式响应里的 usage。只有当你传了stream_options={"include_usage": true} 时, OpenAI 才会在最后一个 chunk 上带上 usage 块。一个好网关会 对每一个上游都尊重这个标志,这样你那套 token 记账的代码,就不用为某个 模型单独写特例。完整的流式契约见 对话补全文档

工具与函数调用:同样的形状,不同的引擎

工具调用是 OpenAI 这层抽象真正物有所值的功能 —— 因为这三家供应商有着完全不同的原生格式,而网关把这一切全藏了起来。你发出 OpenAI 的tools 数组;你在消息上拿回 tool_calls。这中间 发生的是一次实打实的翻译:

tools.py
# 工具 / 函数调用:请求这一侧与 OpenAI 完全一致。
tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather for a city",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}]

resp = client.chat.completions.create(
    model="claude-opus-4-7",
    messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
    tools=tools,
    tool_choice="auto",
)

# 模型返回 choices[0].message.tool_calls —— 一个列表,每项带有一个 id、
# .function.name 和 .function.arguments(一个你必须 json.loads 的 JSON *字符串*)。
for call in resp.choices[0].message.tool_calls or []:
    print(call.id, call.function.name, call.function.arguments)

# 然后你追加一条 {"role": "tool", "tool_call_id": call.id, "content": result}
# 消息,再调用一次。无论上游是 Claude 还是 Gemini,这个循环都一模一样 ——
# 网关在输出时把各家供应商原生的工具格式映射成 OpenAI 的 tool_calls,
# 输入时再映射回去。

在底层,Anthropic 返回带 input 对象的 tool_use内容块;Gemini 返回带 argsfunctionCall 分块。 网关把两者都映射到 OpenAI 的 tool_calls[] 形状上 —— 包括 这个细节:OpenAI 是把 arguments 作为一个你必须json.loads 的 JSON 字符串交付,而不是一个已解析的 对象。你那套工具执行循环 —— 读取调用、运行函数、追加role: "tool" 消息、再调一次 —— 无论你瞄准的是 哪个模型家族,都逐字节相同。这就是它的全部价值主张:智能体写一次,换 模型只用换个字符串。

诚实的几条注意事项,因为它们确实存在:

  • 并行工具调用。三个家族都能在一轮里请求多个工具,但对 同一个提示,它们在「下手有多积极」上各不相同。别假设确切的数量或顺序 能跨模型照搬 —— 按一个列表来处理,而不是一个固定的数目。
  • 严格 / 结构化的工具 schema。OpenAI 的strict: true JSON-schema 强制约束,是 OpenAI 模型的功能。 在 Claude 和 Gemini 上,网关会把你的 schema 作为工具定义传过去,模型也 会紧密遵循,但那份保证是上游的,不是网关能凭空变出来的魔法。
  • tool_choice 的微妙之处。auto 以及强制调用某个特定函数,在哪儿都得到良好支持;那些 冷门组合,值得在你真正要上线的每个模型上快速测一下。

视觉与 JSON 模式:透传,但有边界

视觉用的是 OpenAI 的多模态内容分块格式 —— 一个混合了 textimage_url 条目的列表。面对一个原生能看图的模型 (Gemini 2.5 Pro/Flash、Claude 家族),网关把图像转发过去,这个多模态 调用就直接能用。JSON 模式 —— response_format: { type: "json_object" } —— 把输出约束成一个可解析的对象:

vision.py
# 视觉:用的是 OpenAI 的多模态内容分块(content-parts)格式,透传给
# 一个原生支持图像的模型。URL 或 base64 的 data URI 都能用。
resp = client.chat.completions.create(
    model="gemini-2-5-pro",
    messages=[{
        "role": "user",
        "content": [
            {"type": "text", "text": "What's in this chart? Give me the trend."},
            {"type": "image_url", "image_url": {
                "url": "https://example.com/q3-revenue.png",
            }},
        ],
    }],
)
print(resp.choices[0].message.content)

# JSON 模式 —— 要求返回一个保证可解析的对象:
resp = client.chat.completions.create(
    model="claude-sonnet-4-6",
    messages=[{"role": "user", "content": "Extract name and email as JSON."}],
    response_format={"type": "json_object"},
)
import json
data = json.loads(resp.choices[0].message.content)  # 每次都能解析成功。

边界在哪里:图像输入的限制(最大尺寸、每个请求最多几张图、接受的 MIME 类型)是由每个上游设定的,不是网关发明的 —— 所以一个 Gemini 会拒收的 50MB TIFF,在 OpenAI 外壳后面照样会被拒收,并附上一个翻译过的错误。而json_object 模式保证的是有效的 JSON,而不是符合你 那个特定 schema 的 JSON;如果你需要某种特定结构,就在提示里把它描述清楚, 并在解析之后做校验。这些都不是网关的 bug —— 它们是底层模型的契约透出来 的样子,而这恰恰是你希望一个忠实的翻译者去保留的东西。

嵌入,以及那些真的搬不过去的东西

还有两个值得诚实说明的面。嵌入(/v1/embeddings)简单又稳定 —— 但向量在不同模型之间是 不可互换的。一个 Gemini 嵌入和一个 OpenAI 嵌入,活在维度不同的 不同空间里;你不能把它们混进同一个索引,也不能比较它们的余弦相似度。 选定一个嵌入模型;如果你要切换,就把整个语料库重新嵌入一遍。接口是 兼容的;数学不兼容。

还有那些任何兼容性垫片都糊弄不过去的漏点 —— 那些根本没有对应 OpenAI 字段来承载它们的、供应商特有的功能:

  • Anthropic 提示缓存。原生的 cache_control断点,存在于 Anthropic 的 Messages API 上。在 OpenAI 外壳上,你拿到的 是 OpenAI 风格的自动前缀缓存;要显式驱动缓存,你就用原生的/v1/messages 接口。(两者在 Brievio 上都能用 —— 见 API 文档。)
  • 分词器因家族而异。「1,000 个 token」在 GPT、Claude 和 Gemini 之间,对应的字符串长度并不相同 —— 各家都有自己的分词器。所以 当你换模型时,max_tokens 的预算和你的成本估算都会变,哪怕 字段名没变。一个好网关会在 usage 里如实上报每个上游诚实的 token 数;它没法让三个分词器达成一致,而那种假装它们 一致的网关,你也不该信。
  • 扩展思考 / 推理。Claude 的扩展思考和 Gemini 的思考 模式,呈现方式都跟 OpenAI 的推理不一样。内容会传过来;但确切的字段 管路是模型特有的,所以别把某一家供应商的推理形状硬编码到所有模型上。
  • 系统提示的语义。三家都接受一条系统消息,但它们在 加权和截断上略有不同。行为能搬过去;但并非逐比特相同。请按模型测试 你的提示。

一个好网关如何把这一切归一化

兼容层的活儿,是在常规路径上做一个忠实、无损的翻译者,在边界上做一个 诚实的翻译者。具体来说就是:双向映射请求 schema;逐 token 翻译流式事件, 连结束标记一起;把每家供应商原生的工具格式与 tool_calls互相转换;保留 finish_reason 的语义;把真实图像透传给支持 视觉的模型;以及 —— 这是最容易作弊的那部分 —— 上报上游真实的 token 数, 而不是一个注了水的数字。在 Brievio 上,外壳背后的模型就是真正的第一方 模型,可追溯到 AWS Bedrock 和 Google Vertex,所以你在归一化的那个行为, 是真模型的行为,而不是一个更便宜的替身。如果你想亲自确认这一点, 你的 Claude 真是 Claude 吗 里的那四个测试,大约花一分钟。

关于这一切,任何在「兼容」接口上做开发的人,都能得出两条原则。第一,测试你真正会用到的功能 —— 一个能通过的对话调用,完全 说明不了流式是不是增量刷出,也说明不了工具调用的 ID 能不能往返。 第二,尊重那些漏点:分词器、嵌入空间、缓存语法、推理 形状,都是上游的属性,而那个对它们诚实的网关,才是你在生产里能信任的 那个。兼容是一道光谱,而它有用的那部分,是能扛住你真实工作负载的那 部分 —— 不是能扛住一次演示的那部分。

落到实处的要点

把 OpenAI SDK 指向 https://api.brievio.com/v1,改掉模型字符串,然后跑你已有的 测试套件 —— 不是 hello-world,是你的套件。带上 include_usage去试流式,跑一次工具调用的往返,发一张图,要一个 json_object。 如果这四样在你打算上线的那个模型上都通过了,那这次迁移真的就是两行。 哪里需要某个供应商特有的功能 —— 显式的 Anthropic 缓存、原生的推理控制 —— 就在那条路径上降到原生 接口,而在其他所有地方保持 OpenAI 外壳。想要 从一份现有的 OpenAI 代码库逐步移植过来?先从 用 OpenAI SDK 调用 Claude开始,再去翻一翻 模型列表,挑出跑在外壳背后的那个。