Model Routing in Production: Automatic Selection for Cost
Learn model routing in production through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Muhammad Abdul Sami
· 9 min read
- LLM
- LLM Serving
- Evaluation
- Cost Optimization
Table of Contents:
- Why Route Between Models?
- Routing Strategies
- Complexity-Based Routing
- Confidence-Based Cascading
- Production Implementation
- Benchmarks and Cost Analysis
- Monitoring and Optimization
- Frequently Asked Questions
Why Route Between Models?
Short answer: Model routing automatically selects the right LLM (GPT-4o vs GPT-4o-mini, or 70B vs 8B) based on request complexity — achieving 40-70% cost reduction with <3% quality degradation by routing simple queries to cheap models and complex ones to expensive models.
After implementing routing for production AI systems at HinterBuild, the economics are clear: 70% of queries can be handled by models 10-20x cheaper than your most capable model without users noticing. The remaining 30% justify the expensive model.
Key Takeaways:
- 80/20 rule: 80% of requests are simple enough for cheap models, 20% need expensive models
- Cascading pattern: Try cheap → medium → expensive until confidence threshold met
- 40-70% cost reduction typical with intelligent routing
- <3% quality degradation when routing thresholds tuned correctly
- Best for: chat applications, customer support, general Q&A, code generation
For teams with LLM serving infrastructure or AI agents handling diverse queries, routing is the highest-ROI cost optimization after quantization.
Routing Strategies
Strategy 1: Keyword-Based Routing
Simplest approach: Route based on keywords or patterns.
from enum import Enum
from typing import Literal
ModelTier = Literal["cheap", "medium", "expensive"]
def route_by_keywords(query: str) -> ModelTier:
"""Route based on keyword patterns."""
query_lower = query.lower()
technical_keywords = [
"architecture", "algorithm", "proof", "derivation",
"implementation details", "edge case", "optimization",
]
if any(kw in query_lower for kw in technical_keywords):
return "expensive"
# Code generation → medium model
code_keywords = ["write code", "implement", "function", "debug"]
if any(kw in query_lower for kw in code_keywords):
return "medium"
# Simple queries → cheap model
return "cheap"
# Example usage
query = "What is the capital of France?"
tier = route_by_keywords(query) # → "cheap"
query = "Derive the backpropagation algorithm from first principles"
tier = route_by_keywords(query) # → "expensive"
Pros: Fast, deterministic, no inference cost
Cons: Brittle, requires manual tuning, misses nuance
Strategy 2: Length-Based Routing
Rule: Longer queries/contexts → more capable model.
def route_by_length(
query: str,
context_tokens: int = 0,
) -> ModelTier:
"""Route based on input length."""
total_tokens = len(query.split()) + context_tokens
if total_tokens > 2000:
return "expensive" # Long context needs strong model
elif total_tokens > 500:
return "medium"
else:
return "cheap"
Pros: Simple, works for RAG systems with variable context
Cons: Doesn't capture semantic complexity
Strategy 3: Classifier-Based Routing
Best approach: Train a lightweight classifier to predict complexity.
from transformers import pipeline
# Train classifier on: (query, required_model_tier) pairs
classifier = pipeline(
"text-classification",
model="distilbert-base-uncased-finetuned-query-complexity",
)
def route_by_classifier(query: str) -> ModelTier:
"""Use ML classifier to predict complexity."""
result = classifier(query)[0]
# Classifier outputs: simple, medium, complex
tier_map = {
"simple": "cheap",
"medium": "medium",
"complex": "expensive",
}
return tier_map[result["label"]]
Training data collection:
# Collect ground truth by comparing outputs
async def collect_routing_data():
"""Build dataset: (query, required_tier)."""
queries = load_production_queries(sample=10000)
dataset = []
for query in queries:
# Generate with both cheap and expensive models
cheap_response = await generate(query, model="gpt-4o-mini")
expensive_response = await generate(query, model="gpt-4o")
# Human eval or automated similarity score
similarity = compute_similarity(cheap_response, expensive_response)
# If cheap model is good enough, label as "simple"
required_tier = "simple" if similarity > 0.95 else "complex"
dataset.append({"text": query, "label": required_tier})
return dataset
For production AI systems, classifier-based routing achieves 10-15% better cost/quality tradeoff than rules.
Complexity-Based Routing
Multi-Tier Router
from dataclasses import dataclass
from typing import Callable
import structlog
logger = structlog.get_logger()
@dataclass
class ModelConfig:
name: str
cost_per_1k_tokens: float
capability_score: float # 0-100
MODELS = {
"cheap": ModelConfig("gpt-4o-mini", 0.0015, 65),
"medium": ModelConfig("gpt-4o", 0.015, 85),
"expensive": ModelConfig("o1", 0.060, 95),
}
class ComplexityRouter:
"""Route queries based on estimated complexity."""
def __init__(self, classifier_model: str = "query-complexity-classifier"):
self.classifier = pipeline("text-classification", model=classifier_model)
async def route(
self,
query: str,
context: str = "",
user_tier: str = "standard", # "free", "standard", "premium"
) -> ModelConfig:
"""Select optimal model based on complexity and user tier."""
# Step 1: Estimate complexity
complexity_score = self._estimate_complexity(query, context)
# Step 2: Apply user tier constraints
max_cost = self._get_max_cost(user_tier)
# Step 3: Select model
model = self._select_model(complexity_score, max_cost)
logger.info(
"model_routed",
query_preview=query[:100],
complexity=complexity_score,
model=model.name,
user_tier=user_tier,
)
return model
def _estimate_complexity(self, query: str, context: str) -> float:
"""Estimate query complexity (0-100)."""
# Combine multiple signals
# Signal 1: Classifier prediction
result = self.classifier(query)[0]
classifier_score = {
"simple": 20,
"medium": 50,
"complex": 80,
}[result["label"]]
# Signal 2: Length (more tokens = more complex)
length_score = min(len(query.split()) / 50 * 100, 100)
# Signal 3: Technical keywords
technical_keywords = ["algorithm", "proof", "architecture", "implement"]
keyword_matches = sum(1 for kw in technical_keywords if kw in query.lower())
keyword_score = min(keyword_matches * 20, 60)
# Signal 4: Context length (RAG systems)
context_score = min(len(context.split()) / 2000 * 50, 50)
# Weighted combination
score = (
classifier_score * 0.5 +
length_score * 0.2 +
keyword_score * 0.2 +
context_score * 0.1
)
return min(score, 100)
def _get_max_cost(self, user_tier: str) -> float:
"""Max cost per query based on user tier."""
return {
"free": 0.002, # Max cheap model
"standard": 0.020, # Max medium model
"premium": 0.100, # Any model
}[user_tier]
def _select_model(self, complexity: float, max_cost: float) -> ModelConfig:
"""Select cheapest model that meets complexity threshold."""
# Thresholds (tune based on evals)
if complexity < 40 and MODELS["cheap"].cost_per_1k_tokens <= max_cost:
return MODELS["cheap"]
elif complexity < 75 and MODELS["medium"].cost_per_1k_tokens <= max_cost:
return MODELS["medium"]
else:
# Fall back to medium if premium not allowed
if MODELS["expensive"].cost_per_1k_tokens > max_cost:
return MODELS["medium"]
return MODELS["expensive"]
Usage Example
router = ComplexityRouter()
# Simple query → cheap model
model = await router.route("What is the capital of France?")
# → gpt-4o-mini ($0.0015/1k)
# Complex query → expensive model
model = await router.route(
"Derive the backpropagation algorithm from first principles"
)
# → o1 ($0.060/1k)
# Premium user gets best model regardless
model = await router.route(
"What is the capital of France?",
user_tier="premium",
)
# → Could still route to cheap model (waste of money), or force expensive
For AI agent tool calling, route tool selection to cheap model, execution validation to expensive model.
Confidence-Based Cascading
Cascading Pattern
Try cheap model first. If confidence low, try more expensive model.
from typing import Optional
import asyncio
class CascadingRouter:
"""Try cheap → medium → expensive until confidence threshold met."""
def __init__(
self,
cheap_model: str = "gpt-4o-mini",
expensive_model: str = "gpt-4o",
confidence_threshold: float = 0.8,
):
self.cheap = cheap_model
self.expensive = expensive_model
self.threshold = confidence_threshold
async def generate(
self,
query: str,
max_retries: int = 2,
) -> dict:
"""Generate with cascading fallback."""
# Try 1: Cheap model
result = await self._generate_with_confidence(query, self.cheap)
if result["confidence"] >= self.threshold:
logger.info("cascade_success", model=self.cheap, confidence=result["confidence"])
return result
# Try 2: Expensive model
logger.info("cascade_fallback", from_model=self.cheap, to_model=self.expensive)
result = await self._generate_with_confidence(query, self.expensive)
return result
async def _generate_with_confidence(
self,
query: str,
model: str,
) -> dict:
"""Generate response and estimate confidence."""
response = await llm_generate(
query,
model=model,
temperature=0.3, # Lower temp for confidence estimation
logprobs=True, # Get token probabilities
)
# Estimate confidence from logprobs
confidence = self._compute_confidence(response.logprobs)
return {
"text": response.text,
"model": model,
"confidence": confidence,
}
def _compute_confidence(self, logprobs: list) -> float:
"""Estimate confidence from token log probabilities."""
if not logprobs:
return 0.5 # Default
# Average probability across tokens (convert log → prob)
probs = [math.exp(lp) for lp in logprobs]
avg_prob = sum(probs) / len(probs)
return avg_prob
Cost/Quality Tradeoff:
| Query Type | Cheap Success Rate | Avg Cost | Quality |
|---|---|---|---|
| Simple factual | 95% | $0.002 | High |
| General chat | 80% | $0.004 | Medium-high |
| Technical deep-dive | 40% | $0.018 | High |
| Overall | 78% | $0.006 | High |
Result: 78% of queries served by cheap model → 60% cost reduction vs always using expensive model.
For RAG pipelines, cascade retrieval quality assessment through models of increasing capability.
Production Implementation
Full Router Service
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import httpx
import time
app = FastAPI()
class RouteRequest(BaseModel):
query: str
context: str = ""
user_tier: str = "standard"
max_latency_ms: int = 5000
class RouteResponse(BaseModel):
response: str
model_used: str
cost: float
latency_ms: int
confidence: Optional[float] = None
@app.post("/v1/route")
async def route_and_generate(req: RouteRequest) -> RouteResponse:
"""Route to optimal model and generate response."""
start = time.time()
# Step 1: Select model
router = ComplexityRouter()
model = await router.route(req.query, req.context, req.user_tier)
# Step 2: Generate (with cascading fallback)
cascading = CascadingRouter(
cheap_model=model.name,
expensive_model="gpt-4o", # Fallback
)
result = await cascading.generate(req.query)
# Step 3: Track metrics
latency_ms = int((time.time() - start) * 1000)
tokens = len(result["text"].split())
cost = (tokens / 1000) * model.cost_per_1k_tokens
# Record to metrics
record_route_metrics(
model=result["model"],
latency_ms=latency_ms,
cost=cost,
confidence=result.get("confidence"),
)
return RouteResponse(
response=result["text"],
model_used=result["model"],
cost=cost,
latency_ms=latency_ms,
confidence=result.get("confidence"),
)
A/B Testing Router Performance
from dataclasses import dataclass
import random
@dataclass
class RouterVariant:
name: str
route_fn: Callable
allocation_percent: int
variants = [
RouterVariant("complexity", route_by_complexity, 50),
RouterVariant("cascade", route_by_cascade, 50),
]
def select_router_variant(user_id: str) -> RouterVariant:
"""Consistent A/B assignment."""
hash_val = hash(f"{user_id}-router-experiment-2026-09")
cumulative = 0
for variant in variants:
cumulative += variant.allocation_percent
if (hash_val % 100) < cumulative:
return variant
return variants[0] # Fallback
# Usage
router_variant = select_router_variant(user_id="user-123")
model = await router_variant.route_fn(query)
Deploy with Kubernetes platform engineering and monitor variant performance.
Benchmarks and Cost Analysis
Real Production Results
From customer support routing system (2M queries/month):
Before routing (always GPT-4o):
- Model: gpt-4o - Cost/query: $0.024 - Monthly cost: $48,000 - Quality score: 92%
After routing (complexity + cascade):
- Models: 72% gpt-4o-mini, 28% gpt-4o - Avg cost/query: $0.009 - Monthly cost: $18,000 - Quality score: 91% Savings: $30,000/month (62.5% reduction) Quality degradation: -1%
Query Distribution
| Query Type | % of Traffic | Routed To | Avg Cost |
|---|---|---|---|
| Simple factual | 45% | gpt-4o-mini | $0.002 |
| General chat | 27% | gpt-4o-mini | $0.004 |
| Code questions | 18% | gpt-4o | $0.018 |
| Complex technical | 10% | gpt-4o | $0.032 |
For LLM cost optimization, routing is the second-highest ROI after caching.
Monitoring and Optimization
Key Metrics
from prometheus_client import Counter, Histogram, Gauge
# Routing decisions
route_decisions = Counter(
"llm_route_decisions_total",
"Total routing decisions",
["source_model", "target_model", "reason"],
)
# Cost tracking
route_cost = Histogram(
"llm_route_cost_dollars",
"Cost per routed request",
buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1],
)
# Quality tracking (requires human eval or automated scoring)
route_quality = Histogram(
"llm_route_quality_score",
"Quality score per routed request",
["model"],
buckets=[0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 1.0],
)
# Cascade fallback rate
cascade_fallback_rate = Gauge(
"llm_cascade_fallback_rate",
"Percentage of queries falling back to expensive model",
)
Optimization Loop
async def optimize_routing_thresholds():
"""Weekly job to tune routing thresholds."""
# Collect last week's data
queries = await load_queries_with_ground_truth(days=7)
# For each complexity threshold, compute cost and quality
best_threshold = None
best_score = 0
for threshold in [30, 35, 40, 45, 50, 55, 60]:
cost = 0
quality = 0
for query, ground_truth in queries:
complexity = estimate_complexity(query)
model = "cheap" if complexity < threshold else "expensive"
cost += get_model_cost(model, query)
quality += compute_similarity(
generate(query, model),
ground_truth,
)
# Optimize for cost-adjusted quality
score = quality - (cost * cost_penalty_factor)
if score > best_score:
best_score = score
best_threshold = threshold
# Update routing threshold
update_routing_config(complexity_threshold=best_threshold)
logger.info("routing_optimized", new_threshold=best_threshold)
Monitor with observability infrastructure.
Primary references: official documentation, official documentation, official documentation, official documentation.
Operating Model Routing in Production as a System
The implementation is only one part of Model Routing in Production. A production design also needs an explicit contract for inputs, outputs, ownership, and failure behavior. Write that contract before selecting a library. It should identify which component validates input, where state lives, what may be retried, and which result is authoritative when two components disagree. This prevents a convenient prototype boundary from silently becoming the long-term architecture.
Start with a representative baseline. Capture request shape, traffic distribution, dependency latency, error classes, and the quality signal users actually care about. Averages hide the cases that cause incidents, so keep percentiles and segment measurements by workload type. Record the configuration and dataset version beside every result. Without that context, a faster or more accurate run cannot be reproduced and should not be used to approve a rollout.
Define the failure model
List failures by where they originate: invalid input, capacity exhaustion, dependency timeout, partial state change, malformed output, and semantically wrong output. Each class needs a different response. Validation errors should fail immediately. Transient dependency failures may be retried with a budget and jitter. An operation that may have committed must use an idempotency key or reconciliation step before retrying. A syntactically valid but incorrect result belongs in evaluation and review, not a blind retry loop.
Set a deadline for the complete operation and derive smaller budgets for each dependency. Local timeouts that add up to more than the caller's deadline merely create abandoned work. Propagate cancellation where the protocol supports it. Bound every queue, retry loop, context buffer, and concurrency pool; an unbounded safety mechanism becomes a second outage during overload.
Design a degraded mode before it is needed. Depending on the workload, that can mean returning a cached answer, selecting a simpler path, placing work in a durable queue, or asking for human review. The degraded response must be visible in telemetry and, where it changes meaning, visible to the caller. Silent fallback makes quality regressions almost impossible to diagnose.
Measure the decision, not just the component
Use three layers of signals. System metrics cover latency, throughput, saturation, and errors. Correctness metrics measure whether the result satisfies its contract. Business or user metrics show whether the system solved the intended problem. Improving only one layer can move the others backward, so release criteria should name acceptable movement for all three.
Attach a reason code to every route, rejection, fallback, and retry. Include version identifiers for configuration, code, model, schema, and data when relevant. Logs should let an engineer reconstruct a decision without storing secrets or raw personal data. Traces should cross process boundaries, while metrics should remain low-cardinality enough to operate reliably.
Alert on symptoms that require action, not every internal anomaly. A useful alert names the affected service objective, links to a runbook, and distinguishes a customer-visible incident from exhausted headroom. Dashboards serve a different purpose: they support diagnosis and capacity planning. Treating a dashboard as an alerting strategy leaves failures undiscovered until someone happens to look.
Roll out with reversible steps
Ship Model Routing in Production behind a versioned interface and a kill switch. Begin with offline replay using production-shaped, privacy-safe samples. Then use shadow execution when duplicate work has acceptable cost and side effects can be suppressed. A small canary should exercise the real dependency graph before traffic expands. Compare the canary with the baseline by cohort rather than mixing both populations into one aggregate.
Promotion gates should be written before the rollout. Include a minimum sample size or observation window, maximum regression in tail latency and error rate, and a correctness threshold. Roll back automatically when a hard safety boundary is crossed; use manual review for ambiguous quality movement. Preserve enough evidence from both paths to explain why the gate passed or failed.
Configuration deserves the same discipline as code. Review changes, validate them before activation, keep an immutable history, and make rollback a single operation. If a deployment changes code and configuration together, record both versions. Otherwise an incident responder may roll back the binary while leaving the triggering configuration active.
Capacity and cost controls
Model capacity in units the bottleneck understands: concurrent connections, tokens, queue jobs, database transactions, GPU memory, or bytes in flight. Convert the expected traffic distribution into those units and include burst behavior. Then load-test the first constrained dependency, not merely the public endpoint. A system that accepts more work than it can finish within its deadline is overloaded even if CPU utilization looks comfortable.
Cost is also a reliability limit. Add per-request attribution, tenant or workflow budgets, and a global circuit breaker for unexpectedly expensive paths. Review unit economics at the same granularity as performance; a cheap median can conceal a small class of requests responsible for most spend. Optimize only after measuring, because reducing context, replicas, validation, or redundancy can trade visible cost for less visible risk.
Production readiness review
Before launch, ask an engineer who did not build the feature to follow the runbook through one simulated failure. Verify backups or checkpoints by restoring them, not by checking that a job reported success. Exercise credential rotation, dependency unavailability, bad configuration, and rollback. Assign an owner for each alarm and a date for reviewing thresholds after real traffic arrives.
The final architecture document should be short enough to remain current. Keep the decision, rejected alternatives, invariants, dependency contracts, dashboards, and rollback procedure. Link detailed experiments rather than pasting them into the document. Teams that need help turning this review into an operable service can use our Model Routing in Production engineering support.
Frequently Asked Questions
What is model routing in LLM systems?
Model routing automatically selects which LLM to use per request based on query complexity, user tier, or confidence thresholds — routing simple queries to cheap models and complex ones to expensive models.
How much can routing save?
Typically 40-70% cost reduction with <3% quality loss. Savings depend on query distribution — more simple queries = higher savings.
Does routing add latency?
10-50ms for classifier-based routing (negligible). Cascading fallback adds one extra LLM call for 20-30% of queries.
Can I route between self-hosted models?
Yes. Route between 8B, 70B, and 405B open-source models. Same pattern, even larger savings (no API costs).
How do I measure routing quality?
Compare routed responses to "always use best model" baseline via human eval or automated similarity scoring (embeddings, ROUGE, etc.).
What's the best routing strategy?
Classifier-based for accuracy, cascade for quality guarantees. Combine both: classifier for initial route, cascade as fallback.
Can I route based on user tier?
Yes. Free users get cheap models, premium users get expensive models. Implement in router's _get_max_cost() method.
How do I train a routing classifier?
Collect (query, required_model) pairs by comparing cheap vs expensive outputs. Fine-tune DistilBERT or similar on this dataset.
Conclusion
Model routing is the most practical LLM cost optimization for diverse workloads. By routing 70% of queries to models 10-20x cheaper, you achieve 40-70% cost reduction with <3% quality loss — often imperceptible to users.
The deployment playbook:
- Start with keyword-based routing — validate concept in 1 day
- Build routing classifier — 2-4 weeks to collect data and train
- Add cascading fallback — safety net for quality
- Monitor cost and quality — tune thresholds weekly
- A/B test routing strategies — iterate to optimal policy
At HinterBuild, we build intelligent routing for LLM systems:
Contact us to implement model routing for your production system.
Free consultation
Book a free consultation call on LLM routing & model selection
30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.
Book a meeting
Keep reading
Related articles
LLM Routing: How to Pick the Cheapest Model That Works
LLM Routing guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable production implementation.
Read post
Model Distillation for LLMs: How to Build Smaller, Smarter
Learn model distillation for llms through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
ML Model Versioning: Complete DVC & MLflow Guide for
ML Model Versioning guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable production implementation.
Read post
vLLM in Production: PagedAttention, Continuous Batching, and
vLLM in Production guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable production implementation.
Read post
