Skip to content

Model Deployment Patterns

Prerequisites

  • Completed Lessons 1–6 (LLMOps Introduction through Cost Optimization)
  • Familiarity with Python exception handling and basic async patterns
  • Understanding of API gateway concepts (routing, load balancing)

What You'll Learn

Objective Outcome
Explain why LLM deployments need patterns beyond traditional load balancing Can articulate provider dependencies, cost variability, and quality variability as unique challenges
Implement an API gateway for LLM provider abstraction Can build a single interface routing to any provider
Design a fallback chain across providers Can maintain uptime during provider outages
Deploy prompt and model changes safely using blue-green patterns Can swap configurations with zero-downtime rollback
Execute a canary deployment with automated promotion/rollback Can validate changes on 1% of traffic before full exposure

Intuition First: The Third-Party Dependency Problem

Traditional web app deployments are deterministic and self-contained. You control your servers, your databases, your code. If something breaks, you fix it.

LLM applications depend on third-party model providers—OpenAI, Anthropic, Google, Mistral. You cannot scale them, cannot fix their outages, cannot predict when they update model behavior. In the year following GPT-4's release, OpenAI experienced over 20 significant service disruptions. Anthropic and Google have similar track records.

Your deployment architecture must assume providers will fail, degrade, and change. Everything else follows from this assumption.

Traditional web app reliability:
  YOU control: servers, databases, code, networking
  Failure mode: you broke something → you fix it

LLM app reliability:
  YOU control: prompts, caching, routing logic, fallback chains
  Third party controls: model quality, latency, availability, pricing
  Failure mode: external factors → you need resilience built in from day one

Pattern 1: The API Gateway

An API gateway is a single entry point that abstracts your application from the specifics of LLM providers. All LLM calls go through the gateway, which handles routing, logging, timeout enforcement, and authentication.

import time
import logging
from typing import Optional
from dataclasses import dataclass, field
from openai import OpenAI, RateLimitError, APITimeoutError, APIStatusError
import anthropic

logger = logging.getLogger("llm_gateway")

@dataclass
class GatewayRequest:
    prompt: str
    model: str = "gpt-4o-mini"
    system_prompt: Optional[str] = None
    max_tokens: int = 500
    temperature: float = 0.0
    timeout_seconds: float = 30.0
    trace_id: Optional[str] = None


@dataclass
class GatewayResponse:
    content: str
    model: str
    provider: str
    input_tokens: int
    output_tokens: int
    latency_ms: float
    cost_usd: float
    trace_id: Optional[str] = None
    from_fallback: bool = False


