LangGraph Multi-Agent Systems: Supervisors, Subgraphs and Send
LangGraph multi-agent systems from one overloaded agent to many: supervisor routing, fan-out and fan-in, subgraphs, Send map-reduce and a research assistant.
This article is published in English.
Sources: LangGraph Multi-Agent Systems: From One Brain to Many
Prerequisites: the examples assume you can already build a single-agent LangGraph graph with tools and memory, and that you know how to pause and resume a run with
interrupt(). Everything below builds on that, moving from the question of why you'd want several agents to a complete multi-agent research assistant.
Why split one agent into many
Say a single-agent assistant works well. Then users start asking it to check email, research topics, write reports and manage tasks, all in the same conversation. The usual response is to add tools and pile more instructions into the system prompt. Before long there are 15 tools and a 2,000-word prompt, and quality quietly drops. The model starts mixing things up, reaching for the email tool during a research task and the research tool during an email task.
That's cognitive overload, and it's the main reason multi-agent designs exist. The fix is one software engineers have relied on for decades: divide the responsibilities. In LangGraph, doing so addresses three separate problems:
- Cognitive overload. One model is juggling too many unrelated jobs. Specialized agents, each good at one thing, remove the strain.
- Sequential bottlenecks. Work that could happen simultaneously runs one step after another. Parallel agents cut the latency.
- Complexity. Nobody can reason about a 40-node graph. Smaller subgraphs can be understood and tested one at a time.
Each pattern below targets one of those three problems.
Five levels of architecture
Think of the progression as a staircase where every step adds a single idea.
Level 1: supervisor. One supervisor agent coordinates focused workers. It's still a single decision-maker, but it delegates instead of doing everything itself, which already puts it beyond a plain tool-using chatbot.
Level 2: parallelization. Independent tasks are split up and run at the same time, then merged. The idea is fan-out and fan-in: several branches execute concurrently and feed one later step. It suits repeated work across multiple sources or subtasks.
Level 3: subgraphs. Instead of one enormous workflow, each branch becomes a smaller graph with private internal steps and its own output schema. Complex parts become easier to build, test and understand.
Level 4: dynamic parallelism. With the Send pattern, which follows a map-reduce style, the number of branches isn't known in advance. Workers get created at runtime, one per item, topic or analyst. The agents aren't predefined, they all work simultaneously, and each one owns a specific task.
Level 5: everything combined. A realistic system uses all of the above together, as in the research assistant at the end.
A short way to remember it: Level 1 routes work through one supervisor, Level 2 runs a fixed set of workers together, Level 3 lets each branch be a full graph and Level 4 spawns workers on demand.
Level 1: the supervisor pattern
This is the simplest multi-agent pattern and the right starting point. A supervisor receives the user's request and chooses which specialist should handle it. The specialist does the work and hands results back, and the supervisor decides what happens next.
Picture a law firm. The senior partner talks to the client, grasps the need and assigns work to junior associates, one for contracts, one for litigation, one for compliance. The associate does the deep work, and the partner reviews it and replies to the client.
The flow looks like this:
User Input
↓
[Supervisor Node] ← decides who handles what
↓ ↓
[Specialist A] [Specialist B] ← do the actual work
↓ ↓
[Supervisor Node] ← reviews results, responds or routes again
↓
Response
State
The key design decision is the next_agent field. The supervisor writes to it and the router reads it.
# ── MODULE 2: STATE ─────────────────────────────────────────
from typing import TypedDict, Annotated, Literal
from langgraph.graph import MessagesState
class SupervisorState(MessagesState):
# The supervisor writes this field to indicate who should act next.
# "researcher", "writer", "FINISH" are the possible values.
next_agent: str
Specialist tools
Each specialist gets its own focused tools, with no overlap between them.
# ── MODULE 3: TOOLS ─────────────────────────────────────────
from langchain_core.tools import tool
# Researcher's tools
@tool
def search_web(query: str) -> str:
"""Search the web for factual information, news, or research."""
return f"[Web results for: {query}]"
@tool
def search_academic(query: str) -> str:
"""Search academic papers and journals for scholarly sources."""
return f"[Academic results for: {query}]"
# Writer's tools
@tool
def format_as_report(content: str, title: str) -> str:
"""Format raw content into a structured report with headings."""
return f"# {title}\n\n{content}"
@tool
def check_grammar(text: str) -> str:
"""Check and correct grammar in the provided text."""
return f"[Grammar-checked]: {text}"
# Each specialist gets only its own tools
researcher_tools = [search_web, search_academic]
writer_tools = [format_as_report, check_grammar]
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o", temperature=0)
researcher_llm = llm.bind_tools(researcher_tools)
writer_llm = llm.bind_tools(writer_tools)
Nodes
The supervisor node and the specialist nodes come next. The supervisor's plain-text reply is itself the routing decision.
# ── MODULE 4: NODES ─────────────────────────────────────────
from langchain_core.messages import SystemMessage, HumanMessage
from langgraph.prebuilt import ToolNode
# ── Supervisor ────────────────────────────────────────────
SUPERVISOR_PROMPT = """You are a supervisor coordinating a team of specialists.
Given the conversation, decide who should act next.
Your team:
- researcher: finds information, searches web and academic sources
- writer: formats content, writes reports, checks grammar
- FINISH: use this when the task is complete and you have a final answer
Respond with ONLY the name of the next agent: researcher, writer, or FINISH."""
def supervisor_node(state: SupervisorState) -> dict:
"""Reads the conversation history and routes to the right specialist."""
messages = [SystemMessage(content=SUPERVISOR_PROMPT)] + state["messages"]
response = llm.invoke(messages)
# The supervisor's plain text response IS the routing decision
return {"next_agent": response.content.strip()}
# ── Researcher Specialist ──────────────────────────────────
def researcher_node(state: SupervisorState) -> dict:
"""Specialist that handles all research and information-gathering tasks."""
messages = [
SystemMessage(content="You are a research specialist. Use your search tools to find accurate, thorough information.")
] + state["messages"]
response = researcher_llm.invoke(messages)
return {"messages": [response]}
# ── Writer Specialist ──────────────────────────────────────
def writer_node(state: SupervisorState) -> dict:
"""Specialist that handles all writing, formatting, and editing tasks."""
messages = [
SystemMessage(content="You are a writing specialist. Use your tools to produce well-structured, polished output.")
] + state["messages"]
response = writer_llm.invoke(messages)
return {"messages": [response]}
# Tool execution nodes for each specialist
researcher_tool_node = ToolNode(researcher_tools)
writer_tool_node = ToolNode(writer_tools)
Routing
# ── MODULE 5: ROUTING ────────────────────────────────────────
def route_after_supervisor(state: SupervisorState) -> Literal["researcher", "writer", "__end__"]:
"""Read the supervisor's decision and route accordingly."""
decision = state["next_agent"]
if decision == "FINISH":
return "__end__"
return decision # "researcher" or "writer"
def route_researcher(state: SupervisorState) -> Literal["researcher_tools", "supervisor"]:
"""After researcher acts: did it call a tool, or produce a final answer?"""
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "researcher_tools"
return "supervisor" # Research done - report back to supervisor
def route_writer(state: SupervisorState) -> Literal["writer_tools", "supervisor"]:
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "writer_tools"
return "supervisor"
Graph assembly
# ── MODULE 6: GRAPH ASSEMBLY ────────────────────────────────
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
graph_builder = StateGraph(SupervisorState)
# Register all nodes
graph_builder.add_node("supervisor", supervisor_node)
graph_builder.add_node("researcher", researcher_node)
graph_builder.add_node("researcher_tools", researcher_tool_node)
graph_builder.add_node("writer", writer_node)
graph_builder.add_node("writer_tools", writer_tool_node)
# Entry: always start at supervisor
graph_builder.add_edge(START, "supervisor")
# Supervisor routes to specialist or ends
graph_builder.add_conditional_edges(
"supervisor",
route_after_supervisor,
{"researcher": "researcher", "writer": "writer", "__end__": END}
)
# Researcher loop: tools → researcher → supervisor
graph_builder.add_conditional_edges(
"researcher", route_researcher,
{"researcher_tools": "researcher_tools", "supervisor": "supervisor"}
)
graph_builder.add_edge("researcher_tools", "researcher")
# Writer loop: tools → writer → supervisor
graph_builder.add_conditional_edges(
"writer", route_writer,
{"writer_tools": "writer_tools", "supervisor": "supervisor"}
)
graph_builder.add_edge("writer_tools", "writer")
graph = graph_builder.compile(checkpointer=MemorySaver())
What matters in the assembly is the edge that returns to the supervisor. When a specialist finishes, it reports back. The supervisor reviews the whole conversation and then picks one of three moves: hand off to a different specialist, send the same one around again or finish. At its core, that's what orchestration is.
Level 2: parallelization with fan-out and fan-in
The supervisor pattern is sequential, with one specialist active at a time. Plenty of real tasks contain independent subtasks that can run together, and parallelism solves the latency issue.
Cooking is a fair analogy. Nobody finishes the salad before starting the pasta and then the sauce. You prep the salad, put the water on and start the sauce at once, and dinner is ready sooner.
In LangGraph, a node with several outgoing edges creates the parallel split. LangGraph spots this fan-out and runs all the targets concurrently in what it calls a superstep. The results are gathered before any later node starts, which is the fan-in.
There's one firm rule. When several nodes in the same superstep write to the same state field, LangGraph must know how to merge the writes. Without a reducer it raises an error. With operator.add, the results are safely concatenated.
# ── MODULE 2: STATE FOR PARALLEL PATTERN ────────────────────
import operator
from typing import Annotated
class ResearchState(TypedDict):
query: str
# Without Annotated + operator.add, parallel writes to 'context' would CRASH.
# With it, LangGraph concatenates results from all parallel nodes.
context: Annotated[list[str], operator.add]
final_answer: str
A parallel research example uses two sources:
# ── MODULE 4: PARALLEL WORKER NODES ─────────────────────────
def web_search_node(state: ResearchState) -> dict:
"""Runs in parallel with academic_search_node."""
result = f"[WEB] Results for: {state['query']}"
return {"context": [result]} # Returns a LIST - operator.add will concatenate
def academic_search_node(state: ResearchState) -> dict:
"""Runs in parallel with web_search_node."""
result = f"[ACADEMIC] Papers about: {state['query']}"
return {"context": [result]} # Also a LIST
def synthesize_node(state: ResearchState) -> dict:
"""Runs AFTER both parallel nodes complete (fan-in point).
state['context'] already contains results from BOTH parallel nodes."""
combined = "\n".join(state["context"])
prompt = [
SystemMessage(content="Synthesize the following research into a clear answer."),
HumanMessage(content=f"Research:\n{combined}\n\nQuery: {state['query']}")
]
response = llm.invoke(prompt)
return {"final_answer": response.content}
# ── MODULE 6: PARALLEL GRAPH ASSEMBLY ───────────────────────
from langgraph.graph import StateGraph, START, END
graph_builder = StateGraph(ResearchState)
graph_builder.add_node("web_search", web_search_node)
graph_builder.add_node("academic_search", academic_search_node)
graph_builder.add_node("synthesize", synthesize_node)
# Fan-out: START branches to BOTH search nodes simultaneously
graph_builder.add_edge(START, "web_search")
graph_builder.add_edge(START, "academic_search")
# Fan-in: BOTH search nodes must complete before synthesize runs
graph_builder.add_edge("web_search", "synthesize")
graph_builder.add_edge("academic_search", "synthesize")
graph_builder.add_edge("synthesize", END)
parallel_graph = graph_builder.compile()
The fan-out is simply two edges leaving the same source, START. LangGraph notices and runs both destinations concurrently. The fan-in happens on its own too, because synthesize only runs once both incoming edges are satisfied, meaning both parallel nodes finished.
Level 3: subgraphs for encapsulation
Flat parallel nodes handle simple cases. When each branch is complex, with several steps, its own tools and its own logic, subgraphs are the better tool. A subgraph is a fully compiled StateGraph that runs as one node within a parent graph.
Think of departments in a company. The CEO, the parent graph, delegates to Engineering and Marketing. Each department has internal processes and meetings, but the CEO only hands off work and receives deliverables.
Parent and subgraph communicate through shared state keys. Any key present in both the parent's state and the subgraph's state is passed in when the subgraph starts and passed back out when it ends.
Parent State: {topic, cleaned_data, report_A, report_B, processed_ids}
↓
Subgraph A State: {cleaned_data, internal_step_1, report_A, processed_ids}
← reads 'cleaned_data' from parent
→ writes 'report_A' and 'processed_ids' back to parent
Subgraph B State: {cleaned_data, internal_step_2, report_B, processed_ids}
← reads 'cleaned_data' from parent
→ writes 'report_B' and 'processed_ids' back to parent
Keys that live only in the subgraph, like internal_step_1 above, stay private, and the parent never sees them.
A complete implementation follows:
# ── MODULE 2: STATE SCHEMAS (Three Levels) ──────────────────
# Parent state - what the orchestrating graph tracks
class ParentState(TypedDict):
raw_input: str
cleaned_data: str # passed INTO both subgraphs
failure_summary: str # written back by subgraph A
performance_report: str # written back by subgraph B
processed_ids: Annotated[list[int], operator.add] # BOTH subgraphs write this
# Subgraph A's internal state - includes its private fields + shared keys
class FailureAnalysisState(TypedDict):
cleaned_data: str # shared input from parent
failures: list[str] # private intermediate state - parent never sees this
failure_summary: str # shared output to parent
processed_ids: Annotated[list[int], operator.add] # shared output (accumulated)
# Subgraph A's output schema - filters which keys return to parent
# Without this, ALL keys return, which can cause conflicts
class FailureAnalysisOutput(TypedDict):
failure_summary: str # only these two keys go back to parent
processed_ids: Annotated[list[int], operator.add]
# Subgraph B's internal state
class PerformanceAnalysisState(TypedDict):
cleaned_data: str
metrics: list[str] # private
performance_report: str # shared output
processed_ids: Annotated[list[int], operator.add]
class PerformanceAnalysisOutput(TypedDict):
performance_report: str
processed_ids: Annotated[list[int], operator.add]
# ── SUBGRAPH A: Failure Analysis ─────────────────────────────
def extract_failures(state: FailureAnalysisState) -> dict:
failures = [f"Error in {state['cleaned_data']}: timeout"]
return {"failures": failures, "processed_ids": [1]}
def summarize_failures(state: FailureAnalysisState) -> dict:
summary = f"Found {len(state['failures'])} failure(s): {state['failures']}"
return {"failure_summary": summary}
failure_builder = StateGraph(FailureAnalysisState, output=FailureAnalysisOutput)
failure_builder.add_node("extract_failures", extract_failures)
failure_builder.add_node("summarize_failures", summarize_failures)
failure_builder.add_edge(START, "extract_failures")
failure_builder.add_edge("extract_failures", "summarize_failures")
failure_builder.add_edge("summarize_failures", END)
failure_subgraph = failure_builder.compile() # compiled → can be used as a node
# ── SUBGRAPH B: Performance Analysis ────────────────────────
def collect_metrics(state: PerformanceAnalysisState) -> dict:
metrics = [f"Latency: 120ms", "Uptime: 99.8%"]
return {"metrics": metrics, "processed_ids": [2]}
def write_report(state: PerformanceAnalysisState) -> dict:
report = f"Performance Report:\n" + "\n".join(state["metrics"])
return {"performance_report": report}
performance_builder = StateGraph(PerformanceAnalysisState, output=PerformanceAnalysisOutput)
performance_builder.add_node("collect_metrics", collect_metrics)
performance_builder.add_node("write_report", write_report)
performance_builder.add_edge(START, "collect_metrics")
performance_builder.add_edge("collect_metrics", "write_report")
performance_builder.add_edge("write_report", END)
performance_subgraph = performance_builder.compile()
# ── PARENT GRAPH ─────────────────────────────────────────────
def clean_data_node(state: ParentState) -> dict:
return {"cleaned_data": state["raw_input"].strip().lower()}
def combine_reports_node(state: ParentState) -> dict:
combined = f"{state['failure_summary']}\n\n{state['performance_report']}"
print(f"Final combined report:\n{combined}")
return {}
parent_builder = StateGraph(ParentState)
parent_builder.add_node("clean_data", clean_data_node)
parent_builder.add_node("failure_analysis", failure_subgraph) # subgraph as node
parent_builder.add_node("performance_analysis", performance_subgraph) # subgraph as node
parent_builder.add_node("combine_reports", combine_reports_node)
parent_builder.add_edge(START, "clean_data")
# Fan-out to both subgraphs (parallel execution)
parent_builder.add_edge("clean_data", "failure_analysis")
parent_builder.add_edge("clean_data", "performance_analysis")
# Fan-in: combine only after both subgraphs complete
parent_builder.add_edge("failure_analysis", "combine_reports")
parent_builder.add_edge("performance_analysis", "combine_reports")
parent_builder.add_edge("combine_reports", END)
parent_graph = parent_builder.compile()
The output schema, FailureAnalysisOutput and PerformanceAnalysisOutput in this example, is the part many tutorials skip, which is why people then hit state key conflicts. It filters exactly which fields the subgraph exposes to its parent, and anything missing from it counts as private internal state.
Level 4: the Send API and map-reduce
Parallel branches and subgraphs cover cases where the number of branches is known at design time. But what if a user wants 5 topics researched today and 20 tomorrow? Hard-coding 20 branches isn't an option. That's the map-reduce pattern, powered by Send.
Think of a publisher assigning chapters. One editor receives the manuscript, splits it into chapters and gives each to a different copy editor at the same moment. Whether there are two chapters or twenty, that's how many copy editors get hired. When all are done, the results are collected into the final book. The number of parallel workers is decided at runtime, not at design time.
Three keywords matter:
Send(node_name, state), imported fromlanggraph.constants, creates a fresh instance of a node with its own state payload. A routing function that hands back a list of them makes LangGraph start every one at once, each with independent state.operator.addon the collecting field is the reduce step. As workers finish, their results accumulate into a growing list in the parent state.- Worker state can be a custom dict that doesn't need to match the parent's state. A worker only carries the data for its own slice of work.
Here's the full map-reduce implementation:
# ── MODULE 2: MAP-REDUCE STATE ──────────────────────────────
from langgraph.constants import Send
class OverallState(TypedDict):
topic: str
subjects: list[str] # populated by the map step
jokes: Annotated[list[str], operator.add] # accumulated by the reduce step
best_joke: str
class JokeState(TypedDict):
"""The private state each parallel worker gets.
Notice: this does NOT need to match OverallState."""
subject: str
# ── MODULE 4: MAP-REDUCE NODES ──────────────────────────────
def generate_subjects(state: OverallState) -> dict:
"""MAP step 1: Expands the topic into a list of subjects to process."""
prompt = [
SystemMessage(content="Generate a list of 3 sub-topics related to the given topic. Return as comma-separated values only."),
HumanMessage(content=state["topic"])
]
response = llm.invoke(prompt)
subjects = [s.strip() for s in response.content.split(",")]
return {"subjects": subjects}
def generate_joke(state: JokeState) -> dict:
"""MAP step 2: Worker node. Each instance handles exactly ONE subject.
Receives its own isolated JokeState - not the full OverallState."""
prompt = [HumanMessage(content=f"Write a short, funny joke about: {state['subject']}")]
response = llm.invoke(prompt)
# Returns a LIST - operator.add in OverallState will accumulate these
return {"jokes": [response.content]}
def pick_best_joke(state: OverallState) -> dict:
"""REDUCE step: All jokes are now in state['jokes']. Pick the winner."""
jokes_text = "\n".join([f"{i}. {j}" for i, j in enumerate(state["jokes"])])
prompt = [
SystemMessage(content="You are a comedy judge. Pick the funniest joke from the list below. Reply with ONLY the number."),
HumanMessage(content=jokes_text)
]
response = llm.invoke(prompt)
winning_index = int(response.content.strip())
return {"best_joke": state["jokes"][winning_index]}
# ── MODULE 5: THE SEND ROUTER ────────────────────────────────
def continue_to_jokes(state: OverallState):
"""The routing function that LAUNCHES parallel workers.
Returning a LIST of Send objects (not a string) is what triggers map-reduce.
Each Send creates an independent, parallel execution of 'generate_joke'
with its own state payload. The number of parallel workers is determined
at runtime by how many subjects exist in state.
"""
return [
Send("generate_joke", {"subject": subject})
for subject in state["subjects"]
]
# ── MODULE 6: MAP-REDUCE GRAPH ASSEMBLY ─────────────────────
graph_builder = StateGraph(OverallState)
graph_builder.add_node("generate_subjects", generate_subjects)
graph_builder.add_node("generate_joke", generate_joke)
graph_builder.add_node("pick_best_joke", pick_best_joke)
graph_builder.add_edge(START, "generate_subjects")
# This conditional edge uses the Send router - it fans out dynamically
graph_builder.add_conditional_edges(
"generate_subjects",
continue_to_jokes, # returns a LIST of Send objects
["generate_joke"] # list of possible destination nodes (for graph validation)
)
# Fan-in: pick_best_joke runs after ALL parallel generate_joke instances complete
graph_builder.add_edge("generate_joke", "pick_best_joke")
graph_builder.add_edge("pick_best_joke", END)
map_reduce_graph = graph_builder.compile()
The magic sits in continue_to_jokes. It returns a list of Send objects instead of a string, and when LangGraph receives such a list from a routing function it launches every one of them immediately. Three items in state["subjects"] means three parallel generate_joke nodes, and twenty items means twenty. The graph scales with whatever the runtime produces.
Level 5: a multi-agent research assistant
Now everything combines: supervisor-style orchestration, human approval, parallel subgraphs and the Send API, in one production-style system. It's the capstone project of the LangGraph Academy course, annotated here.
The behavior: a user gives a research topic. The system generates a team of AI analyst personas, and a human can approve or refine them. Each analyst then runs as a parallel interview sub-agent, questioning an AI "expert" with web search and producing one section of a report. Finally, a writer node compiles the sections into the finished document.
User provides topic
↓
[create_analysts] → LLM generates N analyst personas
↓
[human_feedback] ← INTERRUPT: human approves or sends feedback
↓ ↑
[should_continue] → if feedback, loop back to create_analysts
↓
[initiate_research] → Send API: spawns N parallel interview subgraphs
↓↓↓ (N parallel workers running simultaneously)
[interview_subgraph × N] → each analyst interviews an "expert" via web search
↓
[write_report] → LLM compiles all sections into a final report
State at three levels
from pydantic import BaseModel, Field
# ── Analyst persona - a Pydantic model for structured LLM output ──
class Analyst(BaseModel):
name: str = Field(description="The analyst's full name")
role: str = Field(description="Their professional role (e.g. 'Financial Analyst')")
focus: str = Field(description="Their specific analytical focus area")
@property
def persona(self) -> str:
return f"Name: {self.name}\nRole: {self.role}\nFocus: {self.focus}"
class Perspectives(BaseModel):
analysts: list[Analyst]
# ── Outer graph state ─────────────────────────────────────────
class ResearchGraphState(TypedDict):
topic: str
max_analysts: int
human_analyst_feedback: str # human writes here during HITL pause
analysts: list[Analyst]
sections: Annotated[list, operator.add] # accumulated from all parallel interviews
final_report: str
# ── Interview subgraph state ──────────────────────────────────
class InterviewState(MessagesState):
# inherits: messages: Annotated[list[BaseMessage], add_messages]
max_num_turns: int
context: Annotated[list, operator.add] # search results accumulate here
analyst: Analyst
interview: str # formatted transcript
sections: list # completed section (sent back to outer graph)
Generating analysts, with a human check
# ── ANALYST GENERATION NODE ───────────────────────────────────
ANALYST_PROMPT = """You are generating a team of {max_analysts} research analysts
for the topic: {topic}.
{feedback_section}
Each analyst should have a unique perspective and area of focus.
Generate analysts with diverse viewpoints - financial, technical, social, etc."""
def create_analysts(state: ResearchGraphState) -> dict:
"""Generates analyst personas using structured LLM output."""
feedback = state.get("human_analyst_feedback", "")
feedback_section = (
f"User feedback to incorporate:\n{feedback}" if feedback
else "No feedback yet - generate your best initial set."
)
prompt = ANALYST_PROMPT.format(
max_analysts=state["max_analysts"],
topic=state["topic"],
feedback_section=feedback_section
)
# with_structured_output: forces LLM to return a Pydantic model
# instead of free-form text - ensures clean, usable data
structured_llm = llm.with_structured_output(Perspectives)
result = structured_llm.invoke([SystemMessage(content=prompt)])
return {"analysts": result.analysts}
def human_feedback_node(state: ResearchGraphState) -> dict:
"""No-op node. Its only purpose is to be a pause point.
The graph is compiled with interrupt_before=['human_feedback'].
See Part 3 for the full HITL pattern."""
pass # Does nothing - the pause happens BEFORE this node via interrupt_before
def should_continue_to_research(state: ResearchGraphState) -> Literal["create_analysts", "initiate_research"]:
"""After human feedback: loop back to refine, or proceed to interviews."""
if state.get("human_analyst_feedback"):
return "create_analysts"
return "initiate_research"
The interview subgraph
Every analyst runs as its own subgraph, a small ReAct agent that interviews an "expert" (another LLM call playing that role) and searches the web.
from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode
@tool
def web_search_tool(query: str) -> str:
"""Search the web for information on a topic."""
return f"[Search results for '{query}']" # Replace with real search API
interview_tools = [web_search_tool]
interview_llm = llm.bind_tools(interview_tools)
def generate_question(state: InterviewState) -> dict:
"""The analyst node: generates the next interview question."""
analyst = state["analyst"]
messages = [
SystemMessage(content=(
f"You are {analyst.name}, a {analyst.role} focused on {analyst.focus}. "
f"You are interviewing an expert about the research topic. "
f"Ask insightful questions that align with your analytical focus. "
f"When you have enough information, say 'Thank you, that is all I needed.'"
))
] + state["messages"]
response = interview_llm.invoke(messages)
return {"messages": [response]}
def generate_answer(state: InterviewState) -> dict:
"""The expert node: answers the analyst's question, using web search."""
messages = [
SystemMessage(content=(
"You are an expert being interviewed. Answer thoroughly and factually. "
"Use the web search tool when you need current data."
))
] + state["messages"]
response = interview_llm.invoke(messages)
return {"messages": [response]}
def save_interview(state: InterviewState) -> dict:
"""Formats the full Q&A exchange into a clean transcript string."""
transcript = []
for msg in state["messages"]:
if hasattr(msg, "content"):
role = "Analyst" if type(msg).__name__ == "HumanMessage" else "Expert"
transcript.append(f"{role}: {msg.content}")
return {"interview": "\n\n".join(transcript)}
def write_section(state: InterviewState) -> dict:
"""The final node: uses the interview transcript to write a report section."""
analyst = state["analyst"]
prompt = [
SystemMessage(content=(
f"Based on the following interview conducted by {analyst.name} ({analyst.role}), "
f"write a structured report section focused on {analyst.focus}. "
f"Be concise, factual, and cite specific points from the interview."
)),
HumanMessage(content=state["interview"])
]
response = llm.invoke(prompt)
# sections is the key that Send passes back to the outer graph
return {"sections": [response.content]}
def route_interview(state: InterviewState) -> Literal["generate_answer", "save_interview"]:
"""Continue interviewing OR wrap up when analyst is satisfied or max turns hit."""
last_message = state["messages"][-1]
if (
"thank you, that is all" in last_message.content.lower()
or len(state["messages"]) >= state.get("max_num_turns", 6) * 2
):
return "save_interview"
# Check if analyst made a tool call (web search)
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
return "generate_answer"
return "generate_answer"
# ── INTERVIEW SUBGRAPH ASSEMBLY ───────────────────────────────
interview_builder = StateGraph(InterviewState)
interview_tool_node = ToolNode(interview_tools)
interview_builder.add_node("generate_question", generate_question)
interview_builder.add_node("generate_answer", generate_answer)
interview_builder.add_node("interview_tools", interview_tool_node)
interview_builder.add_node("save_interview", save_interview)
interview_builder.add_node("write_section", write_section)
interview_builder.add_edge(START, "generate_question")
interview_builder.add_conditional_edges(
"generate_question",
route_interview,
{"generate_answer": "generate_answer", "save_interview": "save_interview"}
)
interview_builder.add_conditional_edges(
"generate_answer",
lambda s: "interview_tools" if (hasattr(s["messages"][-1], "tool_calls") and s["messages"][-1].tool_calls) else "generate_question",
{"interview_tools": "interview_tools", "generate_question": "generate_question"}
)
interview_builder.add_edge("interview_tools", "generate_answer")
interview_builder.add_edge("save_interview", "write_section")
interview_builder.add_edge("write_section", END)
interview_graph = interview_builder.compile()
The outer orchestration graph
# ── SEND ROUTER: Spawn one interview per analyst ─────────────
def initiate_research(state: ResearchGraphState):
"""Launches N parallel interview subgraphs via Send — one per analyst.
This is the map-reduce Send pattern from Level 4, applied to subgraphs."""
return [
Send("interview", {
"analyst": analyst,
"messages": [HumanMessage(content=f"Research topic: {state['topic']}")],
"context": [],
"max_num_turns": 3,
"interview": "",
"sections": []
})
for analyst in state["analysts"]
]
# ── REPORT COMPILATION NODE ───────────────────────────────────
def write_report(state: ResearchGraphState) -> dict:
"""Fan-in point: all sections have arrived from parallel interviews.
The LLM compiles them into a final report."""
sections_text = "\n\n---\n\n".join(state["sections"])
prompt = [
SystemMessage(content=(
"You are a senior editor. Compile the following report sections written by "
"different analysts into one cohesive, well-structured final report. "
"Add a proper introduction and conclusion. Do not repeat content."
)),
HumanMessage(content=f"Topic: {state['topic']}\n\nSections:\n{sections_text}")
]
response = llm.invoke(prompt)
return {"final_report": response.content}
# ── OUTER GRAPH ASSEMBLY ─────────────────────────────────────
outer_builder = StateGraph(ResearchGraphState)
outer_builder.add_node("create_analysts", create_analysts)
outer_builder.add_node("human_feedback", human_feedback_node)
outer_builder.add_node("interview", interview_graph) # subgraph as node
outer_builder.add_node("write_report", write_report)
outer_builder.add_edge(START, "create_analysts")
outer_builder.add_edge("create_analysts", "human_feedback")
outer_builder.add_conditional_edges(
"human_feedback",
should_continue_to_research,
{"create_analysts": "create_analysts", "initiate_research": "initiate_research"}
)
# initiate_research is a ROUTING FUNCTION (returns Send objects), not a node
outer_builder.add_conditional_edges(
"human_feedback",
initiate_research, # returns list of Send objects
["interview"] # validation: lists possible destinations
)
outer_builder.add_edge("interview", "write_report")
outer_builder.add_edge("write_report", END)
research_graph = outer_builder.compile(
checkpointer=MemorySaver(),
interrupt_before=["human_feedback"] # HITL pause for analyst approval
)
Running the whole thing
if __name__ == "__main__":
config = {"configurable": {"thread_id": "research-001"}}
# Step 1: Generate analysts
result = research_graph.invoke(
{
"topic": "The impact of AI on the future of software engineering",
"max_analysts": 3,
"human_analyst_feedback": "",
"analysts": [],
"sections": [],
"final_report": ""
},
config=config
)
# Step 2: HITL — graph paused at human_feedback node
snapshot = research_graph.get_state(config)
analysts = snapshot.values["analysts"]
print("Generated analysts:")
for a in analysts:
print(f" - {a.name} ({a.role}): {a.focus}")
feedback = input("\nProvide feedback to refine analysts (or press Enter to approve): ")
if feedback.strip():
# Human has feedback — inject it and let supervisor regenerate
research_graph.update_state(
config,
{"human_analyst_feedback": feedback},
as_node="human_feedback"
)
else:
# Approved — clear feedback and proceed to interviews
research_graph.update_state(
config,
{"human_analyst_feedback": None},
as_node="human_feedback"
)
# Step 3: Resume — N parallel interviews launch via Send, then report is compiled
print("\nRunning parallel interviews...")
final_result = research_graph.invoke(None, config=config)
print("\n" + "="*60)
print("FINAL RESEARCH REPORT")
print("="*60)
print(final_result["final_report"])
Choosing a pattern
- A single agent that gets confused between tasks calls for the supervisor pattern. The supervisor routes and specialists execute.
- Independent, slow tasks call for fan-out and fan-in. Run them simultaneously and add
operator.addreducers for shared fields. - Parallel branches that are themselves complicated call for subgraphs. Compile each branch as its own graph, embed it as a node and use output schemas to control what returns to the parent.
- A worker count decided at runtime calls for
Send. A router produces a list ofSendobjects, and each one spawns an independent worker with its own state. - A real production system uses all of the above. The Level 5 assistant combines every pattern because a real task needs them all.
Keyword reference
Supervisor structure. supervisor_node is the orchestrator. It reads state, writes next_agent and never does specialist work. specialist_node handles one focused task with its own tools and prompt, then routes back to the supervisor. next_agent: str is the conventional state field for routing: the supervisor writes a name and the router reads it.
Parallelism. Fan-out means several add_edge calls from one source node, which LangGraph runs concurrently. Fan-in means several edges into one destination, which waits for every source. Annotated[list, operator.add] is mandatory on any field that several parallel nodes write to, since parallel writes crash without it, and operator.add is the usual reducer, concatenating lists from concurrent nodes.
Subgraphs. StateGraph(InternalState, output=OutputSchema) is the two-argument form where the second argument filters which keys go back to the parent. An output schema is a TypedDict with only the keys to expose, and everything else stays private. Overlapping keys act as the channel between parent and child, because any key in both schemas is shared automatically. subgraph.compile() seals the subgraph into a callable, which is then passed to parent.add_node("name", compiled_subgraph). Calling graph.get_graph(xray=1).draw_mermaid() shows the internal subgraph structure in the diagram.
Map-reduce. Send(node_name, state_dict) stands for one parallel worker invocation, and its payload needn't match the parent state. Returning [Send(...), Send(...)] from a router launches them all. When using Send, the third argument to add_conditional_edges is a list, not a dict, of valid destination names, used only for graph validation. Finally, with_structured_output(PydanticModel) chains after a model call to force JSON that conforms to a Pydantic schema. It's the usual way to get reliable supervisor decisions and analyst lists.
Composition is the skill
Moving from one agent to many isn't really a leap. It's the same building blocks applied repeatedly and wired together. Every agent in a multi-agent system is a graph, a node or a subgraph, and the primitives never change. What counts is knowing how to compose them.
The path here was deliberate: one supervisor, after which came flat parallel nodes, parallel subgraphs for complex branches, Send-driven dynamic workers and finally all of it together. If any step felt comfortable, the design did its job, because each level rests cleanly on the previous one.
The research assistant at Level 5 comes close to what a production multi-agent codebase contains: human approval loops, parallel specialists, structured model output, dynamic worker spawning and a closing synthesis step. Together with a canonical project structure, memory management and human-in-the-loop safety, these patterns cover the architecture behind most real LangGraph applications.