teachyou.ai academy
← All posts
LangGraph

LangGraph for Research Agents: Iterative Search and Synthesis

Ira Menon · Jun 17, 2026 · 16 min read

A single LLM call cannot research anything. It can only remember. Ask a plain chat model about a niche technical topic and you get a confident summary of whatever was in its training data, frozen months or years ago. Bolt on one web search and you get marginally better: a summary of whatever the first query happened to return. Real research does not work like that. Real research is a loop — you search, you read, you notice what you still do not know, you search again with sharper questions, and only when the gaps close do you sit down and write. That loop is exactly what LangGraph research agents are built to express: a cyclic graph where search results feed a reflection step, the reflection step decides whether to loop back or move forward, and a synthesis node turns accumulated evidence into a grounded answer. In this article we will build one from scratch — state design, search and reflection nodes, conditional edges, parallel query fan-out, and the stop conditions that keep the whole thing from running forever.

Why Research Agents Need Graphs, Not Chains

The obvious first attempt at a research assistant is a pipeline: generate a search query, call a search API, stuff the results into a prompt, generate an answer. This is a chain — a straight line of steps — and it fails in a very predictable way. The quality of the final answer is capped by the quality of the first query, and the first query is generated before the model has seen anything. If the topic turns out to be broader, narrower, or differently named than the model guessed, there is no mechanism to correct course.

Research is inherently iterative, which means the control flow needs a cycle: after reading results, the agent must be able to go back and search again. Classic chain abstractions cannot express cycles cleanly. You end up writing a while-loop around the chain, passing an ever-growing blob of context by hand, and reinventing state management, retries, and termination logic in application code.

LangGraph solves this at the framework level. You model the agent as a state machine: nodes are functions that read and update a shared state object, edges define which node runs next, and conditional edges let a node's output determine the route — including routing backward to an earlier node. The cycle that was awkward in a chain becomes a first-class citizen: search feeds reflection, reflection either loops back to search or proceeds to synthesis. The graph runtime handles state merging, recursion limits, streaming, and checkpointing so your node functions stay small and testable.

There is a second, less obvious benefit. Because the loop is explicit in the graph topology rather than buried in a prompt, you can put hard engineering controls on it. You decide the maximum number of research iterations in code, not in a system prompt the model may ignore. For anything that touches production — and anything that calls paid search APIs in a loop certainly does — that difference matters enormously.

The Anatomy of an Iterative Research Loop

Before writing code, it helps to name the moving parts. Nearly every serious research agent, from open-source deep-research clones to commercial products, decomposes into the same five roles.

  • Query generation. Take the user's question plus everything learned so far and produce one or more concrete search queries. On the first pass this is decomposition: splitting a broad question into searchable sub-questions. On later passes it is gap-filling: targeting exactly what reflection said was missing.
  • Search and retrieval. Execute the queries against a search API, a vector store, an internal wiki, or all three. Normalize results into a common shape: source identifier, title, and extracted content.
  • Summarization and note-taking. Raw search results are long and repetitive. A dedicated step compresses each batch into dense notes, preserving source attribution so the final answer can cite where claims came from.
  • Reflection. The heart of the loop. Given the original question and the accumulated notes, decide: is this enough to answer well? If not, what specifically is missing, and what queries would fill the gap?
  • Synthesis. When reflection says stop — or the iteration budget runs out — write the final answer from the notes, with citations, acknowledging any gaps that never closed.

The queries in round two are conditioned on the findings of round one. That is the property a flat pipeline can never have, and it is the property that makes the agent feel like it is actually investigating rather than just fetching.

Notice that each role wants a different prompt, and often benefits from a different model. Query generation and reflection are structured-output tasks that a small, fast model handles well. Synthesis is a long-form writing task where a larger model earns its cost. Because LangGraph nodes are just Python functions, mixing models per node is trivial — a practical lever that pipeline-in-a-single-prompt designs cannot pull.

Designing the State for a Research Agent

In LangGraph, state is the contract between nodes. Every node receives the current state and returns a partial update; the framework merges updates according to reducers you declare. Getting the state schema right is most of the design work, so let us do it properly.

import operator
from typing import Annotated, TypedDict


class Source(TypedDict):
    url: str
    title: str
    content: str


