Routing and Model Portfolios
Cascades, confidence signals and cheap-first strategies; abstracting over providers without abstracting away the differences that matter; and measuring whether routing actually saved anything.
Cascades, confidence signals and cheap-first strategies; abstracting over providers without abstracting away the differences that matter; and measuring whether routing actually saved anything.
The router shipped six weeks ago and by its own dashboard it works. Seventy-one percent of invoice extractions are answered by the cheap model. The escalation rate is stable. Nothing regressed on the eval set. The bill went down four percent.
Four percent. Someone is going to ask why, and every honest answer is arithmetic you could have done before writing a line of it. By the end of this lesson you can do that arithmetic: choose between the three routing strategies on their real merits, build an escalation signal out of something checkable rather than the model's opinion of itself, calibrate its threshold on held-out data, and predict what a cascade saves before you build one. You will also know which differences between providers an abstraction must refuse to hide, because the second model's path is code that only ever runs on a bad day.
Routing is running more than one model behind one feature and deciding, per request, which one answers. Static routing is the version where that decision is a constant per step, fixed at build time and visible in the diff.
Take the invoice pipeline this lesson uses throughout. An uploaded document goes through five steps: classify what kind of document it is, extract the header fields, extract the line items, normalise the supplier name against your vendor list, and write a one-line summary for the audit log. That is five separate model choices, not one. Four of those steps are narrow enough that a cheap, weaker model is indistinguishable on the eval set. Line-item extraction from a photographed, five-page delivery note is not one of them.
Static routing has properties the other strategies spend real money to approximate. There is no runtime decision, so there is no misroute. Cost per request is exactly predictable. Each step's choice is justified by that step's own eval set, and the evidence is a table you can re-run in CI. And it captures most of the available saving, because most multi-step features have exactly one step that genuinely needs the strong model and four that were sharing its bill out of habit.
What it cannot do is respond to variance inside a step. If line-item extraction is trivial on a clean digital PDF and brutal on a thermal receipt photographed at an angle, a constant is wrong in one direction or the other on most requests: you either overpay for the easy documents or under-serve the hard ones. That gap is the entire reason to leave this section.
Classifier-based routing puts a small, fast decider in front: it reads the input and predicts which model will be needed, before any answer exists.
Notice what it is predicting. Not a property of the document — difficulty, relative to a specific model, on a specific task. You have no direct label for that. To build the classifier at all you need a labelled routing set: a sample of real inputs, each tagged with whether the cheap model got it right, which you can only produce by running both models over that sample and scoring the results. Once you have built that set, you are holding everything a cascade needs too, which is worth remembering before you spend a week on the classifier.
Its errors are asymmetric, and only one of them is visible. Sending an easy document to the strong model costs money and shows up on the bill. Sending a hard one to the cheap model produces a wrong extraction that looks exactly like a right one, and nothing on your dashboard reacts unless you are already measuring quality split by route. The failure the router exists to prevent is the failure it hides.
It also costs something on every single request, including the ones it routes correctly: a call on the critical path, a third model to version and re-evaluate, and — if the classifier is itself a prompt — a component whose behaviour moves when it is upgraded.
Where classifier routing earns its keep is where difficulty is
predictable from features you already have. Page count, digital
versus scanned, language, document type, whether this supplier
has ever parsed cleanly before. If those features carry the
signal, use the features. A logistic regression over six
columns is a classifier, and so are three if statements on
metadata, and both cost microseconds and never hallucinate.
The best classifier router is usually not a model.
A cascade inverts the order. The cheap model answers first, you check the answer, and only when the check fails do you spend the strong model.
Decides from the input.
Cheap and quick, and it is guessing at difficulty. Its expensive mistake is visible on the bill; its dangerous one is a wrong answer that looks exactly like a right one.
Decides from an answer.
It judges a real output against a real check.
And it pays the cheap call on every request, including all the ones it was always going to escalate.
def extract_line_items(document):
cheap = call_model(CHEAP, EXTRACT_PROMPT, document)
reason = escalation_reason(cheap, document)
if reason is None:
return Extraction(cheap.items, route="cheap")
strong = call_model(STRONG, EXTRACT_PROMPT, document)
return Extraction(strong.items, route="strong",
escalated_for=reason)Two details there are worth copying. Every result carries a
route label, because every measurement in the rest of this
lesson is a split by that field and you cannot reconstruct it
afterwards. And escalation_reason returns a reason rather
than a boolean — the distribution of reasons is the first thing
you will want when the escalation rate moves, and a boolean
throws it away.
A cascade can have more than two rungs. It rarely should. Each rung adds a call paid for by everything that gets past it, and a three-rung cascade is usually a two-rung cascade plus a threshold nobody swept.
Everything above is plumbing. A cascade is exactly as good as
escalation_reason, and this is where they are usually lost.
The obvious move does not work. A model asked to rate its own confidence emits tokens, not a probability. The number moves with the phrasing of the request, clusters on round values, and is highest precisely where the model is most fluent — which is also where it is most convincingly wrong. An invented line item scores the same as a correct one, because the model has no privileged access to which of the two it just did.
Bad — escalates on a number the model made up, at a price you pay for real.
prompt = EXTRACT_PROMPT + (
"\nAlso return confidence, 0-100, that the extraction "
"is correct."
)
result = call_model(CHEAP, prompt, document)
if result.confidence < 90:
result = call_model(STRONG, EXTRACT_PROMPT, document)Good — escalates on something the document itself can settle.
result = call_model(CHEAP, EXTRACT_PROMPT, document)
line_total = sum(item.amount for item in result.items)
if (result.schema_errors
or abs(line_total - result.stated_total) > TOLERANCE):
result = call_model(STRONG, EXTRACT_PROMPT, document)The first version escalates a hallucinated total that came back at 95 exactly as often as a perfect extraction that came back at 88 — it is a roughly random escalation rate wearing a threshold's clothes, and you pay strong-model prices for the randomness. The second version fires when the numbers do not reconcile, which is not correlated with correctness; on the property it checks, it is correctness.
These are the signals that carry real information, roughly best first:
Token-level log-probabilities sit awkwardly in that list. For a field copied out of the document, like an invoice number, they carry real information about whether the model was hedging between candidate strings. They still measure certainty about the surface form rather than the truth of the claim, so they say nothing useful about a total that was never in the document at all. Use them as one input, never as the threshold.
Binary signals need no threshold. Continuous ones — verifier scores, retrieval scores, grounding coverage, length deviation — need one, and picking it by feel is how a cascade ends up escalating everything or nothing.
Run the cheap model over an eval set that has ground truth. For each item record two things: the signal value, and whether the cheap answer was actually correct. Then sweep, and read two numbers at every threshold — the escalation rate, which is what you pay, and the leaked error rate, the share of all items that were wrong and were not escalated, which is what a customer eventually pays.
def sweep(records, thresholds):
"""records: (signal, cheap_was_correct) per eval item."""
total = len(records)
for threshold in thresholds:
escalated = sum(1 for signal, _ in records
if signal < threshold)
leaked = sum(1 for signal, correct in records
if signal >= threshold and not correct)
print(f"{threshold:>5.2f} "
f"escalate={escalated / total:>4.0%} "
f"leaked={leaked / total:>5.1%}")On the line-item step, with a cheap verifier's score as the signal, that prints:
0.50 escalate= 11% leaked= 6.8%
0.60 escalate= 19% leaked= 4.1%
0.70 escalate= 28% leaked= 2.2%
0.80 escalate= 44% leaked= 1.4%
0.90 escalate= 71% leaked= 0.9%Read it as a price list. Moving from 0.70 to 0.80 buys you eight-tenths of a point of leaked error for sixteen points of escalation. Past 0.80 the curve flattens hard: you are sending most of your traffic to the strong model to chase errors the signal largely cannot see anyway. Where you stop is a business decision about what one wrong invoice costs, and the sweep's only job is to make it a decision instead of a default.
Two disciplines make the number defensible. Choose the threshold on one half of the labelled set and report it on the other half you never looked at, or you have fitted the threshold to your eval set and the figures you present are optimistic by exactly the amount you would most like to know. And treat the threshold as valid only for the specific pair of models and versions it was measured on — when either one moves, the sweep is stale and the escalation rate will drift with it.
Do this in units of one strong-model call, so no prices appear and the result survives every price change.
Let c be the cost of the cheap call as a fraction of a strong
call for your prompt. Let e be the escalation rate and v
the cost of the verifier, zero when the check is free. One
request through the cascade costs c + v + e; always using the
strong model costs 1. So the cascade pays only while
e < 1 - c - v.
def cascade_cost(cheap_ratio, escalation, verifier=0.0):
"""One request, in units of one strong-model call."""
return cheap_ratio + verifier + escalation
def break_even_escalation(cheap_ratio, verifier=0.0):
"""Escalation rate above which always-strong is cheaper."""
return 1.0 - cheap_ratio - verifierThree shapes, all of which occur:
c=0.05 v=0.00 e=0.30 -> 0.35 saves 65%
c=0.30 v=0.00 e=0.60 -> 0.90 saves 10%
c=0.30 v=0.30 e=0.60 -> 1.20 costs 20% moreThe third line is the case people do not expect: a cascade that is more expensive than never routing at all. It is not exotic. It needs only a "cheap" model that is three times cheaper rather than twenty, a genuinely hard task the cheap model fails more often than not, and a verifier that is itself a model call. Each of those is a decision someone made for a good local reason.
The largest trap is in c. It is not the published per-token
price ratio. Both models read the same document, input tokens
dominate a long-document feature, and the cheap model often
writes a longer answer that then needs a repair round. Measure
c from a real day of logged token counts on both paths, not
from a rate card. A team that assumes a fiftieth and measures a
fifth has already spent the entire margin they thought they had
before the escalation rate is even in the equation.
Latency moves in two directions at once, and only one of them is an improvement. The requests answered by the cheap model come back faster than they used to. The escalated ones now wait for the cheap call, then the check, then the strong call — strictly worse than before. Your median improves and your p95 degrades. If the feature has a deadline, the deadline has to hold on the escalated path, not on the average one; when it cannot, the cascade is unavailable to you at any price.
Log four things on every request: the model id and version, the route label, the escalation reason, and the tokens and wall time for each call separately. The route label is the one people forget, and without it nothing else can be split.
Then make the dashboard prove three claims, because none of them is true by default.
The saving is against the counterfactual, not against last month. Bills move for a dozen reasons at once and traffic mix is one of them. Take one or two percent of real requests, run them through the strong model as well as through the router, and compare cost and quality on those identical inputs. That is the only comparison that isolates what routing did.
A saving on one step is capped by that step's share of spend. This is the answer to the four percent in the opening. The cascade did roughly what the arithmetic predicted — it halved the cost of line-item extraction. Line-item extraction was eight percent of the bill. Halving eight percent is four percent, and no threshold sweep anywhere changes that. Find the dominant term first, route that, or route nothing. A cache in front of the whole step often beats routing outright, which is why caching has its own lesson in this course.
Quality, split by route, or the saving is unpriced. Break every quality metric you have by route label, and look specifically at the cheap route's score on the slices your signal cannot check. If it is materially worse, you did not reduce cost — you sold quality at a price nobody wrote down. What that price actually is, and how it interacts with margin, is the subject of The Economics of an AI Product.
A portfolio means at least one interface sitting over more than one model, and frequently over more than one vendor. Some of that surface is safely portable and some of it is a trap, and the trap is that the unportable parts fail quietly.
Portable enough to hide: a conversation as roles and turns, text in and text out, a temperature-like sampling knob, a maximum output length, streaming, and a usage report. Write an interface over that much and it will hold.
Not portable, in rough order of how much damage each one does:
Bad — one prompt, one tokeniser and one parser, for models that share none of the three.
class ModelClient(Protocol):
def complete(self, prompt: str) -> str: ...
def extract(document: str, client: ModelClient) -> Invoice:
document = trim_to_tokens(document, MAX_INPUT_TOKENS)
raw = client.complete(EXTRACT_PROMPT + document)
return Invoice.model_validate_json(raw)Good — each model owns its prompt, its tokeniser and its parsing; the caller owns routing.
class Adapter(Protocol):
name: str
max_input_tokens: int # in ITS tokeniser
def count_tokens(self, text: str) -> int: ...
def extract(self, document: str) -> Invoice: ...
def extract(document: str, adapter: Adapter) -> Invoice:
document = trim_for(document, adapter) # its tokeniser
return adapter.extract(document) # its parsingThe first version is perfect for exactly as long as one model sits behind it. The day the router sends a document to the other one, the trim is wrong by a fifth and the parser meets a code fence — and by construction that is a day on which something had already gone wrong.
The escalation path and the second provider share the property that makes them the least reliable code you own: they run only when something else has already failed. On every good day they are dead code that compiles.
Four things exercise them for real.
Run the eval set against every model in the portfolio. Same inputs, same assertions, same schedule as the primary — not "does the fallback return a string", but does it do the task. This is the check that catches the missing constrained output and the prompt that relied on system-level adherence.
Make the route injectable. The router takes the adapter as a parameter so a test can force each branch and assert on the answer rather than on a mock.
Send it real traffic on purpose. Route a small fixed slice — one percent, selected by a stable hash so the same user does not wander between models mid-session — down the secondary continuously. It is the only way the path meets inputs nobody imagined, and it costs one percent of the price difference.
Turn the primary off deliberately, in staging, with production-shaped inputs. An hour of that finds the timeout nobody set and the token budget computed with the wrong tokeniser, on a day when finding it is interesting rather than expensive.
Bad — proves the exception handler is reachable.
def test_falls_back(monkeypatch):
monkeypatch.setattr(primary, "extract", raise_unavailable)
invoice = extract_invoice(SAMPLE_INVOICE)
assert invoice is not NoneGood — proves every model in the portfolio can do the job.
@pytest.mark.parametrize("adapter", ALL_ADAPTERS,
ids=lambda a: a.name)
def test_extraction_matches_golden_set(adapter):
scores = [
field_accuracy(adapter.extract(case.document),
case.expected)
for case in GOLDEN_SET
]
assert mean(scores) >= 0.92assert invoice is not None passes against a fallback that
returns an empty invoice for every document ever uploaded. That
test has been green since the day it was written, and it will
still be green all the way through the outage it exists for.
PICK A STRATEGY
static constant per step; no misroutes; start here
classifier predicts difficulty before an answer exists
cascade checks a real answer; always pays the cheap call
best classifier usually metadata and three ifs, not a model
ESCALATION SIGNALS, BEST FIRST
domain invariant totals reconcile, dates in range free, exact
schema error you already compute it free, exact
grounding cover every value appears in the source free
retrieval score top score, and top-to-second gap free
refusal / empty shape differs per provider; detect per model
length outlier needs a measured per-model baseline
cheap verifier one narrow property, not a redo costs a call
two-sample split detects instability, not shared bias 2x cheap
logprobs copied fields only; never the threshold
stated confidence not a probability; do not threshold on it
CALIBRATE
label (signal, was the cheap answer right) per item
sweep read escalation rate AND leaked error rate
split choose on one half, report on the other
re-run whenever either model or version changes
ARITHMETIC (units of one strong call)
cascade c + v + e c=cheap share, v=verifier, e=esc
always strong 1.00
break-even e < 1 - c - v
c is not the rate card; measure it from real token logs
latency median improves, p95 degrades
deadline must hold on the escalated path, not the mean
ceiling saving <= the routed step's share of spend
PORTABLE ACROSS PROVIDERS
yes roles and turns, text, temperature, max output,
streaming, a usage report
no tokenisation, tool-call dialect, system-prompt
priority, refusal shape, structured-output
guarantee
so adapters own prompt, tokeniser and parsing
TEST THE BAD DAY
eval set run it against every model, same assertions
injectable route force each branch; assert answers, not mocks
live slice 1% by stable hash, continuously
game day primary off in staging, real-shaped inputsYou can now name which strategy you are running and why, build an escalation signal out of something checkable, defend its threshold with a sweep on data you held back, and predict the saving before you build the machinery. You also know that the answer is sometimes "keep using the strong model everywhere", which is a real result and a cheap one to reach.
Next is Drift, Regression, and Model Upgrades. Everything
in this lesson is calibrated against one specific pair of
models: the threshold, the escalation rate, the measured value
of c, the prompt variant each adapter owns. That lesson
answers what happens when one of them moves underneath you —
how to pin a version, how to move off it deliberately, and how
shadow traffic turns an upgrade from a leap into a measurement.
The thing to do this week is the sweep. Pick one step of one feature, run the cheap model over your eval set, and record for each item whether it was right and what a free invariant said about it. Most people find one of two things: a signal that separates cleanly and a saving they can bank, or a break-even escalation rate their real traffic already exceeds. Both are worth an afternoon, and the second one is worth a quarter.