Agent 速成笔记 · 第 14 章 自动化深度研究智能体
Agent 速成笔记 · 第 14 章 自动化深度研究智能体
源:Datawhale《Hello-Agents》第 14 章 | 定位:实战案例(全栈多智能体系统) | 一句话:把"研究一个开放主题"这件发散的事,收敛成「规划 3-5 个 TODO → 逐条搜索总结 → 整合成带引用的长报告」的确定性流水线。
0. 一章速览(30 秒)
- 一句话:深度研究助手 = TODO 驱动的三阶段流水线(规划 / 执行 / 报告),由三个专职 Agent 顺序接力,两个工具(SearchTool、NoteTool)负责取数与落盘,SSE 把全过程实时推给前端。
- 本章解决什么问题:搜索引擎只能回答"单个问题",而真实研究需要回答"一系列相关问题"。本章给出一种可工程化的范式,把开放主题变成有结构、可追溯、可复现的报告。
- 必须记住的 5 个点:
- TODO 驱动是范式内核:复杂主题 → 3-5 个子任务(title / intent / query)→ 逐个执行 → 整合。太少覆盖不全,太多冗余。
- 三 Agent 顺序协作:TODO Planner、Task Summarizer、Report Writer,各配一套专用 Prompt,无并发。
ToolAwareSimpleAgent在SimpleAgent上加了tool_call_listener回调,让"工具调用"变成可观测事件(调试 / 日志 / 进度)。- 长报告的关键是"分而治之的上下文压缩":每个子任务的搜索结果先被压成一段带引用的 Markdown 总结,报告 Agent 只读总结、不读原始网页。
- 质量控制靠三层:来源引用标记
[1][2]、URL 去重与重复信息合并、搜索失败降级返回空列表而不中断流程。
1. 项目概述与架构设计(14.1)
1.1 为什么需要深度研究助手
传统研究的三个痛点:信息过载(搜索引擎返回成千上万结果,逐个点开)、缺少结构(找到的信息碎片化)、重复劳动(每次研究都重走"搜索→阅读→总结→整理")。
深度研究助手的核心价值有四点:
| 价值 | 含义 |
|---|---|
| 节省时间 | 把 1-2 小时的研究压缩到 5-10 分钟 |
| 提高质量 | 系统化流程,避免遗漏重要信息 |
| 可追溯 | 记录所有搜索结果与来源,便于验证引用 |
| 可扩展 | 可新增搜索引擎、数据源、分析工具 |
与第 13 章旅行助手相比,深度研究的难点在于信息不断发散、事实快速更新、用户对引用来源要求高。因此智能体必须具备三个核心能力:
- 问题剖析:把开放主题拆解成可检索的查询语句。
- 多轮信息采集:结合不同搜索 API 持续挖掘,并去重整合。
- 反思与总结:依据阶段结果识别知识空白,判断是否继续检索,并生成结构化总结。
1.2 四层架构与一次请求的完整流转
系统采用前后端分离的四层架构:
| 层次 | 技术栈 | 职责 |
|---|---|---|
| 前端层 | Vue3 + TypeScript | 全屏模态对话框 UI、Markdown 结果可视化 |
| 后端层 | FastAPI | API 路由(/research/stream) |
| 智能体层 | HelloAgents | 三个 Agent + 两个工具 |
| 外部服务层 | 搜索引擎 + LLM 提供商 | 提供检索与生成能力 |
智能体层里的固定角色:三个专门 Agent(TODO Planner、Task Summarizer、Report Writer)+ 两个核心工具(SearchTool、NoteTool)。
一次研究请求在系统中的流转是八个步骤,这一段就是全章骨架,值得背下来:
- 用户输入:在前端输入研究主题。
- 前端发送:通过 SSE 连接到
/research/stream。 - 后端接收:FastAPI 接收请求,创建研究状态。
- 规划阶段:调用研究规划 Agent,分解为 3 个子任务。
- 执行阶段:逐个执行子任务——SearchTool 搜索 → 任务总结 Agent 总结 → NoteTool 记录结果。
- 报告阶段:调用报告生成 Agent,整合所有总结。
- 流式返回:通过 SSE 推送进度与结果到前端。
- 前端展示:实时更新任务状态、进度条、日志、报告。
工程要点:一次完整研究约需 1-3 分钟(取决于主题复杂度与搜索响应速度)。这个量级决定了产品形态——必须做流式进度,否则用户会以为页面卡死。
1.3 后端目录结构中的分层意图
后端 backend/src/ 的分层本身就是考点:
agent.py # 核心协调器 DeepResearchAgent
main.py # FastAPI 入口
models.py # 数据模型(TodoItem、SummaryState)
prompts.py # Prompt 模板集中管理
config.py # 配置管理
services/ # planner.py / summarizer.py / reporter.py / search.py
workspace/ # 研究笔记
为什么要拆 services? 每个服务只做一件事:构建 Prompt、调用对应 Agent、处理返回值。Agent 与业务逻辑解耦后,换 Prompt 不影响调度,换模型不影响业务。
前端侧只需关注两个文件:components/ResearchModal.vue(全屏模态 UI)与 composables/useResearch.ts(SSE 与状态管理)。
2. TODO 驱动的研究范式(14.2)
2.1 从"搜一次"到"规划→执行→整合"
传统搜索方式的问题在于:每个链接只覆盖主题的一个方面、缺乏系统性结构、需要手工整理。TODO 驱动把"研究"这个复杂任务转化为规划 → 执行 → 整合的固定流程。
以"Datawhale 是一个什么样的组织?"为例,系统先规划出四个方向(基本信息 / 主要项目 / 社区文化 / 影响力),再对每个 TODO 执行"搜索 → 总结 → 记录来源",最后整合为带参考文献的四段式报告。
它的优势:子问题清晰、每条子任务的搜索结果与总结都被记录便于追溯、流程化避免遗漏、可随时增删子任务或调整顺序。
三个核心要素(对应三个 Agent 的雏形):
| 要素 | 职责 | 关键约束 |
|---|---|---|
| 智能规划器 | 把主题分解为子任务 | 3-5 个;为每个子任务设计合适的搜索查询 |
| 任务执行器 | 执行子任务 | 搜索取资料、提取关键信息去冗余、保存来源引用 |
| 报告生成器 | 整合子任务结果 | 按逻辑顺序组织、合并重复信息、每个观点带引用 |
整个流程是线性的,每个阶段有明确输入输出——这带来的是可调试性:任何一环出问题都能单独复现。
2.2 三阶段流程的输入输出契约
阶段 1:规划(Planning)
- 输入:研究主题 + 当前日期(用于获取最新信息)。
- 输出:JSON 格式子任务列表,每条含
title(任务标题)、intent(研究意图)、query(搜索查询)。 - 分解策略:通常从基础概念入手,再到技术现状、实际应用、发展趋势,必要时做对比分析。
- 好的规划四条标准:覆盖全面、逻辑清晰、查询精准、数量适中。
- 细节:Prompt 中提示 query 可用英文以获得更好的搜索结果,并明确"只返回 JSON,不要包含其他文本"。
阶段 2:执行(Execution)
- 输入:子任务列表 + 搜索引擎配置。
- 输出:每个子任务的 Markdown 总结 + 来源引用列表。
- 单任务四步循环:
- 搜索资料:调用 SearchTool,参数
{"input": task.query, "backend": "tavily", "mode": "structured", "max_results": 5}。 - 获取结果:提取每条结果的标题、URL、摘要(
title/url/snippet)。 - 调用总结 Agent:把任务信息与检索结果一起交给 Task Summarizer。
- 记录总结与来源:写入 NoteTool,
tags=["research","summary"]。
- 搜索资料:调用 SearchTool,参数
- 执行期间持续向前端推送状态事件,类型形如
status("正在搜索:…"、"正在总结搜索结果…")与task(带id、title、status: completed)。
阶段 3:报告(Reporting)
- 输入:所有子任务总结 + 研究主题。
- 输出:Markdown 最终报告,固定五个部分:标题 / 概述 / 各子任务详细分析 / 总结 / 参考文献。
- 报告 Agent 做四件事:按子任务逻辑顺序组织内容、在开头补一段简要概述、合并重复信息、把所有来源引用统一整理到参考文献(按子任务分组)。
注意 case 编号的转换:引用编号是局部的——每条子任务内部的 [1][2] 只对应它自己那份来源列表;报告阶段按子任务分组重排参考文献,而不是全局重新编号。这是实现引用可追溯时最容易出错的地方。
3. 智能体系统设计(14.3)
3.1 为什么不用单个 SimpleAgent
第 7 章的 SimpleAgent 设计理念是简单直接:每次 run() 时分析问题、决定是否调用工具、返回结果。这在简单任务上足够,但面对深度研究这种"多阶段 + 长上下文 + 需要引用"的任务就不够了——它没有阶段划分,也没有产出物的结构约束。
因此本章继续采用多智能体协作:把流程切成三段,每段一个专职 Agent。好处是每个 Agent 都很简单、Prompt 可定制、互不影响、易于维护。
三个 Agent 的职责对照:
| Agent | 职责 | 核心动作 |
|---|---|---|
| TODO Planner | 研究规划专家 | 主题 → 3-5 个子任务(title/intent/query) |
| Task Summarizer | 任务总结专家 | 检索结果 → 带引用的 Markdown 总结 |
| Report Writer | 报告撰写专家 | 多份总结 → 结构化最终报告 |
3.2 三套 Prompt 的关键设计点
TODO Planner 的 Prompt 必须包含:current_date、research_topic、三条子任务要求(覆盖重要方面 / 有明确研究目标 / 能被搜索引擎找到)、JSON 字段定义、示例输出,以及四条约束(数量 3-5、子任务间有逻辑关系如"从基础到应用、从现状到趋势"、查询能准确找到资料、只返回 JSON)。
Task Summarizer 的 Prompt 必须包含:task_title、task_intent、task_query、格式化后的 search_results。输出要求三条:核心观点、关键数据(数字、日期、名称)、来源引用(用 [1]、[2] 标记)。附加要求:简洁避免冗余、保留重要细节、每个观点都要带引用、使用 Markdown。
Report Writer 的 Prompt 必须包含:research_topic 与拼接好的 task_summaries。输出结构五项(标题 / 概述 2-3 段 / 各子任务详细分析用二级标题 / 总结 1-2 段 / 参考文献按子任务分组)、四条要求(结构清晰逻辑连贯、消除重复信息、保留所有来源引用、Markdown 格式)。
三套 Prompt 的共同套路:字段占位符 + 结构化输出要求 + 示例输出 + 否定式约束("不要包含其他文本")。示例输出是让模型稳定产出结构化结果最便宜的手段。
3.3 ToolAwareSimpleAgent:把工具调用变成可观测事件
是什么:SimpleAgent 的扩展类,多了一个 tool_call_listener 回调参数,每次工具调用时触发。
怎么运作:它继承 SimpleAgent 并重写 _execute_tool_call——先解析参数,再调 super() 真正执行工具,最后把四元组回调出去。
def _execute_tool_call(self, tool_name: str, parameters: str) -> str:
parsed_parameters = self._parse_parameters(parameters)
result = super()._execute_tool_call(tool_name, parameters)
if self._tool_call_listener:
self._tool_call_listener({
"agent_name": self.name,
"tool_name": tool_name,
"parsed_parameters": parsed_parameters,
"result": result,
})
return result
这段代码在做的事:在工具执行的"前后夹一层",把 (谁、调了什么工具、传了什么参数、得到什么结果) 结构化地广播出去。回调内容与返回值解耦,因此监听器可以随时增删,不影响 Agent 行为。
四个用途:调试(看调了哪些工具、传了什么参数)、日志(记录研究全过程)、分析(观察 Agent 行为模式)、进度展示(实时显示 Agent 正在做什么)。
在 DeepResearchAgent 中,一个监听器被三个 Service 共用,回调里统一转成事件推给前端:
def tool_listener(call_info):
self._emit_event({
"type": "tool_call",
"agent": call_info["agent_name"],
"tool": call_info["tool_name"],
"parameters": call_info["parsed_parameters"],
})
self.planner = PlanningService(self.llm, tool_listener)
self.summarizer = SummarizationService(self.llm, tool_listener)
self.reporter = ReportingService(self.llm, tool_listener)
这段代码在做的事:一处监听、全局可见。所有 Agent 的工具调用都被记录并通过 SSE 推送,用户能看到实时进展。
3.4 顺序协作与核心协调器
三个 Agent 之间是顺序协作,特征三条:线性流程(固定顺序执行)、明确的输入输出(每个 Agent 的输入来自上一个的输出)、无并发(同一时刻只有一个 Agent 在工作)。
DeepResearchAgent 是全系统的核心协调器,run() 就是三阶段流水线本身:
def run(self, research_topic: str) -> str:
# 1. 规划阶段
self._emit_event({"type": "status", "message": "正在规划研究任务..."})
todo_list = self.planner.plan_todo_list(research_topic)
self._emit_event({"type": "tasks", "tasks": todo_list})
# 2. 执行阶段
task_summaries = []
for task in todo_list:
self._emit_event({"type": "status", "message": f"正在研究:{task.title}"})
search_results = self.search_service.search(task.query)
summary = self.summarizer.summarize_task(task, search_results)
task_summaries.append((task, summary))
self._emit_event({"type": "task_completed", "task_id": task.id})
# 3. 报告阶段
self._emit_event({"type": "status", "message": "正在生成报告..."})
report = self.reporter.generate_report(research_topic, task_summaries)
self._emit_event({"type": "report", "content": report})
return report
这段代码在做的事:协调器不写业务逻辑,只做调度与事件广播。三个阶段的中间产物(todo_list、task_summaries)在内存里逐级传递,事件在阶段边界与每个任务边界发出——前端进度条的粒度天然就等于"子任务粒度"。
4. 工具系统集成(14.4)
4.1 SearchTool 的多引擎扩展
第 7 章的 SearchTool 只集成了 Tavily 与 SerpApi;本章新增 DuckDuckGo、Perplexity、SearXNG,并增加 Advanced 模式(组合多个搜索引擎)。所有引擎共享统一调用接口,换引擎不改调用代码。
引擎通过配置选择,而非硬编码:
class SearchAPI(str, Enum):
TAVILY = "tavily"
DUCKDUCKGO = "duckduckgo"
PERPLEXITY = "perplexity"
SEARXNG = "searxng"
ADVANCED = "advanced"
默认值是 SearchAPI.DUCKDUCKGO(免密钥,开箱可用);.env 里一行 SEARCH_API=tavily 即可切换。
返回值契约(这是接服务层时的关键约定):
| 字段 | 含义 |
|---|---|
results |
结果列表,每条含标题、URL、摘要 |
backend |
实际使用的搜索引擎 |
answer |
AI 生成的答案(仅 Perplexity) |
notices |
通知信息(API 限制、错误等) |
搜索结果有两类必须先处理的问题:
- URL 重复 →
deduplicate_sources()用set记录seen_urls,保留首次出现的条目。 - 单条文本过长 →
limit_source_tokens()截断:按"1 Token ≈ 4 字符"估算,max_tokens=2000即max_chars=8000,超出就截断并加"..."。
工程要点:limit_source_tokens 是一种极便宜的上下文控制手段——不用真的分词器计数,用字符数近似即可,误差可接受但能挡住"一条摘要吃掉半个上下文"的情况。
4.2 NoteTool:把研究过程落盘
NoteTool(第 9 章集成的内置工具)负责持久化研究进度,支持创建、读取、更新、删除笔记。笔记以 Markdown 文件存在工作空间目录下,文件名就是任务 ID。
落盘后形成的产物结构:
workspace/
├── notes/
│ ├── 1.md # 任务1的笔记
│ ├── 2.md
│ └── 3.md
└── reports/
└── final_report.md
NotesService.save_task_summary() 写入的内容分三块:任务信息(意图、查询)、搜索结果(逐条 [idx] 标题 / URL / 摘要)、总结。
为什么必须落盘(而不是只放内存):一是研究中断后能从上次进度继续;二是便于复盘全部操作、分析研究的质量与效率;三是审计需要——报告里的每个引用都能回溯到原始 URL。
易错点:落盘的笔记已经包含"搜索结果快照",它同时是引用溯源依据和调试样本。如果把原始结果丢掉只存总结,后续就没法验证总结是否失真。
4.3 ToolRegistry 与工具调用六步流程
ToolRegistry 是框架的工具注册表,统一管理注册与调用。使用顺序是先建工具 → 再建注册表 → 注册 → 最后创建 Agent:
search_tool = SearchTool(backend="hybrid")
note_tool = NoteTool(workspace="./workspace/notes")
registry = ToolRegistry()
registry.register_tool(search_tool)
registry.register_tool(note_tool)
agent = ToolAwareSimpleAgent(
name="研究助手", system_prompt="你是一个研究助手",
llm=llm, tool_registry=registry
)
工具调用的六步流程(全章最值得背的机制之一):
- Agent 生成指令:如
[TOOL_CALL:search_tool:{"input": "Datawhale组织", "backend": "tavily"}]。 - 解析指令:
ToolRegistry提取工具名称与参数。 - 查找工具:按名称查找对应工具。
- 调用工具:调用工具的
run方法并传入参数。 - 返回结果:工具返回执行结果。
- 格式化结果:把结果格式化为字符串,回填给 Agent。
[TOOL_CALL:工具名:JSON参数] 这个约定值得单独记:工具名与参数都是纯文本,所以任何兼容 OpenAI 接口的模型都能用,不依赖特定厂商的 function calling 字段。
5. 服务层实现(14.5)
服务层是连接 Agent 与工具的桥梁,负责具体业务逻辑(构 Prompt、调 Agent、洗数据)。四个服务各司其职:
| 服务 | 输入 | 输出 |
|---|---|---|
| PlanningService | 研究状态(含主题) | List[TodoItem] |
| SummarizationService | 任务 + 检索结果 | 总结文本 + 来源 URL 列表 |
| ReportingService | 主题 + 各任务总结 | 最终 Markdown 报告 |
| SearchService | 查询语句 | 去重限流后的结果列表 |
5.1 PlanningService:结构化输出的鲁棒解析
四个职责:构建规划 Prompt(含当前日期)、调用 TODO Planner、从响应中提取 JSON、校验必需字段。
解析之所以要写成"鲁棒"的,是因为 LLM 返回 JSON 有三类常见问题:包含额外文本(JSON 前后夹着说明)、格式错误(缺引号、缺逗号)、字段缺失。
对应的三层方案:
# 方法1:正则提取 JSON 数组
json_match = re.search(r'\[.*\]', response, re.DOTALL)
if json_match:
return json.loads(json_match.group(0))
# 方法2:没有数组就直接整体解析
try:
return json.loads(response)
except json.JSONDecodeError:
raise ValueError("无法从响应中提取JSON")
# 字段验证:每个任务必须含 title / intent / query
if not all(key in item for key in ["title", "intent", "query"]):
raise ValueError(f"任务{idx}缺少必需字段")
这段代码在做的事:用最省事的兜底换稳定性。提示词里已经写了"只返回 JSON",但仍按"可能不听话"来设计——re.DOTALL 让 . 能跨行匹配,[...] 锚定 JSON 数组边界,最后给每个 TodoItem 分配 id=idx(从 1 开始,这个 id 就是后续 NoteTool 的文件名)。
5.2 规划质量评估:一个可落地的自检打分
规划是全流程最关键的一步,因此本章给出一个显式的评分函数 evaluate_plan(),基准 100 分:
- 子任务 少于 3 个:扣 20 分,提示"可能遗漏重要信息"。
- 子任务 多于 5 个:扣 10 分,提示"可能存在冗余"。
- 某个 task 的
query按空格切分少于 2 个词:扣 10 分,提示"查询过于简单"。
返回 {"score": ..., "suggestions": [...]}。
工程要点:这是"启发式规则打分"而非模型评估——成本为零、结果稳定,适合做上线前的兜底断言(分数过低就不执行,直接提示用户改主题)。逻辑关系的检查留了口子但未实现,因为"逻辑是否连贯"很难用规则描述。
5.3 总结与报告服务
SummarizationService 四步:格式化搜索结果(序号 + 标题 + URL + 摘要)、用 task_title/task_intent/task_query/search_results 填 Prompt、调用 Task Summarizer、返回 (summary, source_urls)。source_urls 是从原始结果里直接取 URL 拼成的列表,不依赖模型输出——这样引用列表永远真实存在,不会因为模型"忘了写"而丢失。
ReportingService 四步:格式化各任务总结(任务序号 + 标题 + 意图 + 总结内容 + 来源 URL)、填 Prompt、调用 Report Writer、返回 Markdown。
for idx, (task, summary, source_urls) in enumerate(task_summaries, start=1):
formatted.append(
f"## 任务{idx}:{task.title}\n\n"
f"**意图**:{task.intent}\n\n"
f"{summary}\n\n"
f"**来源**:\n"
)
for url in source_urls:
formatted.append(f"- {url}\n")
这段代码在做的事:把"来源"和"总结"绑在一起喂给报告 Agent。报告 Agent 因此不需要(也无法)访问原始网页,它只做"编排与去重",这正是长报告不爆上下文的原因。
上下文与压缩策略小结(本章最核心的工程思想):
| 层级 | 上下文里装什么 | 谁负责压缩 |
|---|---|---|
| 搜索层 | 单条结果按 2000 Token 截断 | SearchService |
| 任务层 | 5 条结果 → 一段带引用总结 | Task Summarizer |
| 报告层 | 3-5 段总结 → 一份报告 | Report Writer |
| 持久层 | 全量原始结果写磁盘 | NoteTool |
关键点:上下文是被逐层收窄的。原始网页摘要只在"执行单个子任务"这一刻进上下文,出了这个循环就只剩总结,磁盘上另有一份全量快照兜底。这就是为什么既能做 3-5 段的长报告,又不会把上下文撑爆。
5.4 SearchService:调度、降级与缓存
SearchService 的定位很特别——它没有让 SimpleAgent 直接调工具,而是加了一层中间层把结果转交给 Agent。原文的理由是:这样能让 Agent 更专注于处理得到的信息,而不是分心在"怎么调搜索、调哪个引擎、结果怎么洗"上。
四个职责:按配置调度引擎、调用 SearchTool、处理结果(去重 / 限 Token / 格式化)、错误处理。
搜索是外部依赖,必然失败,所以写成"降级返回空列表":
try:
raw_response = self.search_tool.run({
"input": query,
"backend": self.config.search_api.value,
"mode": "structured",
"max_results": max_results,
})
results = raw_response.get("results", [])
results = self._deduplicate_sources(results)
results = self._limit_source_tokens(results)
return results
except Exception as e:
logger.error(f"搜索失败:{query},错误:{e}")
return []
这段代码在做的事:把"某条子任务查不到"降级成局部失败。返回 [] 而不是抛异常,流水线得以继续——总结 Agent 会基于空结果产出一段"信息不足"的总结,其余子任务与最终报告不受影响。这是长流程系统必须有的容错姿态。
缓存:为提效降本,可用磁盘缓存搜索结果。缓存键是 md5(f"{query}_{max_results}_{search_api}"),文件存放在 ./cache/search/,命中就直接读 JSON 返回,未命中才真搜并回写。注意缓存键包含引擎名——换引擎必须换缓存,否则会拿到别的引擎的结果。
5.5 服务层小结
四个服务串起来就是一条完整流水线:PlanningService 定任务 → SearchService 取数据 → SummarizationService 出中间产物 → ReportingService 出交付物。它们各司其职、接口清晰,而且每个都能单独测试与替换。
6. 前端与实时交互(14.6)
6.1 全屏模态对话框
选择全屏模态而非普通弹窗,理由是四条:沉浸式(避免干扰)、层次清晰(主页面与研究页分离)、易关闭(点关闭按钮或按 ESC 返回)、响应式(适配不同屏幕)。
UI 四区:顶部栏(研究主题 + 关闭按钮)、进度区域(当前阶段:规划 / 执行 / 报告)、内容区域(Markdown 结果)、底部栏(状态文案)。响应式靠媒体查询:max-width: 768px 收敛为 95vw/95vh,max-width: 480px 全屏且去圆角。
6.2 SSE:为什么不用 WebSocket
系统用 SSE(Server-Sent Events,服务器推送技术) 做实时进度。流程五步:客户端 POST 发起请求 → 服务器返回 text/event-stream 建立连接 → 服务器按阶段周期性推送进度 → 客户端监听事件更新 UI → 推送最终报告后关闭连接。
后端用 FastAPI 的 StreamingResponse,响应头固定两个:Cache-Control: no-cache 与 Connection: keep-alive。每条消息格式为 data: {JSON}\n\n。
进度百分比的分配值得记:规划阶段发 10%,执行阶段按 10 + (idx / n) * 70 平滑推进,报告阶段 90%,完成 100%。
事件类型共七种,前端按 type 分支处理:
| type | 含义 | 前端动作 |
|---|---|---|
progress |
阶段进度 | 更新百分比与文案 |
plan |
规划结果 | 展示子任务列表 |
task_summary |
单任务总结 | 追加到 Markdown |
report |
最终报告 | 覆盖 Markdown 内容 |
error |
异常 | 提示并关闭连接 |
completed |
完成 | 关闭连接、结束 loading |
tool_call |
工具调用 | 显示日志 |
为什么选 SSE 而不是 WebSocket:这是单向推送(服务器 → 客户端),客户端只需要一次 POST 表达意图,之后不要求双向通信。SSE 基于 HTTP、实现更简单、天然支持断线重连语义,对"进度播报"这类场景刚好够用。
6.3 结果可视化
用 marked 把 Markdown 转 HTML,配置两项:breaks: true(支持换行)、gfm: true(支持 GitHub Flavored Markdown)。报告中引用密集,参考文献按子任务分组、每条用 [标题](URL) 形式呈现,便于点击核验。
易错点:Markdown 内容用了 v-html 渲染,这要求输入可信。本系统的报告来自自家 LLM 与检索结果,若把用户输入直接拼进 Markdown 再渲染,就需要考虑注入风险。
7. 工程实践要点(从零复现的清单)
按依赖顺序复现,关键决策与踩坑都在这里:
- 先定数据模型再写逻辑。
TodoItem(id, title, intent, query)与SummaryState(research_topic, ...)是全系统的数据契约,尤其TodoItem.id同时是 NoteTool 的文件名,必须先定。 - 把三套 Prompt 抽到
prompts.py单独管理。三套 Prompt 都用到.format()占位符,集中存放才不会漏字段;同时占位符里的字面花括号要写成{{ }}转义(示例 JSON 里尤其要注意)。 - 规划阶段务必注入当前日期。研究主题常有"最新""现状"这类时间敏感词,缺日期会导致检索到过期信息。
- 执行阶段按任务串行、任务内并行可选。本章是串行(无并发),好处是日志顺序清晰、易调试;要提高吞吐可对多个 TODO 并发,但要接受事件顺序错乱与限流风险。
- 搜索失败必须降级。让
SearchService.search()捕获异常返回[],而非上抛。否则一个引擎抖动就会整轮研究失败。 - 上下文要在三个层级设闸:单条结果 2000 Token 截断、任务层压缩成一段总结、报告层只吃总结。缺任何一层都可能在中途撞上下文上限。
- 引用编号是局部的,报告阶段不要重新编号,改为按子任务分组列参考文献,避免"编号对不上来源"这类不可追溯问题。
- 中间产物一律落盘(
workspace/notes/{id}.md+reports/final_report.md),既为断点续跑,也为事后审计。 - 把工具调用做成事件流。用
ToolAwareSimpleAgent的监听器统一收口,前端进度、日志、调试三件事一次解决。 - 给规划加自检门槛。用
evaluate_plan()的分数判断是否直接执行,低成本挡住"3 个以上同义子任务"这类坏规划。 - 搜索结果缓存键要带引擎名,否则切换
SEARCH_API后会命中错误的缓存。 - 进度必须真实分段。按"规划 → 执行(第 i/n) → 报告"上报,比"转圈圈"更能安抚用户——1-3 分钟的空等是不可接受的体验。
这套架构的可迁移经验:任何"输入开放、需要多轮检索、要求可追溯产出"的任务都适用——把长流程切成固定阶段、每个阶段一个专职 Agent 配一套 Prompt、阶段之间只传压缩后的结构化中间产物、全量原始数据落盘、全流程事件化上报。它与具体的"研究"领域无关,换掉 Prompt 与工具就能变成别的分析型 Agent。
8. 高频考点 & 易错点速查
- TODO 驱动的三阶段是规划 / 执行 / 报告,三个 Agent 依次是 TODO Planner、Task Summarizer、Report Writer;三个核心能力是问题剖析、多轮信息采集、反思与总结。
- 子任务数量 3-5 个:少于 3 覆盖不全,多于 5 冗余;每条子任务三个字段
title/intent/query。 - 三 Agent 是顺序协作:线性、输入输出明确、无并发(不是并行、不是辩论、不是层级制)。
ToolAwareSimpleAgent的机制:继承SimpleAgent、重写_execute_tool_call、通过tool_call_listener回调四元组信息;用途是调试 / 日志 / 分析 / 进度展示。- 工具调用六步:生成指令 → 解析 → 查找 → 调用 → 返回 → 格式化;指令格式
[TOOL_CALL:工具名:JSON参数]。 - SearchTool 返回四字段:
results/backend/answer(仅 Perplexity)/notices。 - Token 估算约定:1 Token ≈ 4 字符;单条来源上限 2000 Token(约 8000 字符)。
- 易混点:
SearchService不让 Agent 直接调SearchTool,而是走中间层——目的是让 Agent 专注处理信息,别把调度与清洗混进推理。 - 易混点:引用编号是"子任务内局部编号",报告阶段按子任务分组列参考文献,不做全局重排。
- 易错点:搜索异常吞掉返回
[]是设计而非缺陷,这是保住长流水线不中断的降级策略。 - 易错点:缓存键必须含引擎名,只按 query 做键会在切换引擎后返回脏数据。
- 易错点:JSON 解析要按"模型不听话"设计——正则提取数组 + 整体解析兜底 + 必需字段校验,三层缺一不可。
- 易错点:报告 Agent 拿不到原始网页,它只吃总结与来源 URL;把原始结果也塞进去是最常见的上下文爆炸原因。
- 章末习题的考点映射:①简述 TODO 驱动三阶段(考流程)②为什么拆三个 Agent 而非一个(考职责划分)③
ToolAwareSimpleAgent解决了什么问题(考可观测性)④SearchTool去重与限 Token 的必要性(考上下文控制)⑤NoteTool落盘的价值(考状态管理)⑥SSE 为何优于轮询(考实时交互选型)⑦规划质量评估的规则设计(考质量控制)⑧如何扩展到新搜索引擎或新数据源(考可扩展性)。
9. 与前后章节的衔接
本章是"实战案例"链条上的一环:输入来自第 7 章的 SimpleAgent 与 SearchTool、第 9 章的 NoteTool,以及第 13 章多智能体产品化(旅行助手)的组织经验——本章把 SimpleAgent 扩展为 ToolAwareSimpleAgent,并把工具集从"两个搜索引擎"扩到"多引擎 + Advanced 模式"。输出则是一套可复用的长流程架构:分层服务、结构化中间产物、事件流上报、断点落盘。下一章把视角转向"与游戏引擎结合的多 Agent 系统——赛博小镇",重点变成 Agent 之间的复杂交互与协作模式。

浙公网安备 33010602011771号