LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 4 · Parallelization
1 4-1 Parallelization
2 4-2 Sub Graph
3 4-3 Map Reduce
4 4-4 Research Asst
HomeLangGraphModule 4 · Parallelization4-4 Research Asst
📓 Notebook 📖 Explained
LANGGRAPH MODULE 4 · LESSON 4

Research Assistant: Multi-Agent Automation Research
Parallel · Subgraph · The ultimate synthesis of Map-Reduce

Integrate all Module 4 technologies into one: AI analyst team interviews experts in parallel, subgraphs encapsulate conversation status, and Map-Reduce summarizes and writes the final report.

1Overall architecture: What is this system doing?

Research is a time-consuming and labor-intensive task - collecting information, analyzing different perspectives, synthesizing insights, and writing reports often requires the collaboration of an entire team. The goal of this lesson is to build aLightweight multi-agent research system, automatically completing the entire process above.

Source of core ideas

The system’s design is inspired by Stanford’s 2024STORM paper(Synthesis of Topic Outlines through Retrieval and Multi-perspective Question Asking) - Let AI analysts from different perspectives refine in-depth insights through multiple rounds of interviews, and finally synthesize them into a high-quality report.

Five major capabilities design of the system:

Agent role in the system

🔬
AI Analyst
Generated by LLM, each has a unique background, function, and focus. Take the initiative to ask questions, inquire deeply, and finally end the interview with "Thank you for your help!"
🧠
Expert AI
Answer analyst questions. Before answering, search the web and Wikipedia in parallel, using the retrieved material as context to generate an answer with citations.
✍️
Report Writing Agent
Collect all interview memos, synthesize them into a coherent insight report, and write an introduction and conclusion respectively.
🎯
Orchestrator (main image)
Control the overall process: generate analysts → manual approval → start all interviews in parallel → wait for all to complete → trigger writing nodes.

2Phase 1: Generating an Analyst Team (Human-in-the-loop)

Before the study begins, the system first generates a group of AI analysts and allows users to review and adjust the lineup through Human-in-the-loop until they are satisfied.

Data Model: Analyst and Perspectives

Every analyst uses PydanticBaseModelDescription, including four core attributes and a prompt word used to generatepersonaProperties:

from pydantic import BaseModel, Field

class Analyst(BaseModel):
    affiliation: str = Field(description="分析师所属机构")
    name: str = Field(description="分析师姓名")
    role: str = Field(description="分析师在本课题中的角色")
    description: str = Field(description="分析师的关注重点、担忧和动机")

    @property
    def persona(self) -> str:
        # 合并成提示词片段,传给 LLM 作为角色扮演指令
        return f"Name: {self.name}\nRole: {self.role}\nAffiliation: {self.affiliation}\nDescription: {self.description}\n"

class Perspectives(BaseModel):
    # LLM 的结构化输出包装器:一次性返回多个分析师
    analysts: List[Analyst] = Field(description="分析师列表")

class GenerateAnalystsState(TypedDict):
    topic: str                    # 研究主题
    max_analysts: int              # 分析师数量上限
    human_analyst_feedback: str   # 用户反馈(用于迭代修改)
    analysts: List[Analyst]       # 当前分析师列表
Why use Pydantic BaseModel instead of plain TypedDict?

Structured output of LLM (llm.with_structured_output()) requires a Pydantic model to define the JSON Schema. In this way, LLM will strictly follow the field format output, rather than free text.PerspectivesAs an outer wrapper, LLM can output a list of multiple analyst objects at once.

create_analysts node: LLM Build Analysts

# 提示词模板:要求 LLM 根据主题和用户反馈生成分析师
analyst_instructions = """You are tasked with creating a set of AI analyst personas...
1. First, review the research topic: {topic}
2. Examine any editorial feedback: {human_analyst_feedback}
3. Determine the most interesting themes.
4. Pick the top {max_analysts} themes.
5. Assign one analyst to each theme."""

