cd ../back to blog
$Guide//June 4, 2026//8 min read

「OpenAI 相容」實際相符的是什麼:一份實地指南

OpenAI 相容是一道光譜。串流、工具、視覺與 JSON 模式能乾淨移植,但分詞器、嵌入向量、Anthropic 快取與推理會默默不同。

「OpenAI 相容」是 AI 基礎設施市場裡最被濫用的一句話。它可以指「你能把 OpenAI SDK 指向我們的 URL,一個基本的對話呼叫就有回應」 — 這是輕鬆的 那 80% — 也可以指「每一個欄位、每一個串流事件、每一趟工具呼叫的來回, 以及每一個 usage 數字,都照你的程式碼早已預期的方式運作」。 這兩者之間的落差,正是生產環境事故的溫床。這篇文章就是那份實地指南: 為了讓你既有的程式碼原封不動地運作,到底有哪些 真的必須相符、哪些在各個上游之間表現一致,以及當 OpenAI 形狀背後的模型 其實是 Claude(Anthropic)或 Gemini(Google)而不是 GPT 時,又有哪些 會默默地不一樣。

Brievio 是一個架在真正第一方模型前面的 OpenAI 相容閘道,所以這是從 翻譯者的座位寫成的 — 也就是那一層,得讓 Anthropic 的 Messages API 與 Google 的 Vertex API 都從 OpenAI 形狀的同一根管子裡流出來。我會明確 指出抽象在哪裡乾淨俐落、又在哪裡會滲漏,因為假裝它從不滲漏,正是你 會在凌晨兩點被叫醒的原因。

「相容」實際上必須意味著什麼

相容不是一個行銷用的勾選框;它是一份你早已 import 進來的那套 SDK 的 契約。OpenAI 的 Python 與 Node 函式庫對線路格式有著很硬的假設。一個 閘道唯有全部遵守,才稱得上相容:

  • 請求結構描述。 POST /v1/chat/completions 帶上 modelmessages(一個由 role/content 物件組成的串列),以及可選的旋鈕 — temperature max_tokenstop_pstop toolsresponse_format。未知的參數應該被 接受並忽略,而不是回個 400。
  • 回應外層封裝。一個物件,帶有 idobject: "chat.completion" modelchoices[](每一項都帶有 messageindexfinish_reason), 以及一個 usage 區塊。SDK 會把它反序列化成具型別的物件; 少了一個欄位,resp.choices[0].message.content 就會在 別人的機器上拋出例外。
  • 串流協定。採用 Server-Sent Events,帶有 data: [DONE] 哨符與逐權杖的 delta 物件。這是「相容」閘道最常見、又最容易在細微處 出錯的一項。
  • 錯誤形狀與 HTTP 代碼。429 得看起來像個速率上限, 400 得帶上一個 error 物件,裡頭有 type message。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。

如果一個閘道連這個都做不到,掉頭就走。但這只是入場門檻,不是終點線。 真正有趣的問題是:當你把真實 app 會用到的功能都打開時,會發生什麼事。

串流:相容默默滲漏之處

串流是最可能「技術上有、實務上壞」的功能。SDK 的串流迭代器預期三 件事:一個 text/event-stream 的內容型別、差量逐步落在 choices[0].delta.content 上,以及一行字面上的 data: [DONE] 來關閉串流。任何一項弄錯,症狀都令人抓狂 — 在你的 curl 測試裡好端端的,到了生產環境卻卡住。

stream.py
# 串流正是天真的「相容」最會出包的地方。你所仰賴的契約是:
# - Content-Type: text/event-stream
# - 每個事件都是 "data: {json}\n\n",差量(delta)會落在 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)

# 閘道若處理錯了、就會弄壞用戶端的情況:
#   - 先把整段回應緩衝起來,再一次傾倒成單一區塊(這不是真正的串流)
#   - 漏掉 [DONE] 哨符(某些 SDK 會一直等它而卡住)
#   - usage 只在最後才給 — 傳 stream_options={"include_usage": True} 才拿得到。

最常見的「假串流」失敗,是閘道呼叫上游後,等著整個回應,再 把它當成一兩個大區塊送出。SDK 不會報錯 — 你只是失去了串流的全部意義 (首權杖時間依舊糟透了)。一個真正的閘道,會對上游保持連線開著,並在 每個權杖抵達時就轉發出去。對 Claude 來說,那意味著把 Anthropic 的 content_block_delta 事件即時翻譯成 OpenAI 的 chat.completion.chunk 事件;對 Gemini 來說,則是針對 Vertex 的串流格式做同一件事。輸出在你的程式碼看來一模一樣,但底下的 機器正在做真正的逐事件翻譯。

