Menu

Multi-Agent Systems — Supervisor, Swarm, and Network Architectures

Written by Selva Prabhakaran | 32 min read

You build an agent that researches topics, writes drafts, and checks facts. It works — until the prompts get so long that the model forgets instructions halfway through. You add more tools, and the agent starts calling the wrong ones. Sound familiar? This is the single-agent ceiling. Multi-agent systems fix this by splitting responsibilities across specialized agents that collaborate through well-defined patterns.

Before we write any code, here’s how the three multi-agent architectures work at a high level.

In the supervisor pattern, one central agent acts as a manager. It receives every request, decides which specialist to call, reads the result, and either calls another specialist or returns a final answer. The supervisor sees everything. The workers see only their own task.

In the swarm pattern, there’s no central manager. Agents hand off control directly to each other. Agent A finishes its work and says “Agent B should handle this next.” Each agent decides who goes next. The flow emerges from the handoffs.

In the network (or mesh) pattern, any agent can call any other agent at any time. There’s no fixed routing. An agent decides mid-task that it needs help from another agent and calls it directly. This is the most flexible pattern — and the hardest to debug.

We’ll build all three in LangGraph, compare their tradeoffs, then combine them into a real-world research team.

Why Single Agents Hit a Ceiling

A single ReAct agent handles simple tasks well. Ask it to search the web and summarize results — no problem. But real-world tasks aren’t simple.

Consider a content pipeline: research a topic, write a draft, fact-check the draft, then format it for publishing. A single agent needs tools for all four stages. Its system prompt grows to hundreds of lines. The context window fills with irrelevant tool descriptions.

Three specific problems show up:

  • Context pollution. Every tool’s description sits in the prompt, even when the agent only needs one. More tools means more noise and worse tool selection.
  • Role confusion. A single prompt that says “you are a researcher AND a writer AND a fact-checker” gives the model conflicting objectives.
  • Prompt length limits. Long conversations with many tool calls consume the context window. The agent loses track of earlier instructions.

Multi-agent systems solve all three. Each agent gets its own focused prompt, its own tools, and its own slice of the conversation.

Key Insight: **Multi-agent systems don’t make individual agents smarter.** They make the overall system smarter by letting each agent focus on what it does best — with a clean context and a clear role.

Prerequisites

  • Python version: 3.10+
  • Required libraries: langgraph (0.4+), langgraph-supervisor (0.0.7+), langgraph-swarm (0.0.5+), langchain-openai (0.3+), langchain-core (0.3+)
  • Install: pip install langgraph langgraph-supervisor langgraph-swarm langchain-openai langchain-core
  • API key: An OpenAI API key set as OPENAI_API_KEY. See OpenAI’s docs to create one.
  • Time to complete: ~45 minutes
  • Prior knowledge: Basic LangGraph concepts (nodes, edges, state, subgraphs) from earlier posts in this series.

The first code block sets up everything we need. We import the LLM wrapper, message types, tool decorators, and the multi-agent utilities from both langgraph-supervisor and langgraph-swarm.

python
import os
import json
from typing import Annotated, Literal, TypedDict

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
from langchain_core.tools import tool
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import create_react_agent
from langgraph.types import Command
from langgraph_supervisor import create_supervisor
from langgraph_swarm import create_swarm, create_handoff_tool

os.environ["OPENAI_API_KEY"] = "your-api-key-here"

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)

The Supervisor Pattern — Central Multi-Agent Orchestration

Why would you want a single agent managing all the others? Because some workflows demand a guaranteed sequence. A customer support system where every query must be classified before routing to billing, technical, or sales — that’s a supervisor.

Here’s the data flow:

python
User → Supervisor → [Researcher | Writer | Fact-Checker] → Supervisor → ... → Final Answer

The supervisor always sits in the middle. Workers never talk to each other directly. Every message passes through the supervisor first.

Building Specialized Agents

Each worker agent needs its own tools and its own system prompt. We’ll build two simple specialists: a researcher and a writer.

The @tool decorator turns a regular Python function into a LangGraph-compatible tool. Each tool gets a clear docstring that tells the LLM when and how to use it.

python
@tool
def search_web(query: str) -> str:
    """Search the web for information on a topic. Returns a summary of findings."""
    results = {
        "langgraph": "LangGraph is a framework for building stateful AI agents using graph-based workflows.",
        "multi-agent": "Multi-agent systems use multiple specialized AI agents that collaborate on complex tasks.",
    }
    for key, value in results.items():
        if key in query.lower():
            return value
    return f"Search results for '{query}': No specific results found. General AI topic."

@tool
def format_report(content: str, style: str = "markdown") -> str:
    """Format content into a structured report. Style can be 'markdown' or 'plain'."""
    if style == "markdown":
        return f"# Research Report\n\n{content}\n\n---\n*Generated by Writer Agent*"
    return f"REPORT\n{'='*40}\n{content}\n{'='*40}"

These tools are intentionally simple. In production, the search tool would call a real API. The point here is the architecture, not the tools.

Creating the Supervisor

create_supervisor from the langgraph-supervisor package does the heavy lifting. It takes a list of worker agents and a model, then builds a graph where the supervisor routes tasks to workers.

We create each worker with create_react_agent, giving it a focused system prompt and only the tools it needs. Then create_supervisor wraps them into a managed workflow. The output_mode="full" flag returns the complete message history, not just the final answer.

python
research_agent = create_react_agent(
    model=model,
    tools=[search_web],
    prompt="You are a research specialist. Search for information and return factual summaries. Be thorough but concise.",
    name="researcher",
)

