換一個模型只改一行:把 LLM 呼叫收進單一抽象層
這半年,模型換得比 npm 套件還勤。Claude 出到 Opus 4.8,GPT 和 Gemini 一路往上跳版本,開源模型也在幾個評測上追了上來。
為此,我自己是習慣把所有 LLM 呼叫收進單一的抽象層。做完之後,換模型、換供應商對你的應用程式來說,就只是改一個設定值。
先看直接呼叫 SDK 會痛在哪
多數人一開始都這樣寫:哪裡要用 AI,就在哪裡直接呼叫 SDK。
// 反例:SDK 呼叫散落在整個專案
import Anthropic from "@anthropic-ai/sdk";
async function summarize(text: string): Promise<string> {
const client = new Anthropic();
const response = await client.messages.create({
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [{ role: "user", content: `摘要以下內容:\n${text}` }],
});
const first = response.content[0];
return first.type === "text" ? first.text : "";
}
能動,但問題在於:
- 供應商細節滲進商業邏輯:
messages的格式、response.content[0].text這種取值方式,是 Anthropic SDK 特有的。哪天要換 OpenAI,每一處呼叫都得改。 - 同樣的東西散在各處:模型字串、
max_tokens、重試邏輯,複製貼上在幾十個檔案裡。 - 難測試:要模擬 AI 回應,得去模擬 SDK 的內部結構。
步驟一:先定義一個中立的介面
先不管是哪一家,想清楚「你的應用程式需要 AI 做什麼」。多數情況就一件事:給一段輸入,拿一段文字回來。
// llm/base.ts
export interface LLMResponse {
text: string;
inputTokens: number;
outputTokens: number;
}
export interface LLMProvider {
complete(
prompt: string,
options?: { system?: string; maxTokens?: number },
): Promise<LLMResponse>;
}
重點:這個介面裡沒有任何一家供應商的字眼。messages、content[0].text、choices 都不出現。你的商業邏輯只認識 LLMProvider。
步驟二:每一家供應商寫一個轉接器 (adapter)
Claude 的實作是這篇的主角,程式碼實抓自官方 SDK:
// llm/anthropic-provider.ts
import Anthropic from "@anthropic-ai/sdk";
import type { LLMProvider, LLMResponse } from "./base";
export class AnthropicProvider implements LLMProvider {
private client = new Anthropic(); // 讀環境變數 ANTHROPIC_API_KEY
constructor(private model = "claude-opus-4-8") {}
async complete(
prompt: string,
{ system, maxTokens = 1024 }: { system?: string; maxTokens?: number } = {},
): Promise<LLMResponse> {
const response = await this.client.messages.create({
model: this.model,
max_tokens: maxTokens,
...(system ? { system } : {}),
messages: [{ role: "user", content: prompt }],
});
const textBlock = response.content.find(
(b): b is Anthropic.TextBlock => b.type === "text",
);
return {
text: textBlock?.text ?? "",
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
};
}
}
想接第二家的時候 (OpenAI、Kimi、本機開源模型),就再寫一個轉接器,把它自己的回應格式在這一個檔案裡翻譯成 LLMResponse。你的商業邏輯完全不用動。
步驟三:用一個工廠 (factory),靠設定決定要哪一家
// llm/factory.ts
import type { LLMProvider } from "./base";
import { AnthropicProvider } from "./anthropic-provider";
export function getLLM(): LLMProvider {
const provider = process.env.LLM_PROVIDER ?? "anthropic";
if (provider === "anthropic") {
return new AnthropicProvider(process.env.LLM_MODEL ?? "claude-opus-4-8");
}
// if (provider === "openai") {
// return new OpenAIProvider(process.env.LLM_MODEL ?? "gpt-5.6");
// }
throw new Error(`未知的 LLM_PROVIDER:${provider}`);
}
現在最開頭那個 summarize 變成這樣,看不到任何一家供應商:
import { getLLM } from "./llm/factory";
async function summarize(text: string): Promise<string> {
const llm = getLLM();
const result = await llm.complete(`摘要以下內容:\n${text}`, { maxTokens: 1024 });
return result.text;
}
換模型,改 LLM_MODEL 環境變數。換供應商,改 LLM_PROVIDER 加上寫一個新轉接器。商業邏輯一行都不用碰。
這套抽象也有邊界
- 別為了還沒用到的功能先抽:介面只放你現在真的會呼叫的能力。想一次把串流、工具呼叫、結構化輸出、影像辨識都包進去,最後那個介面反而每一家都套得不太順。先給
complete(),哪天真的要串流再加stream()。 - 各家的獨門功能,抽象層包不住:提示快取、結構化輸出、進階推理這些,Claude 有,別家不一定有對應。我的做法是介面上開一個中立參數 ( 例如
responseSchema),各家轉接器自己對應到內部機制,對不上就當沒這回事,重點是別讓某一家的參數名稱漏到介面上。 - 例外也要翻譯一次:
Anthropic.RateLimitError是 Anthropic 專屬的,OpenAI 叫別的名字。在轉接器裡接住,統一轉成你自己的LLMError,商業邏輯只要認得這一種就好。
抽象層做的事說穿了就一件:讓模型變成設定檔裡的一個值,而不是散在幾十個檔案、動一個就要全部重改的東西。
模型還會一直換,開源也還在追。真到了某家漲價、或你想換另一個模型跑跑看的時候,差別只在於你是改一行環境變數,還是又要把整個專案翻一遍。
如果你正在把 AI 接進自己的產品、或想討論多模型架構怎麼設計得好維護,歡迎找我聊聊。