Splitting one agent into many — and picking the traffic cop
Derrick Bozkurt • Prompt Engineers
Last time: HITL — how to pause one agent safely.
Today: what happens when one agent isn't enough. Who decides which specialist runs?
Anthropic's taxonomy. Most teams now use the same words.
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.
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.
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.
One agent with too much to do will get worse.
14 tools · 1 sprawling prompt
Tool-choice confusion · conflicting modes · over-acting · prompts that contradict themselves
1 router · 3–5 focused agents
Smaller decision space per agent · prompts tuned to one job · cheaper tiers where possible
Customer-support assistant. Four intents. Pick a traffic cop.
One message in, one of four intents out, then a handler does the work.
Routers are cheap. Handlers that do real work run on smart-tier models. That's cost-aware routing.
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 |
| Deterministic | yes |
| Natural phrasing | breaks |
Good as a fast path on top of something smarter. Never as the only router.
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 |
| Deterministic | yes |
| Natural phrasing | handles it |
Dramatically underrated. For fixed intent sets, this is faster, cheaper, and more deterministic than an LLM.
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.
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.
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.
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)
| Latency | 200–500 ms |
|---|---|
| Cost | ~$0.65 / 10k |
| Deterministic | no |
| Context-aware | yes |
Reach for it when nuance matters — slang, sarcasm, "yes please" follow-ups.
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)
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 |
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.
Sometimes the previous turn already made the answer obvious. The router doesn't need to think.
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
agent › Here are 3 slots:
1. 2pm 2. 3pm 4pm
// state: awaiting_slot_selection
user › the 3pm one
// router skipped. Handler: booking.
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.
Framework vocabulary is muddled. These shapes are different.
A calls a function that happens to run B. A keeps the conversation.
Default. Easiest to debug. Use when B returns a small answer.
A transfers control to B. B now drives the conversation. A is done.
"Wrong specialist, you take it." OpenAI Agents SDK, LangGraph supervisor.
A creates isolated workers with fresh contexts. Runs in parallel. Returns outputs.
Claude Code subagents. Use when parallelism is the point.
| Your situation | Pick |
|---|---|
| < 7 tools, one clear domain | No router. Single agent. |
| Fixed intents, tight latency, high volume | Embeddings. |
| Fixed intents, occasional ambiguity | Hybrid. |
| Fuzzy intents, context-dependent | LLM classifier. |
| Framework has handoffs (Agents SDK) | Skip upstream routing. |
| Need an audit trail | Rules or embeddings (deterministic) |
There is no "best" router. Pick trade-offs consciously.
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.
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