Theory: Multi-Agent Systems
Coordinating multiple specialised LLM agents to solve complex tasks through orchestration, message passing, and shared state.
Why Multi-Agent
Lesson 10 introduced the single-agent ReAct loop: one LLM, a set of tools, and a loop that runs until the task is complete. This works remarkably well for focused tasks, but it starts to break down when the scope grows. A single agent asked to research a topic, write a report, review it for accuracy, and format it for publication must juggle four distinct roles in one system prompt and one context window. The result is often tool confusion (the agent picks the wrong tool), context overflow (the conversation history exceeds the window), or role drift (the agent forgets it was supposed to be writing and starts researching again).
Multi-agent systems address these problems by decomposing a complex workflow into distinct agent roles. Each agent has a narrow focus: a small set of tools, a clear system prompt, and a fresh context window. The system becomes more modular, easier to debug, and often more reliable because each agent's task is simpler.
However, multi-agent systems add coordination overhead. You need to design communication protocols, manage shared state, handle partial failures, and reason about the order of operations. The key engineering question is whether the benefits of specialisation outweigh the costs of coordination. For most applications, the answer is: start with one agent, and split only when you hit a specific bottleneck.
Orchestrator Pattern
The orchestrator pattern is the most common and most practical multi-agent architecture. A single "manager" agent receives the user's task, breaks it into sub-tasks, delegates each sub-task to a specialised agent, collects the results, and synthesises them into a final answer.
The orchestrator itself is an LLM call. Its system prompt describes the available agents and instructs it to plan and delegate. Critically, the orchestrator does not execute tools directly — it only decides which agent to call and with what instruction. Each sub-agent then runs its own ReAct loop independently.
def orchestrate(task: str, agents: dict[str, Agent]) -> str:
# Step 1: Ask the orchestrator LLM to create a plan
plan = plan_task(task, list(agents.keys()))
# Step 2: Execute each sub-task
results = {}
for step in plan:
agent = agents[step["agent"]]
result = agent.run(step["instruction"])
results[step["agent"]] = result
# Step 3: Merge results into a final answer
return merge_results(task, results)
This pattern keeps each agent's context small and focused. The orchestrator sees only the high-level plan and the summarised results — not the full tool-call traces of each sub-agent. This is a natural form of context compression.
Message Passing
In a multi-agent system, agents communicate through messages. The simplest form is a string: the orchestrator sends a natural-language instruction to a sub-agent and receives a natural-language response. But as systems grow more complex, structured messages become important.
A well-designed message includes metadata alongside the content: the sender's identity, a task ID for tracing, confidence scores, citations, and status indicators. This metadata lets the orchestrator make informed decisions about how to use the result — for example, re-running a sub-agent if its confidence is below a threshold.
@dataclass
class AgentMessage:
sender: str
receiver: str
task_id: str
content: str
metadata: dict # confidence, citations, timing, etc.
@dataclass
class AgentResult:
status: str # "success" | "partial" | "error"
content: str
error: str = ""
steps_used: int = 0
confidence: float = 1.0
Three common message passing patterns emerge in practice:
- Request-response: The orchestrator sends a task to a sub-agent and waits for the result. This is the simplest and most common pattern.
- Pipeline: Agent A produces output that feeds directly into Agent B. For example, a Researcher produces facts, then a Writer turns them into prose. The orchestrator chains the calls sequentially.
- Broadcast: The orchestrator sends the same context to multiple agents in parallel and collects all results. This is useful when you need multiple perspectives on the same data.
Shared State
Message passing is not always sufficient. Some workflows require agents to read and update a shared data structure — a document draft, a knowledge base, or a task board. Shared state introduces coordination challenges (what happens when two agents write to the same key?) but is sometimes the most natural abstraction.
The cleanest approach is to expose shared state as tools. Define state_write(key, value) and state_read(key) tools and include them in each agent's tool set. This way, agents interact with shared state through the same tool-use mechanism they use for everything else, and you get a clear audit trail of who wrote what and when.
Design principle: Prefer message passing over shared state when possible. Shared state creates implicit dependencies between agents, making the system harder to reason about. Use shared state only when agents genuinely need to collaborate on a single evolving artefact (like a document draft).
Fan-out / Fan-in
One of the strongest reasons to adopt multi-agent architecture is parallelism. If a task can be decomposed into independent sub-tasks, you can run multiple agents concurrently and merge their results. This is the fan-out/fan-in pattern.
import asyncio
async def parallel_research(topics: list[str], agent: Agent) -> list[str]:
"""Fan-out: run the same agent on multiple topics in parallel."""
tasks = [agent.run_async(f"Research: {topic}") for topic in topics]
results = await asyncio.gather(*tasks)
return results
Fan-out is especially valuable when calling external APIs (search, database queries) where the latency is dominated by network round-trips. Running three sub-agents in parallel takes roughly the same wall-clock time as running one.
The fan-in step is where the orchestrator collects all parallel results and synthesises them. This is often another LLM call: "Given these research results from three agents, write a unified summary." The quality of the fan-in step depends on how well the orchestrator's prompt instructs it to handle conflicts, gaps, and redundancies in the sub-agent outputs.
Error Handling
Multi-agent systems have more failure modes than single agents. A sub-agent can fail (hit max steps, return empty results, raise an API error), and that failure must be handled at the orchestrator level without crashing the entire pipeline.
The key design decision is the AgentResult contract. Every sub-agent call should return a structured result with a status field. The orchestrator then implements a policy for each status:
- success: Use the result as-is.
- partial: Use the result but note the limitation. Optionally retry with a different approach.
- error: Log the error. Retry with the same or different agent. If retries are exhausted, continue without this sub-agent's contribution or fail the entire task.
def orchestrate_with_retry(task, agents, max_retries=2):
plan = plan_task(task, list(agents.keys()))
results = {}
for step in plan:
agent = agents[step["agent"]]
for attempt in range(max_retries + 1):
result = agent.run(step["instruction"])
if result.status == "success":
break
if attempt < max_retries:
print(f"Retrying {step['agent']} (attempt {attempt + 2})")
results[step["agent"]] = result
return merge_results(task, results)
Circuit breaker: If an agent fails repeatedly, stop retrying and move on. Infinite retries waste tokens and can cascade into rate-limit errors that affect other agents in the system.
Further Reading
- MetaGPT: Meta Programming for Multi-Agent Collaborative Framework (Hong et al., 2023)
- CAMEL: Communicative Agents for “Mind” Exploration of Large Language Model Society (Li et al., 2023)
- AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation (Wu et al., 2023)
- Building Effective Agents (Anthropic blog)
- AgentScope: A Flexible yet Robust Multi-Agent Platform (Gao et al., 2024)