Multi-Agent System
Build a system where multiple specialized agents collaborate to solve complex problems
Multi-Agent System
TL;DR
Complex tasks often need multiple perspectives: a researcher gathers info, a writer creates content, a critic reviews it. Multi-agent systems orchestrate specialized agents that collaborate, with a supervisor routing tasks and maintaining shared context.
| Difficulty | Advanced |
| Time | ~3 days |
| Code Size | ~600 LOC |
| Prerequisites | ReAct Agent, Planning Agent |
Why Multi-Agent?
A single agent with a long prompt can research, write, and review -- but it struggles when tasks require genuinely different expertise, parallel processing, or independent quality checks. Multi-agent systems split work across focused specialists so each agent does one thing well.
When to consider multi-agent over single-agent:
- The task involves conflicting objectives (e.g., creative writing vs. critical review)
- You need independent verification -- an agent reviewing its own output has inherent bias
- Different subtasks benefit from different model settings (low temperature for fact-checking, high for creative writing)
- You want to swap or upgrade individual capabilities without rewriting the whole system
Single Agent vs Multi-Agent
Single Agent
One prompt handles everything. Works for simple tasks, but quality degrades as scope grows. The agent reviews its own work (bias), and a failure in one area (e.g., bad research) cascades through the entire output.
Multi-Agent System
RecommendedSpecialized agents with focused prompts. Each agent is independently testable and replaceable. A critic provides unbiased review. The supervisor can retry individual steps without restarting everything.
What You'll Learn
- Multi-agent architectures and patterns
- Agent specialization and role assignment
- Inter-agent communication protocols
- Supervisor and worker patterns with LangGraph
Tech Stack
| Component | Technology | Why |
|---|---|---|
| Framework | LangGraph | Built-in state management, conditional edges, and checkpointing for agent workflows |
| LLM | OpenAI GPT-4o | Strong instruction following for supervisor routing and specialized agent tasks |
| Orchestration | Custom supervisor | Full control over routing logic, context sharing, and iteration limits |
| Communication | Message passing | Clean decoupling between agents -- each agent only sees its input and shared context |
Multi-Agent Patterns
Multi-Agent Architecture Patterns
Hierarchical (Supervisor/Worker)
Supervisor delegates to Worker 1, Worker 2, Worker 3. Best for task delegation, clear hierarchy, and parallel work.
Collaborative (Peer-to-Peer)
Agents communicate bidirectionally. Best for debates/reviews, consensus building, and complex decisions.
Pipeline (Sequential)
Agent 1 passes to Agent 2, then Agent 3. Best for content creation, data processing, and step-by-step tasks.
Project Structure
multi-agent-system/
├── src/
│ ├── __init__.py
│ ├── agents/
│ │ ├── __init__.py
│ │ ├── researcher.py # Research agent
│ │ ├── writer.py # Writing agent
│ │ ├── critic.py # Review agent
│ │ └── coder.py # Coding agent
│ ├── supervisor.py # Agent orchestration
│ ├── graph.py # LangGraph workflow
│ └── api.py
├── tests/
└── requirements.txtImplementation
Step 1: Setup
openai>=1.0.0
langchain>=0.1.0
langchain-openai>=0.0.5
langgraph>=0.0.40
pydantic>=2.0.0
fastapi>=0.100.0
uvicorn>=0.23.0Step 2: Base Agent Class
"""
Base agent class and shared utilities.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional, Any
from openai import OpenAI
@dataclass
class AgentMessage:
"""Message passed between agents."""
sender: str
content: str
metadata: dict = None
def __post_init__(self):
if self.metadata is None:
self.metadata = {}
@dataclass
class AgentResponse:
"""Response from an agent."""
agent_name: str
content: str
status: str # "complete", "needs_help", "error"
next_agent: Optional[str] = None
class BaseAgent(ABC):
"""Base class for all agents in the system."""
def __init__(
self,
name: str,
model: str = "gpt-4o",
temperature: float = 0.7
):
self.name = name
self.model = model
self.temperature = temperature
self.client = OpenAI()
self.system_prompt = self._get_system_prompt()
@abstractmethod
def _get_system_prompt(self) -> str:
"""Return the agent's system prompt."""
pass
@abstractmethod
def process(self, message: AgentMessage, context: dict) -> AgentResponse:
"""Process a message and return a response."""
pass
def _call_llm(self, messages: list[dict]) -> str:
"""Call the LLM with messages."""
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self.system_prompt},
*messages
],
temperature=self.temperature
)
return response.choices[0].message.contentUnderstanding the Base Agent Design:
The BaseAgent abstract class enforces a contract: every agent must define its own system prompt (_get_system_prompt) and processing logic (process), but they all share the same LLM calling mechanism. This is the Template Method pattern -- common infrastructure lives in the base class, while specialization happens in subclasses.
The two dataclasses define the communication protocol between agents:
| Dataclass | Role | Key Fields |
|---|---|---|
AgentMessage | Input to an agent | sender identifies who sent it, metadata carries extra context |
AgentResponse | Output from an agent | status signals completion state, next_agent suggests routing |
The status and next_agent fields are what make the system self-routing -- each agent can recommend who should handle the task next, rather than relying on a hardcoded sequence.
Step 3: Specialized Agents
"""Research agent - gathers information."""
from . import BaseAgent, AgentMessage, AgentResponse
import httpx
class ResearcherAgent(BaseAgent):
"""Agent specialized in research and information gathering."""
def __init__(self):
super().__init__(name="researcher")
def _get_system_prompt(self) -> str:
return """You are a Research Agent specialized in gathering information.
Your responsibilities:
1. Analyze research requests and identify key topics
2. Search for relevant information
3. Summarize findings clearly and accurately
4. Cite sources when possible
When you need more specific research, indicate what's needed.
When research is complete, summarize your findings."""
def process(self, message: AgentMessage, context: dict) -> AgentResponse:
# Extract research query
query = message.content
# Perform search (simplified - use real search API in production)
search_results = self._search(query)
# Have LLM synthesize findings
synthesis_prompt = f"""Research request: {query}
Search results:
{search_results}
Provide a comprehensive research summary. If more research is needed, specify what."""
response = self._call_llm([{"role": "user", "content": synthesis_prompt}])
# Determine if research is complete
needs_more = "need more" in response.lower() or "further research" in response.lower()
return AgentResponse(
agent_name=self.name,
content=response,
status="needs_help" if needs_more else "complete",
next_agent="supervisor" if needs_more else None
)
def _search(self, query: str) -> str:
"""Search for information."""
try:
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{query.replace(' ', '_')}"
response = httpx.get(url, timeout=10.0)
if response.status_code == 200:
data = response.json()
return f"Wikipedia: {data.get('extract', 'No results')}"
return "No search results found."
except Exception as e:
return f"Search error: {e}""""Writer agent - creates content."""
from . import BaseAgent, AgentMessage, AgentResponse
class WriterAgent(BaseAgent):
"""Agent specialized in writing and content creation."""
def __init__(self):
super().__init__(name="writer")
def _get_system_prompt(self) -> str:
return """You are a Writer Agent specialized in creating content.
Your responsibilities:
1. Transform research and information into well-written content
2. Adapt tone and style to the requested format
3. Structure content logically with clear sections
4. Request clarification or more research if needed
Output formats you support:
- Blog posts
- Technical documentation
- Reports
- Summaries"""
def process(self, message: AgentMessage, context: dict) -> AgentResponse:
# Get research context if available
research = context.get("research", "")
format_type = context.get("format", "article")
writing_prompt = f"""Task: {message.content}
Research/Context provided:
{research}
Write a {format_type} based on this information.
If you need more information or research, specify what's missing."""
response = self._call_llm([{"role": "user", "content": writing_prompt}])
needs_more = "need more" in response.lower() or "missing" in response.lower()
return AgentResponse(
agent_name=self.name,
content=response,
status="needs_help" if needs_more else "complete",
next_agent="researcher" if needs_more else "critic"
)"""Critic agent - reviews and improves content."""
from . import BaseAgent, AgentMessage, AgentResponse
class CriticAgent(BaseAgent):
"""Agent specialized in reviewing and critiquing work."""
def __init__(self):
super().__init__(name="critic", temperature=0.3)
def _get_system_prompt(self) -> str:
return """You are a Critic Agent specialized in reviewing content.
Your responsibilities:
1. Review content for accuracy, clarity, and completeness
2. Identify factual errors or inconsistencies
3. Suggest specific improvements
4. Approve content when it meets quality standards
Provide structured feedback with:
- Overall assessment (approve/needs_revision)
- Specific issues found
- Suggested improvements"""
def process(self, message: AgentMessage, context: dict) -> AgentResponse:
content_to_review = context.get("draft", message.content)
original_request = context.get("original_request", "")
review_prompt = f"""Review this content:
Original request: {original_request}
Content to review:
{content_to_review}
Provide your assessment. If approved, say "APPROVED".
If revisions needed, specify exactly what to fix."""
response = self._call_llm([{"role": "user", "content": review_prompt}])
is_approved = "APPROVED" in response.upper()
return AgentResponse(
agent_name=self.name,
content=response,
status="complete" if is_approved else "needs_help",
next_agent=None if is_approved else "writer"
)Understanding Specialized Agent Design:
Why Specialized Agents Outperform a Single Agent
Single Agent
"You are a helpful AI that researches, writes, AND reviews content." Problems: conflicting objectives, long confusing prompts, self-reviewing bias.
Specialized Agents
RecommendedResearcher only gathers info, Writer only creates content, Critic only reviews quality. Benefits: clear focused prompts, easier to debug, independent perspectives.
Agent Communication Flow
Key Design Patterns:
| Pattern | Implementation | Purpose |
|---|---|---|
status: "needs_help" | Agent signals it can't proceed | Enables graceful handoffs |
next_agent: "researcher" | Agent suggests who should help | Decentralized routing |
| Temperature variation | Critic uses 0.3, Writer uses 0.7 | Different creativity needs |
| Keyword detection | "need more", "missing" triggers re-route | Simple but effective signals |
Step 4: Supervisor Agent
"""
Supervisor that orchestrates multiple agents.
"""
from dataclasses import dataclass, field
from typing import Optional
from openai import OpenAI
from .agents import AgentMessage, AgentResponse
from .agents.researcher import ResearcherAgent
from .agents.writer import WriterAgent
from .agents.critic import CriticAgent
@dataclass
class WorkflowState:
"""Current state of the multi-agent workflow."""
task: str
current_agent: str
messages: list[AgentMessage] = field(default_factory=list)
context: dict = field(default_factory=dict)
iterations: int = 0
max_iterations: int = 10
status: str = "running"
class Supervisor:
"""
Orchestrates multiple agents to complete complex tasks.
The supervisor:
1. Analyzes the task and selects initial agent
2. Routes messages between agents
3. Maintains shared context
4. Determines when task is complete
"""
def __init__(self, model: str = "gpt-4o"):
self.client = OpenAI()
self.model = model
# Initialize agents
self.agents = {
"researcher": ResearcherAgent(),
"writer": WriterAgent(),
"critic": CriticAgent()
}
self.routing_prompt = """You are a supervisor coordinating a team of AI agents.
Available agents:
- researcher: Gathers information and does research
- writer: Creates written content
- critic: Reviews and improves content
Given a task, decide which agent should handle it first.
Respond with just the agent name: researcher, writer, or critic"""
def run(self, task: str) -> dict:
"""
Run the multi-agent workflow on a task.
Args:
task: The task to complete
Returns:
Final result and workflow history
"""
state = WorkflowState(
task=task,
current_agent=self._select_initial_agent(task),
context={"original_request": task}
)
while state.status == "running" and state.iterations < state.max_iterations:
state.iterations += 1
# Get current agent
agent = self.agents.get(state.current_agent)
if not agent:
state.status = "error"
break
# Create message for agent
message = AgentMessage(
sender="supervisor",
content=state.task if state.iterations == 1 else self._get_latest_content(state)
)
# Process with agent
response = agent.process(message, state.context)
# Record the exchange
state.messages.append(message)
state.messages.append(AgentMessage(
sender=response.agent_name,
content=response.content
))
# Update context based on agent
self._update_context(state, response)
# Check if complete
if response.status == "complete" and response.next_agent is None:
state.status = "complete"
elif response.status == "complete" and response.next_agent == "critic":
# Writer done, send to critic
state.current_agent = "critic"
elif response.next_agent:
state.current_agent = response.next_agent
else:
state.status = "complete"
return {
"task": task,
"status": state.status,
"result": self._get_final_result(state),
"iterations": state.iterations,
"agents_used": list(set(m.sender for m in state.messages if m.sender != "supervisor"))
}
def _select_initial_agent(self, task: str) -> str:
"""Select the first agent to handle the task."""
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self.routing_prompt},
{"role": "user", "content": f"Task: {task}"}
],
temperature=0
)
agent_name = response.choices[0].message.content.strip().lower()
return agent_name if agent_name in self.agents else "researcher"
def _update_context(self, state: WorkflowState, response: AgentResponse) -> None:
"""Update shared context based on agent response."""
if response.agent_name == "researcher":
state.context["research"] = response.content
elif response.agent_name == "writer":
state.context["draft"] = response.content
elif response.agent_name == "critic":
state.context["review"] = response.content
def _get_latest_content(self, state: WorkflowState) -> str:
"""Get the most recent content for the next agent."""
if state.messages:
return state.messages[-1].content
return state.task
def _get_final_result(self, state: WorkflowState) -> str:
"""Extract final result from state."""
# Prefer approved draft, otherwise latest content
if "draft" in state.context and "APPROVED" in state.context.get("review", "").upper():
return state.context["draft"]
return self._get_latest_content(state)Understanding the Supervisor Pattern:
Supervisor Responsibilities and State Management
1. Routing
2. Context
3. Flow Control
Why max_iterations = 10?
Without a limit, the loop can run forever: Critic rejects, Writer rewrites, Critic rejects again. With a limit, after 10 iterations the system forces completion with the best draft. Better to return "good enough" than loop infinitely.
Supervisor vs LangGraph:
| Approach | Best For | Trade-off |
|---|---|---|
| Custom Supervisor | Full control, simple flows | More code to maintain |
| LangGraph | Complex graphs, checkpointing | Learning curve |
Step 5: LangGraph Workflow
"""
LangGraph-based multi-agent workflow.
"""
from typing import TypedDict, Annotated, Sequence
from langgraph.graph import StateGraph, END
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
import operator
class AgentState(TypedDict):
"""State shared across all agents."""
messages: Annotated[Sequence[BaseMessage], operator.add]
task: str
research: str
draft: str
review: str
next_agent: str
iterations: int
def create_agent_graph():
"""Create a LangGraph workflow for multi-agent collaboration."""
llm = ChatOpenAI(model="gpt-4o")
def researcher_node(state: AgentState) -> dict:
"""Research agent node."""
prompt = f"""You are a researcher. Research this topic and provide findings:
Task: {state['task']}
Provide comprehensive research findings."""
response = llm.invoke([HumanMessage(content=prompt)])
return {
"messages": [AIMessage(content=f"[Researcher]: {response.content}")],
"research": response.content,
"next_agent": "writer",
"iterations": state["iterations"] + 1
}
def writer_node(state: AgentState) -> dict:
"""Writer agent node."""
prompt = f"""You are a writer. Create content based on this research:
Task: {state['task']}
Research:
{state['research']}
Write a well-structured article."""
response = llm.invoke([HumanMessage(content=prompt)])
return {
"messages": [AIMessage(content=f"[Writer]: {response.content}")],
"draft": response.content,
"next_agent": "critic",
"iterations": state["iterations"] + 1
}
def critic_node(state: AgentState) -> dict:
"""Critic agent node."""
prompt = f"""You are a critic. Review this draft:
Original task: {state['task']}
Draft:
{state['draft']}
If the draft is good, respond with "APPROVED: [brief praise]"
If it needs work, provide specific feedback."""
response = llm.invoke([HumanMessage(content=prompt)])
is_approved = "APPROVED" in response.content.upper()
return {
"messages": [AIMessage(content=f"[Critic]: {response.content}")],
"review": response.content,
"next_agent": "end" if is_approved else "writer",
"iterations": state["iterations"] + 1
}
def should_continue(state: AgentState) -> str:
"""Determine next node or end."""
if state["iterations"] >= 6:
return "end"
return state["next_agent"]
# Build the graph
workflow = StateGraph(AgentState)
workflow.add_node("researcher", researcher_node)
workflow.add_node("writer", writer_node)
workflow.add_node("critic", critic_node)
workflow.set_entry_point("researcher")
workflow.add_conditional_edges(
"researcher",
should_continue,
{"writer": "writer", "end": END}
)
workflow.add_conditional_edges(
"writer",
should_continue,
{"critic": "critic", "end": END}
)
workflow.add_conditional_edges(
"critic",
should_continue,
{"writer": "writer", "end": END}
)
return workflow.compile()Understanding the LangGraph Approach:
The LangGraph version replaces the custom while loop with a declarative state graph. Each agent becomes a node (a function that reads state and returns updates), and transitions become conditional edges controlled by should_continue.
Key differences from the custom supervisor:
| Aspect | Custom Supervisor | LangGraph |
|---|---|---|
| State management | Manual WorkflowState dataclass | TypedDict with Annotated reducers |
| Message history | Manually appended to list | operator.add auto-accumulates messages |
| Routing | if/elif chain in the while loop | add_conditional_edges with mapping dict |
| Iteration limit | max_iterations field | Checked in should_continue function |
Why Annotated[Sequence[BaseMessage], operator.add]?
The operator.add reducer tells LangGraph to append new messages to the existing list rather than replacing it. Without this, each node would overwrite the message history. This is how LangGraph handles accumulating state across multiple node invocations.
The should_continue function serves as the graph's routing logic. It checks two things: has the iteration limit been reached (safety valve), and which agent did the previous node request? This replaces the supervisor's LLM-based routing with a simpler, deterministic approach.
Step 6: API
"""FastAPI application for multi-agent system."""
from fastapi import FastAPI
from pydantic import BaseModel
from .supervisor import Supervisor
from .graph import create_agent_graph
app = FastAPI(title="Multi-Agent System API")
supervisor = Supervisor()
agent_graph = create_agent_graph()
class TaskRequest(BaseModel):
task: str
use_langgraph: bool = False
class TaskResponse(BaseModel):
task: str
status: str
result: str
iterations: int
agents_used: list[str]
@app.post("/execute", response_model=TaskResponse)
async def execute_task(request: TaskRequest):
if request.use_langgraph:
# Use LangGraph workflow
result = agent_graph.invoke({
"messages": [],
"task": request.task,
"research": "",
"draft": "",
"review": "",
"next_agent": "researcher",
"iterations": 0
})
return TaskResponse(
task=request.task,
status="complete",
result=result["draft"],
iterations=result["iterations"],
agents_used=["researcher", "writer", "critic"]
)
else:
# Use custom supervisor
result = supervisor.run(request.task)
return TaskResponse(
task=request.task,
status=result["status"],
result=result["result"],
iterations=result["iterations"],
agents_used=result["agents_used"]
)Understanding the Dual-Mode API:
The API exposes a single /execute endpoint that can use either orchestration backend via the use_langgraph flag. This is useful during development: you can compare the custom supervisor and LangGraph approaches side-by-side with the same input and see how they differ in iteration count, agent usage, and output quality.
Both the Supervisor and agent_graph are instantiated at module level (not per-request) because they are stateless orchestrators -- the per-request state lives in WorkflowState or AgentState, not in the orchestrator itself.
Running the System
export OPENAI_API_KEY="your-key"
uvicorn src.api:app --reload --port 8000# Execute a multi-agent task
curl -X POST http://localhost:8000/execute \
-H "Content-Type: application/json" \
-d '{"task": "Write a blog post about the benefits of meditation"}'Multi-Agent Patterns
| Pattern | Use Case | Complexity |
|---|---|---|
| Supervisor | Central coordination | Medium |
| Pipeline | Sequential processing | Low |
| Collaborative | Peer discussion | High |
| Hierarchical | Complex organizations | High |
Key Concepts Recap
| Concept | What It Is | Why It Matters |
|---|---|---|
| Supervisor | Agent that routes tasks to workers | Central coordination point |
| Specialized Agents | Agents with focused roles (researcher, writer) | Better quality through expertise |
| Shared Context | State accessible to all agents | Enables collaboration |
| Message Passing | How agents communicate | Clean interface between agents |
| Workflow State | Tracks progress through agents | Enables debugging and monitoring |
| Agent Selection | Choosing which agent handles what | Critical for efficiency |
| Iteration Limit | Max loops through agents | Prevents infinite cycles |
Next Steps
- Autonomous Agent - Self-directed agents
- Agent Evaluation - Test agent systems