Mentor Session

Routing

Splitting one agent into many — and picking the traffic cop

Derrick Bozkurt  •  Prompt Engineers

Where we left off

Last time: HITL — how to pause one agent safely.

Today: what happens when one agent isn't enough. Who decides which specialist runs?

Thesis: most production agent systems are workflows with one or two LLM decisions in them. Know when to add the agent, know who dispatches the work.

The framing: workflow vs agent

Anthropic's taxonomy. Most teams now use the same words.

Workflow

Steps wired in code. The LLM runs inside predefined boxes but doesn't decide the flow.

Deterministic. Cheap. Easy to debug.

Examples: rule-based router, embedding classifier, fixed pipeline.

Agent

The LLM decides what happens next — which tool, which branch, when to stop.

Flexible. More expensive. Harder to trace.

Examples: LLM classifier, handoffs, orchestrator-workers.

Default: start with a workflow. Promote to an agent only when the LLM's judgment earns its cost.

When do you need an agent?

Three signals the workflow isn't enough:

1   Natural language is getting in the way. Users say "could you possibly schedule..." and your keyword rules miss it.

2   Context changes the answer. "yes please" means nothing without the previous turn.

3   The decision space is open-ended. You can't enumerate the branches in code.

None of these? You don't need an agent. You need a function.

Why split at all?

One agent with too much to do will get worse.

One fat agent

14 tools · 1 sprawling prompt

Tool-choice confusion · conflicting modes · over-acting · prompts that contradict themselves

Router + specialists

1 router · 3–5 focused agents

Smaller decision space per agent · prompts tuned to one job · cheaper tiers where possible

Symptoms of overload: tool count past ~7, the prompt has "modes," the model over-acts, the decision tree is a forest of conditionals. Two of those = consider splitting.

Same task, four routers

Customer-support assistant. Four intents. Pick a traffic cop.

The setup

One message in, one of four intents out, then a handler does the work.

user message ROUTER rules · embeddings · llm · hybrid account billing technical chitchat

Routers are cheap. Handlers that do real work run on smart-tier models. That's cost-aware routing.

Router 1 — Rules

Plain Python. No model. Sub-millisecond.

def route(msg):
    m = msg.lower().strip()
    if m in {"hi","hey","thanks"}:
        return "chitchat"
    if "refund" in m or "charge" in m or "invoice" in m:
        return "billing"
    if "password" in m or "log in" in m or "login" in m:
        return "account"
    if m.endswith("?"):
        return "technical"
    return "technical"  # safe default
Latency< 1 ms
Cost$0
Deterministicyes
Natural phrasingbreaks

Good as a fast path on top of something smarter. Never as the only router.

Router 2 — Embeddings

Embed the intents once at startup. Compare the message against them.

# at startup — runs once
INTENT_VECS = {
    name: embed(desc)
    for name, desc in INTENT_DESCRIPTIONS.items()
}

# per message
async def route(msg):
    v = await embed(msg)
    scores = {n: cosine(v, iv)
              for n, iv in INTENT_VECS.items()}
    return max(scores, key=scores.get)
Latency~50 ms
Cost~$0.02 / 10k
Deterministicyes
Natural phrasinghandles it

Dramatically underrated. For fixed intent sets, this is faster, cheaper, and more deterministic than an LLM.

Why embeddings work — the 60-second version

An embedding is a list of numbers — a vector — that captures the meaning of a piece of text. Texts that mean similar things end up with similar vectors.

The geometry

Picture each sentence as an arrow in space. "I can't log in" and "password reset help" point in nearly the same direction. "Where's my refund?" points somewhere else.

Cosine similarity is just the angle between two arrows. Same direction = 1.0. Unrelated = ~0.

Why this is cheap

The embedding model runs once per message. The intent vectors are pre-computed at startup. Classification is just 4 cosine calculations — pure math, no tokens.

That's why it's ~25× cheaper than calling an LLM: you're not paying to generate — you're paying to measure.

The underlying idea: LLMs already learned meaning during training. Embeddings let you read out that meaning as geometry — without paying the LLM to think.

Router 3 — LLM classifier

Small, fast model. One job: return one label.

PROMPT = """Return ONE label, lowercase:
  account   - login, password, profile
  billing   - invoices, refunds, plans
  technical - how-to, bugs, integration
  chitchat  - greetings, thanks"""

async def route(msg):
    r = await llm.ainvoke([
        {"role":"system", "content": PROMPT},
        {"role":"user",   "content": msg},
    ], temperature=0, max_tokens=8)
    return validate(r.content)