class ResearchState(TypedDict):
    # The user's original question — never mutated.
    question: str

    # Queries planned for the current iteration.
    pending_queries: list[str]

    # Accumulated evidence. operator.add makes this append-only:
    # each node's returned list is concatenated, never replaced.
    sources: Annotated[list[Source], operator.add]

    # Compressed research notes, also append-only.
    notes: Annotated[list[str], operator.add]

    # Reflection output: what is still unknown.
    knowledge_gaps: list[str]

    # Loop control.
    iteration: int
    max_iterations: int

    # Final output.
    report: str

Three decisions here deserve explanation.

First, sources and notes use Annotated[list, operator.add]. This declares a reducer: when a node returns {"notes": ["new note"]}, LangGraph appends to the existing list rather than overwriting it. Evidence accumulates across iterations automatically, and — critically — when we later run searches in parallel, concurrent updates merge instead of clobbering each other.

Second, pending_queries and knowledge_gaps have no reducer, so they are replaced wholesale each iteration. That is correct: they represent the current plan, not history. Mixing up which fields accumulate and which reset is the single most common LangGraph state bug, and it produces agents that either forget everything or drown in stale plans.

Third, iteration and max_iterations live in the state rather than in a global. This makes the loop budget visible to every node and to anyone reading a checkpoint, and it lets different invocations run with different budgets — a quick mode with two iterations, a deep mode with six — without touching the graph definition.

Building the Graph: Nodes and Edges

With the state defined, the nodes are short functions. Here is a compact but complete skeleton using a chat model for the LLM roles and a generic web_search function standing in for your search provider of choice — Tavily, Brave, SerpAPI, or an internal retriever all fit the same shape.

from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, START, END
from pydantic import BaseModel, Field

fast_llm = init_chat_model("claude-haiku-4-5")
writer_llm = init_chat_model("claude-sonnet-4-5")


class QueryPlan(BaseModel):
    queries: list[str] = Field(
        description="2-4 focused web search queries", max_length=4
    )


def plan_queries(state: ResearchState) -> dict:
    gaps = state.get("knowledge_gaps") or []
    context = (
        "Known gaps to target:\n- " + "\n- ".join(gaps)
        if gaps
        else "This is the first pass. Decompose the question."
    )
    plan = fast_llm.with_structured_output(QueryPlan).invoke(
        f"Research question: {state['question']}\n\n{context}\n\n"
        "Write focused search queries. No duplicates, no fluff."
    )
    return {
        "pending_queries": plan.queries,
        "iteration": state["iteration"] + 1,
    }


def search(state: ResearchState) -> dict:
    sources, note_chunks = [], []
    for query in state["pending_queries"]:
        for hit in web_search(query, max_results=3):
            sources.append(
                Source(url=hit.url, title=hit.title, content=hit.text)
            )
            note_chunks.append(f"[{hit.url}] {hit.text[:2000]}")

    summary = fast_llm.invoke(
        f"Question: {state['question']}\n\n"
        "Compress these search results into dense factual notes. "
        "Keep the [url] markers next to every claim.\n\n"
        + "\n\n".join(note_chunks)
    )
    return {"sources": sources, "notes": [summary.content]}

The planner is deliberately capped at four queries. Left uncapped, models happily generate a dozen near-duplicate queries per round, and your search bill scales with their enthusiasm. The summarizer keeps [url] markers inline — a cheap discipline that pays off at synthesis time, because the writer model can only cite sources it can see next to the claims they support.

Wiring comes after we define reflection, but the shape is already clear: plan_queries then search then reflect, with reflect deciding whether to loop.

The Reflection Node: Teaching the Agent to Know What It Doesn't Know

Reflection is where a research agent earns the name. The node asks a model to audit the evidence against the question and emit a structured verdict.

class Reflection(BaseModel):
    is_sufficient: bool = Field(
        description="True if the notes can support a complete answer"
    )
    knowledge_gaps: list[str] = Field(
        description="Specific missing facts or unverified claims"
    )


def reflect(state: ResearchState) -> dict:
    verdict = fast_llm.with_structured_output(Reflection).invoke(
        f"Question: {state['question']}\n\n"
        f"Research notes so far:\n{chr(10).join(state['notes'])}\n\n"
        "Audit the notes. Can the question be answered completely "
        "and accurately from them? List concrete gaps if not. "
        "Do not invent gaps for topics the question never asked about."
    )
    return {"knowledge_gaps": verdict.knowledge_gaps}


def should_continue(state: ResearchState) -> str:
    out_of_budget = state["iteration"] >= state["max_iterations"]
    no_gaps = not state["knowledge_gaps"]
    return "synthesize" if (no_gaps or out_of_budget) else "plan_queries"

