Model Routing › Code Implementation

Michael BrenndoerferFebruary 10, 202613 min read
Language AI HandbookAll chapters
Part 65 of 67Production Systems
Chapter 495Model Routing › Code Implementation
Reading section 7 of 11 · Code Implementation · 13 minSection 7 of 11 · 13 min

Book contents

Section 7 of 11 · 13 min read

Model Routing › Code ImplementationOne section · Sections in this chapter7 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement the core components of a model routing system: a difficulty-based router, a confidence-threshold cascade, and an A/B test evaluation framework.

We start by installing required packages and setting up the simulation environment.

Simulating Requests and Models

We simulate a production environment with requests of varying difficulty and two model tiers with different quality and cost profiles.

In[5]:
Code
@dataclass
class Request:
    """A single inference request with a ground-truth difficulty score."""

    text: str
    difficulty: float  # 0.0 (easy) to 1.0 (hard)
    tokens: int


@dataclass
class ModelResponse:
    """Response from a model tier."""

    output: str
    quality: float  # 0.0 to 1.0
    confidence: float  # model's self-reported confidence
    cost: float  # cost in arbitrary units
    latency_ms: float


def generate_requests(n: int) -> list[Request]:
    """Generate requests with a realistic difficulty distribution.

    Most requests are easy (right-skewed), with a long tail of hard ones.
    """
    difficulties = np.random.beta(a=1.5, b=4.0, size=n)  # skewed toward easy
    token_counts = (20 + difficulties * 200 + np.random.randn(n) * 20).astype(
        int
    )
    token_counts = np.clip(token_counts, 10, 500)
    requests = [
        Request(text=f"request_{i}", difficulty=float(d), tokens=int(t))
        for i, (d, t) in enumerate(zip(difficulties, token_counts))
    ]
    return requests


def small_model_response(req: Request) -> ModelResponse:
    """Simulate a small (cheap) model. Struggles on hard requests."""
    # Quality degrades on harder requests; small model is noisy
    base_quality = 0.9 - 0.7 * req.difficulty
    quality = float(np.clip(base_quality + np.random.randn() * 0.1, 0, 1))
    # Confidence is correlated with actual quality, but noisy
    confidence = float(np.clip(quality + np.random.randn() * 0.15, 0, 1))
    return ModelResponse(
        output="small_answer",
        quality=quality,
        confidence=confidence,
        cost=0.001 * req.tokens,
        latency_ms=10 + req.tokens * 0.2,
    )


def large_model_response(req: Request) -> ModelResponse:
    """Simulate a large (expensive) model. High quality across all difficulties."""
    base_quality = 0.97 - 0.15 * req.difficulty
    quality = float(np.clip(base_quality + np.random.randn() * 0.05, 0, 1))
    confidence = float(np.clip(quality + np.random.randn() * 0.05, 0, 1))
    return ModelResponse(
        output="large_answer",
        quality=quality,
        confidence=confidence,
        cost=0.01 * req.tokens,
        latency_ms=100 + req.tokens * 2.0,
    )
Out[6]:
Console
Generated 1000 requests
Difficulty: mean=0.272, std=0.170
Tokens: mean=74.0

The simulated requests follow a realistic distribution: most are relatively easy (mean difficulty around 0.27), with a long tail of harder requests. The small model's quality degrades as difficulty increases, while the large model maintains high quality throughout. This asymmetry is the key structural fact that makes routing useful.

Rule-Based Router

Our first router uses a simple difficulty threshold based on request length as a proxy.

In[7]:
Code
def rule_based_router(req: Request, token_threshold: int = 100) -> str:
    """Route based on token count as a proxy for difficulty."""
    if req.tokens <= token_threshold:
        return "small"
    return "large"


def evaluate_router(
    requests: list[Request],
    router: Callable[[Request], str],
    label: str,
) -> dict:
    """Evaluate a routing strategy and collect metrics."""
    results = []
    for req in requests:
        decision = router(req)
        if decision == "small":
            resp = small_model_response(req)
        else:
            resp = large_model_response(req)
        results.append(
            {
                "decision": decision,
                "quality": resp.quality,
                "cost": resp.cost,
                "latency_ms": resp.latency_ms,
            }
        )

    qualities = [r["quality"] for r in results]
    costs = [r["cost"] for r in results]
    latencies = [r["latency_ms"] for r in results]
    small_frac = sum(1 for r in results if r["decision"] == "small") / len(
        results
    )

    return {
        "label": label,
        "mean_quality": float(np.mean(qualities)),
        "mean_cost": float(np.mean(costs)),
        "mean_latency_ms": float(np.mean(latencies)),
        "small_model_fraction": float(small_frac),
        "total_cost": float(np.sum(costs)),
    }