def create_analysts(state: GenerateAnalystsState):
    topic = state['topic']
    max_analysts = state['max_analysts']
    human_analyst_feedback = state.get('human_analyst_feedback', '')

    # 强制结构化输出,返回 Perspectives 对象
    structured_llm = llm.with_structured_output(Perspectives)

    system_message = analyst_instructions.format(
        topic=topic,
        human_analyst_feedback=human_analyst_feedback,
        max_analysts=max_analysts
    )
    analysts = structured_llm.invoke(
        [SystemMessage(content=system_message)]
        + [HumanMessage(content="Generate the set of analysts.")]
    )
    return {"analysts": analysts.analysts}

human_feedback node and loop control

human_feedbackIs a no-op node (no-op), which exists only to pause the graph here and wait for the user to update the status.

def human_feedback(state: GenerateAnalystsState):
    """ No-op node that should be interrupted on """
    pass  # 什么都不做,但图会在这里暂停

def should_continue(state: GenerateAnalystsState):
    # 有用户反馈 → 重新生成分析师(循环)
    human_analyst_feedback = state.get('human_analyst_feedback', None)
    if human_analyst_feedback:
        return "create_analysts"
    # 无反馈(用户满意)→ 结束
    return END

# 编译时声明在 human_feedback 节点前中断
graph = builder.compile(interrupt_before=['human_feedback'], checkpointer=memory)

Human-in-the-loop operation process

Analyst-generated human-machine collaboration loop

START
create_analysts
LLM generates analyst list
human_feedback
Figure paused, user reviewed
should_continue() routing
Provide feedback
Return create_analysts
(regenerate)
Satisfied, no feedback
END
analysts confirm
Practical examples

After running the graph and pausing, the user inspects the generated analyst:

graph.update_state(thread, {"human_analyst_feedback": "Add in someone from a startup to add an entrepreneur perspective"}, as_node="human_feedback")

The system was then regenerated, adding an analyst with a startup perspective. When the user is satisfied, pass inNoneProvide feedback to continue.

3Phase 2: Interview subgraph—parallel information collection

Each analyst's interview process is encapsulated in a separateinterview sub-graphin. Subgraphs have their own stateInterviewState, operates independently without interfering with each other.

InterviewState: the internal state of the subgraph

import operator
from langgraph.graph import MessagesState

class InterviewState(MessagesState):  # 继承 MessagesState 获得消息历史管理
    max_num_turns: int                        # 最大对话轮次
    context: Annotated[list, operator.add]     # 搜索结果累积(追加式 Reducer)
    analyst: Analyst                           # 当前分析师的 persona
    interview: str                             # 完整访谈记录(字符串)
    sections: list                              # 写好的备忘录(传递给外层主图)
Key design: context field uses operator.add as reducer

contextFields are labeledoperator.addAs a reducer. This means that instead of overwriting each update,Append to list. Because the expert obtains information from both web search and Wikipedia, the results of both nodes must be retained, so append rather than overwrite must be used.

Nodes within the interview subgraph

The subgraph contains six nodes, forming a cycle of "Ask → Parallel Search → Answer → Ask Again":

Node name Function key details
ask_question Analyst questions LLM plays the role of analyst and generates targeted questions based on the persona; saying "Thank you" marks the end of the interview
search_web Tavily web search Let LLM first convert the question into a search query, and then call the Tavily API to get the latest web content
search_wikipedia Wikipedia search Use WikipediaLoader to load related terms and execute in parallel with search_web
answer_question Expert answer Integrating search results as context, LLM generates answers with source citations; answers are signed "expert"
save_interview Save interview transcript Convert the message history into a complete interview string and save itinterviewField
write_section Write a research memo Based on the source documents searched, write a memo in Markdown format of about 400 words for the analyst

How experts search in parallel while answering

This is a direct application of Module 4 Lesson 1 parallelization techniques. After the analyst asked questions,search_webandsearch_wikipediaStart simultaneously:

# 两个搜索节点都连接到 ask_question → 并行执行
interview_builder.add_edge("ask_question", "search_web")
interview_builder.add_edge("ask_question", "search_wikipedia")

# 两者都完成后,才进入 answer_question
interview_builder.add_edge("search_web", "answer_question")
interview_builder.add_edge("search_wikipedia", "answer_question")

