跳到主要内容

编排骨架:三层 LangGraph 与状态流转

30 秒导读: Open Deep Research 用 LangGraph 把"做一次深度研究"编排成一条主干流水线:先问清楚要研究什么,再把对话压成一份研究提纲,然后交给一个"研究主管"子图去调兵遣将,最后把所有笔记写成报告。本章讲透这条主干怎么走、以及支撑它的三层 State + 一个自定义 reducer 为什么这么设计——研究主管和研究员内部怎么循环,留给 02


1. 先建立直觉:主图就是一条四步流水线

把整个系统想象成一家研究外包公司接一单活:

  1. 前台先跟客户确认需求("你说的'最好的电动车'是指价格、续航、还是充电网络?")——不清楚就打回去问,清楚就放行。
  2. 项目经理把这段对话整理成一份书面立项书(research brief),交给研究部门。
  3. 研究总监拿着立项书拆任务、派给若干研究员并行去查,收集笔记。
  4. 撰稿人把所有笔记汇总成一份最终报告交给客户。

这四步在代码里就是主图(deep_researcher)的四个节点,一条线串下来。它定义在 deep_researcher.py:701-719deep_researcher_builder:

# 示意,非源码 —— 主图的骨架(真实定义见 deep_researcher.py:707-716)
builder.add_node("clarify_with_user", ...) # ① 前台:澄清需求
builder.add_node("write_research_brief", ...) # ② 项目经理:写立项书
builder.add_node("research_supervisor", ...) # ③ 研究总监(一个子图)
builder.add_node("final_report_generation", ...) # ④ 撰稿人:写报告

builder.add_edge(START, "clarify_with_user") # 入口
builder.add_edge("research_supervisor", "final_report_generation") # 研究完 → 写报告
builder.add_edge("final_report_generation", END) # 出口

这节要留意两件"图上看不全"的事:

  • ①→② 和 ①→END 这两条边不在上面的静态边里。澄清节点用 Command(goto=...) 在运行时动态决定跳哪——这是 clarify-or-skip 路由,下面第 3 节讲。
  • research_supervisor 不是一个普通函数节点,而是另一个编译好的子图(supervisor_subgraph)整个塞进来当一个节点。这就是"三层"里的中间层,第 5 节讲。

2. 顶层全景图:节点、边、状态怎么流

先看整条主干怎么转。方向从上到下,实线是静态边,虚线是节点内 Command(goto=...) 的动态跳转:

┌──────────────────────────┐
用户消息 ──▶ │ clarify_with_user │ ① 需要澄清吗?
(messages) │ 澄清 or 跳过 │
└───────┬──────────┬───────┘
need_clar=True │ │ need_clar=False / 关闭澄清
(回一句问题) ▼ ▼ (动态 goto)
END ┌──────────────────────────┐
│ write_research_brief │ ② 对话 → research_brief
│ 定题 + 初始化 supervisor │
└───────────┬──────────────┘
│ (动态 goto,带 override 初始化)

┌──────────────────────────┐
│ research_supervisor │ ③ 子图(见 02)
│ 委派研究 + 收集 notes │
└───────────┬──────────────┘
│ (静态边)

┌──────────────────────────┐
│ final_report_generation │ ④ notes → final_report
└───────────┬──────────────┘
│ (静态边)

END

四个节点的一句话职责:

节点干什么真实符号 · 位置
clarify_with_user判断需求是否清晰;不清晰就回一句问题结束,清晰就放行clarify_with_user · deep_researcher.py:60-115
write_research_brief把整段对话压成一份 research_brief,并初始化 supervisor 的上下文write_research_brief · deep_researcher.py:118-175
research_supervisor一整个子图:主管拆任务、并行派研究员、汇总 notes/raw_notessupervisor_subgraph · deep_researcher.py:353-363
final_report_generation把所有 notes 拼成 findings,生成 final_reportfinal_report_generation · deep_researcher.py:607-697

怎么读这张图: 关键的分支只有一个——在①,要么打回去问用户(走 END,一次对话到此为止,等用户补充后重新进图),要么放行往下走。②③④ 是一条直线,没有分支。真正的复杂度(拆任务、并行、循环)全藏在③那个子图里。


3. clarify_with_user:澄清 or 跳过的路由

这节讲第一个节点怎么做"要不要问用户"的决策。

它要解决的小问题

用户开口常常太笼统:"研究一下大模型推理市场"——是指价格?厂商份额?延迟?一头扎进去研究,很可能查了半天方向不对。所以第一步是判断需求是否清晰,不清晰就先问一句。

三种走向

clarify_with_user 返回一个 Command,goto 有三种可能:

情况判断依据goto附带更新
配置里关掉了澄清allow_clarification 为假write_research_brief无,直接放行
需要澄清模型判定 need_clarification=TrueENDquestion 作为 AI 消息回给用户
不需要澄清模型判定 need_clarification=Falsewrite_research_briefverification(确认语)作为 AI 消息

