Building a Multi-Agent Research Pipeline with Pydantic AI: Complete Guide
Modern research workflows rarely fit neatly into a single prompt-response cycle. Whether you're investigating market trends, summarizing scientific literature, or producing competitive analyses, the work typically involves several distinct stages: gathering sources, extracting key facts, cross-referencing claims, and synthesizing a final report. Each stage benefits from a specialized agent with its own instructions, tools, and structured outputs. Pydantic AI, a framework that pairs LLMs with Pydantic's type validation, is particularly well suited for orchestrating these multi-agent pipelines.
In this guide, you'll learn what a multi-agent research pipeline is, why Pydantic AI is a strong foundation for building one, and how to assemble a working pipeline step by step. By the end, you'll have a runnable architecture you can adapt to your own research domain.
What Is a Multi-Agent Research Pipeline?
A multi-agent research pipeline is a system in which several LLM-powered agents collaborate to complete a research task. Rather than asking one model to do everything, you decompose the task into focused subtasks and assign each to an agent with a narrow scope. The output of one agent becomes the input to the next, forming a directed flow of structured data.
A typical research pipeline might include the following roles:
- Planner Agent — Decomposes the research question into sub-questions and a search strategy.
- Search Agent — Queries external sources (web, databases, internal docs) and returns raw results.
- Extractor Agent — Pulls structured facts and citations from raw source material.
- Critic Agent — Evaluates findings for contradictions, gaps, and source credibility.
- Synthesizer Agent — Produces the final report, integrating all validated findings.
The key advantage is separation of concerns. Each agent can be tested, tuned, and swapped independently. You can also parallelize branches of the pipeline where dependencies allow.
Why Pydantic AI?
Pydantic AI brings several properties that make it ideal for this kind of work:
- Type-safe structured outputs — Every agent returns a validated Pydantic model, so downstream agents receive predictable data rather than free-form text.
- Dependency injection — Tools and external services are injected via typed dependencies, making agents easy to test in isolation.
- Model-agnostic design — You can mix providers (OpenAI, Anthropic, Gemini, Ollama) within the same pipeline.
- Streaming and tool use — Built-in support for streaming responses and custom tools keeps pipelines responsive and extensible.
- Observability via Logfire — Optional integration with Logfire gives you tracing across agent boundaries.
Because each agent's output is a Pydantic model, the contracts between agents are explicit. If the Search Agent changes its schema, the Extractor Agent will fail at validation time rather than producing silent corruption downstream.
Prerequisites and Setup
Install Pydantic AI and a few supporting packages. The examples below use Python 3.10 or newer.
pip install pydantic-ai pydantic httpx python-dotenv
Create a .env file with your API keys:
OPENAI_API_KEY=sk-...
For this tutorial we'll use OpenAI, but you can swap in any supported model by changing the model argument on each agent.
Step 1: Define Shared Data Models
Start by defining the structured data that flows between agents. These models form the contract for the entire pipeline.
from pydantic import BaseModel, Field
from typing import Literal
class SubQuestion(BaseModel):
question: str
rationale: str
search_terms: list[str]
class ResearchPlan(BaseModel):
topic: str
sub_questions: list[SubQuestion]
overall_strategy: str
class SourceSnippet(BaseModel):
sub_question: str
url: str
title: str
raw_text: str
class ExtractedFact(BaseModel):
sub_question: str
claim: str
supporting_quote: str
source_url: str
confidence: float = Field(ge=0.0, le=1.0)
class CritiqueResult(BaseModel):
fact_index: int
verdict: Literal["supported", "contradicted", "uncertain"]
reasoning: str
suggested_followup: str | None = None
class ResearchReport(BaseModel):
topic: str
executive_summary: str
key_findings: list[str]
detailed_analysis: str
sources: list[str]
open_questions: list[str]
Notice how each model is self-describing. The confidence field on ExtractedFact is bounded between 0 and 1, and verdict on CritiqueResult is constrained to a literal set of values. These constraints prevent malformed data from propagating.
Step 2: Build the Planner Agent
The Planner takes a research topic and produces a structured plan. We define it as a Pydantic AI Agent with a system prompt and an expected output type.
from pydantic_ai import Agent
planner_agent = Agent(
model="openai:gpt-4o",
output_type=ResearchPlan,
system_prompt=(
"You are a research planner. Given a topic, break it down into 3-5 "
"specific sub-questions that together cover the topic. For each "
"sub-question, provide a rationale and a list of search terms. "
"Also describe an overall search strategy."
),
)
async def plan_research(topic: str) -> ResearchPlan:
result = await planner_agent.run(f"Topic: {topic}")
return result.output
Because output_type is set to ResearchPlan, Pydantic AI automatically instructs the model to produce JSON conforming to that schema and validates the response. If the model returns invalid data, the framework retries with corrective feedback.
Step 3: Build the Search Agent with Tools
The Search Agent needs access to external sources. We'll define a simple search tool and inject it as a dependency. In production you'd replace this with a real search API.
from dataclasses import dataclass
@dataclass
class SearchDeps:
http_client: httpx.AsyncClient
async def web_search(ctx, query: str) -> str:
"""Search the web and return raw text snippets."""
deps: SearchDeps = ctx.deps
# Placeholder: in production, call a real search API.
# Here we simulate a result.
return (
f"Simulated search result for '{query}': "
f"According to recent reports, {query} shows significant growth "
f"with multiple contributing factors. Source: https://example.com/{query.replace(' ', '-')}"
)
search_agent = Agent(
model="openai:gpt-4o",
output_type=list[SourceSnippet],
deps_type=SearchDeps,
tools=[web_search],
system_prompt=(
"You are a research search agent. For each sub-question provided, "
"use the web_search tool with appropriate search terms. Collect "
"raw text snippets with their source URLs. Return one SourceSnippet "
"per sub-question."
),
)
async def search_sources(plan: ResearchPlan) -> list[SourceSnippet]:
async with httpx.AsyncClient() as client:
deps = SearchDeps(http_client=client)
prompt = "\n".join(
f"Sub-question: {sq.question}\nSearch terms: {', '.join(sq.search_terms)}"
for sq in plan.sub_questions
)
result = await search_agent.run(prompt, deps=deps)
return result.output
The deps_type parameter tells Pydantic AI what dependency object to expect, and the tool function receives a context object that exposes those deps. This pattern keeps the agent decoupled from concrete implementations, which is essential for testing.
Step 4: Build the Extractor Agent
The Extractor reads raw snippets and pulls out structured facts with citations and confidence scores.
extractor_agent = Agent(
model="openai:gpt-4o",
output_type=list[ExtractedFact],
system_prompt=(
"You are a fact extraction agent. Given raw source snippets, "
"extract discrete factual claims. For each claim, include the "
"exact supporting quote, the source URL, and a confidence score "
"between 0 and 1 reflecting how clearly the source supports the "
"claim. Map each fact to the sub-question it addresses."
),
)
async def extract_facts(snippets: list[SourceSnippet]) -> list[ExtractedFact]:
formatted = "\n\n".join(
f"[Sub-question: {s.sub_question}]\nURL: {s.url}\nTitle: {s.title}\nText: {s.raw_text}"
for s in snippets
)
result = await extractor_agent.run(formatted)
return result.output
Step 5: Build the Critic Agent
The Critic evaluates each extracted fact for support, contradictions, and gaps. This is where the pipeline gains reliability — instead of trusting the model's first pass, we add a verification layer.
critic_agent = Agent(
model="openai:gpt-4o",
output_type=list[CritiqueResult],
system_prompt=(
"You are a research critic. You receive a list of extracted facts. "
"For each fact, assess whether it is supported by its cited quote, "
"whether it contradicts other facts, and whether it is uncertain. "
"Return a CritiqueResult for each fact. If a fact is contradicted "
"or uncertain, suggest a follow-up search."
),
)
async def critique_facts(facts: list[ExtractedFact]) -> list[CritiqueResult]:
formatted = "\n\n".join(
f"Fact {i}: {f.claim}\nQuote: {f.supporting_quote}\n"
f"URL: {f.source_url}\nConfidence: {f.confidence}\nSub-question: {f.sub_question}"
for i, f in enumerate(facts)
)
result = await critic_agent.run(formatted)
return result.output
You can then filter facts based on the critic's verdicts before passing them to the synthesizer:
def filter_supported_facts(
facts: list[ExtractedFact],
critiques: list[CritiqueResult],
) -> list[ExtractedFact]:
supported = []
for fact, critique in zip(facts, critiques):
if critique.verdict == "supported":
supported.append(fact)
return supported
Step 6: Build the Synthesizer Agent
The Synthesizer takes the validated facts and produces the final structured report.
synthesizer_agent = Agent(
model="openai:gpt-4o",
output_type=ResearchReport,
system_prompt=(
"You are a research synthesizer. Given a topic and a list of "
"validated facts with citations, produce a structured research "
"report. Include an executive summary, key findings, a detailed "
"analysis, a list of source URLs, and any open questions that "
"remain unanswered. Be precise and cite sources."
),
)
async def synthesize_report(
topic: str,
facts: list[ExtractedFact],
) -> ResearchReport:
formatted = "\n\n".join(
f"- {f.claim} (Source: {f.source_url}, Confidence: {f.confidence})"
for f in facts
)
prompt = f"Topic: {topic}\n\nValidated facts:\n{formatted}"
result = await synthesizer_agent.run(prompt)
return result.output
Step 7: Orchestrate the Full Pipeline
Now wire everything together. The orchestrator runs each stage in sequence, passing structured outputs between agents.
import asyncio
from dotenv import load_dotenv
load_dotenv()
async def run_research_pipeline(topic: str) -> ResearchReport:
print(f"[1/5] Planning research for: {topic}")
plan = await plan_research(topic)
print(f" Plan has {len(plan.sub_questions)} sub-questions")
print("[2/5] Searching sources")
snippets = await search_sources(plan)
print(f" Collected {len(snippets)} snippets")
print("[3/5] Extracting facts")
facts = await extract_facts(snippets)
print(f" Extracted {len(facts)} facts")
print("[4/5] Critiquing facts")
critiques = await critique_facts(facts)
supported = filter_supported_facts(facts, critiques)
print(f" {len(supported)} facts supported out of {len(facts)}")
print("[5/5] Synthesizing report")
report = await synthesize_report(topic, supported)
return report
if __name__ == "__main__":
topic = "The impact of remote work on commercial real estate"
report = asyncio.run(run_research_pipeline(topic))
print("\n=== RESEARCH REPORT ===\n")
print(f"Topic: {report.topic}")
print(f"\nExecutive Summary:\n{report.executive_summary}")
print(f"\nKey Findings:")
for finding in report.key_findings:
print(f" - {finding}")
print(f"\nDetailed Analysis:\n{report.detailed_analysis}")
print(f"\nSources:")
for src in report.sources:
print(f" - {src}")
print(f"\nOpen Questions:")
for q in report.open_questions:
print(f" - {q}")
Running this script executes the full pipeline end to end. Each stage prints its progress, and the final report is a validated ResearchReport instance you can serialize to JSON, store in a database, or render into a template.
Adding Parallelism
Some stages can run in parallel. For example, if sub-questions are independent, you can search and extract for each one concurrently:
async def search_and_extract_one(
sub_question: SubQuestion,
client: httpx.AsyncClient,
) -> list[ExtractedFact]:
deps = SearchDeps(http_client=client)
search_prompt = (
f"Sub-question: {sub_question.question}\n"
f"Search terms: {', '.join(sub_question.search_terms)}"
)
search_result = await search_agent.run(search_prompt, deps=deps)
snippets = search_result.output
formatted = "\n\n".join(
f"[Sub-question: {s.sub_question}]\nURL: {s.url}\nText: {s.raw_text}"
for s in snippets
)
extract_result = await extractor_agent.run(formatted)
return extract_result.output
async def search_and_extract_parallel(
plan: ResearchPlan,
) -> list[ExtractedFact]:
async with httpx.AsyncClient() as client:
tasks = [
search_and_extract_one(sq, client)
for sq in plan.sub_questions
]
results = await asyncio.gather(*tasks)
return [fact for batch in results for fact in batch]
This can significantly reduce latency for pipelines with many sub-questions, since the bottleneck shifts from sequential LLM calls to the provider's rate limits.
Best Practices
- Keep agent scopes narrow. An agent that tries to plan, search, and synthesize will produce lower-quality output than three specialized agents. Resist the urge to consolidate.
- Validate at every boundary. Pydantic AI handles this automatically when you set
output_type, but make sure your schemas are strict enough to catch real problems. UseLiteraltypes, bounded numbers, and required fields. - Use dependency injection for external services. Never hardcode API clients inside agent functions. Inject them via
deps_typeso you can swap in mocks during testing. - Add a critic or verification layer. The Critic Agent pattern is one of the highest-leverage additions to any research pipeline. It catches hallucinations and contradictions before they reach the final report.
- Log intermediate outputs. Save the output of each stage to disk or a database. When a report looks wrong, you can trace exactly which stage produced the problematic data.
- Choose models per stage. Use a stronger model for planning and synthesis, and a cheaper, faster model for extraction. Pydantic AI's model-agnostic design makes this trivial — just change the
modelargument. - Handle retries gracefully. Pydantic AI retries on validation failures, but network calls to search APIs can also fail. Wrap external tool calls in retry logic with exponential backoff.
- Test agents in isolation. Because each agent has a typed input and output, you can unit test them by feeding in fixture data and asserting on the structured response. This is far more reliable than testing free-form text.
- Consider cost and token budgets. Multi-agent pipelines multiply token usage. Monitor costs per stage and set limits where appropriate. Caching intermediate results across runs can dramatically reduce repeat costs.
Extending the Pipeline
Once the core pipeline is working, consider these extensions:
- Iterative refinement — If the Critic flags many uncertain facts, loop back to the Search Agent with follow-up queries before synthesizing.
- Human-in-the-loop — Insert a review checkpoint after extraction where a human approves or edits facts before synthesis.
- Multi-source search — Add separate tools for web search, academic databases, and internal knowledge bases, then let the Search Agent choose which to use.
- Streaming output — Use Pydantic AI's streaming support to deliver the final report incrementally to a user interface.
- Persistent memory — Store extracted facts in a vector database so future research on related topics can reuse prior findings.
Conclusion
Building a multi-agent research pipeline with Pydantic AI gives you a structured, type-safe, and maintainable way to tackle complex research tasks. By decomposing the work into specialized agents — planner, searcher, extractor, critic, and synthesizer — you gain fine-grained control over each stage, the ability to test components independently, and the flexibility to swap models or tools without rewriting the entire system. The typed contracts between agents, enforced by Pydantic models, turn what would otherwise be fragile prompt-chaining into a robust data pipeline. Start with the architecture described here, adapt the schemas and tools to your domain, and iterate by adding verification layers, parallelism, and persistence as your needs grow. The result is a research system that is not only more capable than a single monolithic prompt, but also far easier to debug, extend, and trust.