How to Build a Deep Research Agent in Python: Planning, Search, and Synthesis (2026)

Build a deep research agent in Python with LangGraph. Get planning, parallel search sub-agents, a reflection loop, and cited synthesis with runnable code and real cost figures.

Updated: August 20, 2026

A deep research agent is a Python program that decomposes a research question into sub-questions, runs parallel web searches with tool use, reflects on gaps in the accumulated evidence, and synthesizes a cited long-form report (instead of returning a single chat reply). In practice, it's a planner LLM, a fleet of search sub-agents, a reflection loop, and a synthesizer, wired together with a state machine like LangGraph. This guide walks through the architecture and gives you a runnable implementation you can adapt to your own domain.

  • Deep research is a workflow, not a prompt. Split it into planning, execution, reflection, and synthesis stages so each part can be tested independently.
  • LangGraph's StateGraph is the current default for orchestration; the reference implementation is langchain-ai/open_deep_research.
  • Pick search APIs by query pattern. Tavily for general RAG, Exa for semantic/research discovery, Brave for speed and index independence, Serper for cheap Google SERPs.
  • Run sub-agents in parallel with isolated context windows, then compress before synthesis. The single biggest determinant of report quality is context management, not model choice.
  • Budget a real cost per report: roughly $0.30–$3 in tokens plus $0.05–$0.20 in search calls for a 10–15 source report on GPT-4.1-class models.
  • Evaluate with a held-out set of questions and rubric-based scoring. Deep Research Bench is the current public reference.

What is a deep research agent?

A deep research agent is a multi-step autonomous program that turns a broad question ("What are the trade-offs between GPUDirect Storage and traditional NVMe over Fabrics for LLM training?") into a structured research plan, executes that plan against the web or a private corpus, iteratively fills gaps, and returns a long report with inline citations. OpenAI, Anthropic, Perplexity, and Google all shipped hosted versions in 2025 and 2026. The open-source equivalents (see open_deep_research, GPT-Researcher, Google's LangGraph+Gemini reference, and Alibaba's Tongyi DeepResearch) reached feature parity by mid-2026.

Honestly, I've shipped three of these in the last year, and the pattern that survives contact with production is the same every time: a small planner, isolated sub-agents doing narrow work, a compression step before synthesis, and a critic loop. "Just prompt a big model with a search tool" fails on any question that requires more than three or four sources, because the model runs out of usable context long before it runs out of things to look up. The point of a research agent is context management as much as it is reasoning.

So, practically, the trigger for reaching for a deep research agent (instead of a plain RAG pipeline or a single tool-calling loop) is a question whose answer requires synthesis across sources the caller does not know in advance. If you know the sources, use RAG. If the answer fits in one search, use a tool-calling agent. Anything else benefits from planning and reflection.

Architecture: planner, searcher, reflector, synthesizer

Every deep research agent I've seen in production breaks into the same four responsibilities. Keep them as separate nodes in a graph. Don't fold planning into the synthesizer or reflection into the searcher, or you'll lose the ability to debug and evaluate each step in isolation.

  1. Planner. Takes the user's research brief, optionally asks a clarifying question, and emits a structured ResearchPlan with 3–7 sub-questions, each with a search strategy and success criteria.
  2. Search sub-agents. One per sub-question, run in parallel, each with its own scratchpad and search tool. They do broad-then-deep reading: a few queries to survey, then targeted fetches on the most promising URLs.
  3. Reflector. Compares accumulated findings against the plan, decides whether coverage is sufficient, and either terminates or spawns follow-up sub-questions. Both a per-loop iteration cap and a token budget are enforced here.
  4. Synthesizer. Receives compressed findings and a global citation registry, then writes the final report. This node never issues its own searches; if it needs more, it goes back through the reflector.

The state machine looks like this in LangGraph:

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

