Technical report · AVZ-TR-2026-01

Learning to Route: A Shadow-First Contextual Bandit for Per-Request LLM Model Selection

Avriz Engineering · avriz.io · July 2026 · control-plane/rl_router.py
Abstract Serving every request with a frontier language model wastes an order of magnitude of cost on work a small model handles; serving every request with a small model fails the work that matters. Production LLM platforms therefore route: they pick a model per request. We describe the routing system deployed on Avriz, a managed development-box platform, in three layers: (i) a hand-built LLM judge that classifies each turn's difficulty onto a five-rung model ladder, with a mid-turn escalation valve; (ii) a shadow-mode contextual bandit that learns per-rung reward models online from the platform's existing billing ledger, without storing chat content and without affecting user traffic; and (iii) a flag-gated ε-greedy serving path that lets the learned policy earn real traffic incrementally, bounded by a rung ceiling and measured by an on-policy A/B comparison. The design premise throughout is that observation precedes influence: the learner trains off-policy from decisions the judge already makes, activates only past an explicit data bar, and every step toward autonomy is one configuration flag from rollback. We give the full reward construction, the per-arm estimator and its update rule, the activation and serving math, an economics-fingerprint mechanism that resets a rung's learned weights when the model or price behind it changes, and the honest limitations of direct-method off-policy evaluation at our scale.

1Introduction

Avriz gives each subscriber an isolated cloud machine with a coding agent on it. Every conversational turn the agent takes is ultimately a call to some large language model, and the platform's managed tier lets it choose which. The price spread across the available ladder is large — wholesale output-token cost spans 12× from the cheapest rung to the most expensive (Fig. 2) — while a majority of real turns ("rename this", "what does this function do?") are handled perfectly by the cheapest rung. Routing is thus a direct lever on both margin and user experience: under-routing a hard request produces a bad answer; over-routing an easy one burns money.

The first version of a router is always a heuristic, and heuristics rot: thresholds hand-tuned in June are silently wrong by September as models, prices and usage change. The interesting question is not "can we write a good routing rule" but "can the platform keep the rule good, from its own traffic, without ever risking that traffic". This report documents our answer, which is deliberately conservative: a learning system that spends its whole early life in shadow.

Three properties drove the design:

Zero-risk learning. The learner must not change a single user-visible decision until a human flips a flag — and even then only for a bounded slice of traffic under a rung ceiling.

Zero new data. Training must use only what the platform already records for billing (token counts, realized cost, status codes) plus derived, content-free features. No chat text is stored by, or recoverable from, the routing system.

Legibility. Every stage — features, prior, reward, estimator, activation — is inspectable in an admin report, and the model itself is a set of eleven-dimensional linear weights a human can read.

2Background and related work

Selecting one action per context and observing a reward only for the chosen action is the contextual bandit problem, the one-step special case of reinforcement learning [8]. The linear-payoff formulation and its optimistic variant LinUCB were established for news recommendation by Li et al. [3]; posterior-sampling alternatives descend from Thompson [1] (see [7] for a modern tutorial), and confidence-based exploration from UCB [2]. Evaluating a new policy from logs generated by an old one is the off-policy problem: the direct method (fit a reward model, evaluate the candidate against it), inverse-propensity weighting, and their doubly-robust combination are analyzed by Dudík et al. [4]; Bottou et al. [5] treat the same machinery as counterfactual reasoning about a production system, which is exactly our setting. Practical system design for production bandits — logging, delayed rewards, gating — is discussed in Agarwal et al. [6] and evaluated at scale in [9]. Cost-aware routing across an LLM cascade was introduced as FrugalGPT [10]; learned routing between model pairs from preference data appears in RouteLLM [11]. Our contribution is not algorithmic novelty — the estimator is deliberately the simplest thing that can learn — but an end-to-end production recipe: reward built from a billing ledger, off-policy training from a heuristic logging policy, an explicit activation bar, and a ceiling-bounded ε-greedy graduation path, all inside a system where routing failures self-heal.

3The live router: judge, ladder, escalation

The unit of decision is a turn: one human message and the burst of agent LLM calls it triggers. The platform's model ladder is ordered easy→hard:

(1)𝒜  =  ⟨ gemini, fugu, sonnet, opus, fugu-ultra ⟩,   |𝒜| = 5

