在 AI 應用中,供應商鎖定通常不是一下子發生的。它是慢慢滲透進來的——這裡直接來一個 import openai,那裡硬編一個模型名稱,還有某個你解析的回應欄位,卻沒確認其他供應商是否也提供相同欄位。六個月後,要更換供應商就意味著要重寫你一半的後端。
鎖定發生的四種方式
多數開發者以為鎖定就是「我在用 OpenAI 的 SDK」。這其實是最不危險的那種。真正的陷阱更微妙:
| 鎖定類型 | 如何發生 | 後果 |
|---|---|---|
| SDK 鎖定 | from openai import OpenAI 到處都是 | 更換 SDK 代表要動到每個檔案 |
| 模型名稱鎖定 | 在商業邏輯中硬編 model="gpt-4o" | 每次更換模型都是一次程式碼變更 |
| 參數鎖定 | 使用 logprobs、n>1 或 reasoning_effort | 這些在 Claude 或 Gemini 上不存在 |
| 回應格式鎖定 | 解析供應商專屬的回應欄位 | 不同供應商回傳的結構不同 |
目標不是消滅所有這些鎖定——其中一些是可以接受的權衡。目標是知道你正在承擔哪些鎖定。
使用相容 OpenAI 的端點作為抽象層
避免 SDK 鎖定的最乾淨做法,是使用一個相容 OpenAI 的單一端點,轉接到多個供應商。你保留 OpenAI SDK,但後端可以是任何供應商。
CometAPI 正是這樣——一個端點、一把金鑰,涵蓋 OpenAI、Anthropic、Google、DeepSeek、xAI 等超過 500 款模型:
import osfrom openai import OpenAIfrom dotenv import load_dotenvload_dotenv()api_key = os.environ.get("AI_API_KEY")if not api_key: raise ValueError("AI_API_KEY environment variable is not set")client = OpenAI( base_url=os.environ.get("AI_BASE_URL", "https://api.cometapi.com/v1"), api_key=api_key,)
在 GPT、Claude、Gemini 間切換僅需一行變更:
# Beforeresponse = client.chat.completions.create(model="gpt-5.4", messages=[...])# After — same code, different modelresponse = client.chat.completions.create(model="claude-sonnet-4-6", messages=[...])
注意: 像 gpt-5.4 和 claude-sonnet-4-6 這樣的模型名稱是 CometAPI 的平台識別碼——它們只在 https://api.cometapi.com/v1 上可用,不能直接用在 OpenAI 或 Anthropic 的 API。完整目錄與定價請見完整模型清單。
把模型名稱從商業邏輯中移開
模型名稱散落在程式碼各處是最常見的鎖定。解法是集中設定,從環境變數讀取:
# config.py — one place to change model assignmentsimport osMODEL_CONFIG = { "summarize": os.environ.get("MODEL_SUMMARIZE", "claude-opus-4-7"), "code": os.environ.get("MODEL_CODE", "gpt-5.4"), "classify": os.environ.get("MODEL_CLASSIFY", "claude-haiku-4-5"), "chat": os.environ.get("MODEL_CHAT", "gpt-5.4-mini"),}# Validate at startup — fail fast rather than getting mysterious API errorsfor task, model in MODEL_CONFIG.items(): if not model: raise ValueError(f"Model config for '{task}' is not set")
你的商業邏輯絕不直接引用模型名稱:
from config import MODEL_CONFIGdef summarize(text: str) -> str: response = client.chat.completions.create( model=MODEL_CONFIG["summarize"], messages=[{"role": "user", "content": f"Summarize: {text}"}], max_tokens=300 # move to config in production ) return response.choices[0].message.content
要在整個應用中切換摘要模型,只需改一個環境變數。不用 grep,也不用全域取代。
封裝回應,避免依賴供應商特有欄位
不同供應商的回應結構略有不同。如果你在程式碼中到處解析原始 API 回應,你就被鎖在該供應商的格式上了。
把它封裝為統一的資料類別:
from dataclasses import dataclassfrom typing import Optionalfrom openai import OpenAI, APIStatusError, APIConnectionError, APITimeoutErrorfrom openai.types.chat import ChatCompletionimport logging@dataclassclass AIResponse: content: str model: str input_tokens: int output_tokens: intdef call_model(task: str, messages: list, **kwargs) -> AIResponse: """ Single entry point for all LLM calls. Returns a normalized AIResponse regardless of which model handled it. Raises on 4xx (client errors). Logs and re-raises on 5xx/network errors. """ model = MODEL_CONFIG.get(task, "gpt-5.4-mini") if not model: raise ValueError(f"No model configured for task '{task}'") try: response: ChatCompletion = client.chat.completions.create( model=model, messages=messages, **kwargs ) except APIStatusError as e: logging.error(f"API error for task={task} model={model}: {e.status_code} {e.message}") raise except (APIConnectionError, APITimeoutError) as e: logging.error(f"Network error for task={task} model={model}: {e}") raise # content is None when the model triggers a tool call instead of returning text content = response.choices[0].message.content or "" # usage is None in streaming mode — default to 0 if not available usage = response.usage input_tokens = usage.prompt_tokens if usage else 0 output_tokens = usage.completion_tokens if usage else 0 logging.info( f"task={task} model={model} " f"input_tokens={input_tokens} output_tokens={output_tokens}" ) return AIResponse( content=content, model=response.model, input_tokens=input_tokens, output_tokens=output_tokens, )
現在你的商業邏輯處理的是 AIResponse 物件,而非原始 API 回應。若某個供應商改了回應格式,你只需在一處修正。
在封裝中加入串流支援
對聊天介面,你會需要串流。封裝中以獨立路徑處理:
from typing import Iteratordef stream_model(task: str, messages: list, **kwargs) -> Iterator[str]: """ Stream tokens from the routed model. Note: streaming doesn't return usage data. Fallback is not supported in streaming mode — you've already started yielding tokens before you know if the full request succeeds. """ model = MODEL_CONFIG.get(task, "gpt-5.4-mini") if not model: raise ValueError(f"No model configured for task '{task}'") stream = client.chat.completions.create( model=model, messages=messages, stream=True, **kwargs ) for chunk in stream: delta = chunk.choices[0].delta.content if delta: yield delta# Usagefor token in stream_model("chat", [{"role": "user", "content": "Hello"}]): print(token, end="", flush=True)
知道哪些參數會造成鎖定
有些參數只存在於特定供應商。使用它們沒問題——只要你明白這是有意識的選擇:
| 參數 | 支援於 | 鎖定風險 |
|---|---|---|
| logprobs | 僅 GPT | 高——在 Claude 或 Gemini 上沒有對等功能 |
| n > 1 | GPT、Gemini(不支援 Claude) | 中——Claude 需以迴圈實作 |
| reasoning_effort | 僅 GPT o 系列 | 高——其他地方沒有對等功能 |
| temperature > 1.0 | GPT、Gemini(不支援 Claude) | 低——Claude 上限為 1.0 |
| tools | 所有主要供應商 | 無——可安心使用 |
| response_format | 所有主要供應商 | 低——結構差異輕微 |
如果你用 logprobs 做信心評分,那在這項功能上你就被鎖在 GPT 上。這是合理的權衡——只要註明原因,讓下一位開發者知道為什麼這麼做。
讓供應商端點可設定
把 base_url="https://api.cometapi.com/v1" 寫死仍然是一種鎖定。把它做成環境變數:
# .env — using CometAPIAI_BASE_URL=https://api.cometapi.com/v1AI_API_KEY=your_cometapi_key# To switch to OpenAI directly, change two lines:# AI_BASE_URL=https://api.openai.com/v1# AI_API_KEY=your_openai_key
第一步的客戶端初始化已從這些變數讀取。現在在 CometAPI 與直接連供應商之間切換,就成了設定變更,而非程式碼變更。
Node.js 版本
import OpenAI from 'openai';const apiKey = process.env.AI_API_KEY;if (!apiKey) throw new Error('AI_API_KEY is not set');const client = new OpenAI({ baseURL: process.env.AI_BASE_URL ?? 'https://api.cometapi.com/v1', apiKey,});// Model IDs are CometAPI platform identifiers — see cometapi.com/modelsconst MODEL_CONFIG = { summarize: process.env.MODEL_SUMMARIZE ?? 'claude-opus-4-7', code: process.env.MODEL_CODE ?? 'gpt-5.4', classify: process.env.MODEL_CLASSIFY ?? 'claude-haiku-4-5', chat: process.env.MODEL_CHAT ?? 'gpt-5.4-mini',};// Validate at startupfor (const [task, model] of Object.entries(MODEL_CONFIG)) { if (!model) throw new Error(`Model config for '${task}' is not set`);}/** * Single entry point for all LLM calls. * Returns normalized response. Raises on 4xx, logs and re-raises on 5xx/network. */async function callModel(task, messages, options = {}) { const model = MODEL_CONFIG[task] ?? 'gpt-5.4-mini'; let response; try { response = await client.chat.completions.create({ model, messages, ...options, }); } catch (err) { // Don't swallow errors — log and re-raise console.error(`API error task=${task} model=${model}:`, err.message); throw err; } // content is null when model triggers a tool call const content = response.choices[0].message.content ?? ''; // usage may be absent in some configurations const inputTokens = response.usage?.prompt_tokens ?? 0; const outputTokens = response.usage?.completion_tokens ?? 0; console.log(`task=${task} model=${model} input=${inputTokens} output=${outputTokens}`); return { content, model: response.model, inputTokens, outputTokens };}/** * Stream tokens from the routed model. * Usage data is not available in streaming mode. */async function* streamModel(task, messages, options = {}) { const model = MODEL_CONFIG[task] ?? 'gpt-5.4-mini'; const stream = await client.chat.completions.create({ model, messages, stream: true, ...options, }); for await (const chunk of stream) { const delta = chunk.choices[0]?.delta?.content; if (delta) yield delta; }}// Usage — blockingconst result = await callModel('classify', [ { role: 'user', content: 'Positive or negative? "Loved it!"' }]);console.log(result.content);// Usage — streamingfor await (const token of streamModel('chat', [ { role: 'user', content: 'Hello' }])) { process.stdout.write(token);}
哪些鎖定可以接受
不是所有鎖定都值得抗拒。有些權衡是合理的:
- 使用 OpenAI SDK — 它是事實上的標準。多數供應商支援。低風險鎖定。
- 你確實需要的供應商品牌特性 — 如果你需要
logprobs,就用它們。把這段程式隔離,使日後容易搜尋與替換。 - 微調模型 — 微調模型本質上綁定於單一供應商。這是預期之內的。
值得避免的是偶然造成的鎖定——模型名稱散落在商業邏輯、解析原始回應分散在各檔案、API 金鑰硬編在原始碼裡。
下一步
你現在已有一層抽象,將供應商細節隔離在商業邏輯之外。這個系列的最後一篇將討論當事情出錯時會發生什麼:如何調試失敗的生成、解讀錯誤代碼,以及打造真正有用的錯誤處理。
Next: 如何調試失敗的 AI API 生成
FAQ
Q: SDK 鎖定與模型鎖定有何不同?
SDK 鎖定是指你的程式碼匯入了特定函式庫,若要更換 SDK 就必須修改程式碼。模型鎖定是指模型名稱散落在商業邏輯中。SDK 鎖定較不危險,因為多數供應商現在都支援 OpenAI SDK 的格式。模型鎖定更隱蔽,因為更難找、更難修。
Q: 使用 CometAPI,是否只是把對 OpenAI 的鎖定換成對 CometAPI 的鎖定?
部分是。你是用代理層取代了直接的供應商鎖定。好處是:一把金鑰、一個端點、容易切換模型。風險是:如果 CometAPI 當機,你所有供應商等同一起掛。緩解方式已在上面的程式中——AI_BASE_URL 是環境變數。如果需要繞過 CometAPI 直接呼叫某個供應商,只是設定變更,不是程式碼變更。
Q: 我可以透過這個模式使用 Claude 的延伸思考或 OpenAI 的 reasoning_effort 嗎?
可以,將它們作為 **kwargs 傳給 call_model。但要知道,如果你將該任務路由到不同模型,這些參數可能會被忽略或造成錯誤。請註明哪些任務使用了供應商特有功能,讓下一位開發者知道原因。
Q: 在 Claude 與 GPT**?** 之間路由時,如何處理 Claude 的 temperature 上限為 1.0?
將 temperature 設在 1.0 或以下,以在兩者間保持安全範圍。如果你確實需要在 GPT 上為創意任務使用更高的 temperature,請在 MODEL_CONFIG 中將那些任務明確路由到 GPT,而不是讓它們走通用路由。
Q: 影像與影片生成 API 是否也該用同樣的抽象方式?
原則相同——集中設定、標準化回應封裝、商業邏輯不依賴供應商特定欄位。影像與影片 API 的結構差異更大(同步/非同步、參數集不同),因此抽象層需要更多工作。先從文字開始,待架構成熟後再擴展此模式。
Q: 各模型的 context window 差異怎麼辦?
這是在路由時的真實風險。GPT-5.5 具有 1M token 的 context window,Claude 模型最高支援到 200K,而 Gemini 3.5 Flash 支援到 1M。如果你將長文件任務路由到 context window 較短的模型,輸入會被悄悄截斷。若你的任務涉及長輸入,請在路由前加入長度檢查——或是在 MODEL_CONFIG 中把長 context 的任務固定路由到特定模型,而不是讓它們走預設值。
