Multi-agent systems
Splitting work across agents, and when not to.
Instead of one agent working through a whole task, you can split it up: one agent plans and hands bounded pieces of work to others, each with its own instructions, its own tools and its own separate conversation. A research assistant that sends three searchers off on different questions and combines what they bring back is a multi-agent system.
The gain is focus — each agent's conversation stays short and on one subject, which is where models do their best work. The cost arrives immediately: several conversations instead of one means several bills, more waiting, and failures that are much harder to trace because no single transcript shows what happened.
It sits on top of the agent loop and should not be attempted before one loop works well. It overlaps with context management, since splitting work is one way of keeping each context small, and it leans on evaluation, because the only honest reason to adopt it is a measurement showing the split beat the single loop. Whether it usually does is genuinely unsettled.
Splitting a task across agents buys focused context and pays for it in cost, latency and traceability.
In more detailAn orchestrator delegating bounded sub-tasks to specialised agents can beat one flat loop, mainly by keeping each agent's context small and focused. It also multiplies cost, latency and failure modes. The honest default is a single well-built loop until you can measure that delegation wins.
Contested
Whether multi-agent architectures beat a single strong loop is genuinely unsettled and highly task-dependent.
Learn first
When more than one agent helps
By the end: You can decide honestly whether to split a system and defend the decision.
- Why splitting can help
- Orchestrator and worker
- What it costs
- Measuring rather than assuming
Splitting one agent into several is the fashionable move and often the wrong one: it multiplies cost, latency and the number of places a run can go wrong. The honest reason it sometimes helps is context. A single agent that researches, then analyses, then writes carries all of it in one window, and by the end the material it needs is buried among everything else. This section covers when that trade pays, the orchestrator-and-worker shape it usually takes, what it costs you in tokens and in debugging, and how to measure the difference rather than assume it.
The default is one agent with more tools#
Start from the answer you will give most of the time, because it is the one most candidates get wrong. When someone proposes a second agent, what they almost always want is a capability the first agent does not have. A capability is a tool. Adding a tool costs you a function and a schema entry. Adding an agent costs you a second system prompt, a second transcript, a second budget, a handoff format, and a new question every time the output is wrong: which of them did this.
So the rule is: if the work needs a new ability, add a tool. Split into a separate agent only when you need something a tool cannot give you, and there are exactly three of those. You need a different context window, because the material genuinely does not fit or contaminates the rest. You need independent work to run at the same time, so three searches take the time of one. Or you need a different set of permissions, so the part reading untrusted text has no ability to act on it.
What you want What to add
another data source a tool
a different output format a prompt instruction
step-by-step reasoning nothing, ask for it
a specialist persona a prompt section, not an agent
work that must run in parallel an agent
a context that must stay clean an agent
untrusted text handled without tools an agent, with no toolsThe cost of getting this wrong is visible in a few lines. Below, the same job is done by one agent holding three tools and by a researcher handing notes to a writer. Watch what the writer posts.
# A minimal agent loop, so the comparison below runs rather than being a sketch.
class Agent:
def __init__(self, name, tools):
self.name = name
self.tools = {t.__name__: t for t in tools}
self.calls = 0
def run(self, plan):
self.calls += 1 # the call that picks the first tool
out = []
for tool_name, arg in plan:
if tool_name not in self.tools:
out.append("ERROR: " + self.name + " has no tool " + tool_name)
continue
out.append(self.tools[tool_name](arg))
self.calls += 1 # the call that reads the result
return " | ".join(out)
def search_docs(q): return "policy: refunds within 30 days"
def run_sql(q): return "sql: 412 refunds in March, 38 over policy"
def post_summary(s): return "posted: " + s[:44] # the channel truncates
RESEARCH = [("search_docs", "refund policy"), ("run_sql", "refunds in March")]
one = Agent("solo", [search_docs, run_sql, post_summary])
print(one.run(RESEARCH + [("post_summary", "38 refunds breached the 30-day policy in March")]))
# posted: 38 refunds breached the 30-day policy in Mar
print("model calls", one.calls) # model calls 4
researcher = Agent("researcher", [search_docs, run_sql])
writer = Agent("writer", [post_summary])
notes = researcher.run(RESEARCH)
print(writer.run([("post_summary", notes)]))
# posted: policy: refunds within 30 days | sql: 412 re
print("model calls", researcher.calls + writer.calls) # model calls 5The real reason it helps#
The benefit is not that several agents are cleverer than one. It is context. A single agent doing research, analysis and writing carries all of it in one window, and by the end the relevant material is buried among everything else.
Splitting gives each part a small focused context. The worker sees only its task; the orchestrator sees only summaries.
One agent: context holds 40k tokens of mixed research,
half irrelevant to the current step
Orchestrator + 3 workers:
each worker 5k tokens, only its own task
orchestrator 3k tokens, only the summariesThe common shape#
An orchestrator decomposes the task, dispatches bounded sub-tasks, and combines results. Workers do one thing and report back. Workers do not talk to each other, which keeps the system understandable.
import asyncio
class Budget:
def __init__(self, max_steps, max_usd):
self.max_steps, self.max_usd = max_steps, max_usd
self.steps, self.usd = 0, 0.0
def spend(self, in_tok, out_tok):
self.steps += 1
self.usd += in_tok / 1e6 * 1.0 + out_tok / 1e6 * 5.0
def exhausted(self):
return self.steps >= self.max_steps or self.usd >= self.max_usd
async def plan(task):
return ["pricing page", "changelog", "support tickets"]
async def run_worker(subtask, budget):
while not budget.exhausted():
budget.spend(1400, 250)
await asyncio.sleep(0.05) # stands in for the model call
if budget.steps >= 3: # this worker's own stopping rule
return "finding from " + subtask
return "incomplete: " + subtask # the budget ran out, so say so
async def synthesise(task, results):
return task + " -> " + " | ".join(results)
async def orchestrate(task):
subtasks = await plan(task)
results = await asyncio.gather(*(
run_worker(s, Budget(max_steps=5, max_usd=0.10)) for s in subtasks
))
return await synthesise(task, results)
print(asyncio.run(orchestrate("why did churn rise")))
# why did churn rise -> finding from pricing page | finding from changelog | finding from support tickets
async def starved():
return await run_worker("pricing page", Budget(max_steps=2, max_usd=0.10))
print(asyncio.run(starved()))
# incomplete: pricing pageWhat it costs#
- More calls, so more money. The orchestrator's planning and synthesis are extra.
- More latency unless workers run concurrently, and they often cannot if one depends on another.
- More failure modes: a worker fails, returns something useless, or misunderstands its brief.
- Harder debugging, because a wrong final answer might come from any worker or from the decomposition itself.
Decomposition is where it usually goes wrong
If the orchestrator splits the task badly, every worker does good work on the wrong question, and the synthesis is confidently wrong. This failure is harder to spot than a worker simply failing, because everything appears to have succeeded.
How much more it costs, actually#
People say multi-agent costs three times as much without ever working it out, and the honest answer is that it depends on one number: how much each worker writes back. Input tokens can go either way, because a single long conversation re-sends its whole history on every turn while a worker's context stays short. What never goes down is the number of calls, and what grows fastest is the output the supervisor has to read.
IN, OUT = 1.0, 5.0 # dollars per million tokens, illustrative
def single(turns=8, base=1200, growth=900, out=200):
calls = tin = tout = 0
ctx = base
for _ in range(turns):
calls += 1; tin += ctx; tout += out
ctx += growth # one tool result appended, re-sent next turn
return calls, tin, tout
def multi(workers=3, turns=3, sup=1000, w_base=800, growth=900, out=200, report=400):
calls = tin = tout = 0
calls += 1; tin += sup; tout += 300 # the plan
for _ in range(workers):
ctx = w_base
for _ in range(turns):
calls += 1; tin += ctx; tout += out
ctx += growth
tout += report # the worker writes its report
sup += report
calls += 1; tin += sup; tout += 100 # the supervisor reads it
calls += 1; tin += sup; tout += 600 # synthesis
return calls, tin, tout
def usd(t): return t[1] / 1e6 * IN + t[2] / 1e6 * OUT
for report in (200, 400, 800, 1600, 3200):
a, b = single(), multi(report=report)
print(report, a[0], round(usd(a), 4), b[0], round(usd(b), 4), round(usd(b) / usd(a), 2))
# report single calls single $ multi calls multi $ ratio
# 200 8 0.0428 14 0.0401 0.94
# 400 8 0.0428 14 0.0449 1.05
# 800 8 0.0428 14 0.0545 1.27
# 1600 8 0.0428 14 0.0737 1.72
# 3200 8 0.0428 14 0.1121 2.62Put your own numbers in before you defend either side in a review. The parameters that move the answer are the number of turns, how much context each turn adds, and the report length, and you can read all three off a week of traces.
Errors compound across handoffs#
Each handoff is a step that can go wrong, and the probabilities multiply. This is the arithmetic that makes long agent chains fail in a way that looks like bad luck but is not. A step that is right ninety-five times in a hundred sounds fine, and five of them in a row is a system that works three times in four.
for p in (0.99, 0.95, 0.90):
print(p, [round(p ** n, 3) for n in (1, 2, 3, 5, 8)])
# per-hop 1 hop 2 3 5 8
# 0.99 0.990 0.980 0.970 0.951 0.923
# 0.95 0.950 0.902 0.857 0.774 0.663
# 0.90 0.900 0.810 0.729 0.590 0.430The only defence is a check at the boundary rather than at the end. Validate what a worker returns against a schema before the supervisor believes it, and make an invalid return a retry of that one worker instead of a failure of the whole run. One retry takes a ninety-five per cent hop to 99.75 per cent, and three such hops end to end at 99.25 rather than 85.7, which is what makes chains of any length survivable.
Context is lost at every handoff, and it is silent#
A worker knows only what is in its brief. Everything the supervisor learned from the user, and everything it has already ruled out, exists only if somebody wrote it into that string. Nothing warns you when it did not, because the worker cheerfully answers the smaller question it was given.
state = { # what the supervisor knows
"question": "why did churn rise in March",
"segment": "enterprise accounts only",
"ruled_out": "plan tier, already checked, explains nothing",
}
brief = "Look into churn in March." # what it actually sent
def worker(brief):
if "enterprise" not in brief.lower():
return "Churn rose because self-serve trials expired."
return "Enterprise churn tracked the support backlog."
print(worker(brief))
# Churn rose because self-serve trials expired.
full = brief[:-1] + ", " + state["segment"] + ". Ruled out: " + state["ruled_out"]
print(worker(full))
# Enterprise churn tracked the support backlog.Measure it#
Run the same task set both ways. Compare success rate, cost and latency. Multi-agent is genuinely better on some workloads and worse on many, and the only way to know which you have is to try.
single multi
success 72% 81%
cost per task $0.14 $0.38
p95 latency 18s 31s
Better, at 2.7x the cost. Whether that is worth it is a
product decision, not a technical one.Interviewers ask about this directly, usually as whether your agents are separate services, tool-based or prompt-based. The strongest answer names the trade-off and says what you measured. The weakest one describes a diagram. If you have never run the comparison, say that, and say which of the three reasons above would make you run it.
What to skip#
Skip the frameworks until you have built the loop by hand once. Every orchestration library is the twenty lines above with a configuration format on top, and reaching for one first means you learn the format instead of the mechanism, then cannot debug it. Skip agents that exist to give a persona a name, because a section of the system prompt does that for nothing. Skip negotiation and voting protocols between agents entirely; they are interesting research and nobody is running them in production, and claiming one in an interview invites a question you cannot answer.
Where people go wrong
Adding agents because the architecture diagram looks more serious. Each one multiplies cost and failure modes, and reviewers will ask what you measured.
Build this: Take a task your single agent handles poorly. Build an orchestrator and workers version, run both over twenty tasks, and report success, cost and latency for each.
Open this section on its own pageCoordination patterns and their costs
By the end: You can name the multi-agent patterns and choose one deliberately.
- Orchestrator and workers
- Pipeline
- Critic and reviewer loops
- Shared state and its dangers
Once one agent works, the obvious move is more of them, and there are only a handful of shapes they can be arranged in. This section names them, an orchestrator with workers, a fixed pipeline, a producer with a critic, several attempts in parallel, and gives the cost of each, since every one multiplies the calls and adds a way to fail. It also covers shared state, which is where multi-agent systems reproduce every concurrency bug you have already met elsewhere.
Common shapes#
- Orchestrator and workers: one agent plans and dispatches, workers execute bounded tasks and report back. The most common and the easiest to reason about.
- Pipeline: fixed stages, each handled by a specialised agent. Predictable, and only suits tasks whose steps are known in advance.
- Critic loop: one agent produces, another reviews and requests changes. Improves quality at the cost of extra calls and the risk of endless revision.
- Parallel exploration: several agents attempt the same problem differently and the best result is chosen.
The three that matter in practice are below, in code, each short enough to hold in your head. They differ in one thing: who decides what happens next. The supervisor decides at runtime, the pipeline decided at design time, and the parallel fan-out does not decide at all because nothing depends on anything else. That single question tells you which one you have, and it is the question to answer out loud before drawing anything.
Are the steps known before you read the task?
yes, and each feeds the next -> pipeline
yes, and they are independent -> parallel fan-out
no, they depend on the task -> supervisor and workers
Does the output need judging as well as producing?
yes, and quality matters more than cost -> add a critic, capped
otherwise -> do notSupervisor and workers#
One agent turns the task into a list of bounded sub-tasks, runs each one, and merges what comes back. Workers never talk to each other, which is the property that keeps it debuggable: every result has exactly one producer and exactly one consumer. Reach for this when the sub-tasks are not known until you have read the task.
# FakeClient stands in for the model so the control flow is runnable.
# In real code, chat() is client.messages.create.
class FakeClient:
def __init__(self):
self.calls = 0
def chat(self, messages):
self.calls += 1
prompt = messages[-1]["content"]
if prompt.startswith("PLAN"):
return "pricing page\nchangelog\nsupport tickets"
if prompt.startswith("WORK"):
return "finding from " + prompt.split(": ", 1)[1]
return "combined: " + prompt.split(": ", 1)[1].replace("\n", " | ")
client = FakeClient()
def supervise(task):
plan = client.chat([{"role": "user", "content": "PLAN the task: " + task}])
subtasks = [line for line in plan.splitlines() if line.strip()]
findings = [client.chat([{"role": "user", "content": "WORK on: " + s}])
for s in subtasks]
return client.chat([{"role": "user", "content": "MERGE these: " + "\n".join(findings)}])
print(supervise("why did churn rise in March"))
# combined: finding from pricing page | finding from changelog | finding from support tickets
print("model calls:", client.calls) # model calls: 5Pipeline#
Fixed stages in a fixed order, each stage taking the previous stage's output. There is no planning call and no merge, so it is the cheapest shape there is, and it only applies when you already know every step. The real reason to use it is not cost but that you can put an ordinary function between two model calls, and validation is an ordinary function.
def extract(doc): return {"amount": 420, "currency": "EUR", "vendor": "Acme"}
def validate(rec):
if rec["amount"] <= 0:
raise ValueError("amount must be positive")
return rec
def summarise(rec): return rec["vendor"] + " invoiced " + str(rec["amount"]) + " " + rec["currency"]
STAGES = [extract, validate, summarise]
def pipeline(doc):
value = doc
for stage in STAGES:
value = stage(value)
print(" after", stage.__name__ + ":", value)
return value
pipeline("<invoice pdf bytes>")
# after extract: {'amount': 420, 'currency': 'EUR', 'vendor': 'Acme'}
# after validate: {'amount': 420, 'currency': 'EUR', 'vendor': 'Acme'}
# after summarise: Acme invoiced 420 EURParallel workers, results merged#
When the sub-tasks do not depend on each other, run them at once. This is the one case where splitting buys something a single agent cannot have at any price, because a single conversation is sequential by construction. Four questions that each take a fifth of a second take four fifths in series and one fifth in parallel.
import concurrent.futures, time
def worker(question):
time.sleep(0.2) # stands in for a model call
return question, "answer to " + question
questions = ["pricing", "uptime", "support hours", "data retention"]
start = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
results = dict(pool.map(worker, questions))
print("parallel %.2fs" % (time.time() - start)) # parallel 0.20s
start = time.time()
results = dict(worker(q) for q in questions)
print("serial %.2fs" % (time.time() - start)) # serial 0.81sThe merge is where this shape goes wrong. Collect results into a structure keyed by sub-task, never by appending to a shared list, and decide before you start what a missing result means: an empty entry the synthesis can see and mention, or a failed run. Silently merging three of four answers produces a confident summary of incomplete evidence, and there is nothing in the output that says so.
Shared state#
Agents that read and write shared state can overwrite each other or act on stale information. Keeping workers independent, each returning a result to the orchestrator rather than modifying shared data, avoids a whole class of concurrency bugs.
It is worth being precise about why this is worse here than in ordinary concurrent code. A thread that reads a stale value usually crashes or produces an obviously wrong number. An agent that reads a stale value writes a paragraph explaining it, and the paragraph is fluent. The lost update does not surface as an exception, it surfaces as a plausible answer nobody questions. If workers genuinely must share something, make it append-only, so the failure is a duplicate rather than a silent overwrite, and give every entry the id of the worker that wrote it so the trace can be read afterwards.
Two agents handing work back and forth#
A critic loop is a producer and a reviewer passing a draft between them. It is the pattern most likely to run away, because a reviewer asked to find problems will find problems, forever. The loop below has no termination condition, only a safety net, and the difference is the whole lesson: the net stops the bleeding, it does not make the loop correct.
class PingPong:
def __init__(self):
self.hops = 0
def writer(self, draft):
self.hops += 1
return draft + " (revised)"
def critic(self, draft):
self.hops += 1
return "needs work" # a critic can always find something
pp = PingPong()
draft, verdict = "first draft", "needs work"
while verdict == "needs work": # no termination condition
draft = pp.writer(draft)
verdict = pp.critic(draft)
if pp.hops > 20: # only this line stops it
print("stopped by the safety net after", pp.hops, "hops")
break
# stopped by the safety net after 22 hops
print(len(draft), "characters") # 121 charactersBound it with three things at once, because each catches a different runaway. A step limit catches the loop that will not converge. A spend limit catches the loop that converges slowly on expensive calls. A deadline catches the worker that is not looping at all but waiting on something that will never arrive. Underneath all three there has to be a real termination condition, which is the only one of the four that ends the run because the work is done rather than because the run was cut off.
import time
class Budget:
def __init__(self, max_steps, max_usd, deadline_s):
self.max_steps, self.max_usd = max_steps, max_usd
self.expires = time.monotonic() + deadline_s
self.steps, self.usd = 0, 0.0
def spend(self, in_tok, out_tok, price_in=1.0, price_out=5.0):
self.steps += 1
self.usd += in_tok / 1e6 * price_in + out_tok / 1e6 * price_out
def exhausted(self):
if self.steps >= self.max_steps: return "step limit"
if self.usd >= self.max_usd: return "budget"
if time.monotonic() >= self.expires: return "deadline"
return None
def critique_loop(budget, accept_after=3):
rounds = 0
while True:
stop = budget.exhausted()
if stop:
return "stopped by " + stop + " after " + str(rounds) + " rounds, spent " + format(budget.usd, ".4f")
budget.spend(3000, 800) # writer
budget.spend(3800, 200) # critic
rounds += 1
if rounds >= accept_after: # the real termination condition
return "accepted after " + str(rounds) + " rounds, spent " + format(budget.usd, ".4f")
print(critique_loop(Budget(max_steps=20, max_usd=1.00, deadline_s=60)))
# accepted after 3 rounds, spent 0.0354
print(critique_loop(Budget(max_steps=4, max_usd=1.00, deadline_s=60)))
# stopped by step limit after 2 rounds, spent 0.0236
print(critique_loop(Budget(max_steps=20, max_usd=0.02, deadline_s=60), accept_after=99))
# stopped by budget after 2 rounds, spent 0.0236Critic loops need a stopping rule
A reviewer will almost always find something to improve. Without a maximum number of rounds and a clear acceptance criterion, a critic loop spends money indefinitely while quality plateaus after the first revision.
Skip the shapes not listed here. Agents that vote, negotiate or hold debates are a research topic, and the honest version of every one of them in production is a supervisor that asks the same question twice and compares. If a design needs more than a supervisor, a pipeline and a parallel fan-out, the thing to suspect first is the decomposition, not the absence of a fourth pattern.
One practical note about combining them, since real systems always do. The composition that works is a pipeline on the outside with a fan-out in one stage, because then the stages are fixed and auditable and only the width of one stage varies. The composition that does not work is a supervisor whose workers are themselves supervisors. Two levels of dynamic planning means nothing in the run was decided by you, the trace has no stable shape to read, and the cost of a single request has no ceiling you can state. If you find yourself drawing that in an interview, the recovery is to say it out loud and flatten it.
Where people go wrong
Letting worker agents write to shared state. They overwrite each other and act on stale data. Have them return results instead.
Build this: Add a critic loop to a generation task and measure quality and cost at one, two and five rounds. Find where improvement stops.
Open this section on its own pageNow actually do it
Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.
1Measure whether splitting helped
5hTake a task your single agent handles poorly. Build an orchestrator and workers version. Run both over 20 tasks and compare success rate, cost and latency.
Done when: You have three numbers for each design and an honest answer about whether the split was worth it.
5 hours of practice
Interview questions
6 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.
When more than one agent helps
Why use multiple agents rather than one?
Mainly to keep each context small and focused. One agent doing everything ends up with the relevant material buried among everything else it has accumulated. It is a context management technique more than an intelligence one.
Then they push further
A colleague proposes five specialised agents for a support bot. What do you ask?
What one agent with good tools fails at, measured on evals. How context and decisions pass between agents and what is lost on the way. What it does to cost and latency. And how anyone will debug a wrong answer that passed through three agents.
What gets marked up. Marked up for treating several agents as a cost that must be justified by measured failures of one, with independent work that can run in parallel as the clearest case for it.
What does a multi-agent system cost you?
More calls and more money, more latency unless workers run concurrently, more failure modes, and harder debugging since a wrong answer can come from any worker or from the decomposition. Bad decomposition is the worst case, because every worker succeeds at the wrong task.
How would you decide between one agent and several?
Measure. Run the same task set both ways and compare success rate, cost and latency. Start with a single loop, because it is simpler and often sufficient, and split only when the numbers justify it.
Coordination patterns and their costs
What multi-agent patterns do you know?
Orchestrator and workers, fixed pipelines, critic or reviewer loops, and parallel exploration where several attempts compete. Orchestrator and workers is the most common and the easiest to debug.
What goes wrong with critic loops?
Without a stopping rule they never end, because a reviewer can always find something. Cap the rounds and define what acceptable means, since quality usually plateaus after the first revision.
Then they push further
What would you cap it with?
Three things at once, because they catch different runaways: a step limit for the loop that will not converge, a spend limit for the one converging slowly on expensive calls, and a deadline for a worker waiting on something that will never arrive. Checked at the top of the loop, so a run already over its limit makes no further calls.
Two of your agents keep handing the same task back to each other. What do you do?
The safety net stops the bill, but the bug is that there is no termination condition, only a limit. Define what acceptable output is, have the critic return a structured verdict rather than prose, and accept on the first pass that meets it. If the critic never returns accept on inputs a person would accept, the critic prompt is the thing to fix, not the cap.
Resources
Free to watch and read, picked for this topic.
Videos
For when more than one agent helps
For coordination patterns and their costs
Full courses: the whole topic, start to finish
Stop 18 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.