class ResearchState(TypedDict):
    brief: str
    plan: dict | None
    findings: Annotated[list, add_messages]
    citations: dict[str, dict]
    loop_count: int
    is_sufficient: bool
    report: str | None

graph = StateGraph(ResearchState)
graph.add_node("plan", plan_node)
graph.add_node("search", search_fanout_node)
graph.add_node("reflect", reflect_node)
graph.add_node("synthesize", synthesize_node)

graph.add_edge(START, "plan")
graph.add_edge("plan", "search")
graph.add_edge("search", "reflect")
graph.add_conditional_edges(
    "reflect",
    lambda s: "synthesize" if s["is_sufficient"] or s["loop_count"] >= 3 else "search",
    {"search": "search", "synthesize": "synthesize"},
)
graph.add_edge("synthesize", END)
research_agent = graph.compile()

This shape appears in the LangChain reference implementation, in Google's Gemini reference, and in the Langflow multi-agent template. The names of the nodes change; the responsibilities do not. For a deeper treatment of the orchestration primitives, see the piece on AI workflow orchestration with LangGraph and Temporal. Deep research is a canonical use case for durable execution, because a single report can involve fifty tool calls over ten minutes.

How to pick a search API for research agents

The search tool is the load-bearing component. Model choice matters less than search choice, because the model can only reason over what the search surfaced. Here's how the four dominant options stack up in 2026.

APIBest forIndex typePricing (~/1K queries)Latency (p50)
TavilyGeneral RAG, LLM-cleaned snippetsHybrid, LLM post-processed$8 (basic) / $16 (advanced)~1.5s
ExaSemantic and research discoveryNeural embeddings~$7~1.2s
Brave SearchIndependent index, speedIndependent web index~$5 (no free tier since Feb 2026)~670ms
SerperRaw Google SERPs, cost-sensitiveGoogle SERP proxy$0.30–$1~800ms

My default for a new project is Tavily plus Exa: Tavily for fresh, time-sensitive queries and Exa for conceptual "find me pages like this" queries. If cost is the constraint, Serper for retrieval plus a self-hosted content extractor (Trafilatura or the open Firecrawl image) is 10x cheaper and about 20% worse on synthesis rubrics. And if you need an index that doesn't touch Google or Bing (for compliance or independence reasons), Brave is the only real answer.

Regardless of provider, wrap the search call in your own tool interface. The agent should call search(query, freshness, depth), not the vendor's raw client. This lets you swap providers, retry, cache, and measure without touching the agent code. It's the same discipline that pays off with LLM tool use and function calling in production.

Content extraction is a separate concern

Search returns URLs and snippets. Snippets aren't enough for synthesis: the agent needs full page text, cleaned of nav, cookie banners, and JavaScript. Use Jina Reader (free tier, one-line API) or Firecrawl (better JS handling, paid) as a second stage. Tavily's "advanced" mode inlines this, which is why it costs 2x the basic tier. If you already pay for Firecrawl you can drop back to Tavily basic and save.

Building the planner in Python

The planner produces a structured plan, not free-form text. Use Pydantic and a structured-output request so the downstream nodes can dispatch sub-agents deterministically. I use the pattern below with Claude Sonnet or GPT-4.1. Anything smaller drops sub-questions or produces overlapping ones.

from pydantic import BaseModel, Field
from anthropic import Anthropic

class ResearchAspect(BaseModel):
    aspect: str = Field(description="A focused sub-question, self-contained.")
    priority: int = Field(ge=1, le=3, description="1=critical, 3=nice-to-have.")
    strategy: str = Field(description="How to search: 'fresh_news', 'semantic', 'academic'.")
    success_criteria: str = Field(description="What a good answer looks like.")

class ResearchPlan(BaseModel):
    question: str
    aspects: list[ResearchAspect] = Field(min_length=3, max_length=7)
    expected_sources: int = Field(ge=5, le=25)

