← cecilia lee

Designing an LLM Routing System

Who decides which model serves a request, where cost is actually computed, and why routing is per-conversation — design notes from studying the space.

july 2026 · systems design

Every serious LLM product eventually grows the same organ: a routing layer. You have multiple models — different providers, different sizes, different prices — and every request has to land on one of them, under constraints of cost, latency, quality, and who the customer is. These are my working notes on how I'd design that system, built up from studying how the large AI platforms do it and pressure-testing the design against the failure modes.

The one-sentence architecture: the edge resolves who you are (facts), the router decides which model (policy), the model gateway serves and meters, and the billing pipeline rates — with a versioned config store as the shared backbone. Everything below unpacks why the responsibilities split exactly there.

The request path

Retail client API client Chat service + conversation store API Gateway auth · rate limits · spend caps · facts Router service policy · experiments · scoring Config store versioned Model Gateway adapters · fallbacks · usage events Billing rates async OpenAI Anthropic Self-hosted vLLM

Two client populations enter differently — retail through a chat service that owns conversation history, API customers straight into the gateway with their own history — but converge on the same spine. Scale assumption for everything below: roughly 10k RPS peak, streaming responses over SSE, with end-to-end latency dominated by the model itself, which is what buys the routing layer its 10–20ms decision budget.

Facts vs. policy: the split that organizes everything

The most important design line in the system is between the API gateway and the router, and it's the line between facts and policy.

The gateway is per-tenant and model-agnostic. It terminates retail JWTs and business API keys and normalizes both into a signed context: {tenant_id, tenant_type, tier, region, experiments_eligible}. It enforces dual rate limits — requests per minute and tokens per minute, because cost lives in tokens, not calls — plus spend caps fed back from billing, abuse pre-filtering, and the unglamorous work of holding 30-second SSE streams open and draining them gracefully on deploys. It can be built on off-the-shelf infrastructure (Envoy, Kong); the routing intelligence deliberately stays out of it.

The router is per-request and model-aware — and stateless. It reads the gateway's facts, looks up what they mean in the config store, and decides which model serves the request.

The test I use: does this change when a model launches? Then it's router config. Does it change when a user upgrades their plan? Then it's a gateway fact. The same human holding a retail subscription and an API key is two tenants, keyed by credential.

The routing decision, in order

Order matters here — each step constrains the next:

  1. Resolve facts to policy. Tier and region map to a candidate model set, fallback rights, capacity pool, and cost ceiling.
  2. Explicit model? If the user named a model, validate and admit it — never silently substitute. A named model that's unavailable is an error or an opt-in fallback, not a quiet swap. Only auto continues to selection.
  3. Hard filters. Data residency, required capabilities (vision, tools, structured output), and — easy to forget — whether the conversation history actually fits the model's context window.
  4. Conversation stickiness. Bias strongly toward the incumbent model of the conversation. This is partly UX (mid-chat model switches are jarring), partly clean experiment metrics, and partly hard economics: prompt caching discounts ~90% of input tokens on a stable prefix, and that requires the same model, same provider, append-only prompt. Once caching enters the picture, routing is per-conversation, not per-message.
  5. Experiment assignment. hash(user_id + salt) into buckets. Experiments can override defaults; they can never override hard filters.
  6. Scoring. Start with rules; graduate to a small difficulty classifier (~5ms, RouteLLM-style); eventually a contextual bandit trained on logged outcomes. Retail traffic optimizes for cost; business traffic optimizes for quality and SLA.
  7. Load-aware admission. Circuit breaker state, queue depth, and rate-limit headroom can demote a degraded candidate at the last moment.
  8. Emit and log {decision, reason, config_version, bucket} — the decision must be deterministic given (user, request, config version), or you can't debug it and can't trust your experiment data.

The router is also a brand-new single point of failure that didn't exist before you added it. It runs as stateless replicas, and the gateway holds a static default route so that total router failure degrades to "everyone gets the safe default model" rather than an outage.

The config store is a control plane, not a settings file

Everything slow-changing that expresses intent lives in one versioned store: the model registry (endpoints, context limits, capabilities, lifecycle state), the pricing catalog (versioned and effective-dated), tier-to-policy mappings, routing thresholds, fallback chains, breaker parameters, experiment definitions, and capacity topology. Writes are validated and dry-run; pushes propagate by watch/subscribe; every consumer caches last-known-good; rollback is one click to a previous version. Model launches, price changes, and routing changes all happen without a deploy.

Just as important is what's deliberately not in it: billing state, conversation history, live health signals, and secrets — each of which has a rightful owner elsewhere.

The model gateway measures; it never prices

Below the router sits an adapter layer that speaks each provider's API (or adopts the OpenAI-compatible format as a lingua franca) behind one unified schema. It handles streaming passthrough with one iron rule — retry only before the first token, never mid-stream — plus fallback chains, per-provider circuit breakers, and token-bucket budgets per provider key.

Its most important output is the usage event: {request_id, requested_model, served_model, input/output/cached tokens, pricing_version}. This is the source of truth for what happened — facts only, no pricing math. Which leads to the part of the design I find most underrated.

Cost is computed in four places — only one is real

StageWherePurposeAuthoritative?
EstimateRouter / gateway, pre-requestAdmission control, cost-aware selectionNever billed
MeasurementModel gateway, post-responseThe usage event — token factsFacts, not cost
RatingBilling aggregator, asyncDedupe by request_id, join versioned price catalog, produce invoices/creditsYes — the only one
Spend counterReal-time (Redis-style)Fast approximate cap enforcement at the gatewayReconciled in batch

Keeping these four honest about their roles prevents an entire family of bugs where two components disagree about what something cost. Rating is replayable — at-least-once event delivery plus idempotent dedupe gives effectively-once billing, and a price-catalog correction can re-rate history.

Edge cases worth having answers for: a stream disconnect bills the tokens actually generated (flush the usage event on abnormal exit); a fallback charges the lower of requested vs. served model; auto mode bills a blended flat rate, so routing savings become margin; and joining provider-side costs per tenant yields gross margin per customer — which feeds back into routing policy.

Chat history is a routing input, not a storage detail

Models are stateless, so history is resent every turn. Retail gets a chat service and conversation store (append-only messages(conversation_id, seq, role, content, tokens, model_id), partition-keyed, partial responses persisted on disconnect, token counts stored at write time); API customers own their own history.

Context assembly is a budgeting problem: budget = context limit − system prompt − reserved output, filled by a sliding window, then rolling summarization (a cheap model compressing old turns), then retrieval. Two couplings back to routing matter: context length is a hard filter (long conversations exclude small models), and prompt caching makes stickiness an economic requirement, not a preference. And since cost grows roughly quadratically with turns, summarization is a cost lever — not just a trick for fitting the window.

Experimentation without fooling yourself

Randomize on user_id, not per-request — consistent UX and clean statistics, at the price of slower ramps; chat products take that trade. Overlapping experiment layers allow concurrent tests without interference. Assignments stay sticky within a conversation. Measure latency, cost per request, error rate, thumbs ratio, regeneration rate, and retention, backed by offline LLM-judge evals; wrap it in guardrail metrics with auto-shutoff, and roll out as shadow traffic → 1% canary → ramp. Shadow traffic in particular is how a new model gets evaluated on real requests with zero user exposure.

Trade-offs I'd state out loud

These are personal design notes from studying how LLM routing systems are built — assembled from public architectures, papers (RouteLLM and friends), and working through the failure modes. Not a description of any system I operate. If you spot something wrong, I'd genuinely like to hear it — email me.