有一個真實的差異要知道:串流回應中的 usage。 OpenAI 只有在你傳了 stream_options={"include_usage": true} 時,才會把 usage 區塊放在最後一個區塊上。一個好的閘道 會對每一個上游都遵守這個旗標,這樣你的權杖計帳程式碼就不必為模型做 特例處理。完整的串流契約請見 對話補全文件

工具與函式呼叫:形狀相同,引擎不同

工具呼叫正是 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 回傳的是帶有 args functionCall 片段。閘道把兩者都對應到 OpenAI 的 tool_calls[] 形狀上 — 包括這個細節:OpenAI 把 arguments 當成一個你必須 json.loads 的 JSON 字串來給,而不是一個已剖析的物件。你的工具執行迴圈 — 讀取 呼叫、執行函式、附上 role: "tool" 訊息、再呼叫一次 — 不論你鎖定哪一個 家族,都是逐位元組相同的。這正是整個價值主張:代理只寫一次,換模型 只要換一個字串。

誠實的但書,因為它們確實存在:

  • 平行工具呼叫。三個家族都能在一個回合裡要求多個 工具,但對於同一個提示,它們積極的程度不一。別假設確切的數量或 順序會在模型之間照搬 — 請處理一個串列,而不是一個固定的數目。
  • 嚴格/結構化的工具結構描述。OpenAI 的 strict: true JSON-schema 強制,是一個 OpenAI 模型的 功能。在 Claude 與 Gemini 上,閘道會把你的結構描述當成工具定義傳 過去,模型也會緊密遵循,但那個保證是上游的,不是閘道能憑空變出 的魔法。
  • tool_choice 的細微差異。 auto 與強制指定某個函式,到處都獲得良好支援;至於那些 冷門的組合,在你真正要出貨的每個模型上都值得快速測一下。

視覺與 JSON 模式:原樣傳遞,但有邊角

視覺採用 OpenAI 的多模態 content-parts 格式 — 一個混合了 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, 不是符合你特定結構描述的 JSON;如果你需要某個特定結構,請在提示裡 描述它,並在剖析後驗證。這些都不是閘道的臭蟲 — 它們是底層模型的契約 透出來的樣子,而這正是你會希望一個忠實的翻譯者所保留的。

嵌入,以及那些真正搬不過去的東西

還有兩個值得誠實點名的面向。嵌入 /v1/embeddings)既簡單又穩定 — 但向量無法在模型之間 互換。一個 Gemini 嵌入與一個 OpenAI 嵌入,活在不同維度的不同 空間裡;你不能把它們混在同一個索引裡,也不能拿它們的餘弦相似度來 比較。挑定一個嵌入模型,要換的話就把你整個語料庫重新嵌入一遍。API 是相容的;數學不是。

還有那些再多相容墊片都遮不住的滲漏 — 那些供應商特有、根本沒有 OpenAI 欄位可以承載的功能:

  • Anthropic 提示快取。原生的 cache_control 斷點,活在 Anthropic 的 Messages API 上。 在 OpenAI 形狀之上,你拿到的是 OpenAI 風格的自動前綴快取;想要明確 地驅動快取,就用原生的 /v1/messages 端點。(兩者在 Brievio 上都能用 — 見 API 文件。)
  • 分詞器各家族不同。「1,000 個權杖」在 GPT、Claude 與 Gemini 之間,不會是同樣的字串長度 — 每一家都有自己的分詞器。所以 當你換模型時,max_tokens 預算與你的成本估算都會位移, 即使欄位名稱沒變。一個好的閘道會在 usage 裡回報每個 上游誠實的權杖數;它沒辦法讓三個分詞器達成一致,而你也 不該信任一個假裝它們一致的閘道。
  • 延伸思考/推理。Claude 的延伸思考與 Gemini 的思考 模式,浮現的方式跟 OpenAI 的推理不一樣。內容會傳過來;但確切的欄位 接管線是各模型特定的,所以別把某一家供應商的推理形狀硬寫死、套用 到全部模型上。
  • 系統提示語意。三家都接受一則系統訊息,但它們在 加權與截斷上略有不同。行為搬得過去;它不是逐位元相同。請對每個 模型測試你的提示。

一個好的閘道如何把這一切正規化

相容層的工作,是在常見路徑上當一個忠實、無損的翻譯者,在邊角上當一個 誠實的翻譯者。具體來說,那意味著:把請求結構描述雙向對應;把串流事件 逐權杖翻譯,連哨符也含在內;把各供應商的原生工具格式來回轉換成 tool_calls;保留 finish_reason 的語意;把真實的影像原樣傳遞給具視覺能力的 模型;以及 — 那個最容易作弊的部分 — 回報上游實際的權杖數,而不是一個 灌過水的數字。在 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 開始,再瀏覽 模型清單,挑出要跑在形狀背後的那一個。