PLANNER_PROMPT = '''You are a research planner. Decompose the user's question into 3-7 self-contained sub-questions. Each sub-question must be answerable independently without reading the others. Prefer non-overlapping coverage over exhaustive coverage. Do not answer the question; only plan.'''

def plan_node(state: ResearchState) -> dict:
    client = Anthropic()
    resp = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=2048,
        system=PLANNER_PROMPT,
        messages=[{"role": "user", "content": state["brief"]}],
        tools=[{
            "name": "emit_plan",
            "description": "Emit the research plan.",
            "input_schema": ResearchPlan.model_json_schema(),
        }],
        tool_choice={"type": "tool", "name": "emit_plan"},
    )
    plan = ResearchPlan(**resp.content[0].input)
    return {"plan": plan.model_dump(), "loop_count": 0}

Two things kill planners in production. First, letting the planner also answer the question. It will hallucinate an outline that matches its priors, and then the searchers get graded on hitting predetermined conclusions instead of finding truth. Keep the planner strictly generative. Second, unbounded aspect counts. A planner that emits fifteen sub-questions burns your token budget on redundant searches. Cap at seven.

Running search sub-agents in parallel

Each sub-question gets its own agent instance with an isolated context window. This is the pattern that gives multi-agent research its edge over a single monolithic agent: each sub-agent's context is bounded, so it can iterate on a narrow topic without pushing earlier findings out of the window. The pattern is described in detail in the multi-agent AI systems guide. Here's the concrete implementation.

import asyncio
from tavily import AsyncTavilyClient

async def search_subagent(aspect: dict, citations: dict) -> dict:
    tavily = AsyncTavilyClient()
    scratchpad = []
    for turn in range(4):  # bounded turns per aspect
        query = await generate_query(aspect, scratchpad)
        results = await tavily.search(
            query=query,
            search_depth="advanced" if aspect["strategy"] == "academic" else "basic",
            max_results=5,
            include_raw_content=True,
        )
        for r in results["results"]:
            cid = f"c{len(citations) + 1}"
            citations[cid] = {"url": r["url"], "title": r["title"]}
            scratchpad.append({
                "cid": cid,
                "url": r["url"],
                "content": r["raw_content"][:8000],
            })
        if await is_aspect_covered(aspect, scratchpad):
            break
    return {
        "aspect": aspect["aspect"],
        "summary": await summarize_findings(aspect, scratchpad),
        "citations_used": [s["cid"] for s in scratchpad],
    }

async def search_fanout_node(state: ResearchState) -> dict:
    citations = state.get("citations", {})
    tasks = [search_subagent(a, citations) for a in state["plan"]["aspects"]]
    findings = await asyncio.gather(*tasks)
    return {"findings": findings, "citations": citations, "loop_count": state["loop_count"] + 1}

Three practical notes from running this in production. First, the citations dict is passed by reference so every sub-agent contributes to a single global registry. That's what lets the synthesizer produce contiguous numbered citations. Second, cap turns per sub-agent (4 is a good default for general research, 6 for academic). Third, truncate raw content aggressively. 8K characters per page is enough for the summarizer and saves an order of magnitude on tokens compared to raw HTML.

Handling flaky search and rate limits

Search APIs fail. Retry with exponential backoff, and if a provider degrades, fall back to a second provider. I keep a Redis-backed cache keyed on (provider, query, depth) with a 24-hour TTL. During development this saves both money and rate-limit headaches. When a sub-agent's search stack fails entirely, mark that aspect as coverage_failed in the state and let the reflector decide whether to retry with a different strategy or synthesize around the gap.

The reflection loop: knowing when to stop

A research agent without reflection is a search agent with extra steps. The reflector reads the accumulated findings and the plan, then makes a structured judgment call: is coverage sufficient, or are there follow-up sub-questions worth spawning?

class ReflectionVerdict(BaseModel):
    is_sufficient: bool
    missing_aspects: list[str] = Field(default_factory=list, max_length=3)
    reasoning: str