writer_agent = create_react_agent(
    model=model,
    tools=[format_report],
    prompt="You are a writing specialist. Take research findings and format them into clear, well-structured reports.",
    name="writer",
)

supervisor = create_supervisor(
    model=model,
    agents=[research_agent, writer_agent],
    prompt="You are a team supervisor. For research questions, delegate to the researcher first, then send findings to the writer for formatting. Always use both agents.",
    output_mode="full",
).compile()

Running the Supervisor

Invoking the supervisor works like any LangGraph graph. Pass a message, and it returns the full conversation history showing each agent’s contribution.

python
result = supervisor.invoke({
    "messages": [HumanMessage(content="Research what LangGraph is and write a short report about it.")]
})

for msg in result["messages"]:
    if hasattr(msg, "name"):
        print(f"[{msg.name}]: {msg.content[:100]}...")
    else:
        print(f"[{msg.type}]: {msg.content[:100]}...")

The output shows the supervisor routing to the researcher first, then passing findings to the writer. Each agent only sees the task delegated to it. The supervisor stitches the pieces together.

Tip: **Keep your supervisor prompt focused on routing logic, not domain knowledge.** The supervisor’s job is to decide WHO does the work, not HOW to do it. Domain knowledge belongs in the worker agents’ prompts.

Quick check: What happens if you remove output_mode="full" from create_supervisor? You’d only get the supervisor’s final response — not the individual agents’ messages. The "full" mode is essential for debugging because it shows you the complete routing trace.

exercise
{
  "type": "exercise",
  "id": "supervisor-ex1",
  "title": "Exercise 1: Add a Third Worker Agent",
  "difficulty": "intermediate",
  "exerciseType": "write",
  "instructions": "Add a fact-checker agent to the supervisor system above. The fact-checker should have a `verify_claim` tool that takes a claim string and returns whether it seems factual. Update the supervisor prompt to route: researcher → writer → fact_checker.",
  "starterCode": "@tool\ndef verify_claim(claim: str) -> str:\n    \"\"\"Verify whether a claim appears factual.\"\"\"\n    # TODO: return 'VERIFIED: <claim>' or 'UNVERIFIED: <claim>'\n    pass\n\nfact_checker = create_react_agent(\n    model=model,\n    tools=[verify_claim],\n    prompt=\"You are a fact-checker. Verify claims using the verify_claim tool.\",\n    name=\"fact_checker\",\n)\n\n# TODO: create supervisor with all three agents\nsupervisor_v2 = create_supervisor(\n    model=model,\n    agents=[research_agent, writer_agent, fact_checker],\n    prompt=\"# TODO: write routing prompt\",\n    output_mode=\"full\",\n).compile()",
  "testCases": [
    {"id": "tc1", "input": "print(len(supervisor_v2.nodes))", "expectedOutput": "5", "description": "Graph should have 5 nodes (supervisor + 3 workers + END)"},
    {"id": "tc2", "input": "print('fact_checker' in [n for n in supervisor_v2.nodes])", "expectedOutput": "True", "description": "fact_checker node should exist"}
  ],
  "hints": [
    "The verify_claim tool just needs to return a string — something like f'VERIFIED: {claim}' for the simulation.",
    "The supervisor prompt should say: 'Route to researcher first, then writer, then fact_checker. Always use all three agents in this order.'"
  ],
  "solution": "@tool\ndef verify_claim(claim: str) -> str:\n    \"\"\"Verify whether a claim appears factual.\"\"\"\n    return f'VERIFIED: {claim}'\n\nfact_checker = create_react_agent(\n    model=model,\n    tools=[verify_claim],\n    prompt=\"You are a fact-checker. Verify claims using the verify_claim tool.\",\n    name=\"fact_checker\",\n)\n\nsupervisor_v2 = create_supervisor(\n    model=model,\n    agents=[research_agent, writer_agent, fact_checker],\n    prompt=\"Route to researcher first, then writer, then fact_checker. Always use all three in this order.\",\n    output_mode=\"full\",\n).compile()",
  "solutionExplanation": "Adding a third agent follows the same pattern: create the agent with create_react_agent, give it a focused tool and prompt, then include it in the supervisor's agent list. The supervisor prompt must mention the new agent and its position in the routing sequence.",
  "xpReward": 20
}

The Swarm Pattern — Multi-Agent Handoffs Without a Supervisor

The swarm pattern flips the supervisor model. There’s no central coordinator. Instead, each agent has handoff tools that let it transfer control directly to another agent.

When does this beat a supervisor? When the workflow branches unpredictably. A support chatbot where a triage agent might hand off to billing, which might hand off to a specialist, which might hand back to triage — encoding that in a supervisor prompt gets messy fast. With swarms, each agent just knows its neighbors.

How Handoffs Work

create_handoff_tool creates a tool that an agent calls to transfer control. When called, LangGraph updates the internal active_agent tracker and routes the next turn to the target agent.

Here’s the key difference: the agent making the handoff decides where to go next. The decision is distributed, not centralized.

We create two agents — a triage agent and a specialist — each with a handoff tool pointing to the other. create_swarm compiles them into a single graph.

python
triage_handoff = create_handoff_tool(
    agent_name="specialist",
    description="Hand off to the specialist for detailed technical questions about AI and ML.",
)

specialist_handoff = create_handoff_tool(
    agent_name="triage",
    description="Hand back to triage when the technical question is answered.",
)

