Design PatternsIntegrationLLM APISoftware ArchitecturePython

LLM API 整合模式:生產環境的設計模式

閱讀 1 分鐘

這個 pull request 的標題是「Add GPT-5.5 fallback」。diff 是:本來二十行就能搞定的事,變成 340 行——複製貼上的 retry decorator、寫死的 model 字串、五個幾乎一樣的 call_llm_with_retry 變體。

你的 review:「這裡需要抽象化。」作者:「具體是怎樣的抽象化?」

這篇文章回答這個問題。

六個 Gang of Four 模式翻譯成 LLM API 領域:模型選擇的 Factory、prompt 的 Strategy、串流的 Observer、retry 與日誌的 Decorator、fallback 的 Chain of Responsibility、agent loop 的 Template Method——每個都附上生產環境的 Python 程式碼,以及它取代的反模式。

模式一:Factory——集中式的模型實例化

問題

chat.py 裡有 "gpt-5.5"。summarizer.py 裡有 "claude-sonnet-4-20250514"。classifier.py 裡有 "deepseek-v4-flash"。模型遷移,意味著要在整個 codebase 裡搜尋取代——還得祈禱沒漏掉某個只在生產環境載入的 config 檔。

模式

一個帶集中式 registry 的 ModelFactory。決定用哪個模型的是任務類型,不是寫死的字串。環境變數讓 canary 部署與 rollback 成為可能。模型的 metadata——capabilities、cost tier、context window——跟 model ID 放在一起。實作用的是 OpenAI Python SDK——OpenAI 相容 API 的標準客戶端函式庫——它的 AsyncOpenAI 客戶端撐起底下的 Factory。

from dataclasses import dataclass
from openai import AsyncOpenAI

@dataclass
class ModelSpec:
    model_id: str
    provider: str
    capabilities: list[str]       # ["chat", "vision", "tools", "json_mode"]
    cost_tier: str                # "cheap", "mid", "frontier"
    context_window: int

class ModelFactory:
    def __init__(self, base_url: str, api_key: str):
        self.client = AsyncOpenAI(base_url=base_url, api_key=api_key)
        self.registry: dict[str, ModelSpec] = {}
        self._load_registry()

    def create(self, task_type: str, requirements: list[str] = None) -> tuple[AsyncOpenAI, ModelSpec]:
        model_id = os.getenv(f"MODEL_OVERRIDE_{task_type.upper()}", None)
        if model_id:
            spec = self.registry[model_id]
        else:
            spec = self._select_by_capability(task_type, requirements or [])
        return self.client, spec

    def _select_by_capability(self, task_type: str, requirements: list[str]) -> ModelSpec:
        candidates = [
            m for m in self.registry.values()
            if all(req in m.capabilities for req in requirements)
        ]
        tier_map = {"classification": "cheap", "generation": "mid", "review": "frontier"}
        tier = tier_map.get(task_type, "mid")
        return next((m for m in candidates if m.cost_tier == tier), candidates[0])

統一的 API endpoint——所有 provider 共用一個 base_url——把 Factory 的 config 從 O(N 個 provider) 縮小成 O(1 個 endpoint + N 個 model 字串)。一個 API key。一個 client 實例。registry 裡每個模型都能透過它存取。建立這種單一入口架構,要從 API key 驗證開始——一份憑證就管住 registry 裡所有模型的存取,消掉 N 把 key × M 個 provider 的混亂擴散。

它取代的反模式

每個呼叫點都寫死 model 字串。模型被棄用時,得對整個 codebase 做一次搜尋取代——而漏掉的第一個跡象,就是在生產環境出現 404 錯誤。

模式二:Strategy——可插拔的 Prompt 範本

問題

prompt 字串內嵌在 business logic 裡。要改 checkout 的語氣,得找出散落在 checkout、support、onboarding 程式裡的每一個 "You are a helpful shopping assistant..."。要 A/B 測試兩個 prompt 變體,就得在每個呼叫點寫 if/else 義大利麵。

模式

一個 PromptStrategy 介面。每個 use case 或實驗變體都有對應的具體實作。執行時期靠 feature flag 或 A/B 測試分桶來選擇。每個 strategy 都是帶版本的產物——你的 prompt registry 把版本對應到 strategy class。

from abc import ABC, abstractmethod

class PromptStrategy(ABC):
    version: str

    @abstractmethod
    def build_messages(self, context: dict) -> list[dict]:
        """Build the messages array for this prompt strategy."""

