Design PatternsIntegrationLLM APISoftware ArchitecturePython

LLM API Integration Patterns: Design Patterns for Production

1 min read

The pull request is titled “Add GPT-5.5 fallback.” The diff: 340 lines for what should be twenty —copy-pasted retry decorators, hardcoded model strings, five near-identical call_llm_with_retry variants.

You review: “We need abstraction here.” The author: “What abstraction, specifically?”

This article answers that question.

Six Gang of Four patterns translated to the LLM API domain: Factory for model selection, Strategy for prompts, Observer for streaming, Decorator for retry and logging, Chain of Responsibility for fallback, Template Method for agent loops —each with production Python code and the anti-pattern it replaces.

Pattern 1: Factory —Centralized Model Instantiation

The Problem

"gpt-5.5" in chat.py. "claude-sonnet-4-20250514" in summarizer.py. "deepseek-v4-flash" in classifier.py. Model migration means find-and-replace across your entire codebase —and hoping you didn’t miss one in a config file that only loads in production.

The Pattern

A ModelFactory with a centralized registry. Task types, not hardcoded strings, determine which model is used. Environment variables enable canary deployments and rollbacks. Model metadata —capabilities, cost tier, context window —lives alongside the model ID. The implementation uses the OpenAI Python SDK —the standard client library for OpenAI-compatible APIs —whose AsyncOpenAI client powers the Factory below.

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])

A unified API endpoint —one base_url for all providers —shrinks the Factory’s config from O(N providers) to O(1 endpoint + N model strings). One API key. One client instance. Every model in the registry accessible through it. Setting up this single-entry-point architecture starts with API key authentication —one credential that gates access to every model in your registry, eliminating the N-keys-times-M-providers sprawl.

The Anti-Pattern It Replaces

Model strings hardcoded at every call site. A model deprecation triggers a codebase-wide search and replace —and the first sign you missed one is a 404 error in production.

Pattern 2: Strategy —Pluggable Prompt Templates

The Problem

Prompt strings inlined in business logic. Changing the checkout tone means finding every "You are a helpful shopping assistant..." scattered across checkout, support, and onboarding code. A/B testing two prompt variants means if/else spaghetti at every call site.

The Pattern

A PromptStrategy interface. Concrete implementations per use case or experiment variant. Runtime selection by feature flag or A/B test bucket. Each strategy is a versioned artifact —your prompt registry maps versions to strategy classes.

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]

When you A/B test checkout prompt V3 against V4, you toggle a feature flag. Zero code changes. The eval suite (see our testing guide) measures which variant wins.

The Anti-Pattern It Replaces

Prompt strings scattered across business logic. Changing tone means finding every copy-pasted variant. There’s no way to know which version of the prompt a user received without tracing through deployment logs.

Pattern 3: Observer —Decoupled Streaming Consumers

The Problem

Your streaming loop has TTS synthesis, UI chunk rendering, cost tracking, and logging all tangled together. Adding a new consumer —analytics, translation overlay, audit recording —means modifying the core generation loop. After three additions, the loop is 200 lines and nobody wants to touch it.

The Pattern

A StreamObserver interface. Concrete observers for each consumer. The generator notifies observers —but doesn’t know what they do. Loose coupling. Observers can be added, removed, or replaced independently.

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])

One observer crashing doesn’t kill the stream —errors are isolated per observer. Add a CostTracker observer. Add a TTSOutput observer. Neither knows the other exists.

The Anti-Pattern It Replaces

All streaming consumer logic inlined in the generation loop. Adding analytics instrumentation requires editing the same function that handles TTS —and risking a regression in audio output because you fat-fingered a variable name.

Pattern 4: Decorator —Operational Layers Without Clutter

The Problem

A 10-line LLM call surrounded by 60 lines of retry logic, cost tracking, structured logging, and error handling. Copy-pasted with slightly different parameters across eight call sites.

The Pattern

Layered decorators wrapping the core LLM call. Each decorator has one responsibility. They compose into different combinations for different call sites.

@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
    )

The retry decorator handles transient errors with exponential backoff and jitter —error types that should not be retried (400, 401, 403) are passed through immediately. Rate limiting mechanics and the full 429 handling architecture are covered in our rate limit handling guide —this pattern encapsulates that logic for consistent application across call sites. The cost tracking decorator logs gen_ai.usage and alerts if per-call cost exceeds budget. Neither decorator knows about the other. Stack order matters: retry outermost (so failed retries still get cost-tracked), logging innermost (so it sees the final response).

This pattern shows how to encapsulate rate-limit handling logic so it’s applied consistently across every call site. The same encapsulation principle applies to prompt caching —a @with_cache decorator intercepts repeated or similar requests before they incur an API call. TokSpan’s prompt caching documentation covers the API-level caching mechanics the decorator wraps.

The Anti-Pattern It Replaces

Operational boilerplate copy-pasted around every LLM call. Inconsistent retry parameters. Missing cost tracking on three of eight call sites. Nobody knows which logging format is “correct” because every call site does it slightly differently.

Pattern 5: Chain of Responsibility —Fallback Pipelines

The Problem