Internal structure of interview subgraph

START
ask_question
Analyst questions (LLM plays analyst)
parallel fork
search_web
Tavily web search
search_wikipedia
Wikipedia search
Convergence (context append)
answer_question
Expert answers (LLM + search context)
route_messages() route
Round limit not reached & no thank you said
Return to ask_question
(continue conversation)
Round arrival / end of interview
save_interview
write_section
END

Interview termination logic: route_messages

def route_messages(state: InterviewState, name: str = "expert"):
    messages = state["messages"]
    max_num_turns = state.get('max_num_turns', 2)

    # 统计专家已回答的次数
    num_responses = len(
        [m for m in messages if isinstance(m, AIMessage) and m.name == name]
    )

    # 条件1:专家已回答次数超过上限 → 结束访谈
    if num_responses >= max_num_turns:
        return 'save_interview'

    # 条件2:分析师说出"Thank you so much for your help" → 结束访谈
    last_question = messages[-2]  # 最后一次分析师提问(倒数第二条消息)
    if "Thank you so much for your help" in last_question.content:
        return 'save_interview'

    # 否则 → 分析师继续提问
    return "ask_question"

4Tavily Search Tool: How to get information online

Tavily is a search API designed specifically for AI agents. Compared to Google search, it returns clean, directly usable text summaries instead of a bunch of HTML.

Complete implementation of search nodes

from langchain_tavily import TavilySearch

# 初始化:每次最多返回 3 条结果
tavily_search = TavilySearch(max_results=3)

# 生成搜索查询的提示词
search_instructions = SystemMessage(content="""You will be given a conversation between an analyst and an expert.
Your goal is to generate a well-structured query for use in retrieval and/or web-search
related to the conversation. Pay particular attention to the final question posed by the analyst.
Convert this final question into a well-structured web search query.""")

def search_web(state: InterviewState):
    # Step 1: 让 LLM 把对话内容转化成搜索关键词
    structured_llm = llm.with_structured_output(SearchQuery)
    search_query = structured_llm.invoke([search_instructions] + state['messages'])

    # Step 2: 调用 Tavily 执行搜索
    data = tavily_search.invoke({"query": search_query.search_query})
    search_docs = data.get("results", data)

    # Step 3: 格式化为带 URL 标签的文档字符串
    formatted_search_docs = "\n\n---\n\n".join([
        f'<Document href="../../../langGraph/module-4/{doc["url"]}"/>\n{doc["content"]}\n</Document>'
        for doc in search_docs
    ])

    # 追加到 context(operator.add Reducer)
    return {"context": [formatted_search_docs]}

Wikipedia search node

from langchain_community.document_loaders import WikipediaLoader

def search_wikipedia(state: InterviewState):
    # 同样先把对话转化为搜索查询
    structured_llm = llm.with_structured_output(SearchQuery)
    search_query = structured_llm.invoke([search_instructions] + state['messages'])

    # 加载最多 2 篇维基百科文章
    search_docs = WikipediaLoader(
        query=search_query.search_query,
        load_max_docs=2
    ).load()

    formatted_search_docs = "\n\n---\n\n".join([
        f'<Document source="{doc.metadata["source"]}" page="{doc.metadata.get("page", "")}"/>\n{doc.page_content}\n</Document>'
        for doc in search_docs
    ])
    return {"context": [formatted_search_docs]}
SearchQuery model: Structured output from LLM

Before the expert searches, he first asks LLM to "compress" the entire conversation into a precise search keyword, which is much more effective than directly throwing the original text of the question to the search engine.SearchQueryis a onlysearch_query: strA Pydantic model of fields used to force LLM to output clean query terms.

Expert Answer Node: Reply Generation with Quotes

answer_instructions = """You are an expert being interviewed by an analyst.
Here is analyst area of focus: {goals}.

To answer question, use this context: {context}

Guidelines:
1. Use only the information provided in the context.
2. Include sources next to relevant statements. For example: [1].
5. List your sources at the bottom: [1] Source 1, [2] Source 2, etc."""

