Module 8: Multi-Agent Orchestration
5. Pattern: Router
Overview
In the three previous capsules you learned patterns where the agents do work: the Supervisor coordinates workers that execute tasks, Handoffs transfer control between agents that process the conversation, Subagents delegate sub-tasks with isolated context. In all of those patterns, the first step is implicit — somebody decides who to send it to. In Supervisor, the supervisor analyzes and picks a worker. In Handoffs, each agent decides when to transfer. In Subagents, the parent decomposes and assigns. But what happens when the user's input could go to one of ten specialized agents, and choosing wrong means burning tokens, time, and producing a poor answer?
The Router solves exactly this: classify the input and direct it to the right agent before any agent starts working. It doesn't coordinate, doesn't execute, doesn't transfer — it just classifies and routes. It's the system's bouncer: it looks at who you are, decides where you go, and gets out of the way. Unlike the Supervisor, the Router doesn't review results or re-assign. Unlike Handoffs, there's no chain of transfers. The Router makes one decision, once, at the start.
Connection to the module: In M3 (capsule 03 — Function Calling Patterns) you saw tool routing: the LLM decides which tool to use. This is agent routing — the LLM (or deterministic logic) decides which agent to use. The difference is scale: choosing the wrong tool costs one call; choosing the wrong agent costs a whole pipeline. The Router is the fourth and final multi-agent orchestration pattern. After this capsule you'll have the complete vocabulary: Supervisor, Handoffs, Subagents, Router — and you'll know how to combine them.
The Router Pattern
The core idea
The Router splits the multi-agent problem into two phases:
- Classification: What kind of input is this? What's the user's intent?
- Routing: Given the type, which specialized agent do I send it to?
┌──────────────────┐
│ ROUTER │
│ │
Input ────────────► │ 1. Classifies │
│ 2. Directs │
│ (doesn't run) │
└──┬───┬───┬───┬───┘
│ │ │ │
┌────────┘ │ │ └────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌────────┐ ┌──────────┐
│ Code │ │ Math │ │ Search │ │ Creative │
│ Agent │ │ Agent │ │ Agent │ │ Agent │
└──────────┘ └────────┘ └────────┘ └──────────┘
Flow: Input → Router → Agent → Answer
(The Router does NOT receive the result. It exits the flow.)
Compare this with the Supervisor:
SUPERVISOR ROUTER
────────── ──────
Input → Supervisor → Worker A Input → Router → Agent A → Answer
↑ │ (exits the flow)
└────────────┘
→ Worker B → Supervisor → END
The Supervisor has a loop: decide, review, re-assign.
The Router has no loop: it classifies and leaves.
Why not a general agent
The alternative to the Router is a general agent with every agent's tools. If you have a Code Agent (5 tools), Math Agent (3), Search Agent (4), Creative Agent (3) — the general agent would have 15 tools. Problems: tool confusion (15+ tools, the LLM picks wrong), an inflated prompt (15 tool descriptions eat the context window), and no prompt specialization (the Code Agent has a system prompt for code, the Creative Agent for writing — a general agent can't have both).
The Router solves all three: it classifies once and sends it to the right agent, which has only its tools, its specialized prompt, and its clean context.
When to use a Router
| Signal | The Router is a good option |
|---|---|
| Diverse input | Users ask for fundamentally different things: code, math, search, writing |
| Agents with disjoint expertise | Each agent does something the others do NOT |
| No need for coordination | The task gets solved by ONE agent, not several |
| High volume | Thousands of requests, efficiency matters |
| Clear categories | The input types are distinguishable |
The Router does not work well when the task requires multiple agents working together (use Supervisor), the flow needs transfers (use Handoffs), the categories constantly overlap, or you only have 2 agents — an if/else is enough.
The Deterministic Router
The deterministic Router uses fixed rules — keywords, regex, explicit categories — to classify the input. It doesn't use an LLM. It's instant, predictable, and costs zero tokens.
Implementation with keywords
ROUTE_MAP = {
"code": ["code", "program", "python", "javascript", "bug", "function", "script", "debug"],
"math": ["calculate", "equation", "integral", "derivative", "sum", "percentage", "statistics"],
"search": ["search", "find", "research", "what is", "who is", "when", "where"],
"creative": ["write", "compose", "story", "poem", "letter", "narrative", "creative"],
}
def deterministic_router(state: dict) -> dict:
user_input = state["messages"][-1].content.lower()
scores = {cat: sum(1 for kw in kws if kw in user_input) for cat, kws in ROUTE_MAP.items()}
if max(scores.values()) == 0:
return {"route": "search"} # fallback
return {"route": max(scores, key=scores.get)}
Implementation with regex
For more sophisticated patterns, use regex. Math operations (\d+\s*[+\-*/]\s*\d+), code blocks in the input (```\w*\n), factual questions ((?:what|who)\s+(?:is|was)). The logic is the same: count matches per category, select the one with the highest score. Regex gives you more precision than simple keywords, but it requires more maintenance.
Advantages and limitations
| Aspect | Advantage | Limitation |
|---|---|---|
| Latency | ~0ms | — |
| Cost | 0 tokens | — |
| Predictability | Same input → same result | Doesn't handle natural variations |
| Ambiguity | — | "Write a program that computes statistics" → code or math? |
The deterministic router is the first line of defense. Fast, cheap, predictable. When it fails — ambiguous inputs, varied language, subtle intents — you need an LLM.
The LLM-based Router
Instead of keywords, you use an LLM to classify the intent. The LLM understands semantics, not just text patterns. "I'd like something that helps me automate reports" → code, even though it doesn't contain the word "code".
Implementation with structured output
from pydantic import BaseModel, Field
from typing import Literal
class RouteDecision(BaseModel):
route: Literal["code", "math", "search", "creative"] = Field(
description="The category of the user's input"
)
confidence: float = Field(
description="Confidence from 0.0 to 1.0 in the classification", ge=0.0, le=1.0
)
reasoning: str = Field(description="A brief justification for the classification")
ROUTER_PROMPT = """Classify the user's intent into ONE category:
- code: Programming, debugging, scripts, automation with code
- math: Calculations, statistics, equations, math problems
- search: Looking up information, facts, research, factual questions
- creative: Creative writing, composition, narrative, original content
Classify by MAIN INTENT:
"Write a program that computes Pi" → code (the intent is to program).
"What's the compound interest formula?" → search (looking up information)."""
router_model = model.with_structured_output(RouteDecision)
def llm_router(state: dict) -> dict:
decision = router_model.invoke([
SystemMessage(content=ROUTER_PROMPT),
state["messages"][-1]
])
return {
"route": decision.route,
"router_confidence": decision.confidence,
"router_reasoning": decision.reasoning,
}
Router with a low-confidence fallback
CONFIDENCE_THRESHOLD = 0.7
def llm_router_with_fallback(state: dict) -> dict:
decision = router_model.invoke([
SystemMessage(content=ROUTER_PROMPT), state["messages"][-1]
])
if decision.confidence < CONFIDENCE_THRESHOLD:
return {"route": "clarify", "router_confidence": decision.confidence}
return {"route": decision.route, "router_confidence": decision.confidence}
Hybrid: deterministic first, LLM as the fallback
The most pragmatic strategy: try the deterministic one. If it isn't confident enough (a score of 0 or a tie), delegate to the LLM.
def hybrid_router(state: dict) -> dict:
user_input = state["messages"][-1].content.lower()
scores = {cat: sum(1 for kw in kws if kw in user_input) for cat, kws in ROUTE_MAP.items()}
max_score = max(scores.values())
winners = [cat for cat, s in scores.items() if s == max_score]
if max_score >= 2 and len(winners) == 1:
return {"route": winners[0], "router_method": "deterministic"}
decision = router_model.invoke([
SystemMessage(content=ROUTER_PROMPT), state["messages"][-1]
])
return {"route": decision.route, "router_method": "llm"}
60-70% of inputs get resolved with keywords (cost 0). The rest use the LLM. Better average latency, lower total cost.
Comparing the strategies
| Aspect | Deterministic | LLM-based | Hybrid |
|---|---|---|---|
| Latency | ~0ms | 200-800ms | ~0ms (60-70%), ~500ms (30-40%) |
| Cost | 0 tokens | ~100-300 tokens/req | Reduced ~60-70% |
| Accuracy on clear input | High | High | High |
| Accuracy on ambiguous input | Low | High | High |
| New categories | Manual keywords | Add to the prompt | Both |
Implementation with LangGraph
The complete graph
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict, Annotated, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain.chat_models import init_chat_model
from langchain_core.messages import SystemMessage, HumanMessage
from pydantic import BaseModel, Field
model = init_chat_model("openai:gpt-4.1-mini")
class RouteDecision(BaseModel):
route: Literal["code", "math", "search", "creative"] = Field(description="Category")
class RouterState(TypedDict):
messages: Annotated[list, add_messages]
route: str
router_model = model.with_structured_output(RouteDecision)
def router_node(state: RouterState) -> dict:
decision = router_model.invoke([
SystemMessage(content="Classify: code, math, search, or creative."),
state["messages"][-1]
])
return {"route": decision.route}
def code_agent(state: RouterState) -> dict:
r = model.invoke([SystemMessage(content="You are a programming expert. Clean code.")] + state["messages"])
return {"messages": [r]}
def math_agent(state: RouterState) -> dict:
r = model.invoke([SystemMessage(content="You are a mathematician. Solve step by step.")] + state["messages"])
return {"messages": [r]}
def search_agent(state: RouterState) -> dict:
r = model.invoke([SystemMessage(content="You are a researcher. Answer with facts.")] + state["messages"])
return {"messages": [r]}
def creative_agent(state: RouterState) -> dict:
r = model.invoke([SystemMessage(content="You are a creative writer. Evocative language.")] + state["messages"])
return {"messages": [r]}
builder = StateGraph(RouterState)
builder.add_node("router", router_node)
builder.add_node("code", code_agent)
builder.add_node("math", math_agent)
builder.add_node("search", search_agent)
builder.add_node("creative", creative_agent)
builder.add_edge(START, "router")
builder.add_conditional_edges("router", lambda s: s["route"], {
"code": "code", "math": "math", "search": "search", "creative": "creative",
})
builder.add_edge("code", END)
builder.add_edge("math", END)
builder.add_edge("search", END)
builder.add_edge("creative", END)
graph = builder.compile()
result = graph.invoke({
"messages": [HumanMessage(content="Write a Python function that computes fibonacci")],
"route": "",
})
print(result["messages"][-1].content)
The structure: START → router → [conditional] → agent → END. There's no loop. There's no re-routing. Compared to Supervisor (which has Supervisor → Worker → Supervisor → ...), the Router is a two-step pipeline.
A Router with ReAct agents
In production, each agent can be a complete subgraph with its own tools using create_react_agent. The Router doesn't know which tools each agent has. It only knows: "if it's code, send it to code. If it's search, send it to search." This separation of responsibilities — the Router classifies, the agents execute with their own tools and loops — is what makes the Router scalable. Adding a new agent doesn't affect the existing ones.
Comparing the 4 Patterns
Now that you know all four patterns, this table gives you the complete map for design decisions:
| Criterion | Supervisor | Handoffs | Subagents | Router |
|---|---|---|---|---|
| Topology | Star (S → workers) | Chain (A → B → C) | Tree (parent → children) | Funnel (R → agents) |
| Who decides | The central supervisor | Each agent | The parent | The Router (once) |
| How many decisions | Multiple (a loop) | Per agent | The parent decides | Just one |
| Context sharing | Shared state | Inherited from the previous one | Isolated per child | Independent |
| Coordinates results | Yes (it aggregates) | No (the last one answers) | The parent aggregates | No (the agent answers directly) |
| Re-routing | Yes | Yes (handoff) | No | No |
| Parallel execution | Possible | Not natural | Natural | No (it picks one) |
| Latency overhead | High (N LLM calls) | Medium (transfers) | Medium (delegation) | Low (1 classification) |
| Debugging | Medium | Hard | Easy | Easy |
| Best for | Multi-step coordination | Conversational pipelines | Decomposable tasks | Diverse input → the right agent |
When to use each
Does the task require MULTIPLE agents to contribute?
└─ No → Could the input go to very different agents?
└─ Yes → ROUTER ✓
└─ No → A single agent is enough
└─ Yes → Do the agents need dynamic coordination?
└─ Yes → Do you need centralized control?
└─ Yes → SUPERVISOR ✓
└─ No → HANDOFFS ✓
└─ No → Are the sub-tasks independent?
└─ Yes → SUBAGENTS ✓
└─ No → SUPERVISOR ✓
The fundamental difference
The Router picks which agent. The Supervisor picks which agent and when and how many times and in what order. The Router is a switch at the start of the pipeline. The Supervisor is a while loop with dynamic decisions on every iteration.
Composing Patterns
In production, real systems combine patterns in layers. The Router is especially powerful as the first layer because it's cheap and fast.
Router → Supervisor
┌──────────────────────────────────────────────────────┐
│ ROUTER │
│ "Research RAG techniques and write a report" │
│ → Category: research_task │
└───────┬──────────────────┬───────────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────────────────┐
│ Simple Q&A │ │ SUPERVISOR │
│ Agent │ │ ├── Researcher worker │
│ (answers │ │ ├── Analyst worker │
│ directly) │ │ └── Writer worker │
└──────────────┘ └──────────────────────────┘
The Router keeps a simple question ("What is RAG?") from firing off a 4-agent pipeline. It classifies it as simple_qa and answers in one hop. Only complex tasks activate the Supervisor.
Router → Subagents
ROUTER: "Compare Python vs Rust for this case"
→ research_comparison
PARENT (research_comparison):
├── Subagent: research_python (isolated)
├── Subagent: research_rust (isolated)
└── Aggregate and compare the results
Router + internal Handoffs
ROUTER: "I was charged twice"
→ customer_support_team
CUSTOMER SUPPORT TEAM (internal handoffs):
General → Billing → Technical → Billing → END
The composition rule
Router = "Where does this go?" (classification)
Supervisor = "Who does what and when?" (coordination)
Handoffs = "Here, you continue" (transfer)
Subagents = "Do this and bring me the result" (delegation)
Typical layers: Layer 1 (Router) classifies the request → Layer 2 (Supervisor/Subagents) orchestrates the agents → Layer 3 (Handoffs) internal communication between the agents of a team. Not every layer is necessary — composition exists for when the complexity demands it.
Connection to the Project
In this module's project (capsule 08), Research Agent v5 uses pattern composition. The Router plays a specific role:
- The Router as the entry point: It classifies whether the input is a simple question (one agent answers directly) or a complex research task (it activates the multi-agent system)
- It avoids unnecessary overhead: "What is RAG?" doesn't need 4 coordinated agents. The Router detects
simple_qaand answers in one hop - Router → Supervisor → Subagents composition: For complex tasks, the Router activates the Supervisor, which coordinates the Researcher, Analyst, and Writer as subagents
| Capsule | Connection with the Router |
|---|---|
| 02 — Supervisor | The Router can route to the Supervisor for complex tasks |
| 03 — Handoffs | Post-Router, a team can use internal handoffs |
| 04 — Subagents | The Router can send to a parent that launches subagents |
| 06 — Shared vs Isolated | The Router doesn't need shared state — each agent operates independently |
| 07 — Advanced Orchestration | Composing Router + Supervisor + Subagents |
Troubleshooting
Problem 1: The Router misclassifies ambiguous inputs
Cause: "Write a program that computes statistics" — is that code or math?
Solution: Add disambiguation rules to the prompt: "If it asks to CREATE something (code, a program, a script), classify it as code even if the content is mathematical." If the ambiguity persists, use confidence and route to clarify when it's low.
Problem 2: The deterministic router fails on language variations
Cause: Keywords like "search" don't match "could you find me information about...".
Solution: Migrate to hybrid. If the deterministic router doesn't match (score 0), fall back to the LLM, which understands synonyms and natural variations.
Problem 3: Adding a new agent requires changing everything
Solution: Centralize the configuration:
AGENTS = {
"code": {"description": "Programming, scripts, debugging", "node": code_agent},
"math": {"description": "Calculations, equations", "node": math_agent},
}
categories = list(AGENTS.keys())
descriptions = "\n".join(f"- {k}: {v['description']}" for k, v in AGENTS.items())
Adding an agent = adding an entry to the dictionary. The prompt, the Literal, and the edges get generated dynamically.
Problem 4: The LLM-based Router adds too much latency
Solution: Use a hybrid Router. Cache the decisions for frequent inputs. Use a small model (gpt-4.1-nano) for classification — you don't need a powerful model to pick among 4 categories.
Problem 5: The right agent gets the input but produces a poor answer
Cause: The Router classified correctly, but the agent fails.
Solution: That isn't a Router problem — it's the agent's. The Router guarantees the input reaches the right agent, not that the agent solves it well. Debug the agent in isolation, outside the routing system.
Exercises
Exercise 1: A basic deterministic Router (Easy)
Implement a deterministic Router with 3 categories: translate (translation), summarize (summary), explain (explanation). Define keywords for each. Connect it to 3 simple agents with LangGraph.
See solution
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain.chat_models import init_chat_model
from langchain_core.messages import SystemMessage, HumanMessage
model = init_chat_model("openai:gpt-4.1-mini")
ROUTE_MAP = {
"translate": ["translate", "translation", "into english", "into spanish", "in french"],
"summarize": ["summarize", "summary", "sum up", "synthesize", "key points"],
"explain": ["explain", "explanation", "what is", "how does", "why"],
}
class State(TypedDict):
messages: Annotated[list, add_messages]
route: str
def router(state: State) -> dict:
text = state["messages"][-1].content.lower()
scores = {cat: sum(1 for kw in kws if kw in text) for cat, kws in ROUTE_MAP.items()}
return {"route": max(scores, key=scores.get) if max(scores.values()) > 0 else "explain"}
def make_agent(prompt):
def node(state: State) -> dict:
return {"messages": [model.invoke([SystemMessage(content=prompt)] + state["messages"])]}
return node
builder = StateGraph(State)
builder.add_node("router", router)
builder.add_node("translate", make_agent("Translate into the requested language."))
builder.add_node("summarize", make_agent("Summarize in 3-5 key points."))
builder.add_node("explain", make_agent("Explain clearly and accessibly."))
builder.add_edge(START, "router")
builder.add_conditional_edges("router", lambda s: s["route"], {
"translate": "translate", "summarize": "summarize", "explain": "explain",
})
for n in ["translate", "summarize", "explain"]:
builder.add_edge(n, END)
graph = builder.compile()
result = graph.invoke({"messages": [HumanMessage(content="Translate this into Spanish: Hello world")], "route": ""})
print(result["messages"][-1].content)
Each input gets classified by keywords and sent to the right agent without using an LLM for the classification.
Exercise 2: An LLM-based Router with confidence (Medium)
Create an LLM-based Router with a RouteDecision (route, confidence, reasoning). If confidence < 0.6, route to a clarify node that asks the user to rephrase. Test it with "Help me with this".
See solution
from pydantic import BaseModel, Field
class RouteDecision(BaseModel):
route: Literal["code", "math", "search", "creative"] = Field(description="Category")
confidence: float = Field(description="Confidence 0.0-1.0", ge=0.0, le=1.0)
reasoning: str = Field(description="Justification")
router_model = model.with_structured_output(RouteDecision)
def router(state: State) -> dict:
decision = router_model.invoke([
SystemMessage(content="Classify: code, math, search, creative. If it's vague, low confidence."),
state["messages"][-1]
])
route = "clarify" if decision.confidence < 0.6 else decision.route
return {"route": route, "confidence": decision.confidence}
def clarify_node(state: State) -> dict:
return {"messages": [HumanMessage(
content="I'm not sure how to help you. Do you need code, calculations, information, or creative writing?",
name="assistant"
)]}
builder = StateGraph(State)
builder.add_node("router", router)
builder.add_node("clarify", clarify_node)
builder.add_node("code", make_agent("You are a programming expert."))
builder.add_node("search", make_agent("You are a researcher."))
builder.add_edge(START, "router")
builder.add_conditional_edges("router", lambda s: s["route"], {
"code": "code", "math": "code", "search": "search", "creative": "search", "clarify": "clarify",
})
for n in ["code", "search", "clarify"]:
builder.add_edge(n, END)
graph = builder.compile()
result = graph.invoke({"messages": [HumanMessage(content="Help me with this")], "route": ""})
print(f"Route: {result['route']}, Response: {result['messages'][-1].content}")
With "Help me with this", the confidence will be low → the system asks for clarification instead of guessing.
Exercise 3: A hybrid Router (Medium)
Combine deterministic + LLM: if the deterministic router has a score ≥ 2 with a single winner, use it. If not, use the LLM. Measure how many of 5 inputs get resolved without an LLM.
See solution
def hybrid_router(state: State) -> dict:
text = state["messages"][-1].content.lower()
scores = {cat: sum(1 for kw in kws if kw in text) for cat, kws in ROUTE_MAP.items()}
max_score = max(scores.values())
winners = [cat for cat, s in scores.items() if s == max_score]
if max_score >= 2 and len(winners) == 1:
return {"route": winners[0], "method": "deterministic"}
decision = router_model.invoke([
SystemMessage(content="Classify: code, math, search, creative."),
state["messages"][-1]
])
return {"route": decision.route, "method": "llm"}
test_inputs = [
"Write Python code that computes fibonacci", # code (2+ kw) → deterministic
"Calculate the average and the statistics of this", # math (2+ kw) → deterministic
"Could you help me with a problem?", # ambiguous → llm
"Search what machine learning is and research it", # search (3 kw) → deterministic
"I'd like something creative about space", # creative (1 kw) → llm
]
det_count = 0
for inp in test_inputs:
result = hybrid_router({"messages": [HumanMessage(content=inp)]})
if result.get("method") == "deterministic":
det_count += 1
print(f" '{inp[:40]}...' → {result['route']} ({result['method']})")
print(f"\nWithout an LLM: {det_count}/{len(test_inputs)}")
Typical result: 3 of 5 without an LLM (60% savings in LLM calls).
Exercise 4: A Router with ReAct agents as subgraphs (Hard)
Create a Router that routes to two complex agents: code_agent (a ReAct agent with an execute_python tool) and qa_agent (a ReAct agent with a search_web tool). Each agent should be a compiled subgraph with a reason-act loop. Use build_react_agent (as in capsule 04) to build each child, and wrappers that invoke the subgraph and return messages[-1].
See solution
from langchain_core.tools import tool
from langchain_core.messages import ToolMessage
@tool
def execute_python(code: str) -> str:
"""Run simple Python code."""
try:
return str(eval(code))
except Exception as e:
return f"Error: {e}"
@tool
def search_web(query: str) -> str:
"""Search the web for information."""
return f"Results for '{query}': relevant information found."
def build_react_agent(system_prompt, tools_list):
model_with_tools = model.bind_tools(tools_list)
tools_map = {t.name: t for t in tools_list}
class S(TypedDict):
messages: Annotated[list, add_messages]
def reason(state):
return {"messages": [model_with_tools.invoke(
[SystemMessage(content=system_prompt)] + state["messages"]
)]}
def exec_tools(state):
last = state["messages"][-1]
return {"messages": [
ToolMessage(content=str(tools_map[tc["name"]].invoke(tc["args"])), tool_call_id=tc["id"])
for tc in last.tool_calls
]}
def should_continue(state):
last = state["messages"][-1]
return "tools" if hasattr(last, "tool_calls") and last.tool_calls else "done"
g = StateGraph(S)
g.add_node("reason", reason)
g.add_node("tools", exec_tools)
g.add_edge(START, "reason")
g.add_conditional_edges("reason", should_continue, {"tools": "tools", "done": END})
g.add_edge("tools", "reason")
return g.compile()
code_sub = build_react_agent("You are a Python expert.", [execute_python])
qa_sub = build_react_agent("You are a researcher.", [search_web])
builder = StateGraph(RouterState)
builder.add_node("router", router_node)
builder.add_node("code", lambda s: {"messages": [code_sub.invoke({"messages": s["messages"]})["messages"][-1]]})
builder.add_node("qa", lambda s: {"messages": [qa_sub.invoke({"messages": s["messages"]})["messages"][-1]]})
builder.add_edge(START, "router")
builder.add_conditional_edges("router", lambda s: s["route"], {"code": "code", "qa": "qa"})
builder.add_edge("code", END)
builder.add_edge("qa", END)
graph = builder.compile()
Each agent has its own ReAct loop with tools. The Router only classifies and routes.
Exercise 5: Router → Supervisor composition (Hard)
Build a system where the Router classifies into simple_qa or research. simple_qa goes to a direct agent. research activates a mini-Supervisor with a researcher and a writer. Test it with: "What is Python?" (simple) and "Research testing in Python and write a report" (research).
See solution
class TopRoute(BaseModel):
route: Literal["simple_qa", "research"] = Field(
description="simple_qa for direct questions, research for complex tasks"
)
top_router = model.with_structured_output(TopRoute)
class SystemState(TypedDict):
messages: Annotated[list, add_messages]
route: str
next_worker: str
task_complete: bool
def router_node(state: SystemState) -> dict:
decision = top_router.invoke([
SystemMessage(content="Classify: simple_qa (a direct question) or research (multi-step research)."),
state["messages"][-1]
])
return {"route": decision.route}
def simple_qa(state: SystemState) -> dict:
r = model.invoke([SystemMessage(content="Answer concisely and directly.")] + state["messages"])
return {"messages": [r]}
class WorkerDecision(BaseModel):
next_worker: Literal["researcher", "writer", "FINISH"] = Field(description="The next one")
sup_model = model.with_structured_output(WorkerDecision)
def supervisor(state: SystemState) -> dict:
decision = sup_model.invoke([
SystemMessage(content="You coordinate a researcher and a writer. Research, then write, then FINISH."),
*state["messages"]
])
is_done = decision.next_worker == "FINISH"
return {"next_worker": decision.next_worker.lower(), "task_complete": is_done,
"messages": [HumanMessage(content=f"[supervisor] → {decision.next_worker}", name="supervisor")]}
def researcher(state: SystemState) -> dict:
r = model.invoke([SystemMessage(content="Research this in detail.")] + state["messages"])
return {"messages": [HumanMessage(content=f"[researcher] {r.content}", name="researcher")]}
def writer(state: SystemState) -> dict:
r = model.invoke([SystemMessage(content="Write a report based on the research.")] + state["messages"])
return {"messages": [HumanMessage(content=f"[writer] {r.content}", name="writer")]}
builder = StateGraph(SystemState)
builder.add_node("router", router_node)
builder.add_node("simple_qa", simple_qa)
builder.add_node("supervisor", supervisor)
builder.add_node("researcher", researcher)
builder.add_node("writer", writer)
builder.add_edge(START, "router")
builder.add_conditional_edges("router", lambda s: s["route"], {
"simple_qa": "simple_qa", "research": "supervisor"
})
builder.add_edge("simple_qa", END)
builder.add_conditional_edges("supervisor",
lambda s: "end" if s.get("task_complete") else s["next_worker"],
{"researcher": "researcher", "writer": "writer", "end": END})
builder.add_edge("researcher", "supervisor")
builder.add_edge("writer", "supervisor")
graph = builder.compile()
for q in ["What is Python?", "Research testing in Python and write a report"]:
result = graph.invoke({
"messages": [HumanMessage(content=q)], "route": "", "next_worker": "", "task_complete": False,
})
print(f"Input: {q} → Route: {result['route']}")
print(f"Response: {result['messages'][-1].content[:120]}...\n")
The simple question: router → simple_qa → END (2 LLM calls). The complex task: router → supervisor → researcher → supervisor → writer → supervisor → END (6+ LLM calls). The Router avoids the Supervisor's overhead when it isn't necessary.
Summary
In this capsule you learned:
- The Router pattern classifies and routes, without coordinating or executing. It's a
switchat the start of the pipeline: one decision, once, and it exits the flow. Unlike Supervisor (a dynamic loop), Handoffs (a chain), or Subagents (delegation), the Router is the simplest and cheapest pattern - The deterministic Router uses keywords, regex, or fixed categories. Latency ~0ms, cost 0 tokens, but it doesn't handle natural language variations or ambiguity
- The LLM-based Router classifies by semantic intent with structured output. More flexible, but it adds ~300-500ms and tokens per request
- The hybrid router combines both: deterministic first, the LLM as a fallback. 60-70% of requests with no LLM — better latency and lower cost
- Comparing the 4 patterns: Supervisor for multi-step coordination, Handoffs for conversational pipelines, Subagents for decomposable tasks with isolation, Router for diverse input → the right agent
- Composing patterns is how real systems work. Router as the first layer (cheap classification) → Supervisor/Subagents as the second layer (orchestration) → Handoffs as internal communication
- The Router doesn't guarantee the agent solves it well — only that the input reaches the right agent. If the agent fails, debug the agent, not the Router
Next capsule: Shared State vs Isolated State — the design of communication between agents. Does everyone see everything (shared state) or does each one see only its own (isolated state)? This decision affects context bloat, debugging, and quality.
Additional Resources
- LangGraph Multi-Agent — Routing — Official documentation for the routing pattern in multi-agent systems
- LangGraph Conditional Edges — A guide to conditional edges, the technical foundation of the Router in LangGraph
- Structured Output — LangChain — The
with_structured_outputreference for typed classification - LangGraph Multi-Agent Architectures — A complete comparison of patterns: supervisor, handoffs, subgraphs, routing
- Semantic Router (GitHub) — A library specialized in semantic routing as an alternative to the LLM-based router