class CheckoutV3(PromptStrategy):
    version = "checkout_v3.2"

    def build_messages(self, context: dict) -> list[dict]:
        return [
            {"role": "system", "content": CHECKOUT_SYSTEM_V3},
            {"role": "user", "content": f"<cart>{context['cart']}</cart>"}
        ]

class PromptRouter:
    def __init__(self, strategies: dict[str, PromptStrategy]):
        self.strategies = strategies

    def select(self, feature_flags: dict, task: str) -> PromptStrategy:
        variant = feature_flags.get(f"prompt_{task}", "default")
        return self.strategies[variant]

當你對 checkout prompt V3 和 V4 做 A/B 測試,只要切一個 feature flag。零程式變更。eval 套件(見我們的測試指南)量出哪個變體勝出。

它取代的反模式

prompt 字串散落整個 business logic。改語氣,就得找出每一份複製貼上的變體。不翻部署日誌的話,根本無從知道使用者收到的是哪個版本的 prompt。

模式三:Observer——解耦的串流消費者

問題

你的串流迴圈把 TTS 合成、UI chunk 渲染、成本追蹤、日誌全部纏在一起。要加新的消費者——analytics、翻譯浮層、稽核記錄——就得改核心的生成迴圈。加三次之後,迴圈變成 200 行,沒人想碰它。

模式

一個 StreamObserver 介面。每個消費者都有具體的 observer。generator 通知 observer——但不知道它們做什麼。鬆散耦合。observer 可以獨立新增、移除或替換。

class StreamObserver(ABC):
    @abstractmethod
    async def on_token(self, token: str, sequence: int): ...
    @abstractmethod
    async def on_complete(self, full_response: str, usage: dict): ...
    @abstractmethod
    async def on_error(self, error: Exception): ...

class StreamObservable:
    def __init__(self, client: AsyncOpenAI):
        self.client = client
        self.observers: list[StreamObserver] = []

    def attach(self, observer: StreamObserver): self.observers.append(observer)

    async def stream(self, **kwargs):
        stream = await self.client.chat.completions.create(stream=True, **kwargs)
        full_response = ""
        try:
            async for chunk in stream:
                token = chunk.choices[0].delta.content or ""
                full_response += token
                await asyncio.gather(*[
                    o.on_token(token, len(full_response)) for o in self.observers
                ])
            await asyncio.gather(*[
                o.on_complete(full_response, usage) for o in self.observers
            ])
        except Exception as e:
            await asyncio.gather(*[o.on_error(e) for o in self.observers])

一個 observer 當掉不會弄死串流——錯誤按 observer 各自隔離。加一個 CostTracker observer。再加一個 TTSOutput observer。誰都不知道另一個的存在。

它取代的反模式

所有串流消費者邏輯都內嵌在生成迴圈裡。要加 analytics 儀表,得去改處理 TTS 的同一個函式——還可能因為打錯一個變數名稱,害音訊輸出出迴歸。

模式四:Decorator——不雜亂的營運層

問題

一個 10 行的 LLM 呼叫,被 60 行的 retry 邏輯、成本追蹤、結構化日誌、錯誤處理團團圍住。在八個呼叫點之間,用稍微不同的參數複製貼上。

模式

分層的 decorator 包住核心的 LLM 呼叫。每個 decorator 只有單一職責。針對不同呼叫點,組合成不同組合。

@with_retry(max_retries=3, backoff="exponential", retry_on=[429, 503])
@with_cost_tracking(budget_per_call=5.00)
@with_structured_logging(log_level="DEBUG")
async def core_llm_call(client, model_spec, messages):
    return await client.chat.completions.create(
        model=model_spec.model_id, messages=messages
    )

retry decorator 用 exponential backoff 與 jitter 處理暫時性錯誤——不該重試的錯誤類型(400、401、403)直接放行。rate limiting 的機制與完整的 429 處理架構,我們在rate limit 處理指南講過了——這個模式把那份邏輯封裝起來,好讓每個呼叫點一致套用。成本追蹤 decorator 記錄 gen_ai.usage,單次呼叫成本超過預算就發警報。兩個 decorator 誰都不知道另一個的存在。堆疊順序很重要:retry 放最外層(這樣重試失敗也還是會被追蹤成本),日誌放最內層(這樣它才看得到最終回應)。