def generate_answer(state: InterviewState):
    analyst = state["analyst"]
    messages = state["messages"]
    context = state["context"]  # 网络搜索 + 维基百科的合并结果

    system_message = answer_instructions.format(
        goals=analyst.persona, context=context
    )
    answer = llm.invoke([SystemMessage(content=system_message)] + messages)

    # 给回答打上 "expert" 标签,供 route_messages 统计轮次
    answer.name = "expert"
    return {"messages": [answer]}

5Phase 3: Map-Reduce starts all interviews

After confirmation by the analyst team, the main image is usedSend()API puts all interviewsAt the same timeDistributed (Map), each interview subgraph runs in parallel, and after completion, the results are automatically aggregated to the main graph state (Reduce).

ResearchGraphState: main graph state

class ResearchGraphState(TypedDict):
    topic: str                                      # 研究主题
    max_analysts: int                                # 分析师数量
    human_analyst_feedback: str                     # 人工反馈
    analysts: List[Analyst]                         # 已确认的分析师列表
    sections: Annotated[list, operator.add]         # 所有备忘录(Send() 的汇聚点)
    introduction: str                               # 报告引言
    content: str                                    # 报告正文
    conclusion: str                                 # 报告结论
    final_report: str                               # 最终完整报告
The sections field is the key to Map-Reduce

sectionsUseoperator.addAs a reducer. After each interview sub-picture is completed, a memo written by yourself (sections) appended to the main imagesectionsin the list. After all interviews were completed in parallel,sectionsIt contains the research results of all analysts - this is the Reduce step.

initiate_all_interviews: Map function

from langgraph.types import Send

def initiate_all_interviews(state: ResearchGraphState):
    """ 这是 'map' 步骤:用 Send API 并行启动所有访谈子图 """

    # 检查是否还有人工反馈未处理
    human_analyst_feedback = state.get('human_analyst_feedback')
    if human_analyst_feedback:
        return "create_analysts"  # 回到分析师生成节点

    # 为每个分析师创建一个 Send 对象 → 并行启动所有子图
    topic = state["topic"]
    return [
        Send(
            "conduct_interview",  # 目标节点(子图)
            {
                "analyst": analyst,
                "messages": [
                    HumanMessage(content=f"So you said you were writing an article on {topic}?")
                ]
            }
        )
        for analyst in state["analysts"]
    ]
How Send() works

Send("conduct_interview", initial_state)It is equivalent to telling LangGraph: "With this initial state, start a pointingconduct_interviewNode's new execution branch". Return aSendobject list, an equal number of execution branches will be launched in parallel - as many parallel interview subgraphs as there are analysts running at the same time.

Execution output example

When actually running, you can see threeconduct_interviewCompleted in parallel, then the writing node is triggered synchronously:

# 执行日志
--Node-- conduct_interview  # 分析师1的访谈(并行)
--Node-- conduct_interview  # 分析师2的访谈(并行)
--Node-- conduct_interview  # 分析师3的访谈(并行)
--Node-- write_conclusion   # 三个写作任务同时触发
--Node-- write_introduction
--Node-- write_report
--Node-- finalize_report    # 最终汇总

6Phase Four: Writing and Summary of Final Report

After all interview subgraphs are completed, the main graph triggers three parallel writing nodes:write_reportwrite_introductionwrite_conclusion, finally byfinalize_reportCombine them into a complete report.

write_report: Comprehensive all memos

report_writer_instructions = """You are a technical writer creating a report on: {topic}

You have a team of analysts. Each analyst has:
1. Conducted an interview with an expert on a specific sub-topic.
2. Written up their findings into a memo.

Your task:
1. You will be given a collection of memos from your analysts.
2. Consolidate these into a crisp overall summary.
3. Start with: ## Insights
4. Create a final consolidated list of sources under: ## Sources"""

def write_report(state: ResearchGraphState):
    sections = state["sections"]   # 所有分析师的备忘录
    topic = state["topic"]

    # 把所有备忘录拼接在一起
    formatted_str_sections = "\n\n".join([f"{section}" for section in sections])

    system_message = report_writer_instructions.format(
        topic=topic, context=formatted_str_sections
    )
    report = llm.invoke(
        [SystemMessage(content=system_message)]
        + [HumanMessage(content="Write a report based upon these memos.")]
    )
    return {"content": report.content}