Out[8]:
Console

Always Large:
  Mean quality:   0.9237
  Mean cost:      0.74011
  Mean latency:   248.0 ms
  Small model %:  0.0%
  Total cost:     740.11

Rule-Based (token):
  Mean quality:   0.8255
  Mean cost:      0.47525
  Mean latency:   140.6 ms
  Small model %:  60.5%
  Total cost:     475.25

The rule-based router routes a substantial fraction of requests to the small model, reducing total cost significantly. The quality drop is small because most short requests are easy. This illustrates the core value proposition: you pay less without sacrificing much quality. The token threshold of 80 was chosen somewhat arbitrarily; in a real system, you would calibrate this by sweeping thresholds and evaluating quality on a labeled validation set.

Cascade Router

Next, we implement a cascade that uses confidence thresholds to decide whether to escalate.

In[9]:
Code
def cascade_router(req: Request, confidence_threshold: float = 0.75) -> dict:
    """Two-tier cascade: try small model first, escalate if confidence is low."""
    small_resp = small_model_response(req)
    if small_resp.confidence >= confidence_threshold:
        return {
            "decision": "small",
            "quality": small_resp.quality,
            "cost": small_resp.cost,
            "latency_ms": small_resp.latency_ms,
            "escalated": False,
        }
    # Escalate to large model
    large_resp = large_model_response(req)
    return {
        "decision": "large",
        "quality": large_resp.quality,
        "cost": small_resp.cost + large_resp.cost,  # paid for both
        "latency_ms": small_resp.latency_ms + large_resp.latency_ms,
        "escalated": True,
    }


def evaluate_cascade(
    requests: list[Request],
    confidence_threshold: float,
    label: str,
) -> dict:
    results = [cascade_router(req, confidence_threshold) for req in requests]
    qualities = [r["quality"] for r in results]
    costs = [r["cost"] for r in results]
    latencies = [r["latency_ms"] for r in results]
    escalation_rate = float(np.mean([r["escalated"] for r in results]))

    return {
        "label": label,
        "mean_quality": float(np.mean(qualities)),
        "mean_cost": float(np.mean(costs)),
        "mean_latency_ms": float(np.mean(latencies)),
        "escalation_rate": escalation_rate,
        "total_cost": float(np.sum(costs)),
    }
Out[10]:
Console
tau=0.50: quality=0.7674, cost=0.24381, escalation=16.0%
tau=0.65: quality=0.8295, cost=0.41921, escalation=36.8%
tau=0.75: quality=0.8734, cost=0.56904, escalation=56.0%
tau=0.85: quality=0.9009, cost=0.67690, escalation=74.4%
tau=0.95: quality=0.9148, cost=0.74571, escalation=86.5%

