Agent AtlasAI Agent 知识库
06.01实战 · In Practice

实战:深度研究助手

从问题拆解、并行检索、证据表到带引用的报告,完整实现一个 Deep Research 风格 Agent。

深度研究助手是检验 Agent 工程能力的好项目:它需要规划、检索、并行、证据管理、写作和引用校验,几乎覆盖了前面所有章节。

目标与边界

目标:给定一个开放问题,产出 800–1500 字、带可核对引用的研究报告。

不做:

  • 不提供投资/医疗建议;
  • 不访问付费墙或需要登录的站点;
  • 不编造数据;资料不足时明确说明。

验收标准:

  • 每个事实性陈述有引用;
  • 引用能定位到原文段落;
  • 关键数字与来源一致;
  • 资料不足时有「不确定」说明;
  • P95 在 90 秒内,单次成本可控。

为什么难

  • 问题开放:不知道要查什么,需要先规划;
  • 来源多:网页、PDF、新闻、报告,质量参差;
  • 证据冲突:不同来源口径不同;
  • 上下文爆炸:10 篇文档就能塞满窗口;
  • 引用幻觉:模型会编造来源或曲解原文;
  • 长尾延迟:串行检索 10 次,用户等 2 分钟。

架构

图 1 · 深度研究助手
Planner澄清问题
生成子问题
→
Retriever并行搜索
去重、抓正文
→
Extractor抽取带来源的事实
→
Synthesizer组织论证
处理冲突
→
Verifier核对引用与数字
前两步可并行;Extractor 用子 Agent 隔离长文档;Verifier 不通过则回到 Synthesizer。

状态设计

不要让所有内容都堆在消息里。用结构化状态:

python
from dataclasses import dataclass, field

@dataclass
class Evidence:
    id: str                 # E1, E2...
    claim: str              # 这条证据支持的结论
    quote: str              # 原文摘录(短)
    source_url: str
    source_title: str
    published_at: str | None
    retrieved_at: str
    confidence: float

@dataclass
class ResearchState:
    question: str
    sub_questions: list[str] = field(default_factory=list)
    evidence: list[Evidence] = field(default_factory=list)
    conflicts: list[dict] = field(default_factory=list)
    outline: list[str] = field(default_factory=list)
    draft: str = ""
    citations_ok: bool = False
    budget: dict = field(default_factory=dict)

关键点:

  • 证据是原子的(一条 claim + 一段 quote),不是整篇文章;
  • 每条证据有 URL、标题、时间,便于引用与时效判断;
  • 冲突单独记录,不要偷偷选一个;
  • 草稿与证据分离,写作时只引用证据 ID。

第一步:规划

text
你是研究规划器。把问题拆成 3–6 个可检索的子问题。

要求:
1. 每个子问题能独立搜索,避免相互依赖。
2. 覆盖:定义/背景、关键数据、不同立场、时间线。
3. 标注每个子问题需要的来源类型(论文/官方/新闻/数据库)。
4. 如果问题本身含糊,先列出需要澄清的点,不要硬编。

问题:{question}
输出 JSON:{"clarifications": [], "sub_questions": [{"q": "...", "source_type": "..."}]}

规划质量决定后面所有步骤。子问题太宽,检索回来一堆噪声;太窄,覆盖不全。

第二步:并行检索

python
import asyncio

async def retrieve_all(sub_questions, per_query=6):
    sem = asyncio.Semaphore(4)  # 控制并发

    async def one(sq):
        async with sem:
            results = await search(sq["q"], limit=per_query)
            pages = await asyncio.gather(*[fetch_text(r.url) for r in results[:3]])
            return list(zip(results[:3], pages))

    batches = await asyncio.gather(*[one(sq) for sq in sub_questions])
    return dedupe_by_url([item for batch in batches for item in batch])

要点:

  • 并发上限,避免触发搜索 API 限流;
  • 只抓前 3 条正文,不要全抓;
  • 按 URL/标题去重;
  • 记录抓取时间与失败原因;
  • 正文抽取后截断到 8k–12k 字符,再交给 Extractor。

第三步:抽取证据

把每篇文档交给一个子 Agent,只回传结构化证据,避免主上下文被原文淹没:

text
从下面的文档中抽取与问题相关的事实。

规则:
1. 每条事实必须附一段原文摘录(≤ 50 字),不得改写。
2. 不要推断文档没有的内容。
3. 数字、日期、机构名必须原样保留。
4. 如果文档与问题无关,返回空数组。
5. 标注这条事实支持哪个子问题。

文档:{text}
子问题:{sub_question}
输出:{"evidence": [{"claim": "...", "quote": "...", "confidence": 0.0-1.0}]}