write_introduction and write_conclusion

Use the same prompt word template for the introduction and conclusionintro_conclusion_instructions, distinguished by different user instructions ("Write the report introduction" vs "Write the report conclusion"):

intro_conclusion_instructions = """You are a technical writer finishing a report on {topic}.
You will be given all sections of the report.
Write a crisp and compelling introduction or conclusion.
Target around 100 words.
For your introduction: create a compelling title with # header, use ## Introduction as section header.
For your conclusion: use ## Conclusion as section header."""

def write_introduction(state: ResearchGraphState):
    sections = state["sections"]
    topic = state["topic"]
    formatted_str_sections = "\n\n".join(sections)
    instructions = intro_conclusion_instructions.format(
        topic=topic, formatted_str_sections=formatted_str_sections
    )
    intro = llm.invoke([instructions] + [HumanMessage(content="Write the report introduction")])
    return {"introduction": intro.content}

# write_conclusion 结构相同,只改用户指令

finalize_report: combined final report

def finalize_report(state: ResearchGraphState):
    content = state["content"]

    # 处理 "## Insights" 标题和 "## Sources" 来源分离
    if content.startswith("## Insights"):
        content = content.strip("## Insights")
    if "## Sources" in content:
        try:
            content, sources = content.split("\n## Sources\n")
        except:
            sources = None
    else:
        sources = None

    # 拼接:引言 + 分隔线 + 正文 + 分隔线 + 结论 + 来源
    final_report = (
        state["introduction"] + "\n\n---\n\n"
        + content + "\n\n---\n\n"
        + state["conclusion"]
    )
    if sources is not None:
        final_report += "\n\n## Sources\n" + sources

    return {"final_report": final_report}
Why can three writing nodes be parallel?

write_reportwrite_introductionwrite_conclusionThey all only rely onsectionsThe fields (both read-only) do not depend on each other and therefore can run simultaneously. LangGraph usesadd_edge("conduct_interview", "write_xxx")Connect the Fan-Out after all interviews are completed to three writing nodes, and then use list edges to aggregate them intofinalize_report
builder.add_edge(["write_conclusion", "write_report", "write_introduction"], "finalize_report")

7Detailed explanation of complete graph topology and data flow

The following is the complete construction code of the main graph (ResearchGraph), including the definition of all nodes and edges:

# 把访谈子图编译后作为一个节点挂入主图
builder = StateGraph(ResearchGraphState)

# ─── 注册节点 ───
builder.add_node("create_analysts", create_analysts)
builder.add_node("human_feedback", human_feedback)
builder.add_node("conduct_interview", interview_builder.compile())  # 子图作为节点
builder.add_node("write_report", write_report)
builder.add_node("write_introduction", write_introduction)
builder.add_node("write_conclusion", write_conclusion)
builder.add_node("finalize_report", finalize_report)

# ─── 定义边 ───
builder.add_edge(START, "create_analysts")
builder.add_edge("create_analysts", "human_feedback")

# 条件边:人工反馈后的路由(返回修改 or 启动访谈)
builder.add_conditional_edges(
    "human_feedback",
    initiate_all_interviews,              # 路由函数 / Map 函数
    ["create_analysts", "conduct_interview"]
)

# 所有访谈完成后,同时触发三个写作节点(Fan-Out)
builder.add_edge("conduct_interview", "write_report")
builder.add_edge("conduct_interview", "write_introduction")
builder.add_edge("conduct_interview", "write_conclusion")

# 三个写作节点全部完成后,才进入 finalize_report(Fan-In)
builder.add_edge(
    ["write_conclusion", "write_report", "write_introduction"],
    "finalize_report"
)
builder.add_edge("finalize_report", END)

# 编译:在 human_feedback 处中断,启用 MemorySaver 保存状态
memory = MemorySaver()
graph = builder.compile(interrupt_before=['human_feedback'], checkpointer=memory)