這個模式示範怎麼封裝 rate limit 處理邏輯,讓它在每個呼叫點被一致套用。同樣的封裝原則也適用於 prompt caching——@with_cache decorator 在重複或相似請求造成 API 呼叫之前,先把它攔下來。TokSpan 的prompt caching 文件涵蓋這個 decorator 包住的 API 層級快取機制。

它取代的反模式

營運樣板程式碼複製貼上在每個 LLM 呼叫周圍。retry 參數不一致。八個呼叫點裡有三個漏掉成本追蹤。沒人知道哪種日誌格式是「正確」的,因為每個呼叫點做得都稍微不同。

模式五:Chain of Responsibility——Fallback 管線

問題

模型 failover 寫死在巢狀的 try/except 區塊裡。try gpt-5.5 —except: try claude-sonnet —except: try deepseek —except: return error。要加一個 fallback 模型或改 chain 順序,就得整個區塊重寫。每個呼叫點的 chain 都稍微不同。

模式

一串 ModelHandler 物件。每個 handler 知道自己的模型和怎麼處理請求。如果失敗了——非暫時性錯誤、timeout、品質低於門檻——就交給下一個 handler。chain 的組成活在 config 裡,不在程式裡。

class ModelHandler(ABC):
    def __init__(self, model_spec: ModelSpec):
        self.model_spec = model_spec
        self._next: ModelHandler | None = None

    def set_next(self, handler: "ModelHandler") -> "ModelHandler":
        self._next = handler
        return handler

    async def handle(self, request: dict) -> dict | None:
        try:
            result = await self._call_model(request)
            if self._quality_check(result):
                return result
        except NonRetryableError:
            pass
        if self._next:
            return await self._next.handle(request)
        return None

class FallbackChain:
    def __init__(self):
        self.head: ModelHandler | None = None
        self.circuit_breaker: dict[str, int] = {}  # model_id —consecutive failures

    async def execute(self, request: dict) -> dict:
        if not self.head:
            raise RuntimeError("Empty fallback chain")
        return await self.head.handle(request)

某個 handler 連續三次失敗——circuit breaker 會暫時把它移出 chain。冷卻期過後,會用一個測試請求把它加回去。我們的多模型架構指南深入講過路由策略——這個模式提供正式化的 chain 實作。

它取代的反模式

巢狀 try/except 的 fallback 邏輯在呼叫點之間複製貼上。chain 順序不一致。沒有 circuit breaker——排在第二位置的劣化模型,永遠成功不了,卻讓每個 fallback 都多一層延遲。

模式六:Template Method——標準化的 Agent 迴圈

問題

每個 agent 的 tool-calling 迴圈都稍微不同。有的用 while True。有的用 for i in range(max_iterations)。有的根本忘了迴圈上限。agent 之間行為不一致。能無限迴圈的那個 agent,有成本失控的風險。

模式

一個帶固定骨架的 AgentLoop template method:plan——執行工具——observe——決定下一步。子類別覆寫 hook 方法來做自訂行為。骨架保證每個 agent 都繼承相同的安全特性——迴圈上限、timeout、成本上限、結構化錯誤處理。

class AgentLoop(ABC):
    def __init__(self, max_iterations: int = 15, timeout: float = 120.0, cost_cap: float = 5.00):
        self.max_iterations = max_iterations
        self.timeout = timeout
        self.cost_cap = cost_cap

    async def run(self, task: str) -> dict:
        start = time.time()
        total_cost = 0.0
        for i in range(self.max_iterations):
            if time.time() - start > self.timeout:
                return {"status": "timeout", "partial_result": self._build_partial()}
            if total_cost > self.cost_cap:
                return {"status": "cost_cap_exceeded"}

            plan = await self.plan(task)            # Hook: override
            tool = await self.select_tool(plan)      # Hook: override
            result = await self.execute(tool)        # Hook: override
            total_cost += result.get("cost", 0)

            if await self.should_stop(i, result):    # Hook: override
                return await self.synthesize()

    @abstractmethod
    async def plan(self, task: str) -> dict: ...
    @abstractmethod
    async def select_tool(self, plan: dict) -> str: ...
    @abstractmethod
    async def execute(self, tool: str) -> dict: ...

我們的單一 agent 指南講過 tool-calling 迴圈的基礎。這個模式提供設計模式的角度:一個正式化的範本,讓安全保證是結構性的,而不是理想喊話。

它取代的反模式