class LLMGateway:
    """
    Provider-agnostic gateway for LLM calls.
    Handles routing, logging, timeout, and cost tracking.
    """

    PROVIDER_MAP = {
        "gpt-": "openai",
        "claude-": "anthropic",
        "gemini-": "google",
    }

    COST_PER_1M = {
        "gpt-4o-mini": {"input": 0.15, "output": 0.60},
        "gpt-4o": {"input": 2.50, "output": 10.00},
        "claude-3-5-haiku-20241022": {"input": 0.80, "output": 4.00},
        "claude-sonnet-4-20250514": {"input": 3.00, "output": 15.00},
    }

    def __init__(self, openai_client: OpenAI,
                 anthropic_client: Optional[anthropic.Anthropic] = None):
        self.clients = {
            "openai": openai_client,
            "anthropic": anthropic_client,
        }
        self._request_count = 0
        self._error_count = 0

    def _detect_provider(self, model: str) -> str:
        for prefix, provider in self.PROVIDER_MAP.items():
            if model.lower().startswith(prefix):
                return provider
        return "openai"  # Default

    def _calculate_cost(self, model: str, input_tokens: int, output_tokens: int) -> float:
        rates = self.COST_PER_1M.get(model, {"input": 1.0, "output": 3.0})
        return (input_tokens / 1e6 * rates["input"] +
                output_tokens / 1e6 * rates["output"])

    def call(self, request: GatewayRequest) -> GatewayResponse:
        start = time.time()
        self._request_count += 1
        provider = self._detect_provider(request.model)

        messages = []
        if request.system_prompt:
            messages.append({"role": "system", "content": request.system_prompt})
        messages.append({"role": "user", "content": request.prompt})

        logger.info({
            "event": "gateway_request",
            "trace_id": request.trace_id,
            "model": request.model,
            "provider": provider,
            "estimated_input_tokens": len(request.prompt.split()) * 1.3,
        })

        try:
            if provider == "openai":
                resp = self.clients["openai"].chat.completions.create(
                    model=request.model,
                    messages=messages,
                    max_tokens=request.max_tokens,
                    temperature=request.temperature,
                    timeout=request.timeout_seconds,
                )
                content = resp.choices[0].message.content
                input_t = resp.usage.prompt_tokens
                output_t = resp.usage.completion_tokens

            elif provider == "anthropic" and self.clients.get("anthropic"):
                resp = self.clients["anthropic"].messages.create(
                    model=request.model,
                    max_tokens=request.max_tokens,
                    messages=[{"role": "user", "content": request.prompt}],
                    system=request.system_prompt or "",
                )
                content = resp.content[0].text
                input_t = resp.usage.input_tokens
                output_t = resp.usage.output_tokens
            else:
                raise ValueError(f"Unsupported provider: {provider}")

            latency = (time.time() - start) * 1000
            cost = self._calculate_cost(request.model, input_t, output_t)

            logger.info({
                "event": "gateway_response",
                "trace_id": request.trace_id,
                "model": request.model,
                "input_tokens": input_t,
                "output_tokens": output_t,
                "latency_ms": round(latency, 1),
                "cost_usd": round(cost, 6),
            })

            return GatewayResponse(
                content=content, model=request.model, provider=provider,
                input_tokens=input_t, output_tokens=output_t,
                latency_ms=latency, cost_usd=cost, trace_id=request.trace_id,
            )

        except Exception as e:
            self._error_count += 1
            logger.error({"event": "gateway_error", "trace_id": request.trace_id,
                          "error": str(e), "model": request.model})
            raise

Pattern 2: Fallback Chains

A fallback chain tries each model in priority order, moving to the next on failure. This provides resilience against provider outages, rate limits, and model-specific errors.

from typing import NamedTuple

class FallbackRoute(NamedTuple):
    model: str
    priority: int
    max_retries: int = 1

class FallbackChain:
    """
    Tries models in priority order. Catches transient errors and falls back.
    Raises only if all routes fail.
    """

    # Error types that should trigger fallback (vs. errors that should be re-raised)
    FALLBACK_ERRORS = (RateLimitError, APITimeoutError)

    def __init__(self, gateway: LLMGateway, routes: list[FallbackRoute]):
        self.gateway = gateway
        self.routes = sorted(routes, key=lambda r: r.priority)

    def call(self, prompt: str, system_prompt: str = "",
             **kwargs) -> GatewayResponse:
        errors = []

        for route in self.routes:
            for attempt in range(route.max_retries):
                try:
                    request = GatewayRequest(
                        prompt=prompt,
                        system_prompt=system_prompt,
                        model=route.model,
                        **kwargs,
                    )
                    response = self.gateway.call(request)
                    # Mark as fallback if we used anything but the first-priority route
                    response.from_fallback = route.priority > 1
                    if response.from_fallback:
                        logger.warning({
                            "event": "fallback_activated",
                            "model": route.model,
                            "prior_errors": len(errors),
                        })
                    return response
                except self.FALLBACK_ERRORS as e:
                    errors.append({"model": route.model, "attempt": attempt, "error": str(e)})
                    logger.warning({"event": "fallback_trigger", "model": route.model,
                                   "error": type(e).__name__})
                    if attempt < route.max_retries - 1:
                        time.sleep(0.5 * (attempt + 1))  # Brief backoff before retry
                except Exception as e:
                    # Non-retriable error: skip retries, try next route
                    errors.append({"model": route.model, "error": str(e)})
                    break

        raise RuntimeError(f"All fallback routes failed: {errors}")