triage_agent = create_react_agent(
    model=model,
    tools=[triage_handoff],
    prompt="You are a triage agent. Classify the user's question. For technical AI/ML questions, hand off to the specialist. For general questions, answer directly.",
    name="triage",
)

specialist_agent = create_react_agent(
    model=model,
    tools=[specialist_handoff, search_web],
    prompt="You are an AI/ML specialist. Answer technical questions thoroughly. When done, hand back to triage.",
    name="specialist",
)

swarm = create_swarm(
    agents=[triage_agent, specialist_agent],
    default_active_agent="triage",
).compile(recursion_limit=25)
Warning: **Swarm agents can create infinite loops if two agents keep handing off to each other.** Always include clear criteria in each agent’s prompt for WHEN to hand off vs. WHEN to respond. Set `recursion_limit` on the compiled graph as a safety net.

Running the Swarm

The triage agent processes the message first (it’s the default_active_agent). If the question is technical, it uses the handoff tool to transfer to the specialist.

python
result = swarm.invoke({
    "messages": [HumanMessage(content="Can you explain how attention mechanisms work in transformers?")]
})

for msg in result["messages"]:
    if hasattr(msg, "name") and msg.content:
        print(f"[{msg.name}]: {msg.content[:120]}...")

The triage agent recognizes a technical question and passes control to the specialist. No central coordinator made that routing decision — the triage agent decided on its own.

Predict the output: What if you sent “Hello, how are you?” to this swarm? The triage agent would answer directly, because it’s not a technical question. No handoff would occur. The specialist would never activate. That’s the beauty of swarms — agents only engage when needed.

Supervisor vs. Swarm — Quick Comparison

Feature Supervisor Swarm
Control flow Centralized — supervisor decides Distributed — each agent decides
Best for Predictable sequences Organic, branching workflows
Visibility Supervisor sees everything No single agent sees everything
Bottleneck risk Supervisor is a single point of failure No single bottleneck
Debugging Easier — trace through supervisor Harder — trace through handoffs
Adding agents Update supervisor prompt Add handoff tools

Pick the supervisor when you need a guaranteed sequence. Pick the swarm when the sequence depends on the input.

exercise
{
  "type": "exercise",
  "id": "swarm-ex1",
  "title": "Exercise 2: Add a Third Agent to the Swarm",
  "difficulty": "intermediate",
  "exerciseType": "write",
  "instructions": "Add a 'billing' agent to the swarm. The triage agent should be able to hand off to either the specialist or the billing agent. The billing agent should be able to hand back to triage. Create the handoff tools and the billing agent, then compile a three-agent swarm.",
  "starterCode": "billing_handoff_from_triage = create_handoff_tool(\n    agent_name=\"billing\",\n    description=\"# TODO: describe when to hand off to billing\",\n)\n\nbilling_to_triage = create_handoff_tool(\n    agent_name=\"triage\",\n    description=\"Hand back to triage after billing question is resolved.\",\n)\n\n# Update triage agent to have both handoff tools\ntriage_v2 = create_react_agent(\n    model=model,\n    tools=[triage_handoff, billing_handoff_from_triage],\n    prompt=\"Classify questions. Technical → specialist. Billing → billing agent. General → answer directly.\",\n    name=\"triage\",\n)\n\nbilling_agent = create_react_agent(\n    model=model,\n    tools=[billing_to_triage],\n    prompt=\"# TODO: billing agent prompt\",\n    name=\"billing\",\n)\n\nswarm_v2 = create_swarm(\n    agents=[triage_v2, specialist_agent, billing_agent],\n    default_active_agent=\"triage\",\n).compile(recursion_limit=25)",
  "testCases": [
    {"id": "tc1", "input": "print('billing' in [n for n in swarm_v2.nodes])", "expectedOutput": "True", "description": "billing node should exist in the swarm graph"},
    {"id": "tc2", "input": "print('triage' in [n for n in swarm_v2.nodes])", "expectedOutput": "True", "description": "triage node should still exist"}
  ],
  "hints": [
    "The billing handoff description should explain when triage should route there — e.g., 'Hand off to billing for payment, invoice, or subscription questions.'",
    "The billing agent prompt should focus on billing tasks: 'You are a billing specialist. Answer questions about payments, invoices, and subscriptions.'"
  ],
  "solution": "billing_handoff_from_triage = create_handoff_tool(\n    agent_name=\"billing\",\n    description=\"Hand off to billing for payment, invoice, or subscription questions.\",\n)\n\nbilling_to_triage = create_handoff_tool(\n    agent_name=\"triage\",\n    description=\"Hand back to triage after billing question is resolved.\",\n)\n\ntriage_v2 = create_react_agent(\n    model=model,\n    tools=[triage_handoff, billing_handoff_from_triage],\n    prompt=\"Classify questions. Technical → specialist. Billing → billing agent. General → answer directly.\",\n    name=\"triage\",\n)\n\nbilling_agent = create_react_agent(\n    model=model,\n    tools=[billing_to_triage],\n    prompt=\"You are a billing specialist. Answer questions about payments, invoices, and subscriptions. Hand back to triage when done.\",\n    name=\"billing\",\n)\n\nswarm_v2 = create_swarm(\n    agents=[triage_v2, specialist_agent, billing_agent],\n    default_active_agent=\"triage\",\n).compile(recursion_limit=25)",
  "solutionExplanation": "Adding agents to a swarm means: (1) create handoff tools connecting the new agent to existing agents, (2) update existing agents' tool lists to include the new handoff, (3) add the new agent to create_swarm. Each agent only needs handoff tools for its direct neighbors.",
  "xpReward": 20
}
Key Insight: **The supervisor pattern gives you control. The swarm pattern gives you flexibility.** Most teams start with a supervisor and switch to swarm only when the routing logic outgrows a single prompt.

