Claude Opus 5 is now live on CometAPI →

如何開發不受單一供應商鎖定的 AI 應用程式

CometAPI
AnnaJun 7, 2026
如何開發不受單一供應商鎖定的 AI 應用程式

在 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_dotenv​load_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.4claude-sonnet-4-6 這樣的模型名稱是 CometAPI 的平台識別碼——它們只在 https://api.cometapi.com/v1 上可用,不能直接用在 OpenAI 或 Anthropic 的 API。完整目錄與定價請見完整模型清單

把模型名稱從商業邏輯中移開

模型名稱散落在程式碼各處是最常見的鎖定。解法是集中設定,從環境變數讀取:

# config.py — one place to change model assignmentsimport os​MODEL_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_CONFIG​def 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: int​def 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 Iterator​def 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 > 1GPT、Gemini(不支援 Claude)中——Claude 需以迴圈實作
reasoning_effort僅 GPT o 系列高——其他地方沒有對等功能
temperature > 1.0GPT、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 的延伸思考或 OpenAIreasoning_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 的任務固定路由到特定模型,而不是讓它們走預設值。

準備好將 AI 開發成本降低 20% 了嗎?

幾分鐘內免費開始。包含免費試用點數。無需信用卡。

閱讀更多