# Production fallback configuration
fallback = FallbackChain(
    gateway=gateway,
    routes=[
        FallbackRoute(model="gpt-4o-mini", priority=1, max_retries=2),
        FallbackRoute(model="claude-3-5-haiku-20241022", priority=2, max_retries=1),
        FallbackRoute(model="gpt-4o", priority=3, max_retries=1),  # Premium fallback
    ],
)

Why Not Just Retry the Same Model?

For rate limit errors, retrying the same model usually fails again immediately. Provider rate limits are enforced per API key per minute—hitting the limit means the limit is exhausted for that window. Falling back to a different provider immediately restores service without waiting.


Pattern 3: Blue-Green Deployment

Blue-green deployment maintains two environments—the current ("blue") and the new ("green")—and switches traffic between them instantaneously. For LLM apps, this pattern handles prompt updates, model migrations, and RAG config changes.

                      ┌──────────────────┐
                      │  Traffic Router   │
                      └────────┬─────────┘
              ┌────────────────┴────────────────┐
              ▼                                 ▼
   ┌──────────────────┐             ┌──────────────────┐
   │   BLUE (active)  │             │  GREEN (standby) │
   │  prompt v3       │             │  prompt v4       │
   │  gpt-4o-mini     │             │  gpt-4o-mini     │
   │  100% traffic    │             │  0% traffic      │
   └──────────────────┘             └──────────────────┘

After validation:
   │   BLUE (standby) │             │  GREEN (active)  │
   │  prompt v3       │             │  prompt v4       │
   │  gpt-4o-mini     │             │  gpt-4o-mini     │
   │  0% traffic      │             │  100% traffic    │
import os
from dataclasses import dataclass

@dataclass
class DeploymentConfig:
    prompt_name: str
    prompt_version: int
    model: str
    temperature: float = 0.0
    max_tokens: int = 500

class BlueGreenDeployer:
    """
    Manages two deployment environments with instant traffic switching.
    Blue is always the "last known good" configuration.
    """

    def __init__(self):
        self.blue: Optional[DeploymentConfig] = None
        self.green: Optional[DeploymentConfig] = None
        self._active = "blue"

    @property
    def active_config(self) -> Optional[DeploymentConfig]:
        return self.blue if self._active == "blue" else self.green

    @property
    def standby_config(self) -> Optional[DeploymentConfig]:
        return self.green if self._active == "blue" else self.blue

    def deploy_to_standby(self, config: DeploymentConfig):
        """Load a new configuration into the standby slot."""
        if self._active == "blue":
            self.green = config
            logger.info({"event": "deploy_to_green", "config": vars(config)})
        else:
            self.blue = config
            logger.info({"event": "deploy_to_blue", "config": vars(config)})

    def promote_standby(self):
        """Switch traffic from the current active to the standby."""
        old_active = self._active
        self._active = "green" if self._active == "blue" else "blue"
        logger.info({"event": "traffic_switched",
                     "from": old_active, "to": self._active})

    def rollback(self):
        """Instantly revert to the previous active environment."""
        self.promote_standby()
        logger.warning({"event": "rollback_executed", "now_active": self._active})

    def status(self) -> dict:
        return {
            "active": self._active,
            "active_config": vars(self.active_config) if self.active_config else None,
            "standby_config": vars(self.standby_config) if self.standby_config else None,
        }


# Deployment workflow:
deployer = BlueGreenDeployer()
deployer.blue = DeploymentConfig("support_agent", 3, "gpt-4o-mini")  # Current

# 1. Load new config into standby (no traffic impact)
deployer.deploy_to_standby(DeploymentConfig("support_agent", 4, "gpt-4o-mini"))

# 2. Run eval suite against standby
# result = tester.run("support_agent", 4, test_suite)
# assert result["pass_rate"] >= 0.95, "Quality gate failed"

# 3. Switch traffic (instantaneous)
deployer.promote_standby()

# 4. Monitor for 30 minutes; if quality drops:
# deployer.rollback()

Time to rollback: Under 1 second. The old environment is always warm and ready.


