換一個模型只改一行:把 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>;
}

重點:這個介面裡沒有任何一家供應商的字眼。messagescontent[0].textchoices 都不出現。你的商業邏輯只認識 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 加上寫一個新轉接器。商業邏輯一行都不用碰。


這套抽象也有邊界

  1. 別為了還沒用到的功能先抽:介面只放你現在真的會呼叫的能力。想一次把串流、工具呼叫、結構化輸出、影像辨識都包進去,最後那個介面反而每一家都套得不太順。先給 complete(),哪天真的要串流再加 stream()
  2. 各家的獨門功能,抽象層包不住:提示快取、結構化輸出、進階推理這些,Claude 有,別家不一定有對應。我的做法是介面上開一個中立參數 ( 例如 responseSchema ),各家轉接器自己對應到內部機制,對不上就當沒這回事,重點是別讓某一家的參數名稱漏到介面上。
  3. 例外也要翻譯一次Anthropic.RateLimitError 是 Anthropic 專屬的,OpenAI 叫別的名字。在轉接器裡接住,統一轉成你自己的 LLMError,商業邏輯只要認得這一種就好。

抽象層做的事說穿了就一件:讓模型變成設定檔裡的一個值,而不是散在幾十個檔案、動一個就要全部重改的東西。

模型還會一直換,開源也還在追。真到了某家漲價、或你想換另一個模型跑跑看的時候,差別只在於你是改一行環境變數,還是又要把整個專案翻一遍。


如果你正在把 AI 接進自己的產品、或想討論多模型架構怎麼設計得好維護,歡迎找我聊聊