「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帶上model、messages(一個由 role/content 物件組成的串列),以及可選的旋鈕 —temperature、max_tokens、top_p、stop、tools、response_format。未知的參數應該被 接受並忽略,而不是回個 400。 - 回應外層封裝。一個物件,帶有
id、object: "chat.completion"、model、choices[](每一項都帶有message、index、finish_reason), 以及一個usage區塊。SDK 會把它反序列化成具型別的物件; 少了一個欄位,resp.choices[0].message.content就會在 別人的機器上拋出例外。 - 串流協定。採用 Server-Sent Events,帶有
data: [DONE]哨符與逐權杖的delta物件。這是「相容」閘道最常見、又最容易在細微處 出錯的一項。 - 錯誤形狀與 HTTP 代碼。429 得看起來像個速率上限, 400 得帶上一個
error物件,裡頭有type與message。SDK 裡的重試與退避邏輯就靠這些來判斷。
這裡是基準線 — 大家都做對的那一部分。改兩行,呼叫就回傳一個正常的 completion 物件:
# 重點就在這裡:只改兩行,其餘程式碼原封不動。
# 同一套 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 測試裡好端端的,到了生產環境卻卡住。
# 串流正是天真的「相容」最會出包的地方。你所仰賴的契約是:
# - 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。 中間發生的,是一場真實的翻譯:
# 工具/函式呼叫:請求端與 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: trueJSON-schema 強制,是一個 OpenAI 模型的 功能。在 Claude 與 Gemini 上,閘道會把你的結構描述當成工具定義傳 過去,模型也會緊密遵循,但那個保證是上游的,不是閘道能憑空變出 的魔法。 tool_choice的細微差異。auto與強制指定某個函式,到處都獲得良好支援;至於那些 冷門的組合,在你真正要出貨的每個模型上都值得快速測一下。
視覺與 JSON 模式:原樣傳遞,但有邊角
視覺採用 OpenAI 的多模態 content-parts 格式 — 一個混合了 text 與 image_url 項目的串列。對著一個原生 看得懂影像的模型(Gemini 2.5 Pro/Flash、Claude 家族),閘道把影像 轉發過去,多模態呼叫就這麼成了。JSON 模式 — response_format: { type: "json_object" } — 把輸出約束成一個可剖析的物件:
# 視覺:採用 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 開始,再瀏覽 模型清單,挑出要跑在形狀背後的那一個。