Pattern 4: Canary Deployment

Canary deployment exposes the new configuration to a small percentage of real users while the majority stays on the stable version. It catches issues that eval suites miss—production data distributions, edge cases, and user-specific behavior.

import hashlib

class CanaryDeployer:
    """
    Gradual traffic shift with automated promotion and rollback.
    Canary percentage increases as confidence in the new config grows.
    """

    def __init__(self, stable: DeploymentConfig, canary: DeploymentConfig):
        self.stable = stable
        self.canary = canary
        self._canary_pct = 0.0       # Start at 0%
        self._metrics: dict[str, list[float]] = {"stable": [], "canary": []}

    @property
    def canary_pct(self) -> float:
        return self._canary_pct

    def set_canary_pct(self, pct: float):
        assert 0 <= pct <= 100
        self._canary_pct = pct
        logger.info({"event": "canary_pct_updated", "pct": pct})

    def get_config(self, request_id: str) -> tuple[DeploymentConfig, str]:
        """
        Deterministically assign a request to stable or canary.
        Returns (config, environment_name).
        """
        bucket = int(hashlib.sha256(request_id.encode()).hexdigest(), 16) % 100
        if bucket < self._canary_pct:
            return self.canary, "canary"
        return self.stable, "stable"

    def record_metric(self, environment: str, metric: str, value: float):
        key = f"{environment}_{metric}"
        if key not in self._metrics:
            self._metrics[key] = []
        self._metrics[key].append(value)

    def should_promote(self, min_samples: int = 200) -> dict:
        """
        Evaluate whether the canary is ready to promote to 100%.
        Returns a decision dict with reasons.
        """
        canary_satisfaction = self._metrics.get("canary_satisfaction", [])
        stable_satisfaction = self._metrics.get("stable_satisfaction", [])

        if len(canary_satisfaction) < min_samples:
            return {
                "action": "hold",
                "reason": f"Insufficient samples: {len(canary_satisfaction)}/{min_samples}",
            }

        canary_avg = sum(canary_satisfaction) / len(canary_satisfaction)
        stable_avg = sum(stable_satisfaction) / len(stable_satisfaction)
        regression_pct = (stable_avg - canary_avg) / stable_avg * 100

        if regression_pct > 3:    # More than 3% regression from stable
            return {
                "action": "rollback",
                "reason": f"Canary {regression_pct:.1f}% worse than stable",
                "canary_avg": canary_avg,
                "stable_avg": stable_avg,
            }

        return {
            "action": "promote",
            "reason": f"Canary within {regression_pct:.1f}% of stable quality",
            "canary_avg": canary_avg,
            "stable_avg": stable_avg,
        }
Stage Canary Traffic Minimum Duration Decision Criteria
1 1% 2 hours Error rate and timeout rate
2 5% 8 hours Quality score vs. stable
3 20% 24 hours Full quality + cost + latency
4 50% 24 hours Statistical significance confirmed
5 100% Promote, retire old version

Never rush through these stages. The 1% stage exists to catch catastrophic failures (model errors, format breakage) before they affect real users.


Edge Cases and Misconceptions

"Blue-green deployments require maintaining duplicate infrastructure." For LLM apps, blue-green is primarily a configuration pattern, not an infrastructure pattern. Both "environments" use the same API endpoint and infrastructure—they just use different prompt versions, model configs, or RAG settings. The cost of maintaining two configs is negligible.

"Canary deployments work like traditional software canaries." Traditional canaries catch code crashes and errors. LLM canaries must additionally measure quality degradation, which requires an evaluation layer (LLM-as-judge or user feedback collection) running on canary traffic in parallel. Without quality measurement, your canary only catches hard failures, not the more common soft degradation.

"Once a fallback activates, it means the primary is down." Fallbacks activate on rate limits, timeouts, and transient errors—not just full outages. Track your fallback activation rate as a metric. A rising fallback rate might indicate you're approaching your rate limit tier and need to upgrade, not that there's a provider outage.

