Lesson 05 of N

Routing

Send each input down the right path — classification decides what it is, dispatch decides who handles it.

← 04 Prompt Chaining 06 Parallelisation →

The Core Idea

No single prompt can be excellent at everything. The wider the variety of requests you push through one do-everything prompt, the more average it becomes at each — every instruction you add for one kind of request is noise for the rest. The routing pattern escapes this by looking at each input before attempting it, and steering it onto whichever specialised path is best equipped to handle it. Where the input comes from doesn't matter — a user typing a request and an upstream planning step handing work down are routed exactly the same way.

Airports solve the identical problem with floor signage: one stream of arriving passengers splits into transfers, passport control, and baggage enquiries — each lane staffed and equipped for exactly one job, and nobody queues behind a problem that isn't theirs.

Without routing: one generalist prompt handles every kind of request Flights? Hotels? Activities? Same prompt, same model, average at all of them WITH ROUTING Incoming request "Change my flight date" Router what kind of request is this? Flights specialist Hotels specialist Activities specialist flights hotels activities

Why Route?

Cost & latency

Match the model to the task. Simple requests go to a fast, cheap model; complex ones earn the powerful, expensive one. The classifier's complexity estimate is what makes this possible.

Specialisation

Each route gets a focused persona, prompt, and toolset honed for one job. A flights specialist with airline change policies in its prompt beats a generalist juggling everything — better accuracy, fewer hallucinations.

Scalability

A new category of work means adding one route and one specialist — the router barely changes, and the existing paths are untouched.

Flexibility

The path is chosen at runtime, per input — so one workflow handles genuinely diverse requests without contorting a single prompt to cover every case.

The Two Stages: Classification and Dispatch

Every router, however sophisticated, does exactly two things in sequence.

Stage 1 — Classification. Work out what the input is before anyone attempts it: what is being asked for, which category of work it belongs to, what the user is really trying to achieve — and sometimes how demanding answering it will be. In a travel assistant this is the moment of recognising "this is a flight-change request", before anyone tries to answer it.

Stage 2 — Dispatch. Act on the label: hand the input, together with the label that explains it, to the destination built for that kind of work — which might be a specialist agent, an entire prompt chain, or nothing more exotic than an ordinary function. This is the branching logic itself: label "flights" takes the flights path to the flights agent, label "hotels" takes the hotels path. The flight-change request lands with the flights desk, not whoever happened to pick up the phone.

The two stages are two halves of one design: every label the classifier can emit needs a route built to handle it. Design the two lists as a pair — the labels the classifier may answer with, and the specialists who handle them — so that each route is good at exactly one label's kind of work, and the hand-off practically chooses itself.

STAGE 1 — CLASSIFICATION STAGE 2 — DISPATCH Incoming input "Can I move my flight to Thursday?" Analyse the input type · intent · complexity "flights" Look up the route flights → flights_agent hotels → hotels_agent activities → activities_agent Flights specialist label The input and its label travel together — the specialist knows why it was chosen

Keep the two stages separate in your head. Classification answers "what is this?" — it needs judgement, so it is usually a Large Language Model (LLM) call. Dispatch answers "who handles it?" — it is a mechanical lookup, so it is usually plain code. Mixing them (one prompt that classifies and answers) gives back the generalist problem you were trying to escape.

The classifier is the keystone. Everything downstream trusts the label, so the accuracy of the whole routing process is capped by the accuracy of this one step. A misclassified input doesn't fail loudly — it gets a confident answer from the wrong specialist: a flight-change request misrouted to the activities planner receives sightseeing tips instead of a new boarding pass. Mitigations all follow Lesson 04's playbook — validate the label programmatically, keep a fallback route, and when the classifier can also report its confidence, send low-confidence inputs to the general route (or a human) rather than guessing.

Ways to Classify

LLM classifier

One small, low-temperature call that returns a label. Most flexible — handles phrasing it has never seen — but the output needs a programmatic gate since the model can return an unexpected label.

Embeddings + similarity