On each fresh turn, a judge — itself the cheapest capable model, prompted to answer with a single word — maps the message to a difficulty tier, and the tier to a rung:

(2)g : text → {trivial, simple, moderate, hard, expert} → a0 ∈ 𝒜

The judge's failure mode is legible from its own construction: it sees only the message text. A terse "fix the login bug" grades trivial and pins a fifty-step debugging session to the weakest model. Two mechanisms bound the damage. First, follow-up calls within a turn reuse the turn's decision (a 10-minute memo), so the judge fires once per human message, not per call. Second, an escalation valve: when a turn's call count crosses fixed checkpoints, the rung is bumped one step —

(3)c ∈ {12, 48} :  a ← min(a+1, max 𝒜)

Escalations are the router confessing its own mistake in-flight, which makes them a precious training signal (§5). Users who pin a model bypass all of this; bring-your-own-key boxes never enter the managed path at all.

turn arrives human message judge g(·) 1-word grade → a₀ serve rung a escalate at 12, 48 ledger llm_calls: cost, status reward R backfilled, eq. (6) bandit: 5 × wₐ SGD-ridge, per-arm, eq. (8) train (off-policy) ε-greedy serve (flag, §6) shadow ledger candidate pick + φ(x), no text
Figure 1 — The learning loop. Solid arrows run in production today: every graded turn flows judge → serve → ledger → reward → bandit training. The dashed arrow is the flag-gated serving path (§6): a configured fraction of turns bypasses the judge for the trained policy. The shadow ledger stores the candidate's pick and a content-free feature vector — never chat text.
wholesale $ / 1M output tokens (log scale) $1$3.2$10$32 gemini$2.50 sonnet$15 fugu$20 opus$25 f-ultra$30 ↕ 12× spread
Figure 2 — Why routing pays. Wholesale output-token cost per ladder rung (log scale; bars sorted by price). A mis-route to the top rung costs 12× the bottom one; the judge's job is to spend that multiplier only when the turn earns it. Input-token rates show the same ordering.

4Problem formulation

Routing is a contextual bandit. At turn t the platform observes a context xt (derived from the message and light tenant state), chooses an arm at ∈ 𝒜, and receives a scalar reward Rt observable only for the chosen arm — the counterfactual "what would opus have done with this turn" is never seen. The goal is a policy π maximizing 𝔼[R].

4.1 Context: an eleven-dimensional, content-free feature map

The feature extractor is pure and cheap — no model call, no stored text. From the last user message m and tenant context it computes counts and flags, then scales them to roughly unit range with capped ratios (order is frozen; new features append):

(4)φ(m, ctx) = [ 1, n_words/60, n_chars/400, code_fence, traceback, n_paths/3, hard_verbs/3, easy_verbs/2, question, prior_rung/4, esc_rate ] ∈ ℝ11

The signals are chosen to see exactly what the text-only judge cannot: pasted code fences, traceback fingerprints, referenced file paths, hard verbs ("refactor", "migrate", "deadlock") versus easy verbs ("rename", "typo"), the rung the conversation was already on, and the tenant's recent escalation rate — a running confession of how often this tenant's turns get under-routed.

4.2 The prior: a transparent difficulty scorer

Before any learning, a hand-set linear scorer maps features to a difficulty in [0,4], aligned to ladder indices — long asks trend harder, code and tracebacks mean real work, easy verbs pull down, a conversation may step down at most one rung between turns:

(5)s(x) = clip( u·x, 0, 4 ),    aprior = 𝒜[ round(s(x)) ]

This scorer is the candidate policy's prior and cold-start: until the bandit clears its data bar (§5.3), the shadow decision is the prior, so early shadow behaviour is deterministic and auditable.

4.3 Reward from the billing ledger

No new telemetry is collected. A turn's reward is assembled after the fact from llm_calls rows the platform already writes for metering — attributed to the decision by a 600-second turn window, bounded by the tenant's next decision, and scored only after a 120-second settling delay:

(6)R = 1·𝟙[success] − 0.5·cost¢ − 0.4·𝟙[escalated] − 1.0·𝟙[hard fail]

Success means at least one usable served completion. Cost enters in cents so its magnitude is commensurate with the indicator terms on typical turns. Soft, self-healing routing events (provider exhaustion, empty completions, auth rolls) are excluded from failure — they are the platform routing around trouble, not the decision being wrong. A mid-turn escalation, by contrast, is charged to the decision: it is a non-counterfactual, in-production measurement of the judge's own mistake, and it is the single most valuable bit in the dataset.

