Lesson 11 Multi-Agent Systems
Theme:

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:

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:

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