The Network Pattern — Any Agent Calls Any Agent

The network pattern is the most flexible — and the most dangerous. Any agent can invoke any other agent at any time. There’s no hierarchy and no predefined handoff path.

You build this yourself using LangGraph’s Command primitive. There’s no create_network library function because the topology is fully custom.

When does this make sense? When agents need to collaborate dynamically. A team where any analyst might need another analyst’s input — that’s a network. But be careful: networks are hard to trace.

Building a Network with Command

We use Command to let agents route dynamically. Each node returns a Command specifying the state update and the next node. The goto parameter controls routing at runtime.

This code builds a three-agent network: a planner that creates a plan, an executor that runs it, and a reviewer that checks the result. The reviewer can approve and exit, or send work back to the executor for revision.

python
class NetworkState(TypedDict):
    messages: Annotated[list, "messages"]
    current_agent: str
    task_status: str

def planner_node(state: NetworkState) -> Command:
    """Plans the approach for a given task."""
    messages = state["messages"]
    response = model.invoke([
        SystemMessage(content="You are a planner. Create a brief plan for the task. Respond with the plan only."),
        *messages
    ])
    return Command(
        update={"messages": [response], "current_agent": "planner", "task_status": "planned"},
        goto="executor"
    )

The planner always routes to the executor. But the executor and reviewer have dynamic routing — the reviewer decides whether to approve or request a revision.

python
def executor_node(state: NetworkState) -> Command:
    """Executes the plan and produces results."""
    messages = state["messages"]
    response = model.invoke([
        SystemMessage(content="You are an executor. Follow the plan and produce a concise result."),
        *messages
    ])
    return Command(
        update={"messages": [response], "current_agent": "executor", "task_status": "executed"},
        goto="reviewer"
    )

def reviewer_node(state: NetworkState) -> Command:
    """Reviews the result and either approves or sends back."""
    messages = state["messages"]
    response = model.invoke([
        SystemMessage(content="You are a reviewer. If acceptable, say 'APPROVED: ' followed by the result. Otherwise say 'REVISION NEEDED: ' with feedback."),
        *messages
    ])
    content = response.content
    update = {"messages": [response], "current_agent": "reviewer"}
    if "APPROVED" in content.upper():
        update["task_status"] = "approved"
        return Command(update=update, goto=END)
    else:
        update["task_status"] = "needs_revision"
        return Command(update=update, goto="executor")

The reviewer’s conditional routing creates a feedback loop. It either exits the graph or bounces back to the executor. This pattern is powerful for quality control workflows.

Compiling the Network Graph

We wire the nodes using StateGraph. We only need an entry edge from START to planner — the Command objects handle all other routing at runtime.

python
network_graph = StateGraph(NetworkState)
network_graph.add_node("planner", planner_node)
network_graph.add_node("executor", executor_node)
network_graph.add_node("reviewer", reviewer_node)
network_graph.add_edge(START, "planner")

network = network_graph.compile()

result = network.invoke({
    "messages": [HumanMessage(content="Write a two-sentence summary of what multi-agent systems are.")],
    "current_agent": "",
    "task_status": "pending"
})

print(f"Final status: {result['task_status']}")
print(f"Final message: {result['messages'][-1].content[:200]}")

The reviewer might approve on the first pass, or send work back two or three times. You don’t know the path in advance — that’s both the power and the risk.

Note: **The network pattern trades predictability for flexibility.** Use it when you genuinely can’t determine the agent sequence at design time. For most production systems, a supervisor or swarm is safer.
exercise
{
  "type": "exercise",
  "id": "network-ex1",
  "title": "Exercise 3: Add a Maximum Revision Count",
  "difficulty": "advanced",
  "exerciseType": "write",
  "instructions": "The current network pattern can loop between executor and reviewer indefinitely. Modify the NetworkState to include a `revision_count` integer. Update the reviewer_node to increment this count on each revision and force approval after 3 revisions (to prevent infinite loops).",
  "starterCode": "class NetworkStateV2(TypedDict):\n    messages: Annotated[list, \"messages\"]\n    current_agent: str\n    task_status: str\n    revision_count: int  # NEW: track revision count\n\ndef reviewer_node_v2(state: NetworkStateV2) -> Command:\n    messages = state[\"messages\"]\n    count = state.get(\"revision_count\", 0)\n    response = model.invoke([\n        SystemMessage(content=\"If acceptable, say 'APPROVED: ' with result. Otherwise 'REVISION NEEDED: ' with feedback.\"),\n        *messages\n    ])\n    content = response.content\n    update = {\"messages\": [response], \"current_agent\": \"reviewer\"}\n    # TODO: Force approval if revision_count >= 3\n    # TODO: Otherwise, check for APPROVED/REVISION NEEDED as before\n    # TODO: Increment revision_count on revision\n    pass",
  "testCases": [
    {"id": "tc1", "input": "state = {'messages': [], 'current_agent': 'reviewer', 'task_status': 'executed', 'revision_count': 3}\nresult = reviewer_node_v2(state)\nprint(result.goto)", "expectedOutput": "__end__", "description": "Should force approval after 3 revisions"},
    {"id": "tc2", "input": "print(type(NetworkStateV2.__annotations__['revision_count']).__name__)", "expectedOutput": "type", "description": "revision_count should be an int type"}
  ],
  "hints": [
    "Check `if count >= 3:` at the top of the function and force an APPROVED Command with goto=END regardless of the LLM's response.",
    "On revision, set revision_count to count + 1 in the update dict: update['revision_count'] = count + 1"
  ],
  "solution": "def reviewer_node_v2(state: NetworkStateV2) -> Command:\n    messages = state[\"messages\"]\n    count = state.get(\"revision_count\", 0)\n    response = model.invoke([\n        SystemMessage(content=\"If acceptable, say 'APPROVED: ' with result. Otherwise 'REVISION NEEDED: ' with feedback.\"),\n        *messages\n    ])\n    content = response.content\n    update = {\"messages\": [response], \"current_agent\": \"reviewer\"}\n    if count >= 3:\n        update[\"task_status\"] = \"approved\"\n        update[\"revision_count\"] = count\n        return Command(update=update, goto=END)\n    if \"APPROVED\" in content.upper():\n        update[\"task_status\"] = \"approved\"\n        update[\"revision_count\"] = count\n        return Command(update=update, goto=END)\n    else:\n        update[\"task_status\"] = \"needs_revision\"\n        update[\"revision_count\"] = count + 1\n        return Command(update=update, goto=\"executor\")",
  "solutionExplanation": "Adding a revision_count to the state and checking it in the reviewer prevents infinite loops. After 3 revisions, the reviewer forces approval regardless of the LLM's judgment. This is a common production safety pattern for any network graph with feedback loops.",
  "xpReward": 20
}