0 +1.0 · success −0.5 · cost (per ¢) −0.4 · escalated −1.0 · hard fail
Figure 3 — Reward decomposition, eq. (6). One positive outcome term (green) and three penalties (amber), drawn at their weights. Cost is continuous (its bar shows the weight per cent of realized spend); the others are indicators. Weights are caller-overridable in the report so the balance can be re-tuned when data argues for it.

5The learner: per-arm linear models, trained online off-policy

5.1 Estimator

Each rung a owns a linear model of estimated reward:

(7)ra(x) = wa · x,    wa ∈ ℝ11

This is the regression / direct method contextual bandit — LinUCB [3] without the per-arm confidence matrix. The simplification is deliberate: at our data scale a full covariance estimate is noise, while eleven readable weights per arm are something an operator can audit in the admin panel.

5.2 Update rule

Weights train online as rewards land in the backfill pass, one SGD step of ridge regression per scored turn, on the arm the live judge actually served:

(8)wawa − η [ (wa·xR) x + λ wa ],    η = 0.05, λ = 10−3

i.e. per-sample gradient descent on ½(w·xR)² + ½λ‖w‖². Training is off-policy by construction: arm a's model learns only from turns that genuinely landed on a under the judge. There is no importance weighting — the logging policy is a deterministic heuristic with no propensities to invert — which is precisely why the system graduates to measured on-policy comparison (§6) rather than trusting counterfactual estimates.

5.3 Activation: the data bar

The learned policy is inert until it has coverage. With na the training count on arm a:

(9)active  ⇔  Σa na ≥ 300  ∧  |{a : na ≥ 30}| ≥ 2

and its decision considers only well-sampled arms, with an optional optimism bonus held at zero in shadow:

(10)π*(x) = argmaxa : na ≥ 30 [ ra(x) + c/√(na+1) ],    c = 0

Below the bar, the candidate is exactly the prior (5); above it, the bandit overrides. The admin report states which regime is live — warming up (heuristic prior) vs bandit driving — with per-arm counts, so there is never ambiguity about what produced a shadow pick.

5.4 Forgetting: resetting an arm when its model changes

An arm's weights encode "what reward this rung earns on requests like x" under a specific underlying model at a specific price. Two events break that premise, and they break it differently. A price change (same model, cheaper) is largely self-correcting: the cost term in eq. (6) is recomputed at backfill time from the actual ledger rows, so post-change turns already carry the new economics into R — only the fitted wa lags, and because the update (8) is constant-step-size SGD (not a decaying rate that would freeze), it never stops tracking; its effective memory is ~1/η ≈ 20 samples on that arm. The uncomfortable case is a model swap behind a fixed rung id (Sonnet 5 → 6, or a repointed pool): the arm keeps its weights and its large n, so its history is not merely stale but actively wrong, and the accumulated count suppresses the very re-exploration that would fix it.

We make that a first-class, operator-free event with an economics fingerprint per rung:

(11)fa = sha256( model-id ∥ base-url ∥ pricein ∥ priceout )

The fingerprint is stored beside each arm's weights and re-checked on every backfill. When it flips — any of model, endpoint, or price changed — that arm is reset (wa ← 0, na ← 0) and re-stamped, so it drops back through the difficulty prior (5) and must re-earn its way past the activation bar (9) on current data. The reset is surgical — only the changed rung forgets; well-sampled neighbours keep their history. An operator "reset arm" control does the same on demand, for a knowing swap you don't want to wait on. This is deliberately hard forgetting rather than a global decay factor: a decay rate slow enough to preserve a stable rung's hard-won signal is too slow to erase a swapped model's, whereas the fingerprint erases exactly the arm that changed, exactly when it changes.

