Applied AI · Multi-Agent Systems
How to take one overloaded “god agent” and split it into specialized sub-agents that hand off cleanly — and why every serious production system is built this way.
A single LLM told to do everything — classify, sanitize, call tools, enforce security, format replies — fails stochastically: its attention spreads too thin and it starts breaking rules at random. The fix is architectural, not a better prompt. Separate the concerns into small agents, give each only the tools it needs, and move state between them through a typed contract. The absence of a capability is the only guarantee it can’t be misused.
| Type | Shape | Use |
|---|---|---|
| Swarm | peer-to-peer, no boss | brainstorm / research only; enterprise = catastrophe |
| Hierarchical | supervisor → workers | gold standard for enterprise |
| Pipeline | A → B → C, linear | fixed sequential transforms |
ticket_type, pii_detected, sanitized_input).add_messages built-in for messages; define custom reducers for every
other field before testing.| Agent | Tools | Owns |
|---|---|---|
| Triage (immune system) | ZERO | raw/sanitized input, pii_detected, injection_detected, ticket_type, is_safe |
| Tech Support (specialist) | CRM lookup, KB search | tool_results, final_response, summary |
.compile() → parent gets an
uncompiled blueprint, not a runnable graph. compile() validates structure + resolves edges.
Non-negotiable.
A support agent that worked in the demo and quietly rotted in production — and the architecture that fixes it.
Picture the agent from the last session. One language model, one long system prompt. It masks personal data, spots prompt-injection attempts, classifies the ticket, calls the CRM, formats a clean reply. In the demo it looked great. Then the knowledge base grew, three more tools got added, and one day it started issuing refunds when a customer merely asked about a bill. Nobody changed the refund logic. The prompt still said “do not issue refunds during classification.” It broke anyway — on maybe three requests in a hundred. That failure is the whole reason this lesson exists.
A monolithic agent is a single LLM call, carrying one system prompt, made responsible for every job in the workflow. Teams reach it naturally: build a proof-of-concept with one model and a long prompt, then keep bolting on responsibilities. Need billing? Add billing instructions. Need inventory? Add inventory instructions. Need security? Add security rules. The prompt accretes duties until it becomes what practitioners call a god agent.
A god agent is the kitchen junk drawer. You keep dropping in miscellaneous things “just for now,” and eventually it contains everything and is useful for nothing — you can never find the one thing you need. It’s also the fresh-startup hire handed five job descriptions (receptionist, accountant, engineer, HR, security) and told to pick the right one per situation. Not a talent failure — a structural one.
The core distinction: a monolithic Python module that has a bug is deterministically wrong — the bug fires every time, so you find it and fix it. A monolithic LLM agent is stochastically wrong — it fires the same bug with some probability on every call, and that probability climbs with scale, load, and time. You can pass every test and still ship a system that misbehaves 3% of the time in the wild.
Context dilution is the failure where a model’s attention is spread so thinly across a long prompt that critical instructions receive almost no weight. LLMs are attention-based: they can attend to any piece of the context window, but that attention is a finite budget divided among everything present. A tight 500-token prompt gives the model sharp resolution on every rule. Stretch the same rules into a 10,000-token prompt and the instructions near the edges become, in practice, invisible.
As the instructor framed it: an instruction at token position k receives attention roughly proportional to 1/k² relative to an instruction at position 1 — instruction-following accuracy falling off with (roughly) the square of the distance from the most-attended region. So in a 10,000-token prompt the last stretch of instructions can sit at a small fraction of the weight the opening tokens get. The model doesn’t forget those rules — it simply stops attending to them reliably.
The real-world symptoms are exactly what you’d predict from attention collapse: an agent that issues refunds on a billing question (the refund tool sat near the classification rules, which drifted out of the attention window); an agent that calls a CRM tool for a plain knowledge-base query; an agent that spits raw JSON when asked a natural-language question. None of these are “the model is dumb” — they’re the geometry of a bloated prompt.
Every tool you bind forces the model to solve an n-way classification (“which of these do I call?”) on every single turn. This isn’t linear. Similar tools create semantic interference in the model’s selection process, so the risk of a wrong pick scales with the number of pairs the model could confuse — and pairs grow quadratically.
Confusable pairs = 50C2 = (50×49)/2 = 1,225. Every extra tool crowds the decision space and raises the odds of a mis-route or a wrong parameter.
Each agent chooses within its own small set, so confusion points add instead of multiplying: 3 + 5 + 4 = 12 local decisions. The quadratic term never forms.
Same total capability, radically different failure surface. Splitting the toolbox is the single cheapest way to buy reliability.
Separation of Concerns (SOC) is the plain idea that a system should be divided into distinct sections, each addressing one concern — one piece of information that affects behavior. It was articulated by Edsger W. Dijkstra around 1974. SOC doesn’t tell you how to separate; only that you must.
In ordinary software, violating SOC gives you code that’s hard to read and maintain. In LLM systems it gives you something worse: a system that is probabilistically incorrect. That’s the reason the principle takes on new urgency here.
Single Responsibility Principle (SRP) — from Robert C. Martin’s SOLID set — sharpens SOC into: a class (here, an agent) should have exactly one reason to change. So the triage agent exists to sanitize and classify; the tech-support agent exists to resolve issues; a billing agent exists to process billing. When new PII patterns appear, only triage changes. When a new CRM tool lands, only tech support changes. The others are untouched.
Before deploying any agent, answer one question: if this component is corrupted or misbehaves, what is the scope of the damage? In a monolith the blast radius is the entire system. In a properly scoped design it’s bounded to that one agent’s job — a triage malfunction can only hurt classification, never resolution. This calculation is a standard, expected step in AI architecture review.
There’s also a hard cost argument. A 10,000-token system prompt is re-sent on every agentic turn — every tool call, every routing decision, every response. At 10,000 requests a day that’s on the order of 100 million tokens/day spent on prompt overhead alone. Split those instructions across a 500-token triage prompt and a ~1,000-token tech-support prompt and the average cost per request drops by roughly an order of magnitude.
Early web apps were monoliths: front-end, business logic, DB access, auth, caching in one process. Change the auth logic and you redeploy the whole server. The industry moved to independently deployable microservices with clean, versioned interfaces for exactly the reasons above. Multi-agent decomposition is that same migration, one layer up.
These aren’t hypothetical — they’re documented patterns. Each is a role bleeding past its mandate because attention on the “stay in your lane” instruction has decayed.
A classify-and-route agent, given retrieval tools “to route better,” starts answering directly. Its attention began treating information-retrieval tools as response-generation tools.
A summarization agent with CRM access “for context” starts firing those tools at the wrong times — “look up customer” and “summarize conversation” share too much semantic overlap.
Worst case. A security filter that also has reporting tools drifts into general assistant behavior under long adversarial input that shoves the security rules to the context edge.
If an agent with n tools has a per-call base error rate, the probability of at least one mis-route across N daily requests approaches certainty as N grows. With a monolith it’s not a question of whether one of these modes fires in production — only when. That’s why the answer is structural, not “write a firmer prompt.”
A topology is the communication pattern between your agents — who is allowed to talk to whom. Choosing it is as consequential as choosing a data model for a database: pick wrong and the system is structurally incapable of meeting enterprise needs (determinism, auditability, cost bounds).
Any agent can invoke any other agent; behavior is emergent from local decisions, and the group converges by something like majority voting. Genuinely useful for creative brainstorming, exploratory research, or simulation — where non-prescriptive, organic exploration is the point.
Everyone speaks when they feel like it. The topic wanders, decisions get contradicted, and the group eventually reaches some conclusion — but the path there is different every time. For an enterprise system that must produce identical outputs for identical inputs and be audited, that’s unacceptable.
With no central authority, no one owns state consistency. Multiple agents mutate shared state at once (race conditions), and A→B→C→A circular routing isn’t just possible — without explicit loop detection it’s structurally guaranteed. There’s no natural termination, so calls and cost are unbounded, and incidents can’t be reproduced. If someone describes “collaborative agents” and the owner of state is “everyone,” it’s failing in production.
A single supervisor holds exclusive routing authority. It dispatches work to specialized workers based on the current state, each worker executes only its function and returns results only to the supervisor — never to another worker. The supervisor integrates results, updates shared state, and makes the next routing decision. This is the gold standard for enterprise systems and the topology this course builds.
The physician (supervisor) evaluates the patient and dispatches orders: bloodwork to the lab, an X-ray to radiology, a prescription to pharmacy. The lab never calls radiology; pharmacy never orders bloodwork. Each specialist does its task and reports back. The physician integrates and decides the next step. Single source of truth, deterministic path, clean audit trail.
The simplest topology: a fixed sequence where each agent’s output is the next agent’s input. A processes raw input into enriched state, B applies its transform, and so on — like an Airflow DAG or an assembly line. No head, but also no branching intelligence.
A handoff is not a function call or a message — it’s the transfer of custody of a structured state object from one agent to the next, governed by a schema contract both agents agree to. Get it wrong and you get the most common class of production failure in multi-agent systems, almost always because someone treated the handoff as an informal string instead of a typed protocol.
The rule that kills ambiguity: the receiving agent must never infer, assume, or recompute anything the
sender should have handed it. If triage classified the ticket as technical, tech support receives
ticket_type = "technical" as an explicit field — not as something it must re-derive from the
conversation text. If PII was masked, it receives pii_detected = True and
sanitized_input as explicit fields.
from typing import TypedDict, Literal
# The handoff contract. Both agents agree to these fields and types.
class SupportState(TypedDict):
raw_input: str
sanitized_input: str # written by triage
pii_detected: bool # written by triage
injection_detected: bool # written by triage
ticket_type: Literal["technical", "billing", "fraud", "general"]
is_safe: bool # triage's final safety verdict
# ...downstream fields owned by tech support live here too
Python TypedDict is not enforced at runtime. If the sending agent forgets to write an
expected field, the receiver blows up with a KeyError — the single most common bug in early
multi-agent code. The contract principle: agent A must leave state in a condition agent B can operate on with
zero ambiguity. In production, wrap the TypedDict with Pydantic validation so required fields
and value constraints are actually checked, not just hinted. [+]
State ownership is the multi-agent version of variable scope. Just as a well-designed function
reads and writes only its own locals and declared parameters, a well-designed agent touches only the state
fields in its scope. Triage owns sanitized_input, pii_detected,
injection_detected, ticket_type, is_safe. Tech support owns
tool_results, final_response, summary. Neither writes the other’s
fields.
This isn’t just tidiness. If tech support could write is_safe or
pii_detected, an adversarially crafted tool result could corrupt the security state and
make the master graph mis-route future requests. Field ownership is a security boundary, not a style
preference.
A reducer is a function that decides how concurrent writes to a state field are merged. When
agents run in parallel, a field with no reducer defaults to plain overwrite — last writer wins
— which silently loses data depending on who happened to finish last. LangGraph ships
add_messages as the built-in reducer for the messages field; for every other field you must define
reducers explicitly, and you must do it before you test, not after you’re debugging a data-loss
incident.
Say both subgraphs write to a shared tool_results field. Triage writes
{"pii_scan": "clean", "injection": "none"}; later tech support writes
{"balance": 998, ...}. With no reducer, the second write replaces the first —
triage’s security findings vanish, and nothing errors. The fix is a reducer that appends
instead of overwrites. This is exercise 3.1 below.
Tool scoping means binding an agent exactly the tools it needs and no others — the AI translation of the principle of least privilege from information security: a process gets exactly the permissions it needs, not one more. Applied to LLMs, this goes beyond access control; it directly changes correctness and predictability.
A language model cannot misuse a tool it was never shown. Tool hallucination — an LLM generating a call for a purpose the tool wasn’t meant for — is structurally impossible if the tool isn’t bound. That’s why the triage agent is given exactly zero tools. Not few, not “harmless” ones — zero.
Engineers keep trying to substitute a strong prompt rule (“do not use the refund tool for classification”) for actual tool absence. It doesn’t hold. LLMs are probabilistic: they will violate any natural-language instruction with some non-zero probability, and across thousands of calls that violation becomes statistically guaranteed. The only reliable mitigation is architectural: if triage should never refund, don’t bind the refund tool to triage.
A real failure: a refund tool was copy-pasted into the triage agent’s tool list by accident. The prompt clearly said “you are a classification agent, do not initiate refunds.” It behaved for 97% of requests — and on the ~3% involving billing complaints, it issued refunds unprompted. At 10,000 requests/day that’s ~300 unauthorized refunds/day. The fix took 30 seconds (delete the binding); the incident report took much longer. Every tool you add “just in case” expands the attack surface, the prompt, the attention dilution, and the cost. If you can’t name which node calls a tool and exactly why, it shouldn’t be in the system.
Processes everything from the outside world before any other agent touches it. Zero tools by design.
is_safe from the combined resultsReceives a clean, pre-validated, pre-classified ticket and focuses only on resolving it. It doesn’t know how intake worked — that ignorance is intentional.
Reads: sanitized_input, ticket_type, summary.
Owns: tool_results, final_response, summary.
Tools: get_customer_details (CRM), search_knowledge_base.
The prompts follow the same logic as the tools. Triage’s system prompt is surgical: classification instructions for the four ticket types, PII-handling rules, and nothing else — no CRM instructions, no tool talk. Tech support’s prompt is purely technical: how to use its tools, interpret KB results, format resolutions, handle tool failures — and zero security or PII content, because triage already handled that. Short prompts mean high attention on what matters.
A subgraph is a fully compiled StateGraph embedded into a parent graph as a single node. LangGraph gives you two options: one unified graph where every node shares a single state definition, or subgraphs where each agent is its own compiled graph plugged into a master graph.
The unified graph rots into a spaghetti problem faster than people expect. Eight nodes with three transitions each is already 24 conditional edges; the number of paths grows roughly as n!. Five developers editing one graph file means constant merge conflicts and no single person who can reason about the whole thing — the exact organizational failure that pushed the industry from monoliths to microservices.
Subgraphs fix this by making ownership enforceable in code. To the parent, a subgraph is a black box: it receives a state object, returns a modified one, and its internal nodes/edges are entirely opaque. Team A owns triage, team B owns tech support, team C owns the master orchestration and the contracts between them.
from langgraph.graph import StateGraph, START, END
# --- triage subgraph ---
triage = StateGraph(SupportState)
triage.add_node("ingress", ingress_node) # PII + injection scan
triage.add_node("classify", classify_node) # LLM @ temp=0
triage.add_edge(START, "ingress")
triage.add_conditional_edges("ingress", route_after_ingress) # blocked? -> END
triage.add_edge("classify", END)
triage_graph = triage.compile() # NON-NEGOTIABLE
# --- embed it in the master graph as ONE node ---
master = StateGraph(SupportState)
master.add_node("triage", triage_graph) # subgraph = a node
master.add_node("tech_support", tech_graph)
master.add_conditional_edges("triage", route_after_triage) # by ticket_type
master_app = master.compile(checkpointer=checkpointer)
If you hand the parent an uncompiled StateGraph, it receives a blueprint, not a
runnable graph — and execution fails. .compile() validates the graph structure and resolves
edge conditions; it must run on every subgraph before embedding. Also: parent and subgraph state must share at
least one overlapping key for data to flow. In practice the subgraph state is a subset and the master
state is the superset of all fields.
Put together, it’s a supervisor/worker hierarchy. The master graph holds the full shared state and two subgraphs. Everything from the outside enters triage; only safe, classified requests reach a worker.
A Python bug is deterministic — it fires every time on the same input, so tests catch it and a fix is permanent. A monolith LLM agent is stochastic: the same “bug” (a mis-route, a wrong tool call) fires with some probability on every call, and that probability rises with scale, load, and time. You can pass all your tests and still misbehave a few percent of the time in production, where it’s far harder to reproduce and diagnose.
Attention is a finite budget spread across the whole prompt. Lengthen the prompt and each instruction gets a smaller share — roughly proportional to 1/k² of its position — so rules near the edges receive near-zero weight and stop being followed reliably. The model doesn’t forget them; it stops attending to them. Shorter, focused prompts per agent restore that attention.
The risk isn’t about the count of tools but the number of pairs the model could confuse, since similar tools interfere semantically. n tools give roughly nC2 confusable pairs — 50 tools is 1,225. Splitting the toolbox across agents means each agent chooses within a small set, so confusion points add (3+5+4=12) instead of forming the quadratic term.
LLMs are probabilistic and will violate any natural-language instruction with some non-zero probability; across thousands of calls that violation is statistically guaranteed. A prompt saying “never use the refund tool” still leaves the tool callable. The only reliable mitigation is architectural — don’t bind the tool. A model cannot misuse a capability it was never given.
No central authority means no owner of state consistency, so parallel writes race, and circular routing (A→B→C→A) is structurally guaranteed without explicit loop detection. There’s no natural termination, making calls and cost unbounded, and the non-deterministic call sequence makes incidents impossible to reproduce or audit. Enterprise systems need identical outputs for identical inputs — swarms can’t promise that. They’re fine for brainstorming and research.
A formal handoff transfers a typed state object under a schema contract, with every field the receiver
needs written explicitly by the sender — no inference or re-derivation. Without it, agents pass
informal strings, the receiver assumes a field exists, and you get a runtime KeyError —
the most common early multi-agent bug, because Python TypedDict isn’t enforced at runtime. Production
adds Pydantic to actually validate the contract.
Any time more than one agent can write the same state field under parallel execution. Without a reducer the
field defaults to overwrite — last writer wins — so an earlier agent’s data is silently
lost depending on completion order. LangGraph provides add_messages for the messages field;
define custom reducers (e.g. append) for every other shared field before testing, not after a data-loss
incident.
.compile() validates the graph structure and resolves edge conditions, producing a runnable
object; hand the parent an uncompiled StateGraph and it gets a blueprint that fails at execution. The parent
and subgraph must share at least one overlapping state key so data can flow; typically the subgraph state is
a subset of fields and the master state is the superset of all subgraphs’ fields.
“What’s the blast radius?” — if this component is corrupted or misbehaves, what’s the scope of the damage? In a monolith it’s the whole system; in a scoped design it’s bounded to that agent’s single responsibility. It’s a standard pre-deploy check, and being unable to answer it is a sign the system isn’t decomposed enough to ship.
Reading proves you recognize the idea. These prove you can use it. Attempt each before opening the walkthrough.
Both subgraphs write findings into a shared findings state field. Triage writes its security
scan; tech support later writes its tool results. With the default overwrite, triage’s findings vanish.
Make both survive.
Requirements
merge_findings(old, new) that appends rather than replaces.findings field on the state so LangGraph uses your reducer.findings must contain both entries, triage
first.# triage node returns:
{"findings": [{"pii_scan": "clean", "injection": "none"}]}
# tech-support node later returns:
{"findings": [{"customer": "C10002", "balance": 998, "status": "past_due"}]}
# EXPECTED final state["findings"] -> a list of length 2, triage entry first
Step 1 — see why the default loses data. A field with no reducer uses “last write wins.” The tech-support return value replaces the triage list wholesale, and nothing raises — the security findings are just gone. That silence is what makes it dangerous.
Step 2 — write an append reducer. A reducer takes the current value and the incoming
value and returns the merged result. For lists, concatenate. Guard against None on the first
write.
Step 3 — attach it with Annotated. LangGraph reads the reducer from the
field’s type annotation. Without Annotated[..., merge_findings] it silently falls back to
overwrite — the annotation is the wiring.
Why it matters: the reducer is the merge policy for shared state. Define it before you test parallel paths — discovering you needed one while debugging a production data-loss incident is the expensive way to learn this.
Below is a triage agent that only classifies tickets. It works in every test. In production, on a small fraction of billing complaints, it issues refunds no one asked for. Predict the exact failure, name the root cause, and fix it in one line. Assume ~10,000 requests/day, ~3% billing complaints.
SYSTEM_PROMPT = """You are a CLASSIFICATION agent.
Return one of: technical | billing | fraud | general.
Do NOT initiate refunds. Do NOT call the refund tool."""
tools = [classify_ticket, search_kb, issue_refund] # copy-pasted list
triage_llm = llm.bind_tools(tools) # temp defaults
def triage_node(state: SupportState) -> dict:
msg = triage_llm.invoke([SYSTEM_PROMPT, state["raw_input"]])
return {"ticket_type": msg.content, "is_safe": True}
Predict the failure first. No exception is raised — that’s the trap. On
roughly 3% of requests (the billing complaints, where the refund tool is semantically closest to the input),
the model emits a real issue_refund tool call. At 10,000 req/day × 3% ≈
~300 unauthorized refunds per day, silently, while every unit test passes.
Root cause — name it precisely. The refund tool is bound to an agent that
should never call it. The prompt rule (“do not initiate refunds”) is a natural-language
instruction, and an LLM violates any such instruction with non-zero probability; across thousands of calls
that violation becomes statistically guaranteed. The prompt is not a security boundary. Secondary smell: no
temperature=0, so classification isn’t even deterministic.
Fix — remove the capability, don’t argue with it.
Why it matters: a model cannot misuse a tool it was never shown. Tool absence — not a firmer prompt — is the only reliable guarantee. If you can’t name the node that calls a tool and exactly why, unbind it.