Two details make or break this node in practice.

The prompt must push in both directions. Without the sufficiency framing, models declare victory after one round because the notes look plausible. Without the "do not invent gaps" instruction, they manufacture ever-finer sub-questions forever — a failure mode anyone who has built one of these agents will recognize immediately. Reflection prompts need both a reason to stop and a reason to continue, explicitly stated.

The routing function checks the budget before trusting the model. should_continue is a plain Python function used as a conditional edge, and it treats max_iterations as a hard ceiling that no amount of model enthusiasm can override. The model advises; the code decides. Keep that separation and your agent's worst case is bounded. Blur it and your worst case is an infinite loop discovered via an invoice.

Wiring the full graph now takes six lines:

builder = StateGraph(ResearchState)
builder.add_node("plan_queries", plan_queries)
builder.add_node("search", search)
builder.add_node("reflect", reflect)
builder.add_node("synthesize", synthesize)

builder.add_edge(START, "plan_queries")
builder.add_edge("plan_queries", "search")
builder.add_edge("search", "reflect")
builder.add_conditional_edges(
    "reflect", should_continue, ["plan_queries", "synthesize"]
)
builder.add_edge("synthesize", END)

graph = builder.compile()

The cycle is right there in the topology: reflect can route back to plan_queries. When you render this graph — LangGraph can draw it as a diagram — the research loop is visible at a glance, which is worth more than any amount of documentation when a teammate inherits the code.

Scaling Out: Parallel Search with Send

The skeleton above searches sequentially: one query, then the next. For three queries per round that is tolerable; for a deep-research agent decomposing a question into many sub-questions, it is painfully slow. LangGraph's Send primitive fixes this by fanning out dynamically: a routing function returns one Send per query, each carrying its own payload, and LangGraph runs the target node once per Send — concurrently.

from langgraph.types import Send


class SearchTask(TypedDict):
    question: str
    query: str


def fan_out(state: ResearchState) -> list[Send]:
    return [
        Send("search_one", {"question": state["question"], "query": q})
        for q in state["pending_queries"]
    ]


def search_one(task: SearchTask) -> dict:
    hits = web_search(task["query"], max_results=3)
    summary = fast_llm.invoke(
        f"Question: {task['question']}\nQuery: {task['query']}\n"
        "Summarize these results into factual notes with [url] markers:\n\n"
        + "\n\n".join(f"[{h.url}] {h.text[:2000]}" for h in hits)
    )
    return {
        "sources": [Source(url=h.url, title=h.title, content=h.text)
                    for h in hits],
        "notes": [summary.content],
    }


builder.add_conditional_edges("plan_queries", fan_out, ["search_one"])
builder.add_edge("search_one", "reflect")

Notice that search_one receives its own private SearchTask payload, not the full research state — each branch knows only its query. But it writes back to the shared state, and this is where those operator.add reducers from the state design section stop being a nicety and become load-bearing. Five parallel branches each return a notes list; the reducer concatenates all five. Without the reducer, the branches would race to overwrite the same key and LangGraph would raise a concurrent-update error — the framework refuses to guess which write wins.

This fan-out pattern generalizes well beyond search. The same shape powers multi-agent research supervisors that spawn a sub-agent per research topic, map-reduce document summarizers, and evaluation harnesses that grade many outputs at once. Learn it once in the research-agent context and you will reuse it constantly.

Synthesis: From a Pile of Notes to a Grounded Report

Synthesis looks like the easy part — one LLM call at the end — and that assumption produces the weakest component of most research agents. The synthesis node has three jobs, and the prompt must state all three.

  • Answer from evidence only. The writer model must be confined to the notes. The prompt should say, in effect: if the notes do not support a claim, do not make it. This is what separates a research agent from a chat model with delusions of citation.
  • Attribute claims. Because we kept [url] markers glued to claims all the way through summarization, the writer can carry them into the final report. Attribution added at the end, from a bare list of URLs, is decoration; attribution preserved from retrieval onward is verifiable.
  • Confess remaining gaps. If the loop exited on budget rather than on sufficiency, knowledge_gaps is non-empty. A trustworthy report says so.
