跳到主要内容

研究引擎: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 循环supervisorsupervisor_toolsConductResearch / ResearchComplete / think_tool研究阶段结束,回主图去写报告
researcher 循环researcherresearcher_tools搜索工具 / MCP 工具 / think_tool / ResearchCompletecompress_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 +1deep_researcher.py:178-223
supervisor_tools判退出;分组处理 think/ConductResearch;并行 fan-out;聚合deep_researcher.py:225-349
supervisor_subgraphsupervisor ↔ supervisor_tools 组成的子图deep_researcher.py:351-363
researcher绑定所有工具调一次 LLM,tool_call_iterations +1deep_researcher.py:365-424
researcher_tools判退出;并行执行工具;继续或去压缩deep_researcher.py:435-509
researcher_subgraphresearcher ↔ 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_iterationsresearch_iterations > max_researcher_iterations反思轮数超限(默认 6,configuration.py:94)
no_tool_calls最近消息没有任何 tool_calls模型没派活也没喊完成,视为无事可做
research_complete_tool_calltool_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):

  1. think_tool 组:每个直接生成一条 ToolMessage,内容是 Reflection recorded: {reflection} ——纯粹把反思"记录在案"接回对话,好让下一轮 supervisor 看得到自己的思考(deep_researcher.py:269-280)。
  2. 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_notesoverride_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_messagesgoto="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 节点:绑定全部工具

researcherget_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)。

4.2 早退:没有工具调用就去压缩

researcher_tools 先看最近消息有没有"要执行的动作"。这里有个易漏的点:除了普通 tool_calls, 还要检测原生网页搜索(deep_researcher.py:457-464):

# 真实源码 deep_researcher.py:457-464(节选)
has_tool_calls = bool(most_recent_message.tool_calls)
has_native_search = (
openai_websearch_called(most_recent_message) or
anthropic_websearch_called(most_recent_message)
)
if not has_tool_calls and not has_native_search:
return Command(goto="compress_research")

为什么要单独检测?因为 OpenAI / Anthropic 的原生 web search 不走标准 tool_calls 协议——它由模型 内部完成,不会在 tool_calls 里留下条目(utils.py:607 anthropic_websearch_calledutils.py:639 openai_websearch_called)。若只看 tool_calls,用原生搜索时会被误判为"没干活"而 提前结束。这一步保证:模型确实搜了,就继续循环。

4.3 并行执行工具

真有 tool_calls 时,按名字查到工具对象,对每个调用起一个 execute_tool_safely 协程,asyncio.gather 并发跑(deep_researcher.py:467-479)。execute_tool_safely 只是包了 try/except——工具报错不会炸掉 整个 researcher,而是把错误串当作观测结果返回(deep_researcher.py:427-432)。每个结果包成一条 ToolMessage(deep_researcher.py:482-489)。

tools_by_nametool.name,拿不到就退回 "web_search"(deep_researcher.py:468-471)—— 这是为原生搜索留的名字映射兜底。

4.4 晚退:到点就去压缩

执行完工具后再判两个退出条件(deep_researcher.py:491-503):

条件触发
exceeded_iterationstool_call_iterations >= max_react_tool_calls(默认 10,configuration.py:107)
research_complete_called本轮 tool_calls 里出现 ResearchComplete

任一为真 → 带着本轮工具结果 goto="compress_research";否则带结果 goto="researcher" 继续下一轮 (deep_researcher.py:498-509)。注意退出也会把这一轮的工具结果一起带上,不丢数据。

两处退出点的对称性: researcher 有"早退"(没动作,4.2)和"晚退"(到点/喊停,4.4)两个 出口,都通向 compress_research。supervisor 只在进 supervisor_tools 时判一次退出(3.2)。

4.5 researcher 子图的构建

比 supervisor 多一个节点(压缩)和一条出边(deep_researcher.py:587-605):

# 真实源码 deep_researcher.py:589-605(节选)
researcher_builder = StateGraph(
ResearcherState, output=ResearcherOutputState, config_schema=Configuration
)
researcher_builder.add_node("researcher", researcher)
researcher_builder.add_node("researcher_tools", researcher_tools)
researcher_builder.add_node("compress_research", compress_research)
researcher_builder.add_edge(START, "researcher")
researcher_builder.add_edge("compress_research", END)
researcher_subgraph = researcher_builder.compile()

关键:声明了 output=ResearcherOutputState(state.py:92-96),只有 compressed_researchraw_notes 两个字段对外可见。所以 §3.4 里 asyncio.gather 拿到的 observation 才干净地只含这两项, 主管取 observation.get("compressed_research", ...) 正好对上。这也是"层套层"能干净对接的原因: 子图的输出契约恰好是父图 ConductResearch 需要的东西