代码:

python
async def extract_all(pages, sub_questions):
    tasks = [extract_one(text, sq) for (meta, text), sq in pair(pages, sub_questions)]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    evidence = []
    for meta, result in zip(pages, results):
        if isinstance(result, Exception):
            continue
        for i, e in enumerate(result["evidence"]):
            if e["confidence"] < 0.5:
                continue
            evidence.append(Evidence(
                id=f"E{len(evidence)+1}",
                claim=e["claim"],
                quote=e["quote"],
                source_url=meta.url,
                source_title=meta.title,
                published_at=meta.published_at,
                retrieved_at=now_iso(),
                confidence=e["confidence"],
            ))
    return evidence

第四步:综合与写作

先把证据按子问题分组,找出冲突:

text
证据表:
[SQ1] E1: 2025 营收 12.4 亿(来源 A)
      E2: 2025 营收 12.1 亿(来源 B)
      → 冲突:口径不同(含/不含汇率影响),在报告中说明

写作提示:

text
根据证据表写一份研究报告。

结构:
1. 结论(3 条以内)
2. 关键发现(按子问题组织)
3. 风险与不确定性
4. 来源列表

规则:
- 每个事实性句子后标注 [E1] 这样的证据 ID。
- 只使用证据表中的内容;不足时写「资料不足」。
- 遇到冲突,说明两种说法与可能原因,不要擅自选一个。
- 不要写投资建议。
- 800–1500 字。

第五步:引用校验

这是最容易被省略、却最影响可信度的一步。

python
def verify_citations(draft, evidence_by_id):
    problems = []
    for sent, ids in extract_sentences_with_citations(draft):
        if not ids:
            if is_factual(sent):
                problems.append(("missing_citation", sent))
            continue
        for eid in ids:
            ev = evidence_by_id.get(eid)
            if ev is None:
                problems.append(("unknown_citation", eid))
                continue
            if not supports(ev.quote, sent):
                problems.append(("quote_mismatch", eid, sent))
    return problems

supports 可以用小模型判断,也可以先用规则:

  • 数字是否出现在 quote 中;
  • 实体名是否一致;
  • 时间是否一致;
  • 是否明显超出 quote 的范围。

校验失败时,只让模型修正有问题的句子,不要重写全文。

成本与延迟预算

阶段调用延迟备注
规划1 次3–8s可用通用模型
检索3–6 路并行5–15s受搜索 API 限制
抽取每篇 1 次,并行10–30s小模型即可
写作1 次10–30s强模型
校验1 次 + 规则5–15s小模型
合计10–20 次40–90s取决于并发

控制手段:

  • 限制子问题数量(≤ 6);
  • 每路子问题只抓 3 篇;
  • 抽取用便宜模型;
  • 证据表去重、截断;
  • 缓存搜索结果。

评估

构造 20–30 个研究问题,人工标注:

  • 必须覆盖的关键事实;
  • 可接受的来源;
  • 不该出现的错误(过时数据、错误实体)。

指标:

  • 事实覆盖率;
  • 引用准确率(引用是否支持结论);
  • 冲突识别率;
  • 拒答/不确定性表达是否恰当;
  • 平均耗时与成本。

一个很有效的测试:故意给一个资料不足的问题,看它是否会编。

失败模式

搜索到低质量内容

SEO 农场、洗稿站。用域名白名单/权威度排序,抽取时要求标注来源类型。

引用张冠李戴

模型把 E3 的结论标成 E5。写作后必须逐句校验。

冲突被抹平

两个来源数字不同,报告只写一个。应明确说明口径差异。

上下文爆炸

10 篇长文直接塞进写作提示。用子 Agent 抽取,只保留证据表。

过度自信

资料只有一条,却写成「业界普遍认为」。要求区分「单一来源」与「多方一致」。

可以继续扩展的方向

  • 加入图表生成(从证据表生成);
  • 支持多语言来源;
  • 加入时间线视图;
  • 人工在中间补充资料;
  • 把报告发布为可分享页面。

小结

  • 深度研究 = 规划 + 并行检索 + 证据抽取 + 综合 + 引用校验。
  • 用结构化证据表代替长文上下文。
  • 子 Agent 隔离长文档,主 Agent 只处理结论。
  • 引用校验是可信度的关键,不能省。
  • 控制子问题数量、抓取数量和模型层级,才能守住延迟与成本。

下一篇做一个更贴近业务的场景:客服工单 Agent。

读完了?进度会保存在本地浏览器。
内容会持续修订。发现错误或想补充,欢迎反馈。 遇到问题?查排查手册 →