Complete data flow visualization

Research assistant main map complete topology

START
create_analysts
LLM Build Analyst Team
human_feedback
Manual approval (breaking point)
Have feedback
Return create_analysts
No feedback → Send() × N
conduct_interview #1
Subplot: Analyst 1
conduct_interview #2
Subplot: Analyst 2
conduct_interview #3
Subplot: Analyst N
All interviews completed and sections aggregated
write_introduction
Introduction (parallel)
write_report
Main text (parallel)
write_conclusion
Conclusion (parallel)
Fan-In: Complete all three
finalize_report
Splicing final report
END

How to transfer data between main graph and subgraph

data direction mechanism
analyst, messages Main image → sub image passSend()The initial_state parameter is passed in
sections Subpicture → Main picture in the final state of the subgraphsectionsfield, throughoperator.addappended to main imagesections
introduction, content, conclusion Writing node → Main image Each writing node returns the corresponding field and stores it directly.ResearchGraphState
final_report finalize_report → main image The final assembled complete Markdown report string

8Module 4 Knowledge Fusion: How the three major technologies work together

This lesson is the final chapter of Module 4. It completely integrates the techniques of the first three lessons into a real scene. Let us sort out the specific embodiment of each technology in the system:

Technology 1: Parallelization — Lesson 1

P
Application location: Inside the interview subgraph
In response to analyst questions, expertssearch_webandsearch_wikipediaexecuted simultaneously. Both search nodes are connected toask_questionnode outputs, and are all connected toanswer_questionNode input. LangGraph recognizes this Fan-Out/Fan-In structure, automatically executes it in parallel, and waits for both to complete before continuing.
P
Application location: final writing stage
Three writing nodes (write_introductionwrite_reportwrite_conclusion) is triggered at the same time after all interviews are completed, independent of each other, and executed in parallel.

Technology 2: Sub-graphs — Lesson 2

S
Application location: conduct_interview node
The complete interview process (asking questions, searching, answering, saving, writing memos) is encapsulated inInterviewStatein the sub-picture. Subgraphs independently manage their own internal states such as message history and search context, and only expose them to the outside world.sectionsresults.interview_builder.compile()The generated subgraph object is passed directly to the main graph.add_node(), used as a "black box node".

Technology 3: Map-Reduce — Lesson 3

M
Application location: initiate_all_interviews routing function
Map stepsinitiate_all_interviewsReturnSendList of objects, launching an interview subgraph instance in parallel for each analyst.
Reduce stepsResearchGraphState.sectionsUseoperator.addAs a reducer, each subgraph appends its own memo after completion. After all subplots are completed,sectionsContains all research results for summary writing nodes.

New technology unique to this section

technology Applications in this section
Multiple dialogue loops Interview sub-pictureask_question → answer_question → ask_questionloop, useroute_messages()Control termination conditions
Structured output as routing criteria Trigger routing redirect when analyst says "Thank you so much for your help"save_interview, convert natural language signals into graph control flow
Sub-picture and main picture status exchange sub-picturesectionsFields are automatically aligned and merged with fields of the same name in the main image to achieve cross-layer status transfer.
Fan-In wait semantics add_edge(["write_conclusion", "write_report", "write_introduction"], "finalize_report")——List side declarationfinalize_reportYou must wait for all three to complete before running
Module 4 Global Perspective

The four lessons of Module 4 constitute a complete technology stack:
Lesson 1Teach you to execute multiple nodes in parallel;Lesson 2Teach you to use subgraphs to encapsulate complex states;Lesson 3Teach you how to use Send() to dynamically distribute tasks and aggregate results;Lesson 4(This section) Integrate the three to build a complete, production-level multi-Agent research system.

How to extend this system in your own projects

Engineering implementation of STORM paper

This system implements the core ideas of the Stanford STORM paper into runnable code: analysts from multiple perspectives collect information in parallel, refine in-depth insights through structured interviews, and finally synthesize it into a research report with complete references and comprehensive perspectives. This is not just a teaching example, it can be used in actual competitive intelligence analysis, technology research, market research and other scenarios with slight modifications.