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

研究助手:多 Agent 自动化研究
并行 · 子图 · Map-Reduce 的终极综合

把 Module 4 所有技术融为一体:AI 分析师团队并行访谈专家、子图封装对话状态、Map-Reduce 汇总写出最终报告。

1 整体架构:这套系统在干什么

研究是一项耗时耗力的工作——搜集资料、分析不同角度、综合形成见解、写成报告,往往需要一整个团队协作完成。这节课的目标是用 LangGraph 构建一个轻量级多 Agent 研究系统,自动化完成上述全流程。

核心思想来源

该系统的设计灵感来自斯坦福 2024 年的 STORM 论文(Synthesis of Topic Outlines through Retrieval and Multi-perspective Question Asking)——让不同视角的 AI 分析师通过多轮访谈提炼深度洞见,最终综合成一篇高质量报告。

系统的五大能力设计:

系统中的 Agent 角色

🔬
AI 分析师
由 LLM 生成,每人有独特的背景、职能和关注点。主动提问、深入追问,最终以"谢谢您的帮助!"结束访谈。
🧠
专家 AI
回答分析师的问题。在回答前,先并行搜索网络和维基百科,用检索到的资料作为上下文生成有引用的答案。
✍️
报告写作 Agent
收集所有访谈备忘录,综合出一份连贯的洞见报告正文,并分别撰写引言和结论。
🎯
编排器(主图)
控制整体流程:生成分析师 → 人工审批 → 并行启动所有访谈 → 等待全部完成 → 触发写作节点。

2 第一阶段:生成分析师团队(Human-in-the-loop)

研究开始前,系统先生成一批 AI 分析师,并通过 Human-in-the-loop 让用户审查、调整阵容,直到满意为止。

数据模型:Analyst 和 Perspectives

每位分析师用 Pydantic BaseModel 描述,包含四个核心属性,以及一个用于生成提示词的 persona 属性:

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]       # 当前分析师列表
为什么用 Pydantic BaseModel 而不是普通 TypedDict?

LLM 的结构化输出(llm.with_structured_output())需要一个 Pydantic 模型来定义 JSON Schema。这样 LLM 会严格按照字段格式输出,而不是自由文本。Perspectives 作为外层包装器,使得 LLM 可以一次输出多个分析师对象的列表。

create_analysts 节点:LLM 生成分析师

# 提示词模板:要求 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 节点与循环控制

human_feedback 是一个空操作节点(no-op),它的存在只是为了让图在此处暂停,等待用户更新状态。

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 的操作流程

分析师生成的人机协作循环

START
create_analysts
LLM 生成分析师列表
human_feedback
图暂停,用户审查
should_continue() 路由
提供反馈
返回 create_analysts
(重新生成)
满意,无反馈
END
分析师确定
实际操作示例

运行图后暂停,用户检查生成的分析师:

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

系统随即重新生成,加入了一位创业公司视角的分析师。当用户满意时,传入 None 反馈即可继续。

3 第二阶段:访谈子图——并行信息收集

每一位分析师的访谈过程被封装在一个独立的访谈子图(interview sub-graph)中。子图有自己的状态 InterviewState,独立运行,互不干扰。

InterviewState:子图的内部状态

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                              # 写好的备忘录(传递给外层主图)
关键设计:context 字段使用 operator.add 作为 Reducer

context 字段标注了 operator.add 作为 Reducer。这意味着每次更新不是覆盖,而是追加到列表。因为专家同时从网络搜索和维基百科获取信息,两个节点的结果都要保留,所以必须用追加而不是覆盖。

访谈子图内的节点

子图包含六个节点,形成一个"提问 → 并行搜索 → 回答 → 再提问"的循环:

节点名 功能 关键细节
ask_question 分析师提问 LLM 扮演分析师角色,基于 persona 生成针对性问题;说出"谢谢您"时标志访谈结束
search_web Tavily 网络搜索 先让 LLM 将问题转化为搜索查询,再调用 Tavily API 获取最新网页内容
search_wikipedia 维基百科搜索 使用 WikipediaLoader 加载相关词条,与 search_web 并行执行
answer_question 专家回答 整合搜索结果作为上下文,LLM 生成带来源引用的答案;答案署名为 "expert"
save_interview 保存访谈记录 将消息历史转换为完整访谈字符串,存入 interview 字段
write_section 撰写研究备忘录 基于搜索到的源文档,为该分析师写一份约 400 字的 Markdown 格式备忘录

专家在回答时如何并行搜索

这是 Module 4 Lesson 1 并行化技术的直接应用。分析师提问后,search_websearch_wikipedia 同时启动:

# 两个搜索节点都连接到 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")

访谈子图内部结构

START
ask_question
分析师提问(LLM 扮演分析师)
并行分叉
search_web
Tavily 网络搜索
search_wikipedia
维基百科搜索
汇聚(context 追加)
answer_question
专家回答(LLM + 搜索上下文)
route_messages() 路由
轮次未到上限 & 未说谢谢
返回 ask_question
(继续对话)
轮次到达 / 访谈结束
save_interview
write_section
END

访谈终止逻辑: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"

4 Tavily 搜索工具:如何联网获取信息

Tavily 是专门为 AI Agent 设计的搜索 API,相比 Google 搜索,它返回的是清洁的、直接可用的文本摘要,而不是一堆 HTML。

搜索节点的完整实现

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="{doc["url"]}"/>\n{doc["content"]}\n</Document>'
        for doc in search_docs
    ])

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

维基百科搜索节点

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 模型:LLM 的结构化输出

专家在搜索之前,先让 LLM 把整段对话"压缩"成一个精准的搜索关键词,这比直接把问题原文扔给搜索引擎效果好得多。SearchQuery 是一个只有 search_query: str 字段的 Pydantic 模型,用于强制 LLM 输出干净的查询词。

专家回答节点:带引用的回复生成

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]}

5 第三阶段:Map-Reduce 启动所有访谈

分析师团队确认后,主图使用 Send() API 把所有访谈同时分发出去(Map),每个访谈子图并行运行,完成后结果自动聚合到主图状态(Reduce)。

ResearchGraphState:主图状态

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                               # 最终完整报告
sections 字段是 Map-Reduce 的关键

sections 使用 operator.add 作为 Reducer。每个访谈子图完成后,会将自己写的备忘录(sections)追加到主图的 sections 列表中。所有访谈并行完成后,sections 就包含了所有分析师的研究结果——这就是 Reduce 步骤。

initiate_all_interviews:Map 函数

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"]
    ]
Send() 是如何工作的

Send("conduct_interview", initial_state) 相当于告诉 LangGraph:"以这个初始状态,启动一个指向 conduct_interview 节点的新执行分支"。返回一个 Send 对象列表,就会并行启动等量的执行分支——有多少个分析师,就有多少个并行的访谈子图同时运行。

执行输出示例

实际运行时,可以看到三个 conduct_interview 并行完成,随后写作节点同步触发:

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

6 第四阶段:最终报告的写作与汇总

所有访谈子图完成后,主图触发三个并行的写作节点:write_reportwrite_introductionwrite_conclusion,最后由 finalize_report 将它们组合成完整报告。

write_report:综合所有备忘录

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 和 write_conclusion

引言和结论使用同一套提示词模板 intro_conclusion_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:组合最终报告

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}
三个写作节点为什么可以并行?

write_reportwrite_introductionwrite_conclusion 都只依赖 sections 字段(都是只读),互相不依赖,因此可以同时运行。LangGraph 用 add_edge("conduct_interview", "write_xxx") 将所有访谈完成后的 Fan-Out 连接到三个写作节点,再用列表边汇聚到 finalize_report
builder.add_edge(["write_conclusion", "write_report", "write_introduction"], "finalize_report")

7 完整图拓扑与数据流详解

下面是主图(ResearchGraph)的完整构建代码,包含所有节点和边的定义:

# 把访谈子图编译后作为一个节点挂入主图
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)

