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.
Muhammad Abdul Sami
· Updated · 9 min read
- LLM
- LLM Serving
- Evaluation
- Cost Optimization
Table of Contents:
- What Is LLM Routing?
- Why Route Instead of One Model?
- Cost-Quality Tradeoffs by Task Type
- Router Pattern 1: Classifier Router
- Router Pattern 2: Cascade Routing
- Router Pattern 3: Semantic Router
- Router Pattern 4: Rule-Based Router
- Frequently Asked Questions
What Is LLM Routing?
Short answer: LLM routing is the practice of sending each request to the cheapest model capable of handling it — not defaulting every query to GPT-4o or Claude Opus because it's "the best."
The goal of LLM routing for the cheapest model that works is simple: match task complexity to model capability, and pay frontier-model prices only when you need frontier-model quality. A password reset lookup doesn't need a $15/M-token reasoning model. A multi-step legal analysis might.
After routing production traffic for multiple clients at HinterBuild, we've seen 40–70% cost reductions with no measurable quality drop on aggregate metrics — because most requests are simpler than teams assume.
Key Takeaways:
- LLM routing sends each request to the right model tier, not the most expensive one
- Cascade routing tries cheap models first, escalates only on low confidence
- Classifier routers use a fast model to pick the execution model
- 60–80% of production LLM traffic can run on small/cheap models without quality loss
- Monitor per-route quality, not just aggregate cost — bad routing saves money and loses users
This guide covers router patterns, cost-quality tradeoffs, cascade routing, and production code for multi-model LLM architectures.
Why Route Instead of One Model?
The Single-Model Trap
Most teams start with one model for everything:
response = openai.chat.completions.create(
model="gpt-4o", # $2.50 input / $10 output per 1M tokens
messages=messages,
)
This works until you scale. At 1M requests/month averaging 2K tokens in + 500 tokens out:
| Model | Input cost | Output cost | Monthly total |
|---|---|---|---|
| GPT-4o | $5,000 | $5,000 | $10,000 |
| GPT-4o-mini | $300 | $600 | $900 |
| Mixed routing (70% mini) | — | — | ~$3,600 |
That's a 64% savings from routing alone — before any caching or prompt optimization.
Not All Tasks Need the Same Model
Break your traffic by complexity:
| Task type | % of traffic | Minimum viable model |
|---|---|---|
| Classification / routing | 25% | GPT-4o-mini, Haiku, Llama 8B |
| Simple Q&A with RAG | 35% | GPT-4o-mini, Gemini Flash |
| Structured extraction | 15% | Fine-tuned 8B or 4o-mini |
| Multi-step reasoning | 10% | GPT-4o, Sonnet |
| Code generation / review | 10% | GPT-4o, Opus, Codex |
| Creative / long-form | 5% | GPT-4o, Opus |
If you're sending classification queries to Opus, you're burning money. LLM routing fixes this systematically.
Quality Doesn't Mean "Best Model"
The cheapest model that works is model-dependent on task, not universal. In our benchmarks:
- Llama 3.1 8B matches GPT-4o on binary classification (97.2% vs 97.8% accuracy)
- GPT-4o-mini matches GPT-4o on RAG Q&A with good retrieval (89% vs 91% human-rated)
- GPT-4o significantly outperforms mini on multi-hop reasoning (78% vs 52% on HotpotQA-style tasks)
Route by measured capability, not model marketing.
Cost-Quality Tradeoffs by Task Type
The Model Tier Landscape (2026)
| Tier | Examples | Input $/1M tokens | Best for |
|---|---|---|---|
| Nano | GPT-4o-mini, Haiku 3.5, Gemini Flash | $0.10–$0.40 | Classification, extraction, simple Q&A |
| Mid | GPT-4o, Sonnet 3.5, Gemini Pro | $1.25–$3.00 | RAG, tool calling, moderate reasoning |
| Frontier | Opus, o1, GPT-4.5 | $5.00–$15.00+ | Complex reasoning, code, analysis |
| Self-hosted | Llama 3.1 8B/70B, Mistral | GPU cost only | High-volume, privacy-sensitive |
The Quality Cliff
Cost savings from LLM routing follow a step function, not a smooth curve:
Quality │ │ ┌──── Frontier (Opus, o1) │ ╱ │ ╱ ← Mid (GPT-4o, Sonnet) — sweet spot for most tasks │ ╱ │ ╱──── Nano (mini, Haiku) — great for simple tasks │╱ └────────────────── Complexity
Below the cliff, cheap models fail hard (not gracefully). Your router must detect the cliff edge per task type.
Measuring "Good Enough"
Define acceptance criteria per route:
ROUTE_THRESHOLDS = {
"classification": {"min_accuracy": 0.95, "model": "gpt-4o-mini"},
"rag_qa": {"min_helpfulness": 0.85, "model": "gpt-4o-mini"},
"reasoning": {"min_accuracy": 0.75, "model": "gpt-4o"},
"code": {"min_pass_rate": 0.80, "model": "gpt-4o"},
}
Run eval suites monthly. Models improve; your routing table should update.
Router Pattern 1: Classifier Router
A classifier router uses a fast, cheap model to classify the request and select the execution model.
How It Works
User query → Classifier (mini/Haiku) → Route label → Execution model → Response
Classifier Router Implementation
from enum import Enum
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI()
class TaskComplexity(str, Enum):
SIMPLE = "simple" # → gpt-4o-mini
MODERATE = "moderate" # → gpt-4o
COMPLEX = "complex" # → gpt-4o (or o1 for reasoning)
class RouteDecision(BaseModel):
complexity: TaskComplexity
reasoning: str
ROUTE_MAP = {
TaskComplexity.SIMPLE: "gpt-4o-mini",
TaskComplexity.MODERATE: "gpt-4o",
TaskComplexity.COMPLEX: "gpt-4o",
}
CLASSIFIER_PROMPT = """Classify this user request by complexity:
- simple: lookup, classification, formatting, single-fact Q&A
- moderate: multi-step Q&A, summarization, structured extraction
- complex: multi-hop reasoning, code generation, analysis, creative writing
Request: {query}"""
def classify_query(query: str) -> RouteDecision:
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[{"role": "user", "content": CLASSIFIER_PROMPT.format(query=query)}],
response_format=RouteDecision,
)
return response.choices[0].message.parsed
def routed_completion(query: str, system: str = "You are a helpful assistant.") -> str:
decision = classify_query(query)
model = ROUTE_MAP[decision.complexity]
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": query},
],
)
return response.choices[0].message.content
Classifier Router Pros and Cons
| Pros | Cons |
|---|---|
| Simple to implement | Classifier errors propagate |
| One cheap call adds ~200ms | Two API calls for every request |
| Easy to update route map | Classifier may underestimate complexity |
| Works with any provider | Needs ongoing eval of classifier accuracy |
Best for: General-purpose assistants with diverse query types. We use this pattern in AI agent development projects with mixed workloads.
Router Pattern 2: Cascade Routing
Cascade routing tries the cheapest model first. If confidence is low, escalate to the next tier. You only pay for expensive models when cheap ones fail.
How Cascade Routing Works
Query → Cheap model → Confidence check → OK? → Return
↓ No
Mid model → Confidence check → OK? → Return
↓ No
Expensive model → Return
Cascade Router Implementation
import json
from dataclasses import dataclass
@dataclass
class CascadeResult:
response: str
model_used: str
confidence: float
cascades: int # how many models were tried
CASCADE_CHAIN = [
{"model": "gpt-4o-mini", "min_confidence": 0.85},
{"model": "gpt-4o", "min_confidence": 0.80},
{"model": "gpt-4o", "min_confidence": 0.0}, # final fallback, always accept
]
CONFIDENCE_PROMPT = """Answer the question. Then rate your confidence 0.0-1.0.
Question: {query}
Respond as JSON: {{"answer": "...", "confidence": 0.0-1.0}}"""
async def cascade_complete(query: str) -> CascadeResult:
cascades = 0
for tier in CASCADE_CHAIN:
cascades += 1
response = await client.chat.completions.create(
model=tier["model"],
messages=[{"role": "user", "content": CONFIDENCE_PROMPT.format(query=query)}],
response_format={"type": "json_object"},
)
parsed = json.loads(response.choices[0].message.content)
confidence = parsed["confidence"]
if confidence >= tier["min_confidence"]:
return CascadeResult(
response=parsed["answer"],
model_used=tier["model"],
confidence=confidence,
cascades=cascades,
)
raise RuntimeError("Cascade exhausted without result")
Cascade Routing Economics
On a 10,000-request eval set for customer support Q&A:
| Strategy | Avg cost/request | Quality (human-rated) | P99 latency |
|---|---|---|---|
| Always GPT-4o | $0.0082 | 4.3/5 | 2.1s |
| Always GPT-4o-mini | $0.0009 | 3.6/5 | 0.8s |
| Cascade (mini → 4o) | $0.0021 | 4.1/5 | 1.4s |
Cascade captured 74% of GPT-4o quality at 26% of the cost. Only 18% of requests escalated past mini.
Self-Consistency Cascade
For higher-stakes tasks, run the cheap model N times and escalate if answers disagree:
async def self_consistency_cascade(query: str, n_samples: int = 3) -> CascadeResult:
answers = []
for _ in range(n_samples):
result = await cheap_complete(query)
answers.append(result)
if len(set(answers)) == 1:
return CascadeResult(response=answers[0], model_used="gpt-4o-mini", confidence=1.0, cascades=1)
# Disagreement → escalate
return await cascade_complete(query)
This is powerful for structured LLM output tasks where consistency matters.
Router Pattern 3: Semantic Router
A semantic router uses embedding similarity to match queries to pre-defined routes — no LLM call needed for routing.
How Semantic Routing Works
Query → Embed → Compare to route embeddings → Best match → Execution model
Semantic Router Implementation
import numpy as np
from openai import OpenAI
client = OpenAI()
ROUTES = [
{
"name": "faq",
"description": "Simple FAQ lookup, hours, pricing, contact info",
"model": "gpt-4o-mini",
"examples": [
"What are your business hours?",
"How much does the pro plan cost?",
"What's your refund policy?",
],
},
{
"name": "technical_support",
"description": "Technical troubleshooting, error messages, configuration",
"model": "gpt-4o",
"examples": [
"I'm getting a 403 error on the API endpoint",
"How do I configure SSO with SAML?",
"The webhook isn't firing after payment",
],
},
{
"name": "analysis",
"description": "Data analysis, comparisons, recommendations",
"model": "gpt-4o",
"examples": [
"Compare our Q3 vs Q4 revenue trends",
"Analyze this customer feedback and suggest improvements",
"What pricing strategy would maximize conversion?",
],
},
]
def embed_texts(texts: list[str]) -> list[list[float]]:
response = client.embeddings.create(model="text-embedding-3-small", input=texts)
return [item.embedding for item in response.data]
def build_route_index():
route_embeddings = []
for route in ROUTES:
example_embeddings = embed_texts(route["examples"])
centroid = np.mean(example_embeddings, axis=0)
route_embeddings.append(centroid)
return route_embeddings
ROUTE_EMBEDDINGS = build_route_index()
def semantic_route(query: str, threshold: float = 0.75) -> dict:
query_embedding = embed_texts([query])[0]
similarities = [
np.dot(query_embedding, route_emb) /
(np.linalg.norm(query_embedding) * np.linalg.norm(route_emb))
for route_emb in ROUTE_EMBEDDINGS
]
best_idx = int(np.argmax(similarities))
best_score = similarities[best_idx]
if best_score < threshold:
return {"name": "fallback", "model": "gpt-4o", "score": best_score}
return {
"name": ROUTES[best_idx]["name"],
"model": ROUTES[best_idx]["model"],
"score": best_score,
}
Semantic Router Advantages
- Zero LLM cost for routing — embedding call only (~$0.00002)
- Sub-50ms routing latency — no generation, just vector math
- Deterministic — same query always routes the same way
- Easy to debug — inspect similarity scores
Best for: High-volume systems with well-defined intent categories. Pair with RAG systems where each route has its own retrieval index.
Router Pattern 4: Rule-Based Router
Sometimes the cheapest LLM routing is no LLM at all. Rules handle predictable patterns.
Rule-Based Router Implementation
import re
from dataclasses import dataclass
@dataclass
class Rule:
name: str
pattern: re.Pattern
model: str
priority: int
RULES = sorted([
Rule("code", re.compile(r"```|def |class |import |function |debug", re.I), "gpt-4o", 1),
Rule("sql", re.compile(r"\b(SELECT|INSERT|UPDATE|DELETE|JOIN)\b", re.I), "gpt-4o-mini", 2),
Rule("short_query", re.compile(r"^.{1,50}$"), "gpt-4o-mini", 3),
Rule("long_document", re.compile(r".{5000,}", re.S), "gpt-4o", 4),
], key=lambda r: r.priority)
DEFAULT_MODEL = "gpt-4o-mini"
def rule_route(query: str) -> str:
for rule in RULES:
if rule.pattern.search(query):
return rule.model
return DEFAULT_MODEL
Hybrid: Rules → Semantic → Cascade
Production multi-model architectures stack router patterns:
async def production_route(query: str, metadata: dict) -> str:
# Layer 1: Hard rules (free, instant)
if metadata.get("user_tier") == "enterprise":
return "gpt-4o" # SLA guarantee
if metadata.get("task_type") == "classification":
return "gpt-4o-mini" # known-simple task
rule_model = rule_route(query)
if rule_model != DEFAULT_MODEL:
return rule_model
# Layer 2: Semantic router (cheap, fast)
semantic = semantic_route(query)
if semantic["score"] > 0.85:
return semantic["model"]
# Layer 3: Classifier (small LLM call)
decision = classify_query(query)
return ROUTE_MAP[decision.complexity]
This layered approach minimizes expensive routing calls while maintaining quality.
Production Router Implementation
Here's a complete production router combining patterns with observability.
Router Service Architecture
from fastapi import FastAPI
from pydantic import BaseModel
import time
import structlog
logger = structlog.get_logger()
app = FastAPI()
class RouteRequest(BaseModel):
query: str
user_tier: str = "standard"
task_type: str | None = None
max_model: str | None = None # cost cap
class RouteResponse(BaseModel):
response: str
model_used: str
route_path: str
latency_ms: float
estimated_cost_usd: float
MODEL_COSTS = {
"gpt-4o-mini": {"input": 0.15, "output": 0.60}, # per 1M tokens
"gpt-4o": {"input": 2.50, "output": 10.00},
}
def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
rates = MODEL_COSTS[model]
return (input_tokens * rates["input"] + output_tokens * rates["output"]) / 1_000_000
@app.post("/v1/completions")
async def routed_completion(req: RouteRequest) -> RouteResponse:
start = time.monotonic()
route_path = "unknown"
# Route selection
if req.task_type == "classification":
model = "gpt-4o-mini"
route_path = "rule:task_type"
elif req.user_tier == "enterprise":
model = "gpt-4o"
route_path = "rule:enterprise"
else:
semantic = semantic_route(req.query)
if semantic["score"] > 0.85:
model = semantic["model"]
route_path = f"semantic:{semantic['name']}"
else:
decision = classify_query(req.query)
model = ROUTE_MAP[decision.complexity]
route_path = f"classifier:{decision.complexity}"
if req.max_model and model_tier(model) > model_tier(req.max_model):
model = req.max_model
route_path += ":capped"
# Execute
response = await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": req.query}],
)
content = response.choices[0].message.content
usage = response.usage
latency = (time.monotonic() - start) * 1000
cost = estimate_cost(model, usage.prompt_tokens, usage.completion_tokens)
logger.info(
"routed_completion",
model=model,
route_path=route_path,
latency_ms=latency,
cost_usd=cost,
input_tokens=usage.prompt_tokens,
output_tokens=usage.completion_tokens,
)
return RouteResponse(
response=content,
model_used=model,
route_path=route_path,
latency_ms=latency,
estimated_cost_usd=cost,
)
Deploy this behind backend API engineering best practices: rate limiting, auth, and request ID tracing.
Integration with Agent Systems
For agentic workflows, route at the step level, not just the request level:
AGENT_STEP_ROUTES = {
"plan": "gpt-4o", # reasoning-heavy
"execute_tool": "gpt-4o-mini", # structured, constrained
"summarize": "gpt-4o-mini", # simple synthesis
"reflect": "gpt-4o", # quality check
}
Agent planning needs a strong model. Tool argument generation with constrained JSON decoding runs fine on mini.
Monitoring and Cost Optimization
LLM routing without monitoring is guessing. Track these metrics.
Essential Metrics
| Metric | Why it matters |
|---|---|
route.distribution | Are 80% going to mini, or is classifier broken? |
route.escalation_rate | Cascade escalation >25% means cheap model isn't good enough |
route.cost_per_request | Trending up? New query types bypassing router |
route.quality_by_model | Per-route human/LLM-judge scores |
route.latency_by_path | Semantic should be <100ms; cascade adds multi-call latency |
route.classifier_accuracy | Misroutes waste money or lose quality |
Implement with observability and monitoring:
from prometheus_client import Counter, Histogram
ROUTE_COUNTER = Counter("llm_route_total", "Routes by path", ["route_path", "model"])
ROUTE_COST = Histogram("llm_route_cost_usd", "Cost per request", ["model"])
ROUTE_LATENCY = Histogram("llm_route_latency_ms", "End-to-end latency", ["route_path"])
Cost Optimization Loop
Monthly optimization cycle:
- Pull route distribution — Identify over-represented expensive routes
- Run eval suite per route — Can a cheaper model pass thresholds?
- A/B test route changes — 5% traffic to new routing table
- Update route map — Promote cheaper models where evals pass
- Check for regressions — User complaints, support ticket quality
Caching Layer
Route + cache for maximum savings:
import hashlib
def cache_key(query: str, model: str) -> str:
return hashlib.sha256(f"{model}:{query}".encode()).hexdigest()
async def cached_routed_complete(query: str) -> RouteResponse:
model = select_model(query)
key = cache_key(query, model)
cached = await redis.get(key)
if cached:
return RouteResponse(**json.loads(cached), route_path="cache:hit")
result = await routed_completion(query, model)
await redis.setex(key, 3600, result.model_dump_json())
return result
Semantic cache (embedding similarity) catches paraphrased repeats. At scale, caching + routing beats either alone.
Pair the dispatcher with a tested token budget management policy so an unexpectedly long request cannot erase the savings.
Deploy on cloud infrastructure with cost alerts when daily spend exceeds thresholds.
Primary references: official documentation, official documentation, official documentation, official documentation.
Frequently Asked Questions
What is LLM routing?
LLM routing is the practice of selecting the most cost-effective model for each request based on task complexity, rather than using one model for all queries. The goal is the cheapest model that works for each specific task.
How much can LLM routing save?
Typical savings are 40–70% on LLM API costs, depending on traffic mix. Teams sending mostly simple queries to frontier models see the largest gains.
What is cascade routing for LLMs?
Cascade routing tries the cheapest model first and escalates to more expensive models only when confidence is low. Most requests resolve at the cheap tier; only hard queries pay frontier prices.
Should I use a classifier or semantic router?
Semantic routers are faster and cheaper (embedding only, no generation). Classifier routers handle ambiguous queries better. Production systems often combine both in a layered approach.
How do I know if my router is working?
Track per-route quality scores (human or LLM-judge), escalation rates, and cost per request. If escalation rate exceeds 25% or quality drops below thresholds, adjust your routing table.
Can LLM routing work with multiple providers?
Yes. Route across OpenAI, Anthropic, and self-hosted models. Example: classify with Haiku, execute simple tasks on GPT-4o-mini, escalate to Claude Sonnet for complex reasoning.
Does routing add latency?
Rule-based routing: ~0ms. Semantic routing: ~50ms (embedding). Classifier routing: ~200–400ms (extra LLM call). Cascade routing: Variable — cheap on easy queries, expensive on hard ones. Net latency often decreases because cheap models respond faster.
How does LLM routing relate to fine-tuning?
Fine-tuning a small model for a specific task often beats routing to a larger general model. Combine both: fine-tuned 8B for domain tasks, cascade to GPT-4o for edge cases. See teacher-student distillation for building custom small models.
Conclusion
LLM routing to the cheapest model that works is the highest-ROI optimization most teams haven't implemented. You don't need a worse product — you need a smarter dispatch layer.
The production playbook:
- Measure your traffic by task type and current model distribution
- Define quality thresholds per route
- Implement layered routing: rules → semantic → classifier → cascade
- Monitor per-route quality, cost, and escalation rates
- Optimize monthly with eval suites and A/B tests
Most teams can run 60–80% of traffic on nano-tier models without users noticing. Save frontier models for the 20% that actually needs them.
At HinterBuild:
- AI Agent Development — Multi-model agent architectures
- RAG & LLM Systems — Route-aware retrieval pipelines
- Backend API Engineering — Production router services
- Observability & Monitoring — Cost and quality tracking
Contact us to design your LLM routing architecture.
Free consultation
Book a free consultation call on LLM routing & multi-model architecture
30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.
Book a meeting
Keep reading
Related articles
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.
Read post
Triton vs vLLM: LLM Serving Framework Comparison for
Triton vs vLLM guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable production implementation.
Read post
LLM Tracing with OpenTelemetry: Complete Observability Guide
Learn llm tracing with opentelemetry through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
Synthetic Data Generation for LLM Evals
Synthetic Data Generation for LLM Evals guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable.
Read post