REFLECT_PROMPT = '''You are a strict research critic. Given the plan and current findings, decide if the report can be written now. Only mark sufficient=true if every high-priority aspect has at least 2 corroborating sources. Otherwise, list up to 3 concrete follow-up sub-questions.'''

def reflect_node(state: ResearchState) -> dict:
    verdict = call_llm_structured(
        model="claude-sonnet-4-5",
        system=REFLECT_PROMPT,
        user=f"PLAN:\n{state['plan']}\n\nFINDINGS:\n{state['findings']}",
        schema=ReflectionVerdict,
    )
    if not verdict.is_sufficient and verdict.missing_aspects:
        new_aspects = [{"aspect": q, "priority": 2, "strategy": "semantic",
                        "success_criteria": "At least one supporting source."}
                       for q in verdict.missing_aspects]
        state["plan"]["aspects"] = new_aspects  # replace, don't append
    return {"is_sufficient": verdict.is_sufficient, "plan": state["plan"]}

Two subtle points. The critic prompt has to be adversarial. A friendly critic almost always votes "sufficient" on the first pass. Force it to cite the two-source rule and it will actually kick things back. And replace the aspect list rather than appending: the next search pass should target only the gaps, not re-run everything. The outer loop cap in the graph (3 iterations in the example) is your hard stop; a runaway reflector will happily loop forever.

Report synthesis and citation tracking

Synthesis is the least novel step and the one people over-invest in. The synthesizer receives compressed sub-agent summaries plus the global citation registry, then writes a report in a fixed structure. The two things that actually matter here are (a) enforcing citations inline (every claim gets a bracketed marker like [c7] that maps to the registry) and (b) compressing findings before they hit the synthesizer's context.

SYNTH_PROMPT = '''Write a research report using ONLY the findings provided. Every factual claim must end with a citation marker like [c7]. Use only citations that appear in the provided registry. Structure: (1) TL;DR, (2) Background, (3) Findings by aspect, (4) Open questions.'''

def synthesize_node(state: ResearchState) -> dict:
    compressed = compress_findings(state["findings"], max_tokens=12000)
    registry = "\n".join(f"[{cid}] {c['title']} - {c['url']}"
                        for cid, c in state["citations"].items())
    report = call_llm(
        model="claude-opus-4-7",
        system=SYNTH_PROMPT,
        user=f"REGISTRY:\n{registry}\n\nFINDINGS:\n{compressed}",
        max_tokens=8000,
    )
    return {"report": validate_citations(report, state["citations"])}

A validate_citations pass rejects reports that reference citation IDs not in the registry. That's a common hallucination when the synthesizer sees a compressed summary that mentions a study without the citation marker. If validation fails, either re-run with the failed IDs explicitly flagged in the prompt or downgrade the offending sentence. For very long reports (>5K words), section-by-section synthesis with a final stitching pass produces measurably better structure than a single-shot generation.

Cost, latency, and production hardening

A realistic 10–15 source report on GPT-4.1 or Claude Sonnet costs $0.30–$3 in LLM tokens and $0.05–$0.20 in search fees, and takes 60–240 seconds end-to-end. Cost scales super-linearly with the aspect count because reflection loops multiply on top of parallel searches. A plan with seven aspects and three reflection rounds can trigger 21 sub-agent invocations. Cap aspects, cap loops, and cache aggressively.

For production hardening, four things pay for themselves within a week:

  • Durable execution. Wrap the graph in Temporal or Inngest so a mid-run crash resumes at the last completed node instead of restarting the whole $2 report. See the workflow orchestration guide for the specifics.
  • Per-tenant token budgets. Enforce a hard cap per report so a runaway plan can't rack up $50 in tokens before hitting the loop limit.
  • Streaming progress to the user. Deep research is slow. Stream plan, then per-aspect status, then synthesis so the user sees signals of life. The LLM streaming patterns article covers the SSE plumbing.
  • Structured logs per node. Log the plan, per-aspect summaries, and citation additions as structured JSON so you can replay any run for debugging or evaluation.

