HinterBuild logoHinterBuild
AI Systems · 9 min read

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

Muhammad Abdul Sami

· 9 min read

  • LLM
  • LLM Serving
  • Evaluation
  • Cost Optimization

Table of Contents:

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.

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

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

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

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

python
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

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

python
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 TypeCheap Success RateAvg CostQuality
Simple factual95%$0.002High
General chat80%$0.004Medium-high
Technical deep-dive40%$0.018High
Overall78%$0.006High

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

python
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

python
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 TrafficRouted ToAvg Cost
Simple factual45%gpt-4o-mini$0.002
General chat27%gpt-4o-mini$0.004
Code questions18%gpt-4o$0.018
Complex technical10%gpt-4o$0.032

For LLM cost optimization, routing is the second-highest ROI after caching.


Monitoring and Optimization

Key Metrics

python
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

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

  1. Start with keyword-based routing — validate concept in 1 day
  2. Build routing classifier — 2-4 weeks to collect data and train
  3. Add cascading fallback — safety net for quality
  4. Monitor cost and quality — tune thresholds weekly
  5. 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