State Sharing Between Multi-Agent Systems

How much should each agent see? That’s the core state-sharing question.

LangGraph offers two approaches. Shared state means all agents read from and write to the same state channels. This is the default with create_supervisor and create_swarm. Isolated state uses subgraphs with different schemas — each agent has private state invisible to others.

Here’s a practical example of isolated state. The parent graph sends a task to an agent subgraph. The agent does its work internally but only returns the final result. Its reasoning stays private.

python
class AgentInternalState(TypedDict):
    task: str
    internal_notes: str
    result: str

def isolated_agent_node(parent_state: MessagesState) -> dict:
    """Runs an agent with isolated internal state."""
    last_message = parent_state["messages"][-1].content

    # Agent's internal state — invisible to parent graph
    internal = {"task": last_message, "internal_notes": "", "result": ""}

    response = model.invoke([
        SystemMessage(content="Analyze the task and provide a concise result."),
        HumanMessage(content=last_message)
    ])

    # Only the result goes back — internal notes stay private
    return {"messages": [AIMessage(content=response.content, name="isolated_agent")]}

The parent sees only the final AIMessage. The agent’s internal reasoning never leaks to other agents. This isolation matters when agents handle sensitive data or when you want to prevent one agent’s verbose output from polluting another’s context.

Tip: **Use shared state as your default.** Isolated state adds complexity. Reach for it only when you have a specific reason: sensitive data, prompt injection concerns, or noisy intermediate output.

Real-World Example: Multi-Agent Research Team

Let’s combine everything into a practical system. We’ll build a research team with three agents: a planner, a researcher, and a writer. This uses the supervisor pattern because we want a predictable sequence: plan, research, write.

Each agent has exactly one job and the tools to do it. No tool overlap between agents — that’s the whole point of specialization.

python
@tool
def create_outline(topic: str) -> str:
    """Create a research outline with key questions to investigate."""
    return f"Research Outline for '{topic}':\n1. Definition and core concepts\n2. Key applications\n3. Current limitations\n4. Future directions"

@tool
def gather_sources(question: str) -> str:
    """Gather information from sources about a specific question."""
    return f"Source findings for '{question}': Multiple authoritative sources confirm this is an active area of research with significant recent developments."

@tool
def write_section(outline_point: str, research: str) -> str:
    """Write a polished section based on an outline point and research."""
    return f"## {outline_point}\n\n{research}\n\nThis section synthesizes findings from multiple sources."

The supervisor prompt spells out the exact sequence: planner first, then researcher, then writer. Clear routing instructions prevent the supervisor from skipping agents or calling them in the wrong order.

python
planner_agent = create_react_agent(
    model=model,
    tools=[create_outline],
    prompt="You are a research planner. Given a topic, create a structured outline. Return the outline only.",
    name="planner",
)

researcher_agent = create_react_agent(
    model=model,
    tools=[gather_sources],
    prompt="You are a researcher. Investigate each outline point by gathering information. Return findings by outline point.",
    name="researcher",
)

writer_agent_team = create_react_agent(
    model=model,
    tools=[write_section],
    prompt="You are a report writer. Write a polished report from the research findings using the write_section tool.",
    name="writer",
)

research_team = create_supervisor(
    model=model,
    agents=[planner_agent, researcher_agent, writer_agent_team],
    prompt=(
        "You manage a research team. For any research request: "
        "1) Send it to the planner first to create an outline. "
        "2) Send the outline to the researcher to gather information. "
        "3) Send the findings to the writer for the final report. "
        "Always follow this exact sequence."
    ),
    output_mode="full",
).compile()
python
result = research_team.invoke({
    "messages": [HumanMessage(content="Research the current state of multi-agent AI systems and write a brief report.")]
})

for msg in result["messages"]:
    if hasattr(msg, "name") and msg.name and msg.content:
        preview = msg.content[:150].replace("\n", " ")
        print(f"\n[{msg.name}]: {preview}...")

The supervisor routes planner, then researcher, then writer — exactly the sequence we specified.

