03 · Production LLM Platform Architecture¶
A single API call to a single model provider works fine for a prototype. A production platform serving many teams and features needs something in front of that: a layer that routes requests to the right model, falls back when a provider is down or rate-limited, enforces quotas so one team can't starve another, and normalizes differences between providers so application code doesn't have to know which one is answering. This module covers how to design that layer — the "LLM gateway" pattern that most serious LLM platforms converge on.
Why you need a gateway at all¶
Without one, every application team writes its own provider client, its own retry logic, its own fallback handling, and its own cost tracking — duplicated N times, inconsistently, with no central point to enforce policy or see aggregate spend. A gateway centralizes:
- Multi-provider abstraction — one internal API shape, translated to each provider's actual request/response format.
- Routing and fallback — which model handles a request, and what happens when the primary choice fails or is too slow.
- Quota and rate limiting — per-team, per-API-key limits that prevent one caller from exhausting shared capacity or budget.
- Observability — every request logged in one place (Level 3, module 9's tracing, applied at the platform level rather than per-app).
A minimal gateway abstraction¶
from dataclasses import dataclass
from enum import Enum
import time
class Provider(Enum):
ANTHROPIC = "anthropic"
OPENAI = "openai"
LOCAL = "local"
@dataclass
class ModelRequest:
messages: list[dict]
max_tokens: int = 1024
team_id: str = "default"
@dataclass
class ModelResponse:
text: str
provider: Provider
latency_ms: float
input_tokens: int
output_tokens: int
class ProviderAdapter:
"""Each provider gets one adapter translating the internal shape to its API."""
def __init__(self, provider: Provider, client):
self.provider = provider
self.client = client
def call(self, request: ModelRequest) -> ModelResponse:
start = time.monotonic()
# translate request.messages into this provider's exact schema here
raw = self.client.send(request.messages, max_tokens=request.max_tokens)
latency_ms = (time.monotonic() - start) * 1000
return ModelResponse(
text=raw["text"],
provider=self.provider,
latency_ms=latency_ms,
input_tokens=raw["usage"]["input_tokens"],
output_tokens=raw["usage"]["output_tokens"],
)
Every consumer of the gateway works against ModelRequest/ModelResponse
— they never see provider-specific payloads, which is what makes it
possible to swap, add, or remove a provider without touching application
code.
Routing and fallback chains¶
Routing decides which model answers a given request — by task type, cost tier, or current health — and a fallback chain decides what happens when the chosen model fails:
class Gateway:
def __init__(self, adapters: dict[Provider, ProviderAdapter], routing_table: dict[str, list[Provider]]):
self.adapters = adapters
self.routing_table = routing_table # task_type -> ordered list of providers to try
def route(self, task_type: str, request: ModelRequest) -> ModelResponse:
chain = self.routing_table.get(task_type, [Provider.ANTHROPIC])
last_error = None
for provider in chain:
try:
return self.adapters[provider].call(request)
except Exception as exc:
last_error = exc
continue # try the next provider in the fallback chain
raise RuntimeError(f"All providers in chain failed for {task_type}: {last_error}")
routing_table = {
"classification": [Provider.LOCAL, Provider.ANTHROPIC], # cheap local model first
"complex_reasoning": [Provider.ANTHROPIC, Provider.OPENAI], # primary, then fallback
"summarization": [Provider.ANTHROPIC],
}
The fallback chain is what turns a single provider outage into a degraded-but-working system instead of a full outage — as long as the fallback model is genuinely capable of handling the request, not just available. Route by task type deliberately: sending a task the fallback model handles poorly is often worse than a clear, fast failure.
Quota and rate limiting¶
A shared token-bucket limiter per team or API key prevents one caller from consuming capacity meant for others:
import threading
import time
class TokenBucket:
def __init__(self, capacity: int, refill_per_second: float):
self.capacity = capacity
self.tokens = capacity
self.refill_per_second = refill_per_second
self.last_refill = time.monotonic()
self.lock = threading.Lock()
def try_consume(self, amount: int = 1) -> bool:
with self.lock:
now = time.monotonic()
elapsed = now - self.last_refill
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_second)
self.last_refill = now
if self.tokens >= amount:
self.tokens -= amount
return True
return False
team_buckets: dict[str, TokenBucket] = {}
def check_quota(team_id: str, requests_per_minute: int = 60) -> bool:
bucket = team_buckets.setdefault(team_id, TokenBucket(requests_per_minute, requests_per_minute / 60))
return bucket.try_consume()
Enforce quota at the gateway, not in each application — it's the only place that sees total cross-team demand, and it's the natural place to reject or queue a request before it ever reaches (and gets billed by) an upstream provider.
Normalizing streaming and errors¶
Providers differ in how they stream tokens and shape error responses. The gateway should normalize both so application code handles one consistent contract:
class NormalizedError(Exception):
def __init__(self, kind: str, retryable: bool, message: str):
self.kind = kind # "rate_limit" | "invalid_request" | "server_error" | "timeout"
self.retryable = retryable
super().__init__(message)
def normalize_provider_error(provider: Provider, raw_exc: Exception) -> NormalizedError:
# each adapter maps its provider's actual exception types/status codes here
if "429" in str(raw_exc):
return NormalizedError("rate_limit", retryable=True, message=str(raw_exc))
if "timeout" in str(raw_exc).lower():
return NormalizedError("timeout", retryable=True, message=str(raw_exc))
return NormalizedError("server_error", retryable=False, message=str(raw_exc))
retryable is the field that matters most operationally — it's what lets
generic retry middleware decide whether to retry, fall back, or fail fast
without needing to understand every provider's specific error taxonomy.
How It Actually Works¶
The gateway pattern works because it inserts a stable interface at the one point in the system where instability is guaranteed: the boundary to external, independently-operated services whose latency, availability, and exact API shape change on a schedule you don't control. Every capability above — routing, fallback, quotas, error normalization — is a variation on the same idea: push provider-specific knowledge to the edge (the adapter), and keep everything behind that edge working against one internal contract that changes far less often than any single provider's API does.
The fallback chain's reliability comes specifically from failing fast and
trying the next option rather than retrying the same failing provider
repeatedly — a provider returning 429 or timing out is, by definition,
not currently able to serve the request, so a fast fallback to a
different provider (or a smaller/cheaper model that's still up) produces a
successful response in the time a naive retry-with-backoff against the
same failing endpoint would still be waiting. The token-bucket limiter's
mechanism — refilling continuously rather than resetting at fixed
intervals — avoids the thundering-herd problem of fixed-window rate
limits, where all capacity resets at once and a burst of queued requests
floods in simultaneously; continuous refill smooths admitted traffic over
time instead.
Cheat sheet¶
| Component | Job |
|---|---|
| Provider adapter | Translates internal request/response shape to one provider's API |
| Routing table | Maps task type to an ordered list of providers/models to try |
| Fallback chain | Tries the next provider on failure instead of failing the whole request |
| Token bucket | Per-team/key rate limiting with smooth, continuous refill |
| Normalized errors | One retryable/non-retryable contract across all providers |
| Centralized logging | One place to see cross-team cost, latency, and error rate |
Exercise¶
Build the Gateway class above with two adapters (a real provider and a
local mock that fails 30% of the time to simulate outages) and a
2-provider fallback chain. Run 100 requests through it, log which
provider actually served each one, and verify the failure rate at the
gateway boundary drops close to zero even though the primary adapter
fails 30% of individual calls. Add a TokenBucket per team and confirm a
team exceeding its quota gets rejected before ever reaching an adapter.