The cascade results show a clear tradeoff: higher confidence thresholds (requiring more certainty before returning the small model's answer) increase escalation rates and costs, but also improve quality. The right threshold depends on your quality requirements and cost budget. Notice that even at the highest threshold (τ=0.95\tau = 0.95), the cascade still costs less than always routing to the large model, because it correctly handles the clearly easy requests at the small model.

A/B Testing Framework

Now let's build a simple A/B testing framework to evaluate routing strategies with statistical rigor.

In[11]:
Code
from scipy import stats


def run_ab_test(
    requests: list[Request],
    router_a: Callable[[Request], str],
    router_b: Callable[[Request], str],
    split_ratio: float = 0.5,
    seed: int = 0,
) -> dict:
    """Run an A/B test comparing two routing strategies."""
    rng = np.random.default_rng(seed)
    # Randomly assign each request to arm A or arm B
    assignments = rng.random(len(requests)) < split_ratio  # True = arm A

    arm_a_results = []
    arm_b_results = []

    for req, in_a in zip(requests, assignments):
        if in_a:
            decision = router_a(req)
            resp = (
                small_model_response(req)
                if decision == "small"
                else large_model_response(req)
            )
            arm_a_results.append(
                {
                    "quality": resp.quality,
                    "cost": resp.cost,
                    "latency_ms": resp.latency_ms,
                }
            )
        else:
            decision = router_b(req)
            resp = (
                small_model_response(req)
                if decision == "small"
                else large_model_response(req)
            )
            arm_b_results.append(
                {
                    "quality": resp.quality,
                    "cost": resp.cost,
                    "latency_ms": resp.latency_ms,
                }
            )

    a_quality = [r["quality"] for r in arm_a_results]
    b_quality = [r["quality"] for r in arm_b_results]

    # Two-sample t-test for quality difference
    t_stat, p_value = stats.ttest_ind(a_quality, b_quality)
    quality_lift = float(np.mean(b_quality) - np.mean(a_quality))

    return {
        "n_a": len(arm_a_results),
        "n_b": len(arm_b_results),
        "quality_a": float(np.mean(a_quality)),
        "quality_b": float(np.mean(b_quality)),
        "quality_lift": quality_lift,
        "t_statistic": float(t_stat),
        "p_value": float(p_value),
        "significant_at_05": bool(p_value < 0.05),
        "cost_a": float(np.mean([r["cost"] for r in arm_a_results])),
        "cost_b": float(np.mean([r["cost"] for r in arm_b_results])),
        "latency_a": float(np.mean([r["latency_ms"] for r in arm_a_results])),
        "latency_b": float(np.mean([r["latency_ms"] for r in arm_b_results])),
    }
Out[12]:
Console
A/B Test: Always Large (Control) vs Cascade tau=0.75 (Treatment)
Sample sizes: n_A=473, n_B=527

Quality:   A=0.9248, B=0.8497, lift=-0.0751
Cost:      A=0.72780, B=0.52633
Latency:   A=245.6ms, B=166.7ms

t-statistic: 12.865
p-value:     0.0000
Significant at alpha=0.05: True

The A/B test quantifies exactly what we gain and lose by switching from always routing to the large model to using a cascade. A small quality drop at lower cost, with statistical significance determined by the p-value. Reading these results correctly requires looking at all metrics together: a statistically significant quality drop that is also economically meaningful requires a different decision than a statistically significant but practically negligible drop.

Visualizing the Cost-Quality Tradeoff

Out[13]:
Visualization
Scatter plot of mean cost per request on x-axis versus mean quality score on y-axis for four routing strategies.
Cost-quality tradeoff curves for different routing strategies. Each point represents a routing configuration, with cascade routing operating at different confidence thresholds (solid circles) and rule-based routing at different token thresholds (dashed squares). The always-large baseline (upward triangle) provides the highest baseline quality, while the always-small baseline (downward triangle) provides the lowest cost. At very high cascade thresholds, cost can exceed always-large because an escalated request pays for both model calls. Across most of the range, cascade routing reaches comparable quality at lower cost than rule-based routing.
Out[14]:
Visualization
Line plot of escalation rate on x-axis versus mean quality on y-axis for cascade routing.
Escalation rate versus mean quality for cascade routing across confidence thresholds from 0.2 to 0.99. Each threshold is evaluated against one fixed set of simulated small- and large-model outcomes, so movement along the curve reflects routing decisions rather than resampling noise. Quality improves as low-confidence requests escalate, with the marginal gain tapering as the cascade approaches always-large behavior.

The escalation-quality curve captures an important practical insight: the marginal benefit of escalation changes across the operating range. Low-confidence requests tend to provide the largest early gains, while further escalation gradually approaches the always-large quality ceiling and continues to add cost. This means the optimal operating point is rarely "escalate everything uncertain" but rather "escalate until marginal quality gain equals marginal cost increase."

Out[15]:
Visualization
Histogram of request difficulties showing a right-skewed distribution peaking near 0.2.
Distribution of request difficulties in the simulated dataset. The right-skewed beta distribution reflects realistic production traffic, where the majority of requests are straightforward and a long tail are genuinely hard.
Line chart comparing small and large model mean quality across ten difficulty bins from 0 to 1.
Mean quality by difficulty bin for the small model (circles) and large model (squares). The gap is smallest for the easiest requests and widens steadily with difficulty, revealing where cascade escalation provides the most value.

The quality comparison by difficulty bin reveals the core insight behind cascade routing: the models are closest on the easiest requests, while the small model's quality drops much faster as difficulty increases. This means the marginal value of escalation is concentrated in the high-difficulty tail. A good cascade exploits this by escalating only when it detects difficulty signals that predict poor small-model quality. Requests in the easy region can safely stay at the small model; the routing overhead on those requests is the price you pay for savings on the difficult ones.

Key Parameters

The key parameters for a cascade routing system are:

  • confidence_threshold: The minimum confidence the small model must report to return its answer without escalation. Lower values route more to the small model (cheaper but lower quality); higher values escalate more frequently (better quality but higher cost).
  • cost_ratio: The ratio of large model cost to small model cost per token. Determines how aggressively to optimize for the small model.
  • escalation_cap: An optional maximum escalation rate, useful when you have a hard budget constraint. If your budget allows at most 20% of requests to reach the large model, set this cap explicitly rather than hoping the threshold happens to produce 20% escalation.
  • routing_overhead_ms: The latency added by the routing decision itself. Must be small relative to the small model's response time to avoid negating the latency benefits of routing easy requests to the fast model.