每個 agent 都自己實作迴圈。安全防護不一致。那個能無限迴圈的 agent,是因為有人複製了 “while True” 版本,卻沒帶 max_iterations 檢查。

速查表:什麼時候用哪個模式?

你的情況建議採用
你的 codebase 中有 3 個以上的 model 字串Factory —集中管理 model 選擇
用 if/else 實作的 Prompt A/B 測試Strategy —封裝 prompt 變體
與生成程式碼耦合的 Streaming 消費者Observer —用事件驅動設計解耦
每個 LLM 呼叫周遭的 60 行樣板程式碼Decorator —分層處理營運關注點
為 model 容錯切換而做的巢狀 try/exceptChain of Responsibility —可設定的備援機制
多個迴圈不一致的 agentTemplate Method —用安全防護標準化

依 codebase 規模的導入順序: 小(<5K 行,1–2 個 use case)——從 Decorator 和 Factory 開始。中(5–50K 行)——加 Strategy 和 Chain of Responsibility。大(50K 行以上,多個 agent)——加 Observer 和 Template Method。

六個模式都能搭配標準的 OpenAI 相容 SDK。統一的 API endpoint 意味著 Factory 的 config 是 1 個 base_url 加 N 個 model 字串——不是 N 個 base URL × M 個 provider。

常見問題

這些模式不會把簡單的 API 呼叫過度設計嗎?

如果你的 codebase 只有一個 LLM 呼叫、而且不會長超過兩個,對——直接寫 50 行的 client.chat.completions.create() 就是正解。當 codebase 長到 10+ 個 LLM 呼叫、3+ 個模型變體、還有生產環境的可靠度要求時,這些模式的 ROI 在第一個事件就浮現——第一次明明改個 config 就好的模型遷移、第一次因為漏了迴圈上限的成本失控、第一次沒有 rollback 路徑的 prompt 迴歸。

該先實作哪個模式?

Decorator。它疊在現有的 LLM 呼叫上,不必修改它們。一疊 decorator——retry、日誌、成本追蹤——套到每個呼叫點。立刻得到生產環境的可靠度提升。現有程式零重構。接著是 Factory——下次要換模型時,你改一個 config 值就好,不用改 15 個檔案。

這些模式能搭配 LangChain 或 LlamaIndex 用嗎?

它們可以共存。Factory 和 Strategy 在 LangChain 外面運作得更乾淨——它們防止模型選擇和 prompt 管理被框架鎖死。Observer 和 Template Method 可以活在 LangChain agent 裡面——迴圈結構和串流消費者能受惠於框架整合。這些模式不取代 LangChain。它們在圍繞你選的框架去結構化程式。

當模型分屬不同 provider API 時,這些模式表現如何?

模式會變得更好實作,不是更複雜。Factory:一個 client 實例就涵蓋所有模型——你的 config 是 1 個 base_url 加 N 個 model 字串,不是 N 個 base URL × M 個 provider。Chain of Responsibility:透過一個整合點,跨 provider 做 fallback。Decorator:因為所有呼叫都走同一個 gateway,成本追蹤保持一致。模式本身是 provider-agnostic 的。統一的 endpoint 縮小每個模式要管理的整合表面——這正是抽象化的重點。想更宏觀理解為什麼單一 endpoint 架構正成為業界預設,我們的AI API 聚合平台轉移分析講了這個趨勢背後的操作面與成本面驅動因素。

除了 GoF,有 LLM 專屬的模式嗎?

有。Semantic Router——用查詢的語義路由,而不是寫死的規則。Guard——在每個 LLM 呼叫前後執行的輸入/輸出驗證管線。Cache-Aside——在發出 API 呼叫前檢查 embedding 相似度的語義快取層。這些是 LLM 原生模式,值得各自一篇專文。這裡列的六個是刻意挑的:多數工程團隊本來就懂 GoF 模式。把它們對應到 LLM API,學習曲線幾乎降到零。

設計模式不是為了炫技。是為了不要因為程式被複製貼上而非結構化,讓同一個 bug 出現在八個地方。

從 Decorator 開始。加上 Factory。下一次模型遷移只要 30 秒——不是一個早上的搜尋取代,加一個下午去除錯那個你漏掉的呼叫點。

把這份參考存到書籤。下次你發現自己第四次在複製貼上 retry 邏輯時,會知道該打開哪個抽屜。想獲得更多生產模式與 LLM API 架構指南,讓你的 codebase 在擴張時保持結構,訂閱我們的部落格。