φ(x) ∈ ℝ¹¹ content-free counts + flags w_gemini · xn≥30? w_fugu · xn≥30? w_sonnet · xn≥30? w_opus · xn≥30? w_ultra · xn≥30? argmax rₐ(x) eligible arms only
Figure 4 — The policy network, such as it is. One shared 11-dimensional feature vector feeds five independent linear heads — one per rung — and the policy is an argmax over the heads that have cleared 30 training turns. 55 parameters total: small enough to read, large enough to beat a text-only judge on signals the judge cannot see.
training turns per arm (illustrative fill toward the bar) — arm eligible at n = 30 n = 30 (eligibility) gemini55 fugu42 sonnet30 opus11 f-ultra4
Figure 5 — The activation bar in practice (illustrative counts). Traffic is skewed cheap, so bottom rungs cross the per-arm threshold first. In this state eq. (9) holds (Σnₐ = 142 < 300 → still warming up); once the total clears 300 with ≥2 eligible arms, the bandit starts driving the shadow pick — and only the eligible arms compete in the argmax, so a barely-sampled top rung can't be chosen on noise.

6Serving: earning traffic ε-greedily under a ceiling

Shadow evaluation is necessary but not sufficient: direct-method estimates share the reward model's bias [4]. The graduation path therefore serves the learned policy on a small, configured slice of live traffic and measures the comparison. With pct ∈ [0,100] an operator flag (default 0) and amax a rung ceiling:

(12)aserved = { min( π*(x), amax )  w.p.  ε = pct/100,  if active;   g(text) otherwise }

Three properties make this safe to turn on. It is a hard no-op below the data bar — deploying the code changes nothing until an admin moves the flag. The ceiling bounds the blast radius: an over-eager policy cannot jump traffic to the top rung while trust is being built. And the platform's existing self-healing sits underneath: if a served rung is unavailable or unfunded, the standard fallback ladder catches it, exactly as for judge picks.

Turns the bandit serves are tagged (live_why='bandit'), which closes the loop twice over: their rewards are measured rather than modeled, and those measured rewards train the policy on its own decisions — the system's first on-policy data. The admin report then renders an A/B block comparing bandit-served vs judge-served reward, success and cost head-to-head; widening ε is justified by that measured table, not by counterfactual estimates.

graded turns 100% 1−ε ε = pct/100 judge g(text) status quo bandit π*(x) only if active, eq. (9) clamp ≤ a_max ceiling flag serve + fallback net bandit turns tagged live_why='bandit'
Figure 6 — ε-greedy serving, eq. (12). Band width is proportional to traffic share at a small ε. The bandit path only exists while the policy is active, its pick is clamped by the ceiling flag, and every rung served — from either path — lands on the same self-healing fallback ladder. Tagged bandit turns feed the measured on-policy A/B.

7Evaluation methodology

The admin report backfills rewards on demand and renders, in order of how much we trust each number:

Agreement and confusion. Judge-vs-candidate agreement rate and the full 5×5 confusion matrix (Fig. 7). Disagreement mass concentrated one rung above the judge on turns that later escalated is the signature of the failure mode we built this to catch.

Escalation catch-rate. Among turns the judge under-routed badly enough to trip eq. (3), the fraction where the candidate had started higher. This is a non-counterfactual win: the escalation actually happened, in production, under the judge.

Reward on the agreement subset. Realized mean reward where both policies chose the same rung — a sanity check that the reward signal itself is stable before any delta between policies is read.

Counterfactual cost estimate — labeled an estimate. Where picks diverge, the candidate's rung was never served; its cost is modeled from that rung's realized per-turn mean. This is the direct method of [4] and inherits its bias; the report says so in the UI, and the number is treated as a screen, not a verdict. The verdict comes from the measured on-policy A/B of §6.

candidate pick → ← judge pick gemfugusonopusultra gemfugusonopusultra 61% 7% 1% · · 2% 12% 4% · · · 1% 6% 2% · · · 1% 2% · · · · 1% · diagonal = agreement above-diag = candidate starts higher
Figure 7 — The confusion matrix the report renders (illustrative shape, single-hue magnitude scale). Mass on the diagonal is agreement. The cells to watch are just above the diagonal: turns where the candidate starts one rung higher than the judge. When those correlate with the judge's own mid-turn escalations, the candidate is catching under-routing before it happens — the exact failure mode of §3.

8Privacy and safety properties

The routing system stores, per decision: timestamps, the tenant id, the two model picks, the eleven scaled features of eq. (4), and the outcome scalars of eq. (6). It stores no message text, and the features are deliberately non-invertible summaries (counts, capped ratios, binary flags). Users who pin a model in settings short-circuit the resolver before either the judge or the bandit is consulted; BYO-key boxes never enter the managed path; utility calls stay pinned to the cheapest rung. The learner is fleet-shared — every tenant's turns pool into one policy — which is both the cold-start solution (a single tenant would never clear eq. 9) and a compounding effect: a lesson extracted from one tenant's mis-routed turn immediately improves routing for all. The honest caveat is representation skew: early on, the policy's opinions are dominated by the most active tenants; the serving guardrails (§6) carry that risk until the data broadens. All of it — shadow recording, training, serving fraction, ceiling — is governed by runtime configuration, so every stage is one flag from off.

