跳到主要内容

05 · 深度研究 Agent:可恢复的研究状态机

这是仓库里最大、最独立的一块(src/agent/deep_research/deep_research_agent.py,1261 行)。它不操作单个网页,而是编排一整场调研:把题目拆成分类任务、逐个去网上查、最后综合成报告。底层每次“查”都开一个 03 章那样的 BrowserUseAgent。本章按“它是什么→状态机长什么样→三节点→可恢复→并行搜索”推进。

5.1 这是什么(零基础)

给它一个题目(topic),它会自动:

  1. 生成研究计划:让 LLM 把题目拆成若干“分类 + 每类几个具体子任务”。
  2. 逐任务执行:对每个子任务,让 LLM 决定搜什么,调“并行浏览器搜索”工具去查。
  3. 综合报告:把所有发现喂给 LLM,写出一份 Markdown 报告。

产物落在 ./tmp/deep_research/{task_id}/ 下三个文件:research_plan.md(计划+进度)、search_info.json(搜集到的原始结果)、report.md(最终报告)(常量见 deep_research_agent.py:42-44)。

5.2 状态机全景(LangGraph)

LangGraph(把 agent 流程建成“节点+条件边”的状态图)编排。图在 _compile_graph 里定义(deep_research_agent.py:1050-1083):

┌──────────────┐
入口 → │ plan_research │ 生成/恢复研究计划
└──────┬───────┘
▼ (总是)
┌──────────────────┐
┌──→ │ execute_research │ 执行“当前指向的那个子任务”
│ └──────┬───────────┘
│ ▼ should_continue 判路
│ 还有待办任务? ──是──┘ (循环回自己)
│ │否
│ ▼
│ ┌───────────────────┐
│ │ synthesize_report │ 综合成报告
│ └──────┬────────────┘
│ ▼
└────────→ end_run (stop_requested / 严重错误 也直接到这)

所有节点共享一个 DeepResearchState(deep_research_agent.py:318-332)——里面带 research_plansearch_results、两个游标 current_category_index / current_task_index_in_categorymessages(LLM 对话历史)、stop_requested 等。两个游标是整台状态机的“程序计数器”:指向“下一个该处理的任务”。

5.3 节点一:planning_node —— 拆题成层级计划

要解决的小问题:把模糊题目变成可执行的、结构化的任务清单。

