当 LLM 从"单轮问答"进化到"多步推理+多跳检索+结构化输出",Deep Research Agent 就不再是玩具,而是真正能替人完成调研工作的系统。Thoughtworks 的 Sarang Kulkarni 在 Arc of AI 2026 大会上分享了他们在生产环境中部署多 Agent 深度研究系统的经验——这些教训值得每一个正在搭建 Agent pipeline 的团队认真看。
深度研究 Agent 到底在做什么
普通 RAG 是"一问一答一检索",Deep Research Agent 则更像一个研究员的工作流:拿到一个复杂问题后,先拆解子问题,再逐个检索、交叉验证、补充遗漏,最后把所有发现组织成一份结构化分析报告。核心能力有三层:
- 动态推理:不是写死流程,而是 Agent 根据中间结果决定下一步走哪条路。
- 多跳检索:第一轮检索的结果可能触发第二轮、第三轮检索,信息是逐层展开的。
- 结构化输出:最终产出不是一段自由文本,而是有章节、有引用、有结论的分析报告。
这三层叠加起来,系统的复杂度会急剧上升——这也是生产落地最难的地方。
多 Agent 架构:拆职责比堆参数更重要
Kulkarni 的核心观点之一是:不要试图让一个"超级 Agent"包办所有事。生产级的研究系统应该把职责拆开,每个 Agent 只做一件事,做到可靠。
典型拆法:
| Agent | 职责 | 关键约束 |
|---|---|---|
| Planner | 拆解研究问题为子任务列表 | 输出必须是可枚举的子问题,不能模糊 |
| Searcher | 对单个子问题执行多跳检索 | 每跳必须带来源 URL,最多 3 跳 |
| Synthesizer | 合并多个 Searcher 的结果 | 必须标注信息冲突和缺失 |
| Reviewer | 检查报告的逻辑完整性和引用准确性 | 只输出 pass/fail + 具体问题清单 |
拆职责的好处是:每个 Agent 的 prompt 更短、行为更可预测、失败更容易定位。坏处是:Agent 间通信和状态管理变复杂了——这正是下一节要解决的问题。
状态与通信:生产系统的隐形杀手
多 Agent 系统在 demo 里跑得漂亮,上线后最先出问题的往往是状态管理。Kulkarni 提到了几个高频故障点:
- 上下文窗口溢出:Searcher 返回的原始网页内容塞进 Synthesizer 的上下文,直接超限。解法是 Searcher 必须做摘要再传递,而不是原样转发。
- 中间结果丢失:Planner 生成了 8 个子问题,Searcher 只完成了 6 个就超时了,Synthesizer 不知道哪些没做。解法是用一个共享状态存储(哪怕就是 JSON 文件)记录每个子任务的状态。
- 循环调用:Reviewer 发现问题后让 Planner 重新拆解,Planner 又生成类似子问题,Searcher 又走一遍——无限循环。解法是设置最大迭代次数,并在 Reviewer 反馈中要求给出具体修改方向,而不是笼统的"再研究一下"。
下面是一个用 Python 实现的最小可运行多 Agent 研究框架,展示了状态管理和 Agent 通信的核心模式:
import json
import os
from datetime import datetime
from typing import Literal
from openai import OpenAI
client = OpenAI() # 默认读取 OPENAI_API_KEY 环境变量
STATE_FILE = "research_state.json"
MAX_ITERATIONS = 3
# ── 状态管理 ──────────────────────────────────────────────
def load_state() -> dict:
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
return json.load(f)
return {"sub_questions": [], "results": {}, "iteration": 0, "status": "planning"}
def save_state(state: dict):
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2, ensure_ascii=False)
# ── Agent 定义 ────────────────────────────────────────────
def planner_agent(question: str, feedback: str | None = None) -> list[str]:
"""拆解研究问题为子问题列表"""
prompt = f"""你是一个研究规划师。把以下研究问题拆解为 3-5 个具体可检索的子问题。
只输出 JSON 数组,不要其他内容。
研究问题:{question}
"""
if feedback:
prompt += f"\n上一轮审阅反馈(请据此调整子问题):{feedback}"
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
)
raw = json.loads(resp.choices[0].message.content)
# 期望 {"sub_questions": ["q1", "q2", ...]}
return raw.get("sub_questions", [])
def searcher_agent(sub_question: str) -> dict:
"""对单个子问题执行模拟检索与摘要"""
prompt = f"""你是一个信息检索与摘要专家。针对以下子问题,
模拟多跳检索过程,输出:
1. findings: 你找到的关键信息(2-3 条)
2. sources: 信息来源(URL 或文档名)
3. confidence: 高/中/低
4. gaps: 还有什么信息缺失
只输出 JSON,不要其他内容。
子问题:{sub_question}
"""
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
)
return json.loads(resp.choices[0].message.content)
def reviewer_agent(question: str, report: str) -> dict:
"""审阅报告完整性,输出 pass/fail + 具体反馈"""
prompt = f"""你是一个研究审阅者。检查以下报告是否完整回答了原始问题。
输出 JSON:
- decision: "pass" 或 "fail"
- feedback: 如果 fail,给出具体缺失方向(不要笼统说"再研究一下")
原始问题:{question}
报告:
{report}
"""
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
)
return json.loads(resp.choices[0].message.content)
# ── 主循环 ────────────────────────────────────────────────
def run_research(question: str) -> str:
state = load_state()
feedback = None
for i in range(MAX_ITERATIONS):
state["iteration"] = i + 1
print(f"\n=== 第 {i+1} 轮迭代 ===")
# 1. 规划
sub_questions = planner_agent(question, feedback)
state["sub_questions"] = sub_questions
print(f"子问题:{sub_questions}")
# 2. 检索(每个子问题一个 searcher)
results = {}
for sq in sub_questions:
result = searcher_agent(sq)
results[sq] = result
print(f" [{sq}] → confidence={result.get('confidence')}, gaps={result.get('gaps')}")
state["results"] = results
# 3. 合成报告
findings_text = ""
for sq, r in results.items():
findings_text += f"\n## {sq}\n"
for f in r.get("findings", []):
findings_text += f"- {f}\n"
findings_text += f"来源: {r.get('sources', [])}\n"
findings_text += f"置信度: {r.get('confidence')}\n"
if r.get("gaps"):
findings_text += f"信息缺口: {r['gaps']}\n"
report = f"# 研究报告:{question}\n{findings_text}"
state["report"] = report
# 4. 审阅
review = reviewer_agent(question, report)
decision = review.get("decision", "fail")
print(f"审阅结果:{decision}")
save_state(state)
if decision == "pass":
print("✅ 报告通过审阅")
return report
else:
feedback = review.get("feedback", "")
print(f"❌ 需要改进:{feedback}")
print("⚠️ 达到最大迭代次数,返回当前报告")
return state.get("report", "未能生成完整报告")
# ── 运行 ──────────────────────────────────────────────────
if __name__ == "__main__":
question = "2024-2025年主流大模型厂商的推理能力对比,包括价格、延迟和准确率"
final_report = run_research(question)
print("\n" + final_report)
运行前确保已设置 OPENAI_API_KEY 环境变量,并安装 openai 包(pip install openai)。这个框架虽然用了模拟检索而非真实搜索引擎,但状态管理、迭代控制、Agent 通信的模式和 Kulkarni 描述的生产系统是一致的。你可以把 searcher_agent 替换为真实搜索 API(如 Tavily、SerpAPI)来升级。
检索层的坑:多跳不是多搜一遍
多跳检索听起来简单——搜不到就再搜一次。但生产环境里,多跳的真正含义是根据上一跳的结果决定下一跳的策略。
几个实操要点:
- 第一跳用宽查询,目的是找到信息地图;第二跳用窄查询,针对具体缺口深挖。
- 每跳必须记录来源。没有来源的发现等于没有发现——Synthesizer 无法验证,Reviewer 无法审计。
- 设置跳数上限。Kulkarni 建议最多 3 跳,超过 3 跳通常意味着第一跳的查询方向就错了,应该回到 Planner 重新拆解,而不是继续在错误方向上深挖。
- 去重与冲突检测。不同子问题的检索结果可能引用同一来源但说法不同,Synthesizer 必须显式标注冲突,而不是悄悄选一个。
上线前的检查清单
把 Kulkarni 的教训浓缩成一份可操作的清单:
| 检查项 | 为什么重要 | 常见遗漏 |
|---|---|---|
| 每个 Agent 的 prompt 是否短于 500 词 | 长 prompt 导致行为不可预测 | 把所有指令塞给一个 Agent |
| Agent 间传递的是摘要还是原文 | 原文会撑爆上下文窗口 | 直接把搜索结果原文传给合成 Agent |
| 是否有共享状态存储 | 否则中间结果丢失后无法恢复 | 只靠内存传递 |
| 是否设置了最大迭代次数 | 否则 Reviewer 反馈可能触发无限循环 | 忘记加循环保护 |
| 检索结果是否带来源 URL | 没有来源就无法审计和验证 | 只返回内容不返回出处 |
| 多跳检索是否有跳数上限 | 否则会在错误方向上越走越远 | 每跳都是独立查询,没有策略递进 |
| Reviewer 反馈是否具体 | "再研究一下"等于没反馈 | Reviewer 只说 fail 不说哪里 fail |
| 是否有成本监控 | 多 Agent 多轮调用,token 消耗是乘法级 | 只测功能不看账单 |
最后一点容易被忽略:成本是乘法级增长的。一个研究问题拆成 4 个子问题,每个子问题 3 跳检索,每跳调一次 LLM,一轮迭代就是 12+ 次调用。如果 Reviewer 打回两轮,就是 36+ 次。在上线前,必须建立每次研究任务的 token 消耗基线,否则账单会让你比系统 bug 更早发现问题。
Deep Research Agent 不是更聪明的聊天机器人,而是一个需要工程纪律的分布式系统。把职责拆清、把状态管好、把成本盯住——这三件事做对了,Agent 才能从 demo 走到生产。