How to evaluate a deep research agent

You cannot ship a research agent on vibes. Build an evaluation harness before you start iterating on prompts, or you'll fool yourself repeatedly. The current public reference is Deep Research Bench, which scores generated reports against gold answers on a fixed question set. A minimal in-house setup:

  1. Held-out question set. 30–100 questions across your target domains, each with a rubric of 5–10 must-include facts. Do not let the agent see this set during development.
  2. LLM-as-judge scoring. A different model grades reports against the rubric using structured output. Calibrate the judge on a small human-labeled sample first. LLM judges are optimistic by default.
  3. Per-stage metrics. Log planner coverage (did the plan hit the required aspects?), searcher recall (did retrieved sources include the gold documents?), and synthesizer citation accuracy (does every claim actually appear in the cited page?).

Bias in LLM judges is real and well documented. See the deeper treatment in the LLM-as-a-judge bias and calibration article. Treat judge scores as directional, not absolute, and re-anchor with human review every couple of weeks. Also watch for benchmark contamination: if you evaluate on public questions, the search tool can retrieve pages that quote the benchmark itself, and your scores balloon by 10–20 points for reasons that have nothing to do with your agent quality.

Can you run a deep research agent locally?

Yes, and the ergonomics improved in 2026. Alibaba's Tongyi DeepResearch (30.5B total / 3.3B active MoE) and ASearcher-QwQ-32B are both trained end-to-end for the deep research trajectory and run on a single H100 with vLLM. Pair either with a self-hosted Firecrawl instance and Brave's paid search tier, and you have a fully sovereign stack. Quality on Deep Research Bench trails frontier hosted models by 8–15 points, which is close enough for many enterprise workloads where data can't leave the perimeter.

Frequently Asked Questions

How does OpenAI Deep Research work under the hood?

OpenAI's Deep Research uses a fine-tuned variant of o3 that plans a research trajectory, invokes web browsing and Python tools in a loop, and emits a cited report. The exact architecture isn't public, but the observable behavior (planning, iterative search, reflection, synthesis) mirrors the open-source pattern in open_deep_research. Public benchmarks put it 5–10 points ahead of the strongest open-source implementations on Deep Research Bench.

Which framework is best for building research agents?

LangGraph is the current default because it models the planner-search-reflect-synthesize graph natively and has the largest ecosystem of production examples. Temporal is the right choice if durability and long-running execution matter more than agent primitives. LangChain's deepagents package is the fastest path to a working prototype; Langflow suits low-code teams that want to swap models per node.

How much does a deep research query cost?

Roughly $0.30–$3 per report on GPT-4.1 or Claude Sonnet class models, plus $0.05–$0.20 in search API fees. Cost scales with the number of aspects and reflection rounds. A 3-aspect single-loop configuration on GPT-4.1-mini can come in under $0.10; a 7-aspect three-loop configuration on Opus routinely exceeds $2.

Can I use a deep research agent over private data instead of the web?

Yes. Replace the web search tool with a retrieval tool against your vector store or hybrid index. The planner, reflector, and synthesizer stay unchanged. This is essentially the enterprise mode of open_deep_research and pairs well with a strong retrieval stack; see the hybrid search RAG guide for the retrieval layer.

How do I stop a research agent from looping forever?

Enforce three limits at once: a max reflection loop count (default 3), a total token budget per report (e.g., 300K tokens), and a wall-clock timeout (e.g., 5 minutes). The loop cap should be a hard graph-level constraint, not a soft prompt instruction. A soft cap will be ignored by an eager reflector under complex questions.

Nikhil Verma
About the Author Nikhil Verma

AI automation engineer chaining LLMs into workflows that actually work. Bullish on tool use; bearish on prompt theatre.