配置开关是第一道闸门(deep_researcher.py:75-77):

# 真实源码 deep_researcher.py:75-77 —— 关掉澄清就直接放行,连模型都不调
if not configurable.allow_clarification:
return Command(goto="write_research_brief")

用结构化输出逼模型给出干净的三段

判断"要不要问"如果让模型自由发挥文本,下游没法可靠地解析。这里用 structured output——强制模型按一个 Pydantic 模型填字段。绑定发生在 deep_researcher.py:89-94:configurable_model.with_structured_output(ClarifyWithUser)

ClarifyWithUser 这个 schema 定在 state.py:30-41,只有三个字段:

# 真实源码 state.py:30-41 —— 结构化输出的契约
class ClarifyWithUser(BaseModel):
need_clarification: bool # 要不要问
question: str # 要问什么(need=True 时用)
verification: str # 确认语(need=False 时用,告诉用户"开始研究了")

拿到这三个字段后,节点就能无歧义地路由——need_clarification 决定 goto,questionverification 决定回给用户哪句话(deep_researcher.py:104-115)。

巧妙处: verification 字段看着多余(不需要澄清干嘛还回一句话?),其实是为了给用户一个"我理解对了、这就开始"的回执,同时把这句话写进 messages,让最终报告阶段(final_report_generation 会读 messages)也能看到这段上下文。


4. write_research_brief:把对话压成提纲,并"接管"下游上下文

这节讲第二个节点——它做两件事:定题初始化 supervisor

第一件事:对话 → 一句话立项书

同样用结构化输出,这次的 schema 是 ResearchQuestion(state.py:43-48),只有一个字段 research_brief。节点把整段 messagesget_buffer_string 拼成文本喂给模型,让它产出一份聚焦的研究提纲(deep_researcher.py:142-154):

# 真实源码 deep_researcher.py:150-154 —— 把多轮对话压成一句 research_brief
prompt_content = transform_messages_into_research_topic_prompt.format(
messages=get_buffer_string(state.get("messages", [])),
date=get_today_str())
response = await research_model.ainvoke([HumanMessage(content=prompt_content)])
# response.research_brief 就是那份提纲

这份 research_brief整个下游的唯一真相源:研究主管、每个研究员、最后写报告,都以它为准,不再回头看原始对话。把"发散的多轮对话"收敛成"一句明确的研究目标",是这一步的核心价值。

第二件事:用 override 给 supervisor 铺好第一屏上下文

难点在这:研究主管子图有它自己的消息历史(supervisor_messages),而此刻它还是空的。这个节点要往里放开场白——一条系统提示(定义主管的角色和并发/迭代上限)+ 一条人类消息(就是那份 brief)。

关键是它用了 {"type": "override"} 的写法(deep_researcher.py:163-175):

# 真实源码 deep_researcher.py:163-174 —— 注意 supervisor_messages 用 override 语义
return Command(
goto="research_supervisor",
update={
"research_brief": response.research_brief,
"supervisor_messages": {
"type": "override", # ← 不是追加,是"设定初值"
"value": [
SystemMessage(content=supervisor_system_prompt),
HumanMessage(content=response.research_brief)]}})

为什么这里必须是 override 而不是普通追加?因为这是在给一段全新的对话铺第一屏,语义是"把 supervisor 的消息历史设为这两条",而不是"往已有历史后面两条"。要理解 override 到底改变了什么,得看下一节的 reducer。


5. 为什么要三层 State:上下文隔离

这是本章最核心的设计。先看有哪三层,再讲为什么要分。

三个 State 类

系统里有三层嵌套的图(主图 → supervisor 子图 → researcher 子图),每一层各有一个 State 类:

State 类属于哪层图核心字段位置
AgentState主图messages / research_brief / notes / raw_notes / final_reportstate.py:65-72
SupervisorStatesupervisor 子图supervisor_messages / research_brief / notes / raw_notes / research_iterationsstate.py:74-81
ResearcherStateresearcher 子图researcher_messages / research_topic / compressed_research / raw_notesstate.py:83-90

注意三层各有自己的消息通道:messages(跟用户的对话)、supervisor_messages(主管的规划对话)、researcher_messages(单个研究员的 ReAct 对话)。

为什么不用一个大 State 装所有消息?

如果三层共用一个 messages,会互相污染:用户的原始对话、主管拆任务的独白、五个研究员各自的搜索记录,全挤在一起——每层调用模型时都得看到别人的一堆无关消息,既烧 token 又容易跑偏。

分三层 State = 给每层一个干净的、只装它自己关心的东西的工作台。 主图不需要看到研究员翻了哪些网页;研究员也不需要看到用户当初怎么措辞。层与层之间只通过少数几个字段传递:

AgentState.research_brief ──(write_research_brief 写入)──▶ SupervisorState 读它当开场
SupervisorState 派任务 ──(research_topic)──▶ ResearcherState 收任务
ResearcherState.raw_notes ──(累加冒泡)──▶ SupervisorState.raw_notes ──▶ AgentState.raw_notes
SupervisorState.notes ──(supervisor_tools 汇总)──▶ AgentState.notes ──▶ 写报告