[UNDER THE HOOD]
How does the supervisor decide routing? It uses the LLM itself. The supervisor prompt describes available agents. The model’s tool-calling capability selects which agent to invoke. Routing quality depends directly on your supervisor prompt — vague prompts produce inconsistent routing.

Choosing the Right Multi-Agent Architecture

Picking an architecture is a design decision based on your workflow’s shape, not a technical preference.

Choose Supervisor when:
– Your workflow has a predictable sequence (plan, execute, review)
– You need centralized logging, auditing, or rate limiting
– Debugging matters more than flexibility

Choose Swarm when:
– The workflow branches based on input (triage to billing, support, or sales)
– Agents are peers, not subordinates
– You want to add agents without rewriting a central prompt

Choose Network when:
– Any agent might need any other agent at any time
– The workflow can’t be predetermined
– You accept the debugging complexity

I’d recommend starting with a supervisor for most projects. It’s the simplest to reason about. Move to swarm when the supervisor prompt becomes unwieldy. Reserve network for genuinely open-ended collaboration.

Limitations and When Multi-Agent Systems Aren’t Worth It

Multi-agent systems add complexity. Sometimes a single agent with good prompting is the right answer.

Don’t use multi-agent when:
– Your task fits in a single prompt with 2-3 tools. The overhead isn’t worth it.
– Latency matters. Each agent hop adds an LLM call. A three-agent supervisor means 4+ LLM calls — 4x the latency and cost.
– Your “agents” are just sequential function calls with no decision-making. That’s a pipeline, not agents.

Known limitations:
Cost. Every agent invocation is an LLM call. Supervisor patterns add extra calls for routing decisions. A three-agent supervisor costs 4-6x a single-agent call.
Error propagation. If Agent A produces bad output, Agent B builds on it. Errors compound. You need validation between agents.
State bloat. Shared message history grows with every agent hop. Long conversations can exceed context limits. Summarize state between agents when needed.

Warning: **Don’t build a multi-agent system because it sounds impressive.** Build one because your single-agent approach is genuinely failing. The simplest architecture that solves your problem is the right one.

Common Mistakes and How to Fix Them

Mistake 1: Giving the supervisor domain knowledge

python
# Wrong — supervisor tries to answer questions itself
supervisor = create_supervisor(
    model=model,
    agents=[researcher, writer],
    prompt="You are an expert in AI research. When users ask about transformers, explain attention first...",
)

Why it’s wrong: The supervisor answers directly instead of delegating.

python
# Correct — supervisor only routes
supervisor = create_supervisor(
    model=model,
    agents=[researcher, writer],
    prompt="Route research questions to the researcher. Route writing tasks to the writer. Never answer directly.",
)

Mistake 2: Missing recursion limits on swarms

python
# Wrong — no recursion limit
swarm = create_swarm(agents=[agent_a, agent_b]).compile()

# Correct — safety net for handoff loops
swarm = create_swarm(agents=[agent_a, agent_b]).compile(recursion_limit=25)

Why it matters: Without a limit, agents that hand off back and forth run forever.

Mistake 3: Sharing all state when agents need isolation

Wrong: Passing entire conversation history to every agent, including sensitive data from other agents’ tool calls.

Correct: Use isolated subgraphs when agents handle sensitive data or when one agent’s verbose output pollutes another’s context. See the State Sharing section for implementation.

[BEST PRACTICE]
Add LangSmith tracing to every multi-agent system. Set LANGSMITH_API_KEY and LANGSMITH_TRACING=true in your environment. Multi-agent debugging without traces is guesswork. With traces, you see every routing decision, every tool call, and every agent response in a single timeline.

Practice Exercise

Build a two-agent supervisor system with a “translator” agent and a “summarizer” agent. The translator converts text to French. The summarizer writes a one-sentence English summary of what was translated.

Click to expand the solution
python
@tool
def translate_to_french(text: str) -> str:
    """Translate English text to French."""
    return f"[French translation of: {text}]"

@tool
def summarize_text(text: str) -> str:
    """Write a one-sentence summary of the given text."""
    return f"Summary: The text discusses {text[:50]}..."

translator = create_react_agent(
    model=model,
    tools=[translate_to_french],
    prompt="Translate the given text to French using the translate tool.",
    name="translator",
)

summarizer = create_react_agent(
    model=model,
    tools=[summarize_text],
    prompt="Summarize the translated text in one English sentence.",
    name="summarizer",
)

pipeline = create_supervisor(
    model=model,
    agents=[translator, summarizer],
    prompt="Send text to the translator first, then send the translation to the summarizer.",
    output_mode="full",
).compile()

result = pipeline.invoke({
    "messages": [HumanMessage(content="Translate and summarize: Machine learning is transforming healthcare.")]
})

for msg in result["messages"]:
    if hasattr(msg, "name") and msg.content:
        print(f"[{msg.name}]: {msg.content[:100]}")

The supervisor routes to the translator first, then the summarizer. Each agent has one tool, one job.

Complete Code

Click to expand the full script (copy-paste and run)
python
# Complete code from: Multi-Agent Systems — Supervisor, Swarm, and Network Architectures
# Requires: pip install langgraph langgraph-supervisor langgraph-swarm langchain-openai langchain-core
# Python 3.10+

import os
import json
from typing import Annotated, Literal, TypedDict

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
from langchain_core.tools import tool
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import create_react_agent
from langgraph.types import Command
from langgraph_supervisor import create_supervisor
from langgraph_swarm import create_swarm, create_handoff_tool

