Skip to main content
intermediatePart 2

LiteLLM complexity routing: the right model for each request, and what it costs in latency

· 12 min read
Rafael Fernandes
NLP Engineer & Tech Writer at WiLine
Share:
LiteLLM+Qwen+
0/5
🎯 Skill path0/5 earned
Self-hosting an LLM gateway

Part 1 ended on an uncomfortable number. The same three-word answer cost 3 tokens from a small model and 200 from a reasoning model — which spent all 200 thinking and returned nothing at all.

Every request your apps send picks a model, and mostly that choice is made once, hardcoded, and never revisited. This post puts the gateway in charge of it instead: classify the request, route it to a model sized for the work. Then it measures what that decision costs, because it is not free and most write-ups skip that part.

Adding LiteLLM's complexity router

The gateway from Part 1 already has three models registered. A router is just another entry in model_list that maps complexity tiers onto them:

config.yaml
- model_name: smart-router
litellm_params:
model: auto_router/complexity_router
complexity_router_config:
tiers:
SIMPLE: qwen-small
MEDIUM: qwen-mid
COMPLEX: qwen-large
REASONING: qwen-large
return_raw_model_name: true

return_raw_model_name: true is the important one for now. Without it the response reports smart-router and you have no idea which model served you. With it, the response names the model that actually ran — so every test below is self-verifying.

docker compose restart litellm

Watching it route

You'll send the same request many times with only the prompt changing, so wrap it once:

ask() { curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"smart-router\",\"messages\":[{\"role\":\"user\",\"content\":\"$1\"}],\"max_tokens\":10}" | jq -r .model; }

Then five prompts of increasing difficulty:

ask "hi"
ask "what is a vpc"
ask "write a python function that retries an http call with backoff"
ask "our two services deadlock under load, walk me through diagnosing it"
ask "prove that the halting problem is undecidable"

One line per prompt, in the order sent:

Output
Qwen2.5-3B-Instruct
Qwen2.5-3B-Instruct
Qwen3.5-9B
Qwen2.5-3B-Instruct
Qwen2.5-3B-Instruct

Five prompts routed by the heuristic scorer, four landing on the 3B model Figure 1. The code request went up a tier. The two hardest questions did not.

A greeting on the small model is right. A Python request on the mid model is right. But a distributed-systems deadlock and one of the hardest questions in computer science both landed on a 3-billion-parameter model.

To understand why, you have to know what the router is actually doing.

What a heuristic is, and how LiteLLM scores one

By default this router makes zero API calls. It scores the prompt locally with pattern matching — that's what "heuristic" means here: a cheap rule of thumb that approximates a judgement without making it.

It scores seven dimensions, each producing a value between −1 and +1, then multiplies each by a fixed weight and adds them up:

DimensionWeightFires on
codePresence0.30function, class, api, schema, …
reasoningMarkers0.25"step by step", "think through", "analyze"
technicalTerms0.25"architecture", "distributed", "encryption"
tokenCount0.10−1.0 under 15 tokens, +1.0 over 400
simpleIndicators0.05"what is", "define", greetings — scores −1.0
multiStepPatterns0.03"first… then", numbered steps
questionComplexity0.02more than three question marks

The weighted sum maps to a tier at three boundaries: below 0.15 is SIMPLE, below 0.35 MEDIUM, below 0.60 COMPLEX, and above that REASONING. Those values are all documented and all configurable.

Two dimensions can push the score down. A short prompt scores −1.0 on tokenCount. A prompt containing "what is" scores −1.0 on simpleIndicators.

The arithmetic, on a real prompt

The router records its own working. Every routed request writes a decision into the spend log:

curl -s http://127.0.0.1:4000/spend/logs \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
| jq -r '.[].metadata.routing_decision | select(.) | "\(.tier) \(.score) -> \(.routed_model) \(.signals)"' | head -5

Newest first, so this reads bottom-up against the order the prompts were sent:

Output
SIMPLE -0.1 -> qwen-small ["short (11 tokens)"]
SIMPLE 0 -> qwen-small null
MEDIUM 0.3 -> qwen-mid ["code (function, python)"]
SIMPLE -0.15000000000000002 -> qwen-small ["short (3 tokens)","simple (what is)"]
SIMPLE -0.15000000000000002 -> qwen-small ["short (0 tokens)","simple (hi)"]

Routing decisions showing tier, score and the signals behind each Figure 2. Not a black box — the router reports the score and the signals it fired.

Take the last one. prove that the halting problem is undecidable:

tokenCount -1.0 × 0.10 = -0.10 11 tokens, under the 15 threshold
codePresence 0.0 × 0.30 = 0.00 no code keywords
reasoningMarkers 0.0 × 0.25 = 0.00 "prove" is not in the marker list
technicalTerms 0.0 × 0.25 = 0.00
simpleIndicators 0.0 × 0.05 = 0.00
multiStepPatterns 0.0 × 0.03 = 0.00
questionComplexity 0.0 × 0.02 = 0.00
------
-0.10 below 0.15 → SIMPLE → 3B model

Every term is zero except a penalty for being short. The question gets routed down because it is brief.

Now the deadlock prompt, which is worse: it scored 0.00 with no signals at all. Not a low score — nothing matched. "Our two services deadlock under load, walk me through diagnosing it" contains no keyword the scorer recognises, so it falls to SIMPLE by default.

Skill unlocked 🏅

You can read a routing decision — tier, score, and the signals that produced it — and reproduce the arithmetic by hand from the dimension weights.

The router isn't malfunctioning. It is doing exactly what its rules say. The rules just have no way to see difficulty that isn't spelled out in vocabulary it knows.

Where this bites

The failure mode is systematic, not random: short prompts that need deep thinking get routed down. Those are also the prompts where a wrong model is most obvious to the user.

A keyword never matches its own plural

Single-word keywords are matched on word boundaries, so endpoint does not match endpoints. Every single-word keyword in the default lists is singular, and none of them match a plural.

Whether that changes anything depends on how close the score already sits to a boundary, because the dimensions are stepped rather than linear — technicalTerms scores 0.5 at two matches and 1.0 at four, so losing one match often changes nothing. Sometimes it changes the model:

Review our api endpoint for authentication and authorization problems → COMPLEX +0.425
Review our api endpoints for authentication and authorization problems → MEDIUM +0.275

One letter, one tier. The direction is always the same: a plural scores lower or equal, never higher, so the drift is toward the cheaper model.

Plurals you care about can be appended to the technical list with custom_technical_keywords. There is no equivalent for code keywords — the only lever is code_keywords, which replaces the built-in list rather than extending it.

Asking a model instead

The alternative is to spend a model call on the decision. Four lines:

config.yaml
classifier_type: llm
classifier_llm_config:
model: qwen-small
timeout_ms: 3000

Now the router sends the prompt to qwen-small with a rubric and a schema that forces back exactly one of SIMPLE, MEDIUM, COMPLEX, REASONING, and routes on the answer. The classifier here is the cheapest model we have — the same 3B that was wrongly answering the hard questions a moment ago. It turns out to be a better judge of difficulty than it is an answerer of it.

Restart, then re-run the identical five prompts:

Output
Qwen2.5-3B-Instruct
Qwen3.5-9B
Qwen3.5-122B
Qwen3.5-122B
Qwen3.5-122B

The same five prompts under the LLM classifier, with the hard ones now on the 122B model Figure 3. Both failures corrected — and two prompts moved up that arguably shouldn't have.

PromptHeuristicLLM classifier
hi3B3B
what is a vpc3B9B
python function … backoff9B122B
deadlock under load3B ✗122B ✓
halting problem3B ✗122B ✓

The two broken cases are fixed. But read rows 2 and 3 again — everything moved up. "What is a vpc" is a factual lookup a 3B model answers perfectly well, and it now runs on the 9B. You stopped under-serving hard prompts and started over-serving easy ones. Whether that trade is worth it depends on your traffic mix, and you should measure yours rather than trust this table.

What it costs

This is the part that gets left out. Add a twin that skips the router, then time both:

direct() { curl -s http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"qwen-small\",\"messages\":[{\"role\":\"user\",\"content\":\"$1\"}],\"max_tokens\":10}" | jq -r .model; }
time ask "hi"
time ask "hi"
time direct "hi"
Output
Qwen2.5-3B-Instruct real 0m2.474s ← first call after restart
Qwen2.5-3B-Instruct real 0m1.041s
qwen-small real 0m0.529s

time output comparing a routed call against a direct call Figure 4. Same prompt, same model answers it, twice the wall clock.

The same 3B model produced both answers. The only difference is that one request was classified first. Now decompose it — the spend log times each call separately:

Wall clockClassifier callServing call
2.474 s (cold)1449 ms806 ms
1.041 s549 ms456 ms
0.529 s (direct)495 ms

The arithmetic closes: 549 + 456 = 1005 ms against 1.041 s measured at the shell.

Three things fall out:

The overhead is not a constant. Across the day's samples the classifier call ranged from 476 ms to 1449 ms, and the first request after a restart cost 1449 ms on its own. It is a full inference call and inherits whatever the backend is doing. Any single number you quote for it is the number you happened to catch.

Token cost is fixed and larger than you'd guess. Each classification sent 281–293 input tokens — the rubric — and returned about 10. Routing hi, a one-token prompt, costs ~281 tokens of classification before anything answers.

The serving call slows down too. 456–806 ms when routed, against 495 ms direct for the identical request. The classifier appears to leave the backend busy for the request queued behind it. You would never see this from wall clock alone.

Measure the parts, not the total

Paired wall-clock timing tells you that it got slower, never where. Decompose into the classifier call and the serving call before you quote a figure — the first honest number here was almost double the one a single time run suggested.

The trap worth knowing about

If the classification call times out, returns the wrong shape, or comes back empty, the router falls back to the heuristic scorer — the thing whose failures you just paid half a second to avoid. Silently.

The official docs name the way out: set classifier_fallback: default_model and a timeout routes to a model you chose deliberately, rather than to the scorer that sends hard questions to a 3B model.

Also worth checking rather than assuming: the docs give timeout_ms a default of 2000, while the package in our container defaults to 3000. Read the version you actually installed.

So which one

HeuristicLLM classifier
Latency addedsub-millisecond476–1449 ms
Extra API callsnoneone per request
Extra input tokensnone~280 per request
Hard-but-short promptsrouted downrouted correctly
Easy promptsrouted correctlyrouted up
Fails bybeing confidently wrongtiming out, then being confidently wrong

The heuristic is the right default for high-volume traffic that looks alike, and you can improve it a lot with custom_technical_keywords for your own domain vocabulary. The LLM classifier earns its cost when prompts are varied, when getting the model wrong is expensive, and when half a second doesn't matter — batch work, agents, anything already taking seconds.

What neither of them is, is free.

Troubleshooting

The response says smart-router instead of a model name

return_raw_model_name isn't set. Without it the router echoes the alias you asked for and you can't tell what served the request.

Every prompt routes to the same tier

Check the signals field in the routing decision. If it's null, no dimension matched at all and the prompt scored 0.00, which lands in SIMPLE. That's a sign your traffic doesn't use the vocabulary the default keyword lists expect.

Routing decisions aren't in the spend log

They're on the routed request, under metadata.routing_decision, not on the classifier call. Filter with map(select(.)) as above — half the rows are the classifier calls themselves and carry no decision.

The first request after a restart is much slower

Cold start. The first classification here took 1449 ms against roughly 550 ms once warm. Discard the first sample when timing.

Finished this tutorial?
Mark it complete to earn Route work to the right model on your skill path.

What's next

The gateway now decides which model runs, and you know what that decision costs in latency and tokens. It still forwards every prompt verbatim to whichever model wins — including the ones containing customer names, emails and API keys.

The next post puts a filter in that path.

Further reading

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.