9Limitations and future work

Direct-method bias. Without logging propensities, counterfactual value estimates lean entirely on the reward model; we mitigate by treating them as screens and promoting only on measured on-policy comparisons, but a stochastic logging policy (even ε=0.02 of exploration) would unlock doubly-robust estimation [4].

Reward shaping. Eq. (6)'s weights encode a judgment (a cent of spend is worth half a success). The report accepts weight overrides so sensitivity can be checked, but a principled calibration against retention or task-completion telemetry is future work.

Feature ceiling. Eleven linear features cannot represent interactions (a traceback in a long message means something different). The frozen-order vector design admits appending; the estimator admits replacement by any regressor without touching the recording, reward, or serving layers.

Non-stationarity. Discrete shifts — a model swapped behind a rung, a reprice — are handled by the fingerprint reset of §5.4, and constant-step-size SGD tracks gradual drift within a fixed model. What remains unaddressed is slow drift that never trips the fingerprint (a model's behaviour changing under a stable id and price): the constant learning rate tracks it, but with a lag inversely proportional to that arm's traffic, so a rarely-served top rung adapts slowest. A recency-weighted or windowed fit is the natural upgrade, warranted only once such drift is measurable.

Beyond routing. The ledger → reward → per-arm learner → flag-gated serving pipeline is decision-agnostic. Candidate next users on the platform: which memory facts to inject into a turn, when to sleep an idle box, and voice selection.

10Conclusion

A production LLM platform can grow a learned router the way it would grow any risky capability: behind observation first, a data bar second, and a bounded, measured serving fraction third. The machinery required is small — one ledger table, 55 linear parameters, three config flags — and every piece of it is legible to the operator who must trust it. The judge stays as the prior and the safety net; the bandit earns each increment of traffic with measured wins. Nothing about the recipe is specific to routing, which is rather the point.

RReferences

  1. [1] W. R. Thompson. On the likelihood that one unknown probability exceeds another in view of the evidence of two samples. Biometrika, 25(3–4):285–294, 1933.
  2. [2] P. Auer, N. Cesa-Bianchi, P. Fischer. Finite-time analysis of the multiarmed bandit problem. Machine Learning, 47:235–256, 2002.
  3. [3] L. Li, W. Chu, J. Langford, R. E. Schapire. A contextual-bandit approach to personalized news article recommendation. WWW, 2010.
  4. [4] M. Dudík, J. Langford, L. Li. Doubly robust policy evaluation and learning. ICML, 2011.
  5. [5] L. Bottou, J. Peters, J. Quiñonero-Candela, et al. Counterfactual reasoning and learning systems: the example of computational advertising. JMLR, 14:3207–3260, 2013.
  6. [6] A. Agarwal, S. Bird, M. Cozowicz, et al. Making contextual decisions with low technical debt. arXiv:1606.03966, 2016.
  7. [7] D. Russo, B. Van Roy, A. Kazerouni, I. Osband, Z. Wen. A tutorial on Thompson sampling. Foundations and Trends in Machine Learning, 11(1):1–96, 2018.
  8. [8] R. S. Sutton, A. G. Barto. Reinforcement Learning: An Introduction, 2nd ed. MIT Press, 2018.
  9. [9] A. Bietti, A. Agarwal, J. Langford. A contextual bandit bake-off. JMLR, 22(133):1–49, 2021.
  10. [10] L. Chen, M. Zaharia, J. Zou. FrugalGPT: How to use large language models while reducing cost and improving performance. arXiv:2305.05176, 2023.
  11. [11] I. Ong, A. Almahairi, V. Wu, W.-L. Chiang, et al. RouteLLM: Learning to route LLMs with preference data. arXiv:2406.18665, 2024.
Companion reading: the narrative version of this system lives in the engineering notebook (§07 “Model routing” and §08 “The learning flywheel”). Source of truth: control-plane/rl_router.py and _resolve_model in control-plane/main.py.