5. 两个循环怎么协作(端到端走一遍)

把两层串起来,一次典型研究是这样流转的:

1. write_research_brief 交给 supervisor 子图一份 research_brief
2. supervisor → 调 LLM,吐出 [think_tool, ConductResearch×3]
3. supervisor_tools→ 没命中退出条件
· think_tool 组:记 1 条反思 ToolMessage
· ConductResearch 组:3 个 ≤ 5(并发上限),全部允许
· asyncio.gather 并行拉起 3 个 researcher 子图 ↓↓↓
┌─ researcher#1: researcher↔researcher_tools 多轮搜 → compress → 摘要A
├─ researcher#2: …… → compress → 摘要B
└─ researcher#3: …… → compress → 摘要C
· 3 条 compressed_research 变成 3 条 ToolMessage 接回
· 3 份 raw_notes 拼接写入 raw_notes
4. supervisor → 看到 3 段摘要,再调 LLM:够了 → ConductResearch? 还是 ResearchComplete?
5. 若 ResearchComplete(或 iterations 超 6):supervisor_tools 命中退出
→ notes = 各摘要,goto=END,研究阶段结束,回主图写报告

一句话抓住本质: supervisor 循环的一次"动作"可以炸开成一排 researcher 循环;每个 researcher 循环自己又转好几轮。外层管广度与调度,内层管深度与执行,靠 asyncio.gather 把广度变成并行、靠 子图 output 契约把内层结果干净收回外层。


6. 巧妙之处(可借鉴)

  • 规划与执行彻底分层。 supervisor 不碰搜索工具,只有 think/派活/收工三件事 (deep_researcher.py:202)。职责单一让主管的上下文只装"子课题 + 各子课题的摘要",不被原始搜索 结果污染。

  • 溢出不静默丢,而是回教育性错误。 超并发上限的 ConductResearch 每个回一条"你派多了,降到 N 以内 重试"的 ToolMessage(deep_researcher.py:316-321),把约束反馈进模型下一轮上下文,而不是悄悄截断。

  • 原生 web search 单独探测。 has_native_search 让原生搜索(不落 tool_calls)不被误判为"空转" (deep_researcher.py:457-464),这是跨 provider 兼容的关键小心思。

  • 子图 output 契约让层套层无缝对接。 ResearcherOutputState 只暴露 compressed_research/raw_notes(state.py:92-96),恰好是父图 asyncio.gather 后要取的两项。

  • 单个工具失败不炸全局。 execute_tool_safely 把异常降级成一条错误观测 (deep_researcher.py:427-432),一个搜索挂了不影响同批其他工具。


7. 边界与局限(诚实)

  • fan-out 失败即终止研究。 supervisor_tools 的 except 分支写死 ... or True (deep_researcher.py:334),任何并行异常都直接 goto=END,不重试、不部分补偿——拿现有笔记去写报告。

  • 溢出的子课题不排队。 超过 max_concurrent_research_units 的调用只回错误消息,不会被自动 排到下一批;是否重派完全取决于模型下一轮是否照做(deep_researcher.py:316-321)。

  • iterations 把 think 也算进预算。 supervisor 每次调用都 +1(deep_researcher.py:221), 连纯 think_tool 轮也计数;反思太多会更早撞上 max_researcher_iterations

  • 子课题独立 = 无横向共享。 各 researcher 子图上下文彼此隔离,一个的发现不会实时喂给另一个; 只有各自压缩后的摘要在 supervisor 处汇合。


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

主题文件符号名
supervisor 规划节点(绑 3 工具)deep_researcher.py:178-223supervisor
supervisor 工具节点(退出/分组/fan-out/聚合)deep_researcher.py:225-349supervisor_tools
supervisor 子图构建deep_researcher.py:351-363supervisor_subgraph
researcher 规划节点(绑全部工具)deep_researcher.py:365-424researcher
单工具容错执行deep_researcher.py:427-432execute_tool_safely
researcher 工具节点(早退/并行/晚退)deep_researcher.py:435-509researcher_tools
researcher 子图构建deep_researcher.py:587-605researcher_subgraph
委派工具 schemastate.py:15-22ConductResearch / ResearchComplete
战略反思工具utils.py:220think_tool
从 ToolMessage 抽笔记utils.py:599-601get_notes_from_tool_calls
原生搜索探测utils.py:607 / utils.py:639anthropic_websearch_called / openai_websearch_called
supervisor/researcher 状态state.py:74-96SupervisorState / ResearcherState / ResearcherOutputState
并发/迭代上限配置configuration.py:64,94,107max_concurrent_research_units / max_researcher_iterations / max_react_tool_calls