HinterBuild logoHinterBuild
AI Systems · 9 min read

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, author

Muhammad Abdul Sami

· Updated · 9 min read

  • LLM
  • LLM Serving
  • Evaluation
  • Cost Optimization

Table of Contents:

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:

python
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:

ModelInput costOutput costMonthly 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 trafficMinimum viable model
Classification / routing25%GPT-4o-mini, Haiku, Llama 8B
Simple Q&A with RAG35%GPT-4o-mini, Gemini Flash
Structured extraction15%Fine-tuned 8B or 4o-mini
Multi-step reasoning10%GPT-4o, Sonnet
Code generation / review10%GPT-4o, Opus, Codex
Creative / long-form5%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)

TierExamplesInput $/1M tokensBest for
NanoGPT-4o-mini, Haiku 3.5, Gemini Flash$0.10–$0.40Classification, extraction, simple Q&A
MidGPT-4o, Sonnet 3.5, Gemini Pro$1.25–$3.00RAG, tool calling, moderate reasoning
FrontierOpus, o1, GPT-4.5$5.00–$15.00+Complex reasoning, code, analysis
Self-hostedLlama 3.1 8B/70B, MistralGPU cost onlyHigh-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:

python
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

python
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

ProsCons
Simple to implementClassifier errors propagate
One cheap call adds ~200msTwo API calls for every request
Easy to update route mapClassifier may underestimate complexity
Works with any providerNeeds 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

python
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:

StrategyAvg cost/requestQuality (human-rated)P99 latency
Always GPT-4o$0.00824.3/52.1s
Always GPT-4o-mini$0.00093.6/50.8s
Cascade (mini → 4o)$0.00214.1/51.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:

python
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

python
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

python
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:

python
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

python
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:

python
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

MetricWhy it matters
route.distributionAre 80% going to mini, or is classifier broken?
route.escalation_rateCascade escalation >25% means cheap model isn't good enough
route.cost_per_requestTrending up? New query types bypassing router
route.quality_by_modelPer-route human/LLM-judge scores
route.latency_by_pathSemantic should be <100ms; cascade adds multi-call latency
route.classifier_accuracyMisroutes waste money or lose quality

Implement with observability and monitoring:

python
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:

  1. Pull route distribution — Identify over-represented expensive routes
  2. Run eval suite per route — Can a cheaper model pass thresholds?
  3. A/B test route changes — 5% traffic to new routing table
  4. Update route map — Promote cheaper models where evals pass
  5. Check for regressions — User complaints, support ticket quality

Caching Layer

Route + cache for maximum savings:

python
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:

  1. Measure your traffic by task type and current model distribution
  2. Define quality thresholds per route
  3. Implement layered routing: rules → semantic → classifier → cascade
  4. Monitor per-route quality, cost, and escalation rates
  5. 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:

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