Build a Multi-Agent Research Team (Planner, Searcher, Writer)
Build a working 3-agent research pipeline with a Planner, Searcher, and Writer using LangChain and AutoGen — complete code with role definitions and output format.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
Research is the task that convinced me multi-agent systems actually earn their complexity. I tried to do deep research with a single agent for months. The problem was always the same: either the context window filled up before I had enough information, or the agent would summarize prematurely because it was trying to do everything at once.
The fix was separation of concerns. A Planner that thinks about what to find. A Searcher that's purely focused on finding it. A Writer that does nothing but synthesize. Three agents, three roles, dramatically better results.
This tutorial builds a complete three-agent research pipeline from scratch. You'll get full working code, role definitions, and the output format I've landed on after several iterations.
If you want to understand the theory before the code, multi-agent systems explained and AI agents explained cover the fundamentals.
System Architecture
User Input (Research Topic)
│
▼
[Planner Agent]
- Breaks topic into 4-6 sub-questions
- Outputs structured research plan
│
▼ research_plan (JSON)
[Searcher Agent]
- Executes searches for each sub-question
- Collects and deduplicates findings
- Annotates sources
│
▼ search_results (dict)
[Writer Agent]
- Synthesizes findings into report
- Applies consistent formatting
- Adds citations
│
▼
Final Research Report (Markdown)
Each agent has a clean interface: defined inputs, defined outputs, and no awareness of agents outside its immediate upstream/downstream. This makes the system modular — you can swap out any agent independently.
Setup
pip install langchain langchain-openai langchain-community tavily-python python-dotenv autogen-agentchat
import os
import json
import asyncio
from typing import Dict, List, Any, Optional
from dataclasses import dataclass, field
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain.schema import HumanMessage, SystemMessage
from langchain_community.tools.tavily_search import TavilySearchResults
load_dotenv()
# Model configs — different models for different roles
PLANNER_MODEL = ChatOpenAI(model="gpt-4o", temperature=0.1)
SEARCHER_MODEL = ChatOpenAI(model="gpt-4o-mini", temperature=0.0)
WRITER_MODEL = ChatOpenAI(model="gpt-4o", temperature=0.3)
# Search tool
search_tool = TavilySearchResults(
max_results=5,
api_key=os.environ["TAVILY_API_KEY"]
)
The model choice here is intentional. The Planner needs the best reasoning (GPT-4o at low temperature). The Searcher needs consistency and speed, not creativity (GPT-4o-mini). The Writer needs quality and some stylistic flexibility (GPT-4o, slightly warmer temperature).
Shared State
The agents communicate through a shared state dictionary. Simple, explicit, easy to debug.
@dataclass
class ResearchState:
topic: str
research_plan: Optional[Dict] = None
search_results: Dict[str, List[Dict]] = field(default_factory=dict)
final_report: Optional[str] = None
errors: List[str] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
def add_search_result(self, sub_question: str, results: List[Dict]):
if sub_question not in self.search_results:
self.search_results[sub_question] = []
self.search_results[sub_question].extend(results)
def to_summary(self) -> str:
"""Compact summary for passing to writer."""
summary = f"Research Topic: {self.topic}\n\n"
for question, results in self.search_results.items():
summary += f"## {question}\n"
for r in results[:3]: # Top 3 per question
summary += f"- **Source:** {r.get('url', 'Unknown')}\n"
summary += f" {r.get('content', '')[:300]}...\n\n"
return summary
The Planner Agent
The Planner's only job is to think about the research question and output a structured plan. It doesn't search for anything.
class PlannerAgent:
def __init__(self, llm: ChatOpenAI):
self.llm = llm
self.system_prompt = """You are a research planning specialist.
Your job is to analyze a research topic and create a structured research plan.
Output a JSON object with this exact structure:
{
"topic": "the research topic",
"objective": "what we want to learn",
"sub_questions": [
{
"id": 1,
"question": "specific sub-question",
"priority": "high|medium|low",
"search_queries": ["query1", "query2"]
}
],
"output_format": "description of expected report structure"
}
Rules:
- Generate 4-6 sub-questions that together cover the full topic
- Each sub-question should have 2-3 specific search queries
- Questions should move from foundational to specific
- Do NOT answer the questions — just plan how to research them
"""
def plan(self, topic: str) -> Dict:
messages = [
SystemMessage(content=self.system_prompt),
HumanMessage(content=f"Create a research plan for: {topic}")
]
response = self.llm.invoke(messages)
# Extract JSON from response
content = response.content
# Handle markdown code blocks if present
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
content = content.split("```")[1].split("```")[0].strip()
plan = json.loads(content)
print(f"[Planner] Generated plan with {len(plan['sub_questions'])} sub-questions")
return plan
The Searcher Agent
The Searcher takes the plan and executes searches for each sub-question. It handles rate limiting, deduplication, and annotates results with source metadata.
class SearcherAgent:
def __init__(self, llm: ChatOpenAI, search_tool: TavilySearchResults):
self.llm = llm
self.search_tool = search_tool
self.system_prompt = """You are a research search specialist.
You receive search results and must:
1. Extract the most relevant information for the given question
2. Note the source URL and date if available
3. Flag any conflicting information between sources
4. Return a structured summary
Output format:
{
"question": "the sub-question",
"key_findings": ["finding 1", "finding 2"],
"sources": [{"url": "...", "relevance": "high|medium"}],
"conflicts": ["any conflicting claims"],
"confidence": "high|medium|low"
}"""
def _search_with_query(self, query: str) -> List[Dict]:
"""Execute a single search query."""
try:
results = self.search_tool.invoke(query)
return results if isinstance(results, list) else []
except Exception as e:
print(f"[Searcher] Search failed for '{query}': {e}")
return []
def _deduplicate_results(self, results: List[Dict]) -> List[Dict]:
"""Remove duplicate URLs from search results."""
seen_urls = set()
unique = []
for r in results:
url = r.get("url", "")
if url and url not in seen_urls:
seen_urls.add(url)
unique.append(r)
return unique
def _synthesize_findings(self, question: str, raw_results: List[Dict]) -> Dict:
"""Use LLM to synthesize raw search results into structured findings."""
results_text = "\n\n".join([
f"Source: {r.get('url', 'unknown')}\nContent: {r.get('content', '')[:500]}"
for r in raw_results[:5]
])
messages = [
SystemMessage(content=self.system_prompt),
HumanMessage(content=f"Question: {question}\n\nSearch Results:\n{results_text}")
]
response = self.llm.invoke(messages)
content = response.content
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
try:
return json.loads(content)
except json.JSONDecodeError:
return {"question": question, "key_findings": [content], "sources": [], "confidence": "low"}
def search(self, research_plan: Dict, state: ResearchState) -> ResearchState:
"""Execute searches for all sub-questions in the plan."""
for sub_q in research_plan["sub_questions"]:
question = sub_q["question"]
queries = sub_q["search_queries"]
priority = sub_q.get("priority", "medium")
print(f"[Searcher] Searching: {question} (priority: {priority})")
# Execute all queries for this sub-question
all_raw_results = []
for query in queries:
raw = self._search_with_query(query)
all_raw_results.extend(raw)
# Deduplicate
unique_results = self._deduplicate_results(all_raw_results)
# Synthesize into structured findings
findings = self._synthesize_findings(question, unique_results)
state.add_search_result(question, [findings])
print(f"[Searcher] Found {len(unique_results)} unique sources for: {question}")
return state
The Writer Agent
The Writer reads the research state and produces a structured, well-cited report.
class WriterAgent:
def __init__(self, llm: ChatOpenAI):
self.llm = llm
self.system_prompt = """You are a research writer who synthesizes information into clear,
well-structured reports.
Your reports must:
1. Start with an executive summary (2-3 sentences)
2. Have clearly labeled sections for each major finding
3. Cite sources with [Source: URL] inline
4. Acknowledge uncertainty where confidence is low
5. End with key conclusions and open questions
6. Use markdown formatting throughout
Style guidelines:
- Write for an informed, professional audience
- Use specific numbers and dates when available
- Avoid vague generalizations
- Distinguish between established facts and current estimates
"""
def write(self, topic: str, state: ResearchState, plan: Dict) -> str:
research_summary = state.to_summary()
messages = [
SystemMessage(content=self.system_prompt),
HumanMessage(content=f"""Write a comprehensive research report on: {topic}
Research Objective: {plan.get('objective', 'Not specified')}
Research Findings:
{research_summary}
Please structure the report according to these sections implied by the research plan.
Include all available sources as citations.""")
]
response = self.llm.invoke(messages)
print(f"[Writer] Report generated: {len(response.content)} characters")
return response.content
The Orchestrator
This ties everything together. It instantiates the agents, manages state, and runs the pipeline.
class ResearchOrchestrator:
def __init__(self):
self.planner = PlannerAgent(PLANNER_MODEL)
self.searcher = SearcherAgent(SEARCHER_MODEL, search_tool)
self.writer = WriterAgent(WRITER_MODEL)
def run(self, topic: str) -> ResearchState:
print(f"\n{'='*60}")
print(f"Starting research: {topic}")
print('='*60)
state = ResearchState(topic=topic)
# Step 1: Plan
print("\n[Phase 1: Planning]")
state.research_plan = self.planner.plan(topic)
# Step 2: Search
print("\n[Phase 2: Searching]")
state = self.searcher.search(state.research_plan, state)
# Step 3: Write
print("\n[Phase 3: Writing]")
state.final_report = self.writer.write(topic, state, state.research_plan)
print("\n[Research Complete]")
return state
def save_report(self, state: ResearchState, output_path: str):
with open(output_path, "w", encoding="utf-8") as f:
f.write(f"# Research Report: {state.topic}\n\n")
f.write(state.final_report)
print(f"Report saved to: {output_path}")
# Usage
if __name__ == "__main__":
orchestrator = ResearchOrchestrator()
result = orchestrator.run(
"The current state of open-source LLM fine-tuning methods in 2026"
)
orchestrator.save_report(result, "research_report.md")
print("\n--- FINAL REPORT PREVIEW ---")
print(result.final_report[:500])
AutoGen Version of the Same Pipeline
If you prefer AutoGen over LangChain, here's the equivalent system:
import autogen
llm_config = {"model": "gpt-4o", "api_key": os.environ["OPENAI_API_KEY"]}
planner = autogen.AssistantAgent(
name="Planner",
system_message="""Break the research topic into 4-6 specific sub-questions.
For each question, suggest 2-3 search queries.
Output as a numbered list. End with: 'Plan complete.'""",
llm_config=llm_config
)
searcher = autogen.AssistantAgent(
name="Searcher",
system_message="""Given research questions, find relevant information.
For each question, provide a summary of what's known with source URLs.
Be specific. End with: 'Search complete.'""",
llm_config=llm_config
)
writer = autogen.AssistantAgent(
name="Writer",
system_message="""Synthesize research findings into a structured report.
Use headers, bullet points, and inline citations.
End with: 'REPORT COMPLETE'""",
llm_config=llm_config
)
user_proxy = autogen.UserProxyAgent(
name="UserProxy",
human_input_mode="NEVER",
is_termination_msg=lambda m: "REPORT COMPLETE" in m.get("content", ""),
code_execution_config=False
)
groupchat = autogen.GroupChat(
agents=[user_proxy, planner, searcher, writer],
messages=[],
max_round=12,
speaker_selection_method="auto"
)
manager = autogen.GroupChatManager(
groupchat=groupchat,
llm_config=llm_config,
system_message="""Coordinate: Planner goes first, then Searcher, then Writer.
Each agent speaks once per cycle unless revision is needed."""
)
user_proxy.initiate_chat(
manager,
message="Research: How are companies using AI agents in production in 2026?"
)
For deeper AutoGen group chat patterns, autogen multi-agent group chat tutorial has the full setup guide.
Output Format
The final report follows this structure:
# Research Report: [Topic]
## Executive Summary
[2-3 sentence overview of key findings]
## Background
[Foundational context from Planner's first sub-questions]
## Current State
[Main findings with inline citations [Source: URL]]
## Key Developments
[Specific findings, statistics, examples]
## Challenges and Open Questions
[What we don't know, conflicting information]
## Conclusions
[Synthesized takeaways]
## Sources
[Full list of URLs used]
Performance Notes
A typical 6-sub-question research run with this pipeline takes approximately:
- Planning: 5-10 seconds
- Searching: 30-60 seconds (search API latency × 6 sub-questions)
- Writing: 15-25 seconds
- Total: 50-95 seconds
Compare that to a human researcher who might take 4-8 hours for equivalent coverage. The quality won't match a professional researcher, but for first-pass research, market overviews, or competitive analysis, this pipeline is genuinely useful.
For an alternative approach using LangChain's RAG pipeline, the LangChain RAG pipeline article shows how to add vector store retrieval for document-heavy research tasks.
The AI research agent build article dives into more advanced patterns including self-evaluation and iterative search refinement.
Conclusion
The Planner-Searcher-Writer pattern is one of the most practical multi-agent architectures for real work. The separation of concerns is clean, the communication via shared state is explicit and debuggable, and the output quality improvement over single-agent research is measurable.
Start with the LangChain version if you want more control over each agent's logic. Start with the AutoGen version if you want to experiment with the group chat dynamics. Both produce good results once you've calibrated the system prompts.
The next step is adding a fact-checker agent that verifies claims against sources before the Writer synthesizes — a pattern covered in multi-agent consensus voting negotiation.
Frequently Asked Questions
What does each agent do in the research pipeline? The Planner breaks the research topic into specific sub-questions. The Searcher finds information for each sub-question using search tools. The Writer synthesizes findings into a structured report. Each role has a distinct system prompt and tool set.
How do agents pass information between each other in this pipeline? The Planner outputs a structured research plan (JSON). The Searcher reads that plan, executes searches, and writes findings to a shared state dict. The Writer reads the state dict and produces the final report. State is passed via a shared dictionary object.
Can I swap out the LLM for different agents? Yes. Each agent is independently configured with its own llm_config. You can use GPT-4o for the Planner (reasoning-heavy), GPT-4o-mini for the Searcher (structured, cheaper), and GPT-4o for the Writer (quality-sensitive). Mix and match for cost-performance optimization.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
5 AutoGen Agent Roles (Assistant, UserProxy, CodeExecutor)
Understand the 5 core AutoGen agent types — AssistantAgent, UserProxyAgent, CodeExecutorAgent, and more — with code examples and a comparison table for each role.
How to Deploy AutoGen Agents as APIs with FastAPI (2026)
Learn to serve AutoGen multi-agent systems as production REST APIs using FastAPI with async endpoints and real-time streaming responses.
How to Use AutoGen with Azure OpenAI (Enterprise Security)
Connect Microsoft AutoGen to Azure OpenAI for enterprise-grade AI agents. Step-by-step setup with private endpoints, OAI_CONFIG_LIST, and deployment config.
Build a Code Debugging Agent with AutoGen (Auto-Fix PRs)
Build an AutoGen agent that reviews code, analyzes PR diffs, suggests fixes, and automates code quality improvements with a full working implementation.