Model failover hardcoded in nested try/except blocks. try gpt-5.5 —except: try claude-sonnet —except: try deepseek —except: return error. Adding a fallback model or changing the chain order means rewriting the entire block. Every call site has a slightly different chain.

The Pattern

A chain of ModelHandler objects. Each handler knows its model and how to process a request. If it fails —non-transient error, timeout, quality below threshold —it passes to the next handler. Chain composition lives in config, not code.

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)

Three consecutive failures on a handler —circuit breaker temporarily removes it from the chain. It gets re-added after a cooldown period with a test request. Our multi-model architecture guide covers the routing strategies in depth —this pattern provides the formalized chain implementation.

The Anti-Pattern It Replaces

Nested try/except fallback logic copy-pasted across call sites. Inconsistent chain order. No circuit breaker —a degraded model in position two adds latency to every fallback without ever succeeding.

Pattern 6: Template Method —Standardized Agent Loop

The Problem

Every agent has a slightly different tool-calling loop. Some use while True. Some use for i in range(max_iterations). Some forgot the loop limit entirely. Inconsistent behavior between agents. Cost runaway risk on the agent that can loop indefinitely.

The Pattern

An AgentLoop template method with a fixed skeleton: plan —execute tool —observe —decide next. Subclasses override hook methods for custom behavior. The skeleton guarantees every agent inherits the same safety characteristics —loop limit, timeout, cost cap, structured error handling.

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: ...

Our single agent guide covers the tool-calling loop fundamentals. This pattern provides the design-pattern perspective: a formalized template that makes safety guarantees structural, not aspirational.

The Anti-Pattern It Replaces

Each agent implements its own loop. Inconsistent safety guards. The agent that can loop forever because someone copied the “while True” version without the max_iterations check.

Quick Reference: Which Pattern When?

You Have…Reach For…
>3 model strings in your codebaseFactory —centralize model selection
Prompt A/B tests implemented with if/elseStrategy —encapsulate prompt variants
Streaming consumers coupled to generation codeObserver —decouple with event-driven design
60 lines of boilerplate around every LLM callDecorator —layer operational concerns
Nested try/except for model failoverChain of Responsibility —configurable fallback
Multiple agents with inconsistent loopsTemplate Method —standardize with safety guards

Adoption order by codebase size: Small (<5K lines, 1-2 use cases) —start with Decorator and Factory. Medium (5-50K lines) —add Strategy and Chain of Responsibility. Large (50K+ lines, multiple agents) —add Observer and Template Method.

All six patterns work with standard OpenAI-compatible SDKs. A unified API endpoint means the Factory’s config is one base_url and N model strings —not N base URLs times M providers.

FAQ

Won’t these patterns over-engineer a simple API call?

If your codebase has one LLM call and won’t grow beyond two, yes —50 lines of direct client.chat.completions.create() is the right answer. When your codebase reaches 10+ LLM calls, 3+ model variants, and production reliability requirements, these patterns’ ROI materializes at the first incident —the first model migration that should have been a config change, the first cost runaway from a missing loop limit, the first prompt regression with no rollback path.

Which pattern should I implement first?

Decorator. It layers onto existing LLM calls without modifying them. One decorator stack —retry, logging, cost tracking —applied to every call site. Immediate production reliability gain. Zero refactoring of existing code. Factory second —when you next need to switch models, you’ll change one config value instead of 15 files.

Do these patterns work with LangChain or LlamaIndex?

They coexist. Factory and Strategy work cleaner outside LangChain —they prevent framework lock-in for model selection and prompt management. Observer and Template Method can live inside LangChain agents —the loop structure and streaming consumers benefit from framework integration. These patterns don’t replace LangChain. They structure the code around whatever framework you choose.

How do the patterns hold up when models live behind different provider APIs?

The patterns become simpler to implement, not more complex. Factory: one client instance covers every model —your config is one base_url and N model strings, not N base URLs times M providers. Chain of Responsibility: fallback across providers through one integration point. Decorator: consistent cost tracking because all calls flow through the same gateway. The patterns themselves are provider-agnostic. A unified endpoint reduces the integration surface each pattern has to manage —which is the whole point of abstraction. For a broader perspective on why the single-endpoint architecture is becoming the default across the industry, our analysis of the shift to AI API aggregation platforms covers the operational and cost drivers behind the trend.

Are there LLM-specific patterns beyond GoF?

Yes. Semantic Router —route by query semantics, not hardcoded rules. Guard —input/output validation pipeline that runs before and after every LLM call. Cache-Aside —semantic caching layer that checks embedding similarity before making an API call. These are LLM-native patterns worth their own dedicated article. The six here are deliberate: most engineering teams already know GoF patterns. Mapping them to LLM APIs reduces the learning curve to near zero.

Design patterns aren’t about sophistication. They’re about not having the same bug in eight places because the code was copy-pasted instead of structured.

Start with Decorator. Add Factory. The next model migration will take 30 seconds —not a morning of find-and-replace and an afternoon of debugging the one call site you missed.

Bookmark this reference. Next time you catch yourself copy-pasting retry logic for the fourth time, you’ll know which drawer to open. For more production patterns and LLM API architecture guides that keep your codebase structured as you scale, subscribe to our blog.