落地实现 Anthropic Multi-Agent Research System
📍 阅读路径
根据你的背景,选择合适的切入点:
- 🧠 先看理论 → 第一部分:为什么单 Agent 研究不够用
- ⚙️ 直接看架构 → 第二部分:三层架构设计
- 💻 直接看代码 → 第三部分:完整实现
目录
第一部分:问题与解法 🧠
第二部分:架构设计 ⚙️
第三部分:代码实现 💻
附录
引言
Anthropic 工程博客发布了一篇文章:How we built our multi-agent research system。
文章描述了 Claude Research 功能背后的架构:一个 Lead Agent 规划研究流程,并行 spawn 多个 Search Agent 同时搜索,结果写入文件系统,最后由 CitationAgent 核查引用。
这篇文章把这套架构用 Python 完整实现出来,并作为独立模块集成到 AI Agent 项目中。
第一部分:问题与解法 🧠
单 Agent 研究的三大痛点
让 LLM 做研究,最直接的方式是:
用户: "帮我研究量子计算的最新进展"
↓
Agent
→ web_search("量子计算")
→ web_search("quantum computing 2025")
→ web_search("量子计算应用场景")
→ ... 串行执行,结果全部堆进 context
→ 综合回答
这个方式有三个核心问题:
问题 1:Context 窗口过载
每次搜索结果都直接进入 Agent 的 context,研究越深入,context 越臃肿。当 token 接近上限时,早期的搜索结果会被压缩甚至丢失,导致「遗忘」。
问题 2:串行执行,速度慢
搜索是 I/O 密集型操作,等待网络响应的时间远大于 LLM 思考时间。串行执行 5 个查询,耗时是并行的 5 倍。
问题 3:缺乏深度迭代
单 Agent 往往搜一次就综合,没有「搜到结果后发现新线索、继续深挖」的能力。人类研究员会根据初步结果调整搜索策略,单 Agent 做不到。
Anthropic 的解决思路
Anthropic 的核心思路是:
"an agent that plans a research process based on user queries, and then uses tools to create parallel agents that search for information simultaneously"
把一个大任务拆成三层:
SearchLeadAgent(规划 + 编排)
↓ 并行 spawn
SearchSubagent × N(独立上下文,各自搜透一条查询,写文件)
↓ 结果路径汇总
SearchLeadAgent(综合报告)→ CitationAgent(内联引用)
每一层解决一个具体问题:
- SearchLeadAgent 解决「搜什么、搜够了没有」的问题
- SearchSubagent 解决「怎么把一条查询搜透」的问题
- 文件系统解决「context 过载」的问题
- CitationAgent 解决「声明是否有来源支撑」的问题
第二部分:架构设计 ⚙️
两种循环:LeadAgent vs SubAgent
这套系统有两个不同性质的循环,分别对应不同的决策模式:
SearchSubagent — OODA 循环(微观,响应式)
OODA 是军事决策模型,由美国空军上校 John Boyd 提出,适合「在不确定环境中持续迭代」的场景。SubAgent 面对的正是这种场景:搜索结果不可预测,需要根据每次结果动态调整。
| 阶段 | 在 SubAgent 中的作用 |
|---|---|
| Observe | 并行执行当前批次查询 / 抓取 URL 全文 |
| Orient | LLM 评估结果质量,区分事实与推测,识别冲突 |
| Decide | DONE | REFINE | BROADEN | FETCH |
| Act | 继续搜索 / 抓取 URL / 返回结果 |
SearchLeadAgent — 评估-分类-计划-执行(宏观,主动式)
LeadAgent 不是被动响应,而是主动规划。它在循环开始前先做两步预处理,再进入四步循环:
pre-loop:
评估 → _probe() 快速探索信息版图
分类 → _classify() 判断查询类型(direct / broad / deep)
loop (最多 4 轮):
计划 → _plan_queries() 按类型生成查询,针对 gaps 补搜
执行 → _dispatch() 并行 spawn SearchSubagent
监控 → _evaluate_coverage() 评估覆盖度,识别信息缺口
适应 → _adapt() 贝叶斯更新:继续 or 止损综合
Decide 阶段不调 LLM,直接用规则判断:
def _adapt(self, situation: dict, cycle: int, searched: list[str]) -> str:
confidence = situation.get("confidence", 0.7)
gaps = situation.get("gaps", [])
if cycle >= self.MAX_CYCLES or not gaps:
return "SYNTHESIZE"
if len(searched) >= 10:
return "SYNTHESIZE"
if confidence >= 0.75:
return "SYNTHESIZE"
return "OBSERVE_MORE"
原因:_adapt 的输入(confidence 数值 + gaps 列表)已经是结构化数据,规则判断比 LLM 更快、更稳定、成本更低。
三层拆分
| 维度 | SearchLeadAgent | SearchSubagent | CitationAgent |
|---|---|---|---|
| 职责 | 研究规划 + 并行编排 + 触发引用 | 执行单条查询,搜透为止 | 内联引用插入 |
| 循环模式 | 评估-分类-计划-执行(最多 4 轮) | OODA(budget 由难度决定) | 单次执行 |
| LLM 调用 | classify / plan / evaluate | evaluate(每轮) | extract_citations |
| 输出 | 最终报告文件路径 | 原始结果文件路径 | 修改报告文件(内联标记) |
| Context | 独立,仅含研究状态 + 文件路径 | 独立,仅含单条查询结果 | 独立,仅含报告内容 |
三个组件都是独立的 Python 类,但 CitationAgent 由 SearchLeadAgent 在内部自动调用,调用方只需调用 SearchLeadAgent.run(topic) 即可得到完整的研究报告路径。
文件系统:解决传声筒效应
Anthropic 文章中特别提到 传声筒效应(Telephone Effect):在多层 Agent 传递信息时,每次传递都会消耗大量 token,并且 LLM 会自动摘要导致细节丢失。
解决方案:两级写文件,只传路径
SubAgent 完成搜索
→ 写入 scripts/research/sub_{query}_{timestamp}.md
→ 返回文件路径给 LeadAgent
LeadAgent 收集所有路径
→ all_results = {"量子计算 2025": "scripts/research/sub_量子计算_2025_....md", ...}
→ 内存里只有路径,不堆原始文本
LeadAgent 需要评估时
→ _read_result(path) 按需读取文件内容
→ 读完即丢,不常驻内存
LeadAgent 综合报告
→ 写入 scripts/research/{topic}_{timestamp}.md
→ 返回路径给调用方
_read_result 的实现很简单,但作用关键:
def _read_result(path_or_text: str) -> str:
p = Path(path_or_text)
if p.exists():
return p.read_text()
return path_or_text # 兜底:直接返回文本(standalone 模式)
调用方收到的是一个轻量级路径字符串,而不是几千字的搜索结果。需要时才读取文件,不需要时不占 token。
第三部分:代码实现 💻
SearchSubagent:OODA 深挖循环
SearchSubagent 不是简单的「搜一次返回」,它有完整的 OODA 迭代能力。
动态研究预算
迭代次数根据任务难度动态调整,由 LeadAgent 在 dispatch 时传入:
BUDGET = {"simple": 4, "medium": 5, "hard": 10, "extreme": 15}
MAX_TOOL_CALLS = 20 # 硬限制:工具调用次数
MAX_SOURCES = 100 # 硬限制:收集 URL 数量
四种决策状态
_evaluate 让 LLM 先推理再决策,输出四种状态:
def _evaluate(self, original, current_queries, results, iteration, topic="") -> dict:
resp = self._llm.invoke([
SystemMessage(content=(
"You are a search evaluator.\n"
"Think step-by-step first, then output a JSON decision.\n"
"Output ONLY valid JSON:\n"
'{"reasoning": "brief analysis", "status": "DONE"|"REFINE"|"BROADEN"|"FETCH", '
'"next_queries": ["q1", "q2"], "fetch_urls": ["url1"]}\n'
"- DONE: results are relevant and sufficient\n"
"- REFINE: results exist but key aspects missing; provide 1-3 more specific queries\n"
"- BROADEN: results too sparse or off-topic; provide 1-3 broader/alternative queries\n"
"- FETCH: a specific URL contains critical detail; list up to 2 URLs to fetch\n"
"- in reasoning, distinguish confirmed facts from speculation\n"
"- if sources conflict, note the conflict and prefer authoritative sources"
)),
...
])
reasoning 字段让 LLM 先分析再决策(Careful Reasoning),避免直接跳到结论。BROADEN 解决了「只会越搜越窄」的问题,FETCH 允许抓取 URL 全文获取更深层信息。
Web Fetch 能力
def _fetch_url(self, url: str, max_chars: int = 3000) -> str:
self._tool_calls += 1
resp = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
text = re.sub(r"<[^>]+>", " ", resp.text) # 去除 HTML 标签
text = re.sub(r"\s+", " ", text).strip()
return text[:max_chars]
DuckDuckGo 只返回摘要片段,_fetch_url 可以抓取完整页面内容,适合需要深度阅读的场景。
主循环结构
def run(self, query: str, topic: str = "", research_dir: Path | None = None) -> str:
for i in range(1, self._max_iter + 1):
# 硬限制检查
if self._tool_calls >= MAX_TOOL_CALLS: break
if len(self._sources) >= MAX_SOURCES: break
# Observe:并行执行当前批次查询
batch_results = self._parallel_search(new_queries)
# Orient + Decide
decision = self._evaluate(query, new_queries, combined, i, topic)
status = decision.get("status", "DONE")
# Act
if status == "DONE": break
elif status == "FETCH":
for url in fetch_urls[:2]:
content = self._fetch_url(url) # 抓取全文
elif status in ("REFINE", "BROADEN"):
current_queries = next_queries # 更新查询列表
# 写文件,返回路径
results_text = "\n\n".join(all_results)
if research_dir is not None:
return _write_subagent_results(query, results_text, research_dir)
return results_text
每个 SubAgent 实例独立,上下文互不干扰。完成后写入 sub_{slug}_{timestamp}.md,返回路径。
SearchLeadAgent:评估-分类-计划-执行
LeadAgent 在进入主循环前,先做两步预处理:
Pre-loop:探索 + 分类
def run(self, topic: str, research_dir: Path | None = None) -> str:
_research_dir = research_dir or DEFAULT_RESEARCH_DIR
self._research_dir = _research_dir # 供 _dispatch 传给 SubAgent
# 1. 探索:快速搜索,理解信息版图
probe_hint = self._probe(topic)
# 2. 分类:判断查询类型
query_type = self._classify(topic, probe_hint)
# → "direct" | "broad" | "deep"
_classify 根据 probe 结果让 LLM 判断查询类型,不同类型对应不同的 SubAgent 数量和难度:
_SUBAGENT_COUNT = {"direct": 1, "broad": 3, "deep": 5}
_DIFFICULTY = {"direct": "simple", "broad": "medium", "deep": "hard"}
主循环:四步
for cycle in range(1, self.MAX_CYCLES + 1):
# 计划:生成本轮查询,针对 gaps 补搜
gaps = memory["cycles"][-1]["gaps"] if memory["cycles"] else []
queries = self._plan_queries(topic, memory["searched"], all_results, gaps, query_type)
# 执行:并行 dispatch,每个 SubAgent 写文件返回路径
batch = self._dispatch(new_queries, topic=topic, difficulty=difficulty)
all_results.update(batch) # {query: file_path}
# 监控:评估覆盖度
situation = self._evaluate_coverage(topic, all_results)
confidence = situation.get("confidence", 0.5)
# 适应:决定继续还是综合
if self._adapt(situation, cycle, memory["searched"]) == "SYNTHESIZE":
break
all_results 里存的是文件路径,不是原始文本。_evaluate_coverage 和 _plan_queries 需要读内容时,通过 _read_result() 按需读取:
def _aggregate(results: dict[str, str]) -> str:
for query, path_or_text in results.items():
content = _read_result(path_or_text) # 按需读文件
lines.append(content)
def _summarize(results: dict[str, str], max_chars: int = 3000) -> str:
per_query = max(300, max_chars // max(len(results), 1))
for query, path_or_text in results.items():
content = _read_result(path_or_text)
lines.append(f"[{query}]: {content[:per_query]}") # 截断压缩
_summarize 用于 _plan_queries 的上下文(轻量),_aggregate 用于 _evaluate_coverage 和 _synthesize(完整)。
dispatch:每次新实例
def _dispatch(self, queries, topic="", difficulty="medium") -> dict[str, str]:
def _run(q: str) -> tuple[str, str]:
agent = SearchSubagent(difficulty=difficulty) # 每次新实例,独立上下文
return q, agent.run(q, topic=topic, research_dir=self._research_dir)
with ThreadPoolExecutor(max_workers=len(queries)) as executor:
futures = {executor.submit(_run, q): q for q in queries}
for future in as_completed(futures):
q, result = future.result()
results[q] = result # result 是文件路径
N 个查询同时执行,速度提升 N 倍。with ThreadPoolExecutor 退出时自动 shutdown(wait=True),保证所有 SubAgent 都完成后才继续。
CitationAgent:内联引用插入
研究报告生成后,CitationAgent 在 summary 中精确插入 [^N] 引用标记。它由 SearchLeadAgent 在 _synthesize 之后自动调用,不需要外部手动触发。
核心约束:文本内容 100% 不变,唯一权限是插入引用标记。这是为了支持后续系统的 Diffing 校验——去掉标记后,文本必须与原文完全一致,否则判定篡改。
流程:
1. 读取报告,按 "## Raw Results" 分离 summary 和 raw results
2. LLM 先推理,再识别 (snippet, url) 对
3. 在 summary 中精确插入 [^N] 标记(句末/子句末)
4. 一致性校验:strip 标记后 == 原 summary
5. 追加 ## References 区块,写回文件
LLM 提取引用(Careful Reasoning):
SystemMessage(content=(
"You are a citation extractor.\n"
"First reason about which claims need citations, then output a JSON decision.\n"
"Rules:\n"
"- Only cite factual claims (numbers, dates, specific events), NOT common knowledge\n"
"- snippet must be an EXACT substring from the summary\n"
"- snippet should be a complete semantic unit\n"
"- Place the citation marker at the END of a sentence or clause, never mid-phrase\n"
"- Each URL should appear at most once\n"
'Output ONLY valid JSON: {"reasoning": "...", "citations": [{"snippet": "...", "url": "..."}]}'
))
一致性校验(防篡改):
stripped = re.sub(r"\[\^\d+\]", "", annotated_summary)
if stripped != summary_part:
logger.warning("CitationAgent: consistency check failed, reverting")
return "consistency check failed" # 不写回文件
输出格式:
研究报告正文...量子计算领域在 2025 年取得重大突破[^1],IBM 发布了 1000 量子比特处理器[^2]。
---
## References
[^1]: https://example.com/quantum-2025
[^2]: https://research.ibm.com/...
完整调用链
输入: topic = "量子计算最新进展"
↓
SearchLeadAgent.run(topic)
pre-loop:
_probe() → 快速搜索,了解信息版图
_classify() → "deep"(单一话题,需多视角深挖)
→ max_subagents=5, difficulty="hard", budget=10
cycle 1:
_plan_queries() → ["量子计算 2025", "quantum supremacy", "量子纠错", "量子计算应用"]
_dispatch() → 并行 spawn 4 个 SearchSubagent(各自 OODA,写文件)
├── sub_量子计算_2025_....md
├── sub_quantum_supremacy_....md
├── sub_量子纠错_....md
└── sub_量子计算应用_....md
all_results = {query: file_path, ...} ← 只存路径
_evaluate_coverage() → confidence=0.55, gaps=["商业化进展", "投资动态"]
_adapt() → OBSERVE_MORE
cycle 2:
_plan_queries() → ["量子计算商业化 2025", "量子计算投资"](针对 gaps)
_dispatch() → 并行 spawn 2 个 SubAgent
_evaluate_coverage() → confidence=0.82
_adapt() → SYNTHESIZE(confidence >= 0.75)
_synthesize() → 读取所有文件 → LLM 综合
→ 写入 scripts/research/量子计算_20260227_143022.md
CitationAgent.run(path) ← 内部自动调用
→ 读报告 → LLM 推理 → 插入 [^N] → 一致性校验 → 写回
返回: "scripts/research/量子计算_20260227_143022.md"
调用方只需一行:
path = SearchLeadAgent().run("量子计算最新进展")
# 或通过 @tool 包装
path = search_lead.invoke({"topic": "量子计算最新进展"})
常见问题 FAQ
Q: LeadAgent 为什么不用 OODA?
OODA 是响应式模型,适合「看到结果再决定下一步」的场景。LeadAgent 的工作是主动规划:先探索信息版图、判断查询类型、制定策略,再执行。这是「评估-分类-计划-执行」的主动式循环,而不是 OODA 的响应式循环。SubAgent 才是真正的 OODA——它面对不可预测的搜索结果,需要动态调整。
Q: 为什么用 DuckDuckGo 而不是 Tavily 或 SerpAPI?
DuckDuckGo 不需要 API Key,duckduckgo-search 库开箱即用,适合学习和原型阶段。生产环境可以换成 Tavily(专为 AI 设计,结果质量更好)或 SerpAPI(更稳定),只需替换 search/duckduckgo.py 即可。
Q: SubAgent 的 BROADEN 和 REFINE 有什么区别?
REFINE 是「结果有了但不够精准」,继续缩小范围深挖;BROADEN 是「结果太少或完全偏题」,需要换个角度重新出发。只有 REFINE 会导致查询越来越窄,加入 BROADEN 后 SubAgent 能在搜索陷入死胡同时主动扩展。
Q: CitationAgent 为什么不作为独立工具暴露?
引用核查是研究流程的最后一步,和报告生成强耦合。把它内置在 SearchLeadAgent 里,保证每次研究都自动完成核查,不会被调用方遗漏。如果需要对已有报告单独核查,可以直接实例化 CitationAgent 调用。
Q: 文件系统存储会不会有并发问题?
SubAgent 用 sub_{slug}_{timestamp}_{microseconds} 命名文件,时间戳精确到微秒,并发冲突概率极低。LeadAgent 的最终报告同理。
Q: OODA 最多 10 轮(hard),会不会太多?
有硬限制兜底:MAX_TOOL_CALLS = 20,MAX_SOURCES = 100。无论 budget 设多少,超过硬限制就停止。实际上大多数查询在 3-5 轮内就会 DONE,budget 是上限而不是固定次数。
📝 结语
这套架构的核心思想:
把研究过程的复杂性封装在独立的 Agent 层,调用方只看结果,不看过程。
五个设计决策,每一个都对应一个具体的工程痛点:
| 设计决策 | 解决的问题 |
|---|---|
| SubAgent OODA 循环 | 单次搜索不精准,需要动态迭代 |
| LeadAgent 评估-分类-计划-执行 | 不同查询类型需要不同策略 |
| 两级并行(LeadAgent + SubAgent 内部) | 串行执行速度慢 |
| 两级文件系统(SubAgent + LeadAgent 都写文件) | Context 过载,传声筒效应 |
| CitationAgent 内联引用 + 一致性校验 | 声明无来源,报告不可信 |