"I should test blue-green by running both environments in parallel." Running both environments simultaneously on the same traffic introduces confounding: the same user gets different responses at different times. For comparison testing, use canary deployment (deliberate traffic split) or shadow traffic (run both but only return stable). Blue-green is for zero-downtime deployment with instant rollback, not for A/B comparison.


Production Scenario: Migrating from GPT-4o to Claude 3.5 Sonnet

Your team wants to switch your customer support bot's primary model from GPT-4o to Claude 3.5 Sonnet after internal tests show better instruction-following. Here is how you deploy this safely using the full deployment stack.

Phase 1: Validate Before Any Production Traffic

# Run your golden set against both models; compare pass rates
from eval.runner import run_eval_suite

gpt4o_results = run_eval_suite(model="gpt-4o", golden_set="eval/golden-set/")
claude_results = run_eval_suite(model="claude-3-5-sonnet-20241022", golden_set="eval/golden-set/")

print(f"GPT-4o pass rate:   {gpt4o_results['pass_rate']:.1%}")
print(f"Claude pass rate:   {claude_results['pass_rate']:.1%}")

# If Claude pass rate >= GPT-4o pass rate: proceed
# If Claude pass rate < GPT-4o pass rate - 5%: investigate before proceeding

Suppose results show: GPT-4o 91.2%, Claude 93.4%. Claude is better offline. Proceed to shadow traffic.

Phase 2: Shadow Traffic (Zero-Risk Validation on Real Queries)

Configure the shadow traffic handler to run Claude on production queries for 72 hours while continuing to return GPT-4o responses to users:

shadow_handler = ShadowTrafficHandler(
    stable_handler=GPT4oHandler(),
    candidate_handler=ClaudeHandler(model="claude-3-5-sonnet-20241022"),
    eval_recorder=EvalRecorder(output_dir="shadow_results/"),
)

After 72 hours, analyze: - Output changed in 38% of requests (expected; models have different output style) - Average quality delta: +0.031 (Claude slightly better across production queries) - Average latency delta: +210ms (Claude slightly slower) - No format violations in candidate output

Shadow traffic confirms Claude is safe. Proceed to canary.

Phase 3: Canary Rollout

CANARY_STAGES = [
    {"pct": 1,  "duration_h": 2,  "gate": ["error_rate", "timeout_rate"]},
    {"pct": 5,  "duration_h": 8,  "gate": ["quality_score", "error_rate"]},
    {"pct": 20, "duration_h": 24, "gate": ["quality_score", "latency_p99", "cost_per_request"]},
    {"pct": 50, "duration_h": 24, "gate": ["all"]},
    {"pct": 100,"duration_h": 0,  "gate": []},
]

At 5% canary after 8 hours: - Error rate: 0.3% (within threshold of 2%) - Quality score: 0.891 vs stable 0.874 (+2%) - Cost per request: $0.0042 vs $0.0038 (+11%) - Decision: Proceed to 20% (cost increase acceptable given quality gain)

At 20% canary after 24 hours: - All metrics stable; cost slightly lower than 5% stage (Claude caches better) - Decision: Proceed to 50%

At 50% after 24 hours: - Quality score 0.887 vs baseline 0.874 (+1.5%); cost neutral - Decision: Promote to 100%

Total safe migration time: 60 hours. Zero user-facing quality incidents. Full rollback option available at every stage.


Key Takeaways

  • LLM applications depend on third-party providers—build resilience in from day one, not as an afterthought
  • An API gateway abstracts provider specifics, centralizes logging and cost tracking, and enables flexible routing
  • Fallback chains restore service during rate limit and outage events by trying alternative providers or models in priority order
  • Blue-green deployments enable instant rollback of prompt and model changes without infrastructure changes
  • Canary deployments expose new configurations to a small traffic percentage with quality monitoring before full rollout
  • Track fallback activation rate as a leading indicator of infrastructure stress; a rising rate often precedes outages

Further Reading


Next Lesson

Lesson 8: API Design for AI Services — Learn to design AI APIs with streaming responses, token-based rate limiting, structured error codes, and versioning strategies that keep clients from breaking when your models change.