os.environ["OPENAI_API_KEY"] = "your-api-key-here"

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# --- Tools ---

@tool
def search_web(query: str) -> str:
    """Search the web for information on a topic."""
    results = {
        "langgraph": "LangGraph is a framework for building stateful AI agents using graph-based workflows.",
        "multi-agent": "Multi-agent systems use multiple specialized AI agents that collaborate on complex tasks.",
    }
    for key, value in results.items():
        if key in query.lower():
            return value
    return f"Search results for '{query}': No specific results found."

@tool
def format_report(content: str, style: str = "markdown") -> str:
    """Format content into a structured report."""
    if style == "markdown":
        return f"# Research Report\n\n{content}\n\n---\n*Generated by Writer Agent*"
    return f"REPORT\n{'='*40}\n{content}\n{'='*40}"

@tool
def create_outline(topic: str) -> str:
    """Create a research outline with key questions."""
    return f"Research Outline for '{topic}':\n1. Definition and core concepts\n2. Key applications\n3. Current limitations\n4. Future directions"

@tool
def gather_sources(question: str) -> str:
    """Gather information from sources about a specific question."""
    return f"Source findings for '{question}': Active area of research with significant recent developments."

@tool
def write_section(outline_point: str, research: str) -> str:
    """Write a polished section based on research."""
    return f"## {outline_point}\n\n{research}\n\nSynthesized from multiple sources."

# --- Supervisor Pattern ---

research_agent = create_react_agent(
    model=model, tools=[search_web],
    prompt="You are a research specialist. Search and return factual summaries.",
    name="researcher",
)

writer_agent = create_react_agent(
    model=model, tools=[format_report],
    prompt="You are a writing specialist. Format findings into clear reports.",
    name="writer",
)

supervisor = create_supervisor(
    model=model, agents=[research_agent, writer_agent],
    prompt="Delegate research to the researcher, then send findings to the writer. Always use both.",
    output_mode="full",
).compile()

result = supervisor.invoke({
    "messages": [HumanMessage(content="Research what LangGraph is and write a short report.")]
})
for msg in result["messages"]:
    if hasattr(msg, "name"):
        print(f"[{msg.name}]: {msg.content[:100]}...")

# --- Swarm Pattern ---

triage_handoff = create_handoff_tool(
    agent_name="specialist",
    description="Hand off to the specialist for technical AI/ML questions.",
)
specialist_handoff = create_handoff_tool(
    agent_name="triage",
    description="Hand back to triage when the question is answered.",
)

triage_agent = create_react_agent(
    model=model, tools=[triage_handoff],
    prompt="Classify questions. Hand off technical AI/ML questions to the specialist.",
    name="triage",
)
specialist_agent = create_react_agent(
    model=model, tools=[specialist_handoff, search_web],
    prompt="Answer technical questions thoroughly. Hand back to triage when done.",
    name="specialist",
)

swarm = create_swarm(
    agents=[triage_agent, specialist_agent],
    default_active_agent="triage",
).compile(recursion_limit=25)

result = swarm.invoke({
    "messages": [HumanMessage(content="How do attention mechanisms work in transformers?")]
})
for msg in result["messages"]:
    if hasattr(msg, "name") and msg.content:
        print(f"[{msg.name}]: {msg.content[:120]}...")

# --- Network Pattern ---

class NetworkState(TypedDict):
    messages: Annotated[list, "messages"]
    current_agent: str
    task_status: str

def planner_node(state: NetworkState) -> Command:
    messages = state["messages"]
    response = model.invoke([
        SystemMessage(content="Create a brief plan for the task."),
        *messages
    ])
    return Command(
        update={"messages": [response], "current_agent": "planner", "task_status": "planned"},
        goto="executor"
    )

def executor_node(state: NetworkState) -> Command:
    messages = state["messages"]
    response = model.invoke([
        SystemMessage(content="Follow the plan and produce a concise result."),
        *messages
    ])
    return Command(
        update={"messages": [response], "current_agent": "executor", "task_status": "executed"},
        goto="reviewer"
    )

def reviewer_node(state: NetworkState) -> Command:
    messages = state["messages"]
    response = model.invoke([
        SystemMessage(content="If acceptable, say 'APPROVED: ' with the result. Otherwise 'REVISION NEEDED: ' with feedback."),
        *messages
    ])
    content = response.content
    update = {"messages": [response], "current_agent": "reviewer"}
    if "APPROVED" in content.upper():
        update["task_status"] = "approved"
        return Command(update=update, goto=END)
    else:
        update["task_status"] = "needs_revision"
        return Command(update=update, goto="executor")

network_graph = StateGraph(NetworkState)
network_graph.add_node("planner", planner_node)
network_graph.add_node("executor", executor_node)
network_graph.add_node("reviewer", reviewer_node)
network_graph.add_edge(START, "planner")
network = network_graph.compile()

result = network.invoke({
    "messages": [HumanMessage(content="Write a two-sentence summary of multi-agent systems.")],
    "current_agent": "", "task_status": "pending"
})
print(f"Final status: {result['task_status']}")
print(f"Final message: {result['messages'][-1].content[:200]}")

# --- Research Team ---

planner_agent = create_react_agent(
    model=model, tools=[create_outline],
    prompt="Create a structured research outline. Return the outline only.",
    name="planner",
)
researcher_agent = create_react_agent(
    model=model, tools=[gather_sources],
    prompt="Investigate each outline point by gathering source information.",
    name="researcher",
)
writer_agent_team = create_react_agent(
    model=model, tools=[write_section],
    prompt="Write a polished report from the research findings.",
    name="writer",
)

