数据截至 (上游 commit 1b7d2e80db9f)
研究引擎:supervisor 委派 + researcher ReAct 循环 + 并行 fan-out
30 秒导读: 这是 Open Deep Research 的价值核心。一个 supervisor(研究主管)把一个大课题 拆成若干子课题,通过
asyncio.gather并行拉起多个 researcher(研究员);每个 researcher 自己又跑一个 ReAct 工具循环去搜资料。两层 LangGraph 子图层套层——这就是"深度研究"能同时铺开 又能逐点钻透的引擎。
本章上承 01-orchestration-graph.md(三层编排骨架),下接
03-compression-token-limits.md(研究压缩与容错)和
04-tools-search-mcp.md(工具装配)。本章只讲两个 ReAct 循环怎么转、怎么套;
compress_research、token 重试、工具怎么装配,分别留给 03 和 04。
1. 这是什么(先建直觉)
1.1 一个类比:主管派活,员工干活
把研究引擎想象成一间小型研究所:
- supervisor(研究主管)——拿到一份研究简报(research brief),不亲自搜资料。它只做三件事: 想(think_tool 反思)、派活(ConductResearch 委派子课题)、收工(ResearchComplete 宣布完成)。
- researcher(研究员)——每个只负责主管派下来的一个子课题,拿着搜索工具反复搜、反复读, 直到攒够料,把结果压缩成一段摘要交回去。
主管可以一次派好几个员工同时干——这就是并行 fan-out(扇出)。
1.2 为什么要两层循环
单层 agent 做深度研究会遇到两个矛盾:
| 想要 | 单层 agent 的问题 |
|---|---|
| 覆盖广(一个课题有很多子方面) | 全塞进一个上下文,越搜越长,很快撑爆 |
| 钻得深( 每个子方面要多轮搜索) | 串行做完所有子方面,慢 |
两层循环把"规划"和"执行"分开,顺带解决这两点:
- 广:supervisor 把课题切成互相独立的子课题,分给不同 researcher,各自有独立上下文。
- 深 + 快:每个 researcher 独立跑多轮 ReAct,而多个 researcher 之间并行(
asyncio.gather)。 - 上下文可控:researcher 干完先自己压缩成短摘要再回传,主管的上下文不会被原始搜索结果撑爆。
1.3 两个 ReAct 循环,一句话各自的样子
两个循环都是经典 ReAct(Reason + Act:模型思考→调工具→看结果→再思考)结构,只是工具不同:
| 循环 | 节点对 | 可用工具 | 出口去哪 |
|---|---|---|---|
| supervisor 循环 | supervisor ↔ supervisor_tools | ConductResearch / ResearchComplete / think_tool | 研究阶段结束,回主图去写报告 |
| researcher 循环 | researcher ↔ researcher_tools | 搜索工具 / MCP 工具 / think_tool / ResearchComplete | compress_research 压缩 |
关键点:supervisor 循环的一个 "Act"(调 ConductResearch)= 拉起一整个 researcher 循环。这就是套 层。
2. 顶层全景(两层怎么套在一起)
怎么读这张图: 从上往下是调用深度。虚线框 = 一个 LangGraph 子图。supervisor 子图里的一次
ConductResearch 会 fan-out 成 N 个 researcher 子图并行跑;每个 researcher 子图内部又是一个自循环。
research_brief(研究简报,来自主图 write_research_brief)
│
┌────────────────▼─────────────────────────┐
│ supervisor 子图 (supervisor_subgraph) │
│ │
│ ┌──────────┐ 带 3 个工具 ┌──────────┐ │
│ │supervisor│───────────────▶│supervisor│ │
│ │ (规划) │◀───────────────│ _tools │ │
│ └──────────┘ 循环回来 └────┬─────┘ │
│ │ │
│ ConductResearch 分组 │ │
│ asyncio.gather 并行 fan-out │
└─────────────────────┬──────────────┬────────┘
│ │
┌────────────────▼──┐ ┌───────▼───────────┐
│ researcher 子图 #1 │ │ researcher 子图 #2 │ ...(至多 N 个)
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │researcher│ │ │ │researcher│ │
│ │ (搜) │◀─┐ │ │ │ (搜) │◀─┐ │
│ └────┬─────┘ │ │ │ └────┬─────┘ │ │
│ ▼ │ │ │ ▼ │ │
│ ┌──────────┐ │ │ │ ┌──────────┐ │ │
│ │researcher│──┘ │ │ │researcher│──┘ │
│ │ _tools │ │ │ │ _tools │ │
│ └────┬─────┘ │ │ └────┬─────┘ │
│ ▼ 到点 │ │ ▼ │
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │ compress │ │ │ │ compress │ │
│ └──────── ──┘ │ │ └──────────┘ │
└───────────────────┘ └───────────────────┘
│ │
└──── compressed_research 回传 ────┘
(作为 ToolMessage 塞回主管上下文)
部件职责一句话:
| 部件 | 干什么 | 源码 |
|---|---|---|
supervisor | 带 3 个工具调一次 LLM,产出 tool_calls,research_iterations +1 | deep_researcher.py:178-223 |
supervisor_tools | 判退出;分组处理 think/ConductResearch;并行 fan-out;聚合 | deep_researcher.py:225-349 |
supervisor_subgraph | supervisor ↔ supervisor_tools 组成的子图 | deep_researcher.py:351-363 |
researcher | 绑定所有工具调一次 LLM,tool_call_iterations +1 | deep_researcher.py:365-424 |
researcher_tools | 判退出;并行执行工具;继续或去压缩 | deep_researcher.py:435-509 |
researcher_subgraph | researcher ↔ researcher_tools ↔ compress 组成的子图 | deep_researcher.py:587-605 |
3. supervisor 循环:规划与委派
本节讲清主管怎么规划、怎么并行派活、什么时候收工。
3.1 主管的三个工具
supervisor 节点每次只绑定三个工具,别无其他——它自己不搜索(deep_researcher.py:202
lead_researcher_tools):
| 工具 | 语义 | 定义处 |
|---|---|---|
think_tool | 战略反思:记录"我现在有什么、还缺什么、下一步搜什么"(不产生真实动作) | utils.py:220 think_tool |
ConductResearch | 委派一个子课题给 researcher;参数 research_topic(要求"至少一段"的详细描述) | state.py:15-19 ConductResearch |
ResearchComplete | 宣布研究阶段结束 | state.py:21-22 ResearchComplete |
supervisor 本身很薄:配置好带工具的模型,对 supervisor_messages 调一次 LLM,把回复塞回状态、
research_iterations 计数 +1,然后固定跳到 supervisor_tools(deep_researcher.py:212-223)。
3.2 退出条件:三选一就收工
supervisor_tools 开头先算三个退出旗标,任意一个为真就结束整个研究阶段
(deep_researcher.py:247-262):
| 旗标 | 触发条件 | 含义 |
|---|---|---|
exceeded_allowed_iterations | research_iterations > max_researcher_iterations | 反思轮数超限(默认 6,configuration.py:94) |
no_tool_calls | 最近消息没有任何 tool_calls | 模型没派活也没喊完成,视为无事可做 |
research_complete_tool_call | tool_calls 里出现 ResearchComplete | 主管主动宣布完成 |
命中任一 → goto=END,并把 get_notes_from_tool_calls(supervisor_messages) 抽出的笔记写进
notes(deep_researcher.py:255-262)。get_notes_from_tool_calls 就是把所有 ToolMessage 的
content 收集成列表(utils.py:599-601)——也就是各 researcher 回传的压缩摘要,它们将成为主图写最终报告的素材。
注意 iterations 的语义:
supervisor每跑一次就 +1,而只有一次think_tool也算一轮。 所以主管每反思一次都在消耗预算——这逼它别空想,尽早派活。
3.3 分组处理:think 归 think,派活归派活
没 退出的话,supervisor_tools 把最近一条消息里的 tool_calls 按名字分成两组分别处理
(deep_researcher.py:264-292):
- think_tool 组:每个直接生成一条 ToolMessage,内容是
Reflection recorded: {reflection}——纯粹把反思"记录在案"接回对话,好让下一轮 supervisor 看得到自己的思考(deep_researcher.py:269-280)。 - ConductResearch 组:这些才触发真正的并行研究(下一节)。
两组产生的 ToolMessage 最后合并进 all_tool_messages 一起回传(deep_researcher.py:345-348)。
3.4 并行 fan-out:截断 + gather + overflow 报错
这是引擎最"扇出"的一步。拿到 ConductResearch 组后,分三步走
(deep_researcher.py:288-330):
① 截断到并发上限。 按 max_concurrent_research_units(默认 5,configuration.py:64)切成
"允许跑的"和"溢出的"两半(deep_researcher.py:291-292):
# 真实源码 deep_researcher.py:291-292
allowed_conduct_research_calls = conduct_research_calls[:configurable.max_concurrent_research_units]
overflow_conduct_research_calls = conduct_research_calls[configurable.max_concurrent_research_units:]
② 并行拉起 researcher 子图。 对每个允许的调用,构造一次 researcher_subgraph.ainvoke(...)
协程,asyncio.gather 一把全等回来(deep_researcher.py:295-305)。注意传给子图的初始状态:把
research_topic 既包成一条 HumanMessage 放进 researcher_messages,又单独存一份——这就是子课题的入口。
# 真实源码 deep_researcher.py:295-305(节选)
research_tasks = [
researcher_subgraph.ainvoke({
"researcher_messages": [HumanMessage(content=tool_call["args"]["research_topic"])],
"research_topic": tool_call["args"]["research_topic"]
}, config)
for tool_call in allowed_conduct_research_calls
]
tool_results = await asyncio.gather(*research_tasks)
③ 结果 → ToolMessage;溢出 → 错误 ToolMessage。 每个 researcher 回传的
compressed_research 变成一条 ToolMessage 接回主管上下文;若压缩失败则填占位错误串
(deep_researcher.py:308-313)。被截掉的溢出调用不是静默丢弃——每个都回一条明确的错误
ToolMessage,告诉模型"你派多了,请把并发降到 N 以内重试"(deep_researcher.py:316-321)。这条错误
会进入下一轮 supervisor 的上下文,等于教模型下次少派点。
3.5 raw_notes 聚合与错误兜底
raw_notes 聚合: 每个 researcher 除了压缩摘要,还回传一份未压缩的 raw_notes。主管把所有
researcher 的 raw_notes 拼成一大串,非空才写进 raw_notes(deep_researcher.py:324-330)。
raw_notes 用 override_reducer 语义(state.py:81),它是"压缩摘要之外"的原始存档
(压缩本身留给 03)。
错误兜底: 整个 fan-out 包在 try/except 里。异常时判断分支写着
if is_token_limit_exceeded(...) or True(deep_researcher.py:334)——or True 让任何异常
都走同一条路:直接 goto=END,把已有笔记落进 notes(deep_researcher.py:332-342)。也就是说
一旦并行研究出错,当前实现就结束研究阶段、带着已有成果去写报告,而不是重试。
正常情况下,supervisor_tools 处理完把 all_tool_messages 写回 supervisor_messages、
goto="supervisor",回到主管再规划下一轮(deep_researcher.py:344-349)。
3.6 supervisor 子图的构建
子图很小:两个节点 + 一条入边(deep_researcher.py:351-363)。循环靠 Command(goto=...)
在节点里动态跳转,而非静态边:
# 真实源码 deep_researcher.py:353-363(节选)
supervisor_builder = StateGraph(SupervisorState, config_schema=Configuration)
supervisor_builder.add_node("supervisor", supervisor)
supervisor_builder.add_node("supervisor_tools", supervisor_tools)
supervisor_builder.add_edge(START, "supervisor")
supervisor_subgraph = supervisor_builder.compile()
编译出的 supervisor_subgraph 在主图里作为 research_supervisor 节点使用(见 01)。
4. researcher 循环:单点深挖的 ReAct
本节讲每个被 fan-out 出来的 researcher 内部怎么转。结构和 supervisor 循环对称,但工具是真的搜索工具。
4.1 researcher 节点:绑定全部工具
researcher 先 get_all_tools(config) 装配所有可用工具(搜索 / MCP / think_tool 等,细节见
04),空则直接报错(deep_researcher.py:384-389)。然后绑定工具、拼上
system prompt(含 MCP 上下文)对 researcher_messages 调一次 LLM,tool_call_iterations +1,固定跳到
researcher_tools(deep_researcher.py:406-424)。