research_briefnotesraw_notes 这几个字段在三层里同名共存,正是子图返回时把结果冒泡回上层的"接口"。(冒泡与汇总的细节在 02。)


6. override_reducer:同一份 State,两种更新语义

上一节留了个问题:同样是往 State 里写东西,凭什么 supervisor_messages 是"覆盖"、notes/raw_notes 是"累加"?答案在这个自定义 reducer。

先补一句:LangGraph 的 reducer 是什么

LangGraph 里,每个 State 字段可以挂一个 reducer——它决定"当一个节点返回 {字段: 新值} 时,新值怎么和旧值合并"。默认行为是替换;而 Annotated[list, operator.add] 表示用列表拼接来合并(旧列表 + 新列表)。这正是消息类字段常用累加的原因:每个节点只返回自己新产生的那几条,框架自动追加到历史后面。

override_reducer:给"累加"开一个后门

override_reducer 定在 state.py:55-60,它在 operator.add 之上加了一条特判:

# 真实源码 state.py:55-60 —— 一个字段既能累加、也能被强制覆盖
def override_reducer(current_value, new_value):
if isinstance(new_value, dict) and new_value.get("type") == "override":
return new_value.get("value", new_value) # 特殊信封 → 用 value 整个替换
else:
return operator.add(current_value, new_value) # 普通情况 → 和 operator.add 一样累加

一句话: 平时它就是累加;但如果你传进来的是 {"type": "override", "value": [...]} 这个"特殊信封",它就把整个字段替换value。这让同一个字段同时支持两种语义,由写入方按需选择。

谁用累加、谁用覆盖,以及为什么

对照 state.py:68-71 的字段声明,一眼看清设计意图:

字段reducer默认语义为什么这样选
supervisor_messagesoverride_reducer平时累加主管每轮追加自己的发言(累加);但开局要 override 铺第一屏(见第 4 节),后续 03 里 token 超限时还要用 override 裁剪历史
notesoverride_reducer累加多个研究员的笔记要攒起来汇总成报告;但写完报告后要 override 清空(deep_researcher.py:622 cleared_state)
raw_notesoverride_reducer累加同上,原始笔记从研究员一路累加冒泡到主图
research_brief无(默认替换)替换只有一份提纲,写一次即定,不需要累加
researcher_messagesoperator.add(纯累加)累加单个研究员的 ReAct 对话只增不减,不需要 override 后门

设计意图总结成一句话: 凡是"要么攒着、要么某个时刻得整个换掉/清空"的字段(消息历史、笔记),就用 override_reducer 拿到两种能力;凡是"只累加、从不整体替换"的(研究员消息),用纯 operator.add 就够;凡是"只有一份"的(brief),连 reducer 都不要,默认替换即可。

为什么覆盖不能靠"返回空列表 + 追加"实现? 因为累加 reducer 拿不到"清空"的表达能力——operator.add(old, []) 还是 old。必须有一个能识别"这是替换不是追加"的信号,{"type": "override"} 就是这个带内信号。这是本项目在 LangGraph 默认 reducer 之上最巧妙的一处扩展。


7. 边界与要点

  • 澄清是"一次性打回"而非"多轮追问"。 need_clarification=True 时节点直接 goto=END(deep_researcher.py:104-109),把问题抛回给用户就结束了本次运行;用户补充后是重新进图,而不是图内挂起等待。
  • write_research_brief 之后就不再看原始对话了。 下游只认 research_brief。若这份提纲跑偏,后面查得再多也白搭——它是整条链的"单点"。
  • override 是"带内约定"不是框架原语。 {"type": "override"} 只是 override_reducer 自己认的一个字典约定(state.py:57),换个 reducer 就不认了;读代码时看到这个信封,一定是配了 override_reducer 的字段。
  • 主管/研究员内部的循环、并行 fan-out、迭代上限,本章不展开,见 02;压缩、token 超限重试与最终报告生成见 03

8. 代码地图(导航索引)

主题文件路径符号
主图四节点 + 静态边deep_researcher.py:701-719deep_researcher_builder / deep_researcher
澄清 or 跳过路由deep_researcher.py:60-115clarify_with_user
澄清结构化输出契约state.py:30-41ClarifyWithUser
对话 → 立项书 + 初始化 supervisordeep_researcher.py:118-175write_research_brief
立项书结构化输出契约state.py:43-48ResearchQuestion
supervisor 子图(作为主图一个节点)deep_researcher.py:353-363supervisor_subgraph
最终报告生成deep_researcher.py:607-697final_report_generation
自定义 reducer(覆盖 vs 累加)state.py:55-60override_reducer
三层 Statestate.py:62-96AgentState / SupervisorState / ResearcherState
可配置模型初始化deep_researcher.py:56-58configurable_model