完整数据流可视化

研究助手主图完整拓扑

START
create_analysts
LLM 生成分析师团队
human_feedback
人工审批(中断点)
有反馈
返回 create_analysts
无反馈 → Send() × N
conduct_interview #1
子图:分析师1
conduct_interview #2
子图:分析师2
conduct_interview #3
子图:分析师N
所有访谈完成,sections 聚合完毕
write_introduction
引言(并行)
write_report
正文(并行)
write_conclusion
结论(并行)
Fan-In:三者全部完成
finalize_report
拼接最终报告
END

数据在主图与子图之间如何传递

数据 方向 机制
analyst, messages 主图 → 子图 通过 Send() 的 initial_state 参数传入
sections 子图 → 主图 子图最终状态中的 sections 字段,通过 operator.add 追加到主图的 sections
introduction, content, conclusion 写作节点 → 主图 各写作节点返回对应字段,直接存入 ResearchGraphState
final_report finalize_report → 主图 最终组合后的完整 Markdown 报告字符串

8 Module 4 知识融合:三大技术如何协同

这节课是 Module 4 的终章,将前三节课的技术完整融合进一个真实场景。让我们梳理每项技术在系统中的具体体现:

技术一:并行化(Parallelization)— Lesson 1

P
应用位置:访谈子图内部
专家在回答分析师问题时,search_websearch_wikipedia 同时执行。两个搜索节点都连接到 ask_question 节点的输出,且都连接到 answer_question 节点的输入。LangGraph 识别到这个 Fan-Out/Fan-In 结构,自动并行执行,等两者都完成才继续。
P
应用位置:最终写作阶段
三个写作节点(write_introductionwrite_reportwrite_conclusion)在所有访谈完成后同时触发,互不依赖,并行执行。

技术二:子图(Sub-graphs)— Lesson 2

S
应用位置:conduct_interview 节点
完整的访谈流程(提问、搜索、回答、保存、写备忘录)被封装在 InterviewState 子图中。子图独立管理自己的消息历史、搜索上下文等内部状态,对外仅暴露 sections 结果。interview_builder.compile() 生成的子图对象被直接传给主图的 add_node(),作为一个"黑盒节点"使用。

技术三:Map-Reduce — Lesson 3

M
应用位置:initiate_all_interviews 路由函数
Map 步骤initiate_all_interviews 返回 Send 对象列表,为每个分析师并行启动一个访谈子图实例。
Reduce 步骤ResearchGraphState.sections 使用 operator.add 作为 Reducer,每个子图完成后将自己的备忘录追加进来。所有子图完成后,sections 包含全部研究结果,供写作节点汇总。

本节独有的新技术

技术 在本节的应用
多轮对话循环 访谈子图中 ask_question → answer_question → ask_question 的循环,用 route_messages() 控制终止条件
结构化输出作为路由条件 分析师说"Thank you so much for your help"时触发路由转向 save_interview,将自然语言信号转化为图的控制流
子图与主图状态交换 子图的 sections 字段与主图的同名字段自动对齐合并,实现跨层状态传递
Fan-In 等待语义 add_edge(["write_conclusion", "write_report", "write_introduction"], "finalize_report")——列表边声明 finalize_report 必须等三者全部完成才能运行
Module 4 全局视角

Module 4 的四节课构成了一个完整的技术栈:
Lesson 1 教你并行执行多个节点;Lesson 2 教你用子图封装复杂状态;Lesson 3 教你用 Send() 动态分发任务并聚合结果;Lesson 4(本节)将三者融合,构建出一个完整的、生产级别的多 Agent 研究系统。

如何在自己的项目中扩展这个系统

STORM 论文的工程实现

这套系统将斯坦福 STORM 论文的核心思想落地为可运行的代码:多角度分析师并行采集信息,通过结构化访谈提炼深度洞见,最终综合成一篇引用完整、视角全面的研究报告。这不仅仅是一个教学示例,稍加修改即可用于实际的竞争情报分析、技术调研、市场研究等场景。