思路:给 LLM 一段带 JSON 示例的提示词,要它输出“3–10 个分类、每类 2–6 个任务”的 JSON 数组(deep_research_agent.py:481-521)。拿到后做两件防御:

  • 剥掉 LLM 爱加的代码围栏:若以 ```json 开头就切掉(deep_research_agent.py:531-534)。
  • 容忍任务的多种形态:任务可能是纯字符串,也可能被 LLM 包成 {"task":...}{"task_description":...},都兼容(deep_research_agent.py:547-578)。

解析成功就把计划写进 research_plan.md(_save_plan_to_md)并把两个游标归零(deep_research_agent.py:592-599)。若已有计划且游标不在起点,则视为恢复,直接沿用(deep_research_agent.py:472-477)。

5.4 节点二:research_execution_node —— 一次只啃一个任务

这是最核心的节点(deep_research_agent.py:609-808)。每次进来只处理“游标指向的那一个子任务”:

进入 execute_research:
按游标取出 current_task :632-643
若已 completed → 游标 +1, 返回(跳过) :645-658
llm.bind_tools(tools) 后,带“当前任务提示”调 LLM :664-687
├─ LLM 没调工具 → 标记并返回 :694-702
└─ LLM 调了工具 → 逐个执行 :704-751
执行前检查 stop_event,置位则存盘后中断 :722-728
parallel_browser_search 的结果并入 search_results :734-735
其它工具结果也记进 search_results :739-741
按是否出错把 task 标 completed/failed :753-768
存 plan.md + search_info.json :771-772
游标推进到下一个任务(跨分类则进位) :775-779
把本轮对话(ai_response + tool 结果)并进 messages :781

两个设计点:

  • 任务级对话累积:messages 在任务间累积(deep_research_agent.py:781),让后续任务能看到前面查到的东西,但每个任务的提示词又强调“只聚焦当前任务”(deep_research_agent.py:667-673)。
  • 每步都存盘:每处理完一个任务就把计划和结果写盘(deep_research_agent.py:771-772)——这正是“可恢复”的物质基础。

5.5 可恢复:把断点编码进 Markdown 复选框

这是整章最妙的点。进度不是存在某个数据库,而是直接写在 research_plan.md 的复选框里:- [ ] 待办、- [x] 完成、- [-] 失败(_save_plan_to_md, deep_research_agent.py:421-435)。

恢复时(传 task_id),_load_previous_state 反向解析这个 Markdown:逐行扫,## 是分类,- [ ]/[x]/[-] 是任务,找到第一个 - [ ](待办)就把两个游标定位到它(deep_research_agent.py:355-394):

# 真实逻辑(节选),deep_research_agent.py:365-381
if line.startswith("- [ ]"):
status = "pending"
elif line.startswith("- [x]"):
status = "completed"
elif line.startswith("- [-]"):
status = "failed"
...
if status == "pending" and not found_pending:
next_cat_idx = cat_counter # 把游标定位到第一个待办任务
next_task_idx = task_counter_in_cat
found_pending = True

于是“续跑”= 读回 Markdown → 算出游标 → 状态机从那个任务继续。人类可读的进度文件同时就是机器的断点,无需额外持久化层。

顺带:runsave_dir 做了路径逃逸校验,强制落在 ./tmp/deep_research 下,防止写到任意目录(deep_research_agent.py:1114-1120)。

5.6 条件边:should_continue 怎么判“接着查还是去写报告”

执行节点之后由 should_continue 路由(deep_research_agent.py:938-976)。逻辑直白:停止/严重错误 → end_run;游标还落在某个有效任务上 → 继续 execute_research;游标越界(所有分类任务都处理过)→ synthesize_report(deep_research_agent.py:956-976)。

5.7 并行浏览器搜索:研究 agent 套着浏览器 agent

执行节点用的关键工具是 parallel_browser_search(create_browser_search_tool, deep_research_agent.py:271-298)。它接受一批 query,内部用 asyncio.Semaphore 限并发,每个 query 开一个独立的 BrowserUseAgent 去查(_run_browser_search_toolrun_single_browser_task, deep_research_agent.py:50-268):

parallel_browser_search([q1, q2, ...])
│ 截断到 max_parallel_browsers 个 :216
│ Semaphore(max_parallel_browsers) 限并发 :222
▼ 对每个 q 起 run_single_browser_task:
造 CustomBrowser + context :95-115
造 BrowserUseAgent(task=带“给摘要/标题/URL”的提示) :122-141
await agent.run() → 取 final_result() :159-162
finally 关 context 和 browser :176-193
▼ asyncio.gather 收集所有结果(异常也收) :241-263

所以层级是:DeepResearchAgent(LangGraph) → 调 parallel_browser_search 工具 → 并发开 N 个 BrowserUseAgent → 每个再驱动上游 browser-use 引擎。一个高层研究 agent,真的把低层网页 agent 当“工人”用。

max_parallel_browsers 默认 1(deep_research_agent.py:276),超出的 query 直接截断——所以默认其实是串行,想真并行要在 UI 调大。

5.8 节点三:synthesis_node —— 把发现写成报告

综合节点(deep_research_agent.py:811-932)把 search_results 按“成功的浏览器搜索 / 成功的其它工具 / 失败项”分类格式化成文本,再拼上“走过的研究计划”概要,塞进一个系统+人类的 ChatPromptTemplate,让 LLM 写报告(deep_research_agent.py:872-913)。报告落到 report.md。无结果时直接写一份“未收集到信息”的占位报告(deep_research_agent.py:824-828)。

5.9 停止机制:全局 flag 字典 + 拦截

停止靠两个模块级字典:_AGENT_STOP_FLAGS(task_id → threading.Event)与 _BROWSER_AGENT_INSTANCES(deep_research_agent.py:46-47)。stop() 把对应 Event 置位、并去 _BROWSER_AGENT_INSTANCES 里把还在跑的浏览器 agent 逐个 await agent.stop()(deep_research_agent.py:1226-1258)。执行节点在调工具前会检查这个 Event(deep_research_agent.py:722-728),搜索工具的每个 task wrapper 也会检查(deep_research_agent.py:226-230)——多点拦截确保能及时停。

UI 侧(deep research tab)则轮询 research_plan.md 的修改时间来实时刷新计划展示(deep_research_agent_tab.py:191-213)——又一次“Markdown 当通信媒介”。

巧妙之处

  • Markdown 复选框即断点:人读的进度文件同时是机器的恢复点,反向解析 - [ ] 定位游标,无需独立持久化(_load_previous_state, _save_plan_to_md)。
  • 双层 agent 嵌套:研究 agent 把浏览器 agent 当工具并发调用(create_browser_search_toolrun_single_browser_task)。
  • 游标即程序计数器:两个 index 完整表达“进行到哪”,节点无状态、只读写共享 state(should_continue + 各节点)。
  • 文件 mtime 当事件源:UI 靠监听 plan 文件的修改时间增量刷新,而非和 agent 直接通信(deep_research_agent_tab.py:202-205)。
  • 路径逃逸防护:save_dir 被钉死在安全根目录内(deep_research_agent.py:1114-1120)。

边界与局限

  • 默认串行:max_parallel_browsers=1 且超额 query 被截断(deep_research_agent.py:216),“并行”需手动调大。
  • 任务完成判定粗糙:只要调了工具没报错就标 completed(deep_research_agent.py:762-764),并不校验“是否真查到了有用信息”;TODO 注释也承认想让 LLM 二次总结(deep_research_agent.py:765)。
  • References 形同虚设:综合节点保留了一段引用列表代码,但 references 字典从未被填充(deep_research_agent.py:837/916-924),所以报告没有真正的来源引用。
  • 全局字典=单进程假设:_AGENT_STOP_FLAGS 等是模块级全局,不适合多进程部署。

横向对比

  • 与本仓库 BrowserUseAgent(03 章)相比:那是“一个任务一个浏览器循环”,这是“一场调研、多个浏览器循环 + 计划/综合”,高了一层编排。
  • 与通用 deep-research 类 agent 相比:本实现的特色是“用真实浏览器 agent 取代纯 API 搜索”+“Markdown 即断点”,牺牲了引用溯源(References 未实现)换取了人可读、可续跑。

代码地图

主题文件符号
Agent 入口/编排src/agent/deep_research/deep_research_agent.pyDeepResearchAgent, run, _compile_graph
计划节点src/agent/deep_research/deep_research_agent.pyplanning_node
执行节点src/agent/deep_research/deep_research_agent.pyresearch_execution_node
综合节点src/agent/deep_research/deep_research_agent.pysynthesis_node
路由条件src/agent/deep_research/deep_research_agent.pyshould_continue
可恢复:存/读进度src/agent/deep_research/deep_research_agent.py_save_plan_to_md, _load_previous_state
并行浏览器搜索src/agent/deep_research/deep_research_agent.pycreate_browser_search_tool, _run_browser_search_tool, run_single_browser_task
停止机制src/agent/deep_research/deep_research_agent.pystop, _stop_lingering_browsers, _AGENT_STOP_FLAGS
UI 监控(mtime 轮询)src/webui/components/deep_research_agent_tab.pyrun_deep_research, _read_file_safe