Choosing Models per Route

One App, Several Models

Different parts of an app need different models. A router picks the model per task, and a fallback chain tries another model when one is overloaded.

RouteModelWhy
intent classification, PII checkclaude-haiku-5-5, effort lowtiny outputs, must be fast and cheap
support answers with RAGclaude-sonnet-5-5good quality at medium cost
complex agent tasks, long product copy, judge for evalsclaude-opus-5-5best reasoning and writing

Why Route

Models sit on a quality-cost-latency trade-off curve: larger models are more capable on hard tasks but slower and pricier. Most apps have a mix of tasks, and many are easy. Sending everything to the largest model is like sending every parcel by air: it works, but you pay air rates for things that could go by road. Model routing means choosing the smallest model that meets the quality bar for each route, as measured by evals.

There are two common ways to decide. Static routing fixes the model per task in configuration (intent always goes to Haiku), which is simple and predictable. Dynamic routing decides per request, for example a classifier sends hard questions to a bigger model, or the app tries a small model and escalates if a check fails. Start static; add dynamic routing only where evals show a clear gain.

A fallback chain is about availability, not quality: if the first model returns 529 (overloaded) after retries, try the next one so the user still gets an answer. Choose fallbacks you have already evaluated on that route.

Example

Example

A router with a fallback chain. fake_call is a stand-in that simulates Sonnet being overloaded.

ROUTES = {"intent": ["claude-haiku-5-5"],
          "support_answer": ["claude-sonnet-5-5", "claude-opus-5-5"],
          "agent": ["claude-opus-5-5", "claude-sonnet-5-5"]}
OVERLOADED = {"claude-sonnet-5-5"}

class Overloaded(Exception): pass

def fake_call(model, prompt):  # stand-in for the API call
    if model in OVERLOADED:
        raise Overloaded(f"529 from {model}")
    return f"[{model}] answer to: {prompt}"

def complete(route, prompt):
    errors = []
    for model in ROUTES[route]:
        try:
            return fake_call(model, prompt), errors
        except Overloaded as e:
            errors.append(str(e))
    raise RuntimeError(f"all models failed: {errors}")

for route, prompt in [("intent", "refund kab milega"), ("support_answer", "return window for shoes?")]:
    answer, errors = complete(route, prompt)
    print(f"{route:15} -> {answer}" + (f"   (fallback after {errors})" if errors else ""))

Output

intent          -> [claude-haiku-5-5] answer to: refund kab milega
support_answer  -> [claude-opus-5-5] answer to: return window for shoes?   (fallback after ['529 from claude-sonnet-5-5'])

Note

The intent route goes straight to Haiku. The support route tries Sonnet, gets a simulated 529, and falls back to Opus, and the result records which error caused the fallback so you can monitor how often it happens.

Note: Switching models changes behaviour. Run your eval set on every model in a fallback chain, and remember that caches are per model.

  • Route each task to the smallest model that passes its evals.
  • Static routing first; dynamic routing or escalation where it pays.
  • Fallback chains keep the app available during overloads.
  • Evaluate every model in the chain; prompt caches do not carry across models.