Latency200–500 ms
Cost~$0.65 / 10k
Deterministicno
Context-awareyes

Reach for it when nuance matters — slang, sarcasm, "yes please" follow-ups.

Router 4 — Hybrid

Embeddings first. LLM only for the ambiguous cases.

async def route(msg):
    scores = embedding_scores(msg)
    best   = max(scores, key=scores.get)

    if scores[best] >= 0.55:          # confident → ship it
        return best

    # ambiguous → fall through to the LLM
    return await llm_classify(msg)
The production default. ~75% of messages clear the threshold on the fast path. The LLM only runs on the 25% that actually need it.

Same 12 messages, four routers

Accuracy. Latency. Cost. Side by side.

router accuracy avg router ms route $ total $
rules 67% 0.1 $0.00000 $0.03120
embeddings 83% 48.2 $0.00002 $0.03118
llm 92% 312.7 $0.00049 $0.03165
hybrid 92% 105.3 $0.00018 $0.03134
cd demos/routing-agent && python compare.py

What the numbers say

1   Rules are free — and wrong a third of the time. Fast path only.

2   Embeddings are ~25× cheaper than LLM for ~90% of the accuracy.

3   Hybrid matches LLM at a third of the latency. Most production systems end up here.

Cost-aware routing: the cheap step decides which expensive step runs. A good router saves 80–95% on smart-tier spend compared to running everything through the smart tier.

The under-discussed win: sticky state

Sometimes the previous turn already made the answer obvious. The router doesn't need to think.

Without sticky state

agent › Here are 3 slots:
         1. 2pm  2. 3pm  4pm
user  › the 3pm one
      // router weighs all 4 intents
      // gets it wrong 8% of the time

With sticky state

agent › Here are 3 slots:
         1. 2pm  2. 3pm  4pm
      // state: awaiting_slot_selection
user  › the 3pm one
      // router skipped. Handler: booking.
The idea: when a sticky flow is active and the message looks like a continuation, bypass the router entirely. Cheaper, faster, and deterministic for flows that need to be deterministic.

Wiring up sticky state

Two parts: prior state from the last turn, plus a lightweight pattern check on the current message.

async def dispatch(message, session):
    # 1. Did the prior turn set up a sticky flow?
    sticky = session.get("awaiting")  # e.g. "slot_selection"

    # 2. Does the message look like a continuation of that flow?
    if sticky == "slot_selection" and looks_like_slot_pick(message):
        return "booking"         # skip the router

    if sticky == "confirmation" and looks_like_yes_no(message):
        return session["pending_intent"]

    # 3. Otherwise, fall through to the normal router
    return await router.route(message)

Handlers set the sticky flag when they show a list; the next turn reads it.

Rule: only fast-path when state + message are unambiguous. Borderline cases belong in the router where the model can weigh history. If you're adding heuristics for edge cases, stop — that's router territory.

Three ways to hand work off

Framework vocabulary is muddled. These shapes are different.

Tool call

A calls a function that happens to run B. A keeps the conversation.

Default. Easiest to debug. Use when B returns a small answer.

Handoff

A transfers control to B. B now drives the conversation. A is done.

"Wrong specialist, you take it." OpenAI Agents SDK, LangGraph supervisor.

Worker spawn

A creates isolated workers with fresh contexts. Runs in parallel. Returns outputs.

Claude Code subagents. Use when parallelism is the point.

Mistake: reaching for a handoff when a tool call would do. Handoffs are heavier, harder to trace, and lose caller continuity.

Picking a router

Your situationPick
< 7 tools, one clear domainNo router. Single agent.
Fixed intents, tight latency, high volumeEmbeddings.
Fixed intents, occasional ambiguityHybrid.
Fuzzy intents, context-dependentLLM classifier.
Framework has handoffs (Agents SDK)Skip upstream routing.
Need an audit trailRules or embeddings (deterministic)

There is no "best" router. Pick trade-offs consciously.

What routing doesn't solve

Real users send messages that span two specialties.

"I was charged twice this month — can you also reset my password while you're at it?"

A strict single-intent router has to pick one. The other half of the message is lost.

That's the merge-vs-split tightrope — specialized enough to be accurate, general enough to handle real messages. When cross-cutting messages get handled wrong frequently, the fix is usually to merge, not to add handoffs. (Next session.)

Four rules to take home

1 Workflow by default — agent only when judgment earns its cost
2 Embedding routing is the underrated option — try it first
3 Cheap step decides which expensive step runs
4 Sticky state beats routing when the prior turn already decided the answer

Next session: reliability + observability

Derrick Bozkurt  •  Prompt Engineers