def synthesize(state: ResearchState) -> dict:
    gaps = state.get("knowledge_gaps") or []
    gap_clause = (
        "\n\nThese gaps remain unresolved; acknowledge them briefly "
        "in a limitations section:\n- " + "\n- ".join(gaps)
        if gaps else ""
    )
    report = writer_llm.invoke(
        f"Question: {state['question']}\n\n"
        f"Research notes:\n{chr(10).join(state['notes'])}\n\n"
        "Write a thorough, well-structured answer using ONLY these "
        "notes. Cite the [url] markers inline after the claims they "
        f"support. Do not add facts from memory.{gap_clause}"
    )
    return {"report": report.content}

One practical warning: after several iterations, the accumulated notes can be large. If you hit context limits, add a consolidation step that merges older notes into a tighter digest before synthesis — another node, another edge, no architectural change. This is the quiet superpower of the graph approach: capabilities are added by inserting nodes, not by rewriting an ever-more-fragile mega-prompt.

Failure Modes and How the Graph Absorbs Them

Every research agent that reaches real users hits the same handful of problems. Knowing them in advance saves days.

  1. The infinite loop. Reflection keeps finding gaps; the agent keeps searching. Defense in depth: the max_iterations ceiling in your routing function, plus LangGraph's built-in recursion_limit on invocation as a backstop. Set both. The routing check is your policy; the recursion limit is your circuit breaker.
  2. Query drift. Around iteration three, queries start chasing tangents — the question was about LangGraph checkpointing and the agent is now researching PostgreSQL vacuum settings. The fix is in the planner prompt: always restate the original question and instruct the model to target the listed gaps only. Passing gaps explicitly, rather than letting the model re-derive direction from raw notes, keeps later iterations anchored.
  3. Duplicate sources. Different queries return the same popular pages, wasting summarization tokens on repeats. Deduplicate by URL in the search node before summarizing, and consider a custom reducer for sources that drops already-seen URLs at merge time.
  4. Search API failures. One flaky query should not kill a five-minute research run. Wrap the search call with retry logic inside the node, and return an empty result set with a note recording the failure rather than raising — reflection will route another attempt at the gap if it matters.
  5. Lost work on crashes. A deep research run is minutes of accumulated state. Compile the graph with a checkpointer — in-memory for development, SQLite or Postgres in production — and every node boundary becomes a save point. A crash resumes from the last completed node instead of restarting from zero. Checkpointing also unlocks time-travel debugging: you can inspect the exact state at any step of a bad run and replay from there.

None of these fixes required restructuring the agent. That is the argument for the graph architecture in one sentence: failure handling attaches to nodes and edges locally, instead of threading through a monolithic loop globally.

Streaming, Human-in-the-Loop, and Knowing When You're Done

Two production concerns deserve a final word, because they change how users experience the agent.

A research run takes minutes, and a silent spinner for minutes reads as a hang. LangGraph's streaming modes let you emit progress as it happens: stream state updates to show "iteration 2: searching 3 queries," or stream LLM tokens during synthesis so the report appears as it is written. Users forgive latency they can watch; they abandon latency they cannot.

Human-in-the-loop is the other high-leverage addition. With a checkpointer attached, LangGraph's interrupt mechanism can pause the graph at any node and wait — durably, across process restarts — for human input. The two spots worth gating in a research agent: after the first query plan, so a human can confirm the agent understood the question before any budget is spent, and before final synthesis on high-stakes topics, so a human can eyeball the evidence. Because interrupts are checkpoint-backed, "wait" can mean seconds or days.

How do you know the agent is actually good? Evaluate the loop, not just the output. Track iterations used per question, gap-closure rate between reflections, source diversity, and how often the budget ceiling — rather than reflection — terminates the run. An agent that always exhausts its budget has a reflection problem; an agent that always stops after one round has a sufficiency-bias problem. These numbers tell you which prompt to fix, which output quality scores alone never reveal.

Where to Go From Here

You now have the complete blueprint for LangGraph research agents: state with accumulating reducers, a plan-search-reflect cycle expressed as conditional edges, Send-based parallel fan-out, evidence-bounded synthesis with inline attribution, and hard budget controls that keep the model advisory and the code authoritative. The same skeleton stretches from a quick two-iteration answer engine to a deep multi-topic research system with supervisor and sub-agents — the topology grows, but the concepts do not change.

The best way to make this knowledge permanent is to build the agent yourself, break it, and fix it. Our LangGraph Tutorial course on teachyou.ai walks through exactly that: you build this research agent step by step, add checkpointing and human-in-the-loop approval, wire in real search providers, and finish by deploying a streaming deep-research assistant you can put in front of users. If this article made the architecture click, the course will make it muscle memory.