research_team = create_supervisor(
    model=model,
    agents=[planner_agent, researcher_agent, writer_agent_team],
    prompt="1) Planner creates outline. 2) Researcher gathers info. 3) Writer produces report. Follow this sequence.",
    output_mode="full",
).compile()

result = research_team.invoke({
    "messages": [HumanMessage(content="Research multi-agent AI systems and write a brief report.")]
})
for msg in result["messages"]:
    if hasattr(msg, "name") and msg.name and msg.content:
        print(f"\n[{msg.name}]: {msg.content[:150]}...")

print("\nScript completed successfully.")

Summary

Multi-agent systems in LangGraph solve the single-agent ceiling — context pollution, role confusion, and prompt length limits. The supervisor pattern gives centralized control with predictable routing. The swarm pattern distributes decisions through agent-to-agent handoffs. The network pattern offers maximum flexibility at the cost of debuggability. Shared state gives agents seamless communication, while isolated subgraphs keep private data private. Start with a supervisor, move to swarm when routing gets complex, and reserve network for dynamic collaboration.

Frequently Asked Questions

Can I mix supervisor and swarm patterns in the same application?

Yes. A common approach uses a supervisor at the top level while individual workers are swarm-based subgraphs. A supervisor routes to a “customer support” worker that internally uses swarm handoffs between billing, technical, and sales agents. LangGraph supports nesting because each pattern compiles to a standard graph.

How do I add memory to multi-agent systems?

Pass a checkpointer to .compile(): supervisor.compile(checkpointer=MemorySaver()). Each thread_id gets its own conversation history. For cross-conversation memory, use a Store instance with langgraph-supervisor.

python
from langgraph.checkpoint.memory import MemorySaver
supervisor = create_supervisor(...).compile(checkpointer=MemorySaver())

What’s the maximum number of agents a supervisor can manage effectively?

Practical limits appear around 8-10 agents. The supervisor prompt must describe every agent, and LLMs struggle with tool selection beyond that. For larger systems, use a hierarchical approach — a top-level supervisor routing to sub-supervisors that each manage 3-4 agents.

Do all agents need the same LLM?

No. Each agent’s create_react_agent takes its own model parameter. Use GPT-4o for the supervisor (routing accuracy matters), GPT-4o-mini for workers (cost matters), and specialized models for domain-specific tasks. Mixing models is a common cost optimization.

How do I handle agent failures in production?

Wrap agent invocations in error handling within node functions. Return fallback messages, retry with different parameters, or route to backup agents. LangGraph’s retry and fallback patterns from earlier in this series apply directly to multi-agent nodes.

References

  1. LangGraph Documentation — Multi-Agent Systems: https://langchain-ai.github.io/langgraph/concepts/multi_agent/
  2. langgraph-supervisor GitHub Repository: https://github.com/langchain-ai/langgraph-supervisor-py
  3. langgraph-swarm GitHub Repository: https://github.com/langchain-ai/langgraph-swarm-py
  4. LangChain Blog — Command: A New Tool for Multi-Agent Architectures: https://blog.langchain.com/command-a-new-tool-for-multi-agent-architectures-in-langgraph/
  5. LangChain Blog — Benchmarking Multi-Agent Architectures: https://blog.langchain.com/benchmarking-multi-agent-architectures/
  6. LangChain Documentation — Handoffs: https://docs.langchain.com/oss/python/langchain/multi-agent/handoffs
  7. LangChain Documentation — Subgraphs: https://docs.langchain.com/oss/python/langgraph/use-subgraphs
  8. Wu et al., “AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation,” arXiv:2308.08155, 2023.

Free Course
Master Core Python — Your First Step into AI/ML

Build a strong Python foundation with hands-on exercises designed for aspiring Data Scientists and AI/ML Engineers.

Start Free Course
Trusted by 50,000+ learners
Related Course
Master Gen AI — Hands-On
Join 5,000+ students at edu.machinelearningplus.com
Explore Course
Get the full course,
completely free.
Join 57,000+ students learning Python, SQL & ML. One year of access, all resources included.
📚 10 Courses
🐍 Python & ML
🗄️ SQL
📦 Downloads
📅 1 Year Access
No thanks
🎓
Free AI/ML Starter Kit
Python · SQL · ML · 10 Courses · 57,000+ students
🎉   You're in! Check your inbox (or Promotions/Spam) for the access link.
⚡ Before you go

Python.
SQL. NumPy.
All free.

Get the exact 10-course programming foundation that Data Science professionals use.

🐍
Core Python — from first line to expert level
📈
NumPy & Pandas — the #1 libraries every DS job needs
🗃️
SQL Levels I–III — basics to Window Functions
📄
Real industry data — Jupyter notebooks included
R A M S K
57,000+ students
★★★★★ Rated 4.9/5
⚡ Before you go
Python. SQL.
All Free.
R A M S K
57,000+ students  ★★★★★ 4.9/5
Get Free Access Now
10 courses. Real projects. Zero cost. No credit card.
New learners enrolling right now
🔒 100% free ☕ No spam, ever ✓ Instant access
🚀
You're in!
Check your inbox for your access link.
(Check Promotions or Spam if you don't see it)
Or start your first course right now:
Start Free Course →
Scroll to Top
Scroll to Top
Course Preview

Machine Learning A-Z™: Hands-On Python & R In Data Science

Free Sample Videos:

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science

Machine Learning A-Z™: Hands-On Python & R In Data Science