Embed the input and compare it with example queries per category. Cheaper and faster than an LLM call at scale, at the cost of some nuance on edge cases.

Rules & keywords

Deterministic checks — sender, keywords, field values. Free and instant, but brittle. Best as a fast first pass for unambiguous signals, with a smarter classifier behind it.

Choosing between them comes down to three questions: how messy is the input? (free-form language pushes you towards an LLM; structured fields can stay rule-based), how expensive is a misroute? (the costlier the mistake, the smarter the classifier needs to be), and what does each decision cost? (latency and money, multiplied by every input you'll ever receive). Many production routers layer the methods — rules catch the obvious cases instantly, and only ambiguous inputs pay for the LLM call.

Routing in Code

The whole pattern is three short pieces: the classifier (stage 1) that reads a request and returns a label, the specialists it can route to, and the dispatch table (stage 2) that connects a label to its specialist. None of them is more than a handful of lines.

Stage 1 is a deliberately tiny LLM call — one category in, one word out. (chat() below is the small helper used across this series: it sends a prompt to the LLM and returns the text reply.)

stage 1 — classification
def classify_request(request):
    """One cheap, deterministic LLM call that returns a label."""
    label = chat(
        user_prompt=f"Classify this traveller request into exactly one category.\n\nRequest: {request}",
        system_prompt=(
            "You are a triage classifier for a travel assistant. Reply with "
            "exactly one word: flights, hotels, or activities."
        ),
        max_tokens=5,       # tiny output — fast and cheap
        temperature=0.0,    # classification should be deterministic
    )
    return label.strip().lower().rstrip(".")

Next, the destinations. Each specialist is an ordinary Python function with one focused persona — the flights specialist knows only flights, and so on. They are plain functions, not part of the routing machinery; the router's only job is to pick the right one. Here is the flights specialist; the rest differ only in their system prompt:

the specialists
def flights_agent(request):
    """Specialist: bookings, changes, and cancellations."""
    return chat(
        user_prompt=request,
        system_prompt=(
            "You are a flight specialist. Handle bookings, date changes, "
            "cancellations, and baggage questions. Be precise about dates, "
            "fees, and the exact next steps the traveller should take."
        ),
        max_tokens=300,
    )

# hotels_agent, activities_agent, and general_agent follow the same shape —
# same signature, different persona. general_agent is the fallback route.

Stage 2 — dispatch — is the lookup that connects the two. The dictionary is the route table: hand it a label, get back the specialist that handles it. run_router then ties the pattern together in three lines — classify, look up, call:

stage 2 — dispatch
ROUTES = {
    "flights": flights_agent,
    "hotels": hotels_agent,
    "activities": activities_agent,
}

def run_router(request):
    label = classify_request(request)
    specialist = ROUTES.get(label, general_agent)  # unknown label → safe fallback
    print(f"[Router] {label} → {specialist.__name__}")
    return specialist(request)

That one lookup line does two useful things at once. ROUTES.get(label, general_agent) reads as: "find the specialist for label; if there is no such label, use general_agent instead." Since the label comes from an LLM — which can ignore "reply with one word" and return something off-list — those two things are exactly what you need: first it checks the label is one we actually handle, and second it has a safe fallback ready when it isn't, so an unrecognised label quietly lands on the general agent instead of crashing the workflow. (Plain ROUTES[label] would throw an error on any unexpected label.)

Printing the routing decision is the seed of observability: when a request gets a strange answer, the first question is "where was it routed, and why?"

Send three requests through and each takes its own path:

sample output
Request: Can I move my Rome flight from Tuesday to Thursday?
[Router] flights → flights_agent

Request: Does the hotel in Lisbon include breakfast?
[Router] hotels → hotels_agent

Request: What should we do in Barcelona with two kids for a weekend?
[Router] activities → activities_agent

Growing the Router

The minimal router is genuinely useful as-is, but three upgrades appear in almost every production version.

1. Self-describing specialists

In the minimal router the categories are written out by hand in two places that must agree: the classifier's system prompt (which lists the labels) and the route table (which maps each label to a specialist). Adding a specialist means editing both. The fix: keep the specialists in one list — each entry a name, a one-line description, and the function that does the work — and build the system prompt from that list each time the router runs.

building the prompt from the list
SPECIALISTS = [
    {"name": "flights", "description": "bookings, date changes, cancellations, baggage", "agent": flights_agent},
    {"name": "hotels",  "description": "rooms, amenities, check-in times, reservations",  "agent": hotels_agent},
    # adding a specialist = adding one entry — the routing logic never changes
]

def build_router_prompt(specialists):
    # one line per specialist, e.g. "- flights: bookings, date changes, ..."
    lines = [f"- {s['name']}: {s['description']}" for s in specialists]
    text = "\n".join(lines)  # stack the lines into one block of text
    return (
        "You are a triage classifier for a travel assistant.\n"
        "Pick the best specialist for the request from this list:\n"
        f"{text}\n"
        "Reply with exactly one name from the list. No punctuation, no explanation."
    )

# each time the router runs, it rebuilds its system prompt from the list:
system_prompt = build_router_prompt(SPECIALISTS)

The function does nothing clever — it just glues the list into the classifier's system prompt. For the two-entry list above, system_prompt is exactly what the LLM receives:

the system prompt it builds
You are a triage classifier for a travel assistant.
Pick the best specialist for the request from this list:
- flights: bookings, date changes, cancellations, baggage
- hotels: rooms, amenities, check-in times, reservations
Reply with exactly one name from the list. No punctuation, no explanation.

Python aside: comprehensions. The square-bracket line is a list comprehension — a compact loop that builds a list. It is exactly equivalent to:

lines = []
for s in specialists:                              # s is one specialist entry at a time
    lines.append(f"- {s['name']}: {s['description']}")

It produces one string per specialist. The same shape with curly braces — {s["name"]: s["agent"] for s in specialists} — builds a dictionary instead, which is how the route table can be derived automatically from the same list.

2. Short lists, cascaded routers

Every option you add makes the router's decision harder: the whole list sits in the prompt, the distinctions between specialists get finer, and classification accuracy drifts down. Keep each router's list short. When you genuinely need more destinations, cascade: a route's destination can itself be another router. The top one makes a coarse choice between a few areas; each area's router makes a finer one. Every individual decision stays easy, and the tree handles the scale.

Request any travel question Main router coarse choice: 3 areas Flights desk another router Hotels specialist Activities specialist Bookings Changes Baggage Each router chooses from a short list — depth replaces width

3. Composite routes

Some requests don't fit a single hand-off. Ask "roughly what would a week in Lisbon cost for a family of four?" and no single specialist can answer — fares live with the flights desk, room rates with hotels, and someone still has to put the numbers together. Rather than reviving the generalist with one prompt that covers everything, make the route itself a mini-chain: when the classifier says "budget", the router gathers the prerequisite research from the existing specialists, then hands it all to a budget planner whose only job is synthesis.

Budget request "week in Lisbon, family of 4" Router label: "budget" other labels: one hop, as before Flights agent fare research Hotels agent room rates Budget planner request + both research results The budget route is a mini-chain: gather the research, then fan everything in to one synthesiser routing chooses the path; chaining runs along it

The budget is only as good as the research behind it — weak gathering means a weak budget. The budget route normally gathers that research itself by calling the flights and hotels specialists, but if the code that called the router already has it — fetched earlier for some other reason — it can hand the research in through an optional context argument, and the router uses it directly instead of fetching it again.

Routing vs chaining. A chain runs a request through a fixed sequence of steps, each one feeding the next. Such a chain can branch partway through, picking its next step from a result. Routing makes that same branch decision right at the start, before any work happens — so each path only ever sees one kind of input, which keeps it simple. And a path can itself be a chain: the budget route — gather research, then synthesise — is one.

Lesson Recap

What You Now Know

← 04 Prompt Chaining 06 Parallelisation →