跳到主要内容

引用合成与报告生成(quick vs detailed)

30 秒导读: 搜索引擎捞回一堆网页片段后,这一环负责两件事——先把片段喂给 LLM,产出[1] [2] 编号引用、且不敢编造来源的文字;再把这些文字拼成用户能读的产物。产物分两档:轻量的 quick summary(一段综述)和重量的 detailed report(带目录、逐小节深挖的长文)。本章讲透"编号怎么保持连续""怎么防幻觉""两档产物怎么拼",不重复策略循环(01)和搜索引擎(02)。


1. 这是什么(零基础也能懂)

一句话定义: 把"一堆搜索结果 + 一个问题"变成"一段有理有据、每个论断后面挂着 [3] 这种编号、结尾附来源清单"的可信文本,再装配成报告。

它解决什么问题。 LLM 天生爱"一本正经地编"——你让它"带引用回答",它会顺手编出一个看着像真的、其实不存在的来源。深度研究工具最怕这个:引用一旦是假的,整份报告就不可信。这一环的核心任务,就是用工程手段把"编造引用"这条路堵死。

三个绕不开的小问题:

小问题白话
编号怎么来第 5 篇搜索结果,凭什么在正文里叫 [5]、在来源清单里也叫 [5]?
编号怎么跨轮不乱一份 detailed report 有十几个小节,每节都各搜各的,编号凭什么不从 [1] 重头再来、彼此打架?
怎么不让模型编搜索一无所获时,怎么保证模型不退回"我记得好像是……"然后编个假链接?

一句话直觉: 把整个系统里所有见过的源(网页)想象成一本共享的花名册(all_links_of_system),每条源进册子时就分到一个永久工号(index 字段)。正文里的 [n]、结尾清单里的 [n]、导出的 PDF 里的 [n]——引的都是同一个工号。编号连续、不重号、跨小节可追,全靠这本花名册只有一份。


2. 顶层全景(它大概怎么转)

怎么读这张图: 从左到右是一次合成的数据流。左半("引用侧")把搜索结果变成带编号的可信文本;右半("报告侧")把文本拼成产物并导出。中间那本"共享花名册"是编号连续的关键。

┌──────────────────────────────────────────┐
│ 共享花名册 all_links_of_system │
│ [每条源一个永久 index 工号] │
└───────▲───────────────────────┬────────────┘
│ 写入 index │ 读取全部源
搜索结果 ─────────────────┤ │
(List[Dict]) │ ▼
│ ┌───────┴────────┐ ┌──────────────┐
▼ │ 引用处理器 │ │ 来源清单装配 │
┌─────────┐ │ CitationHandler │ │ format_links │
│ 无源? │──是──▶│ → 按 type 选实现│ │ _to_markdown │
│ 防幻觉门 │ │ → 调 LLM 带编号 │ └──────┬───────┘
└────┬────┘ └───────┬──────────┘ │
│否 / 拒答 │ {content, documents} │
└───────────────────▶│ ▼
▼ ┌──────────────┐
┌─────────────────┐ │ 两种产物 │
│ 带 [n] 的合成文本 │──────────▶│ quick 综述 │
└─────────────────┘ │ detailed 报告 │
└──────┬───────┘

┌──────────────┐
│ 导出 exporters│
│ PDF/ODT/LaTeX │
└──────────────┘

部件一句话职责:

部件干什么在哪
CitationHandler统一入口,按 handler_type 把活派给具体实现citation_handler.py:10
BaseCitationHandler公共底座:建文档、编号、格式化、无源防幻觉门citation_handlers/base_citation_handler.py:15
StandardCitationHandler默认实现:详细分析 + 可选事实核查citation_handlers/standard_citation_handler.py:11
ForcedAnswerCitationHandler逼模型给确定答案(BrowseComp 基准用)citation_handlers/forced_answer_citation_handler.py:14
PrecisionExtractionHandler正则 + LLM 精确抠答案(SimpleQA 用)citation_handlers/precision_extraction_handler.py:21
format_links_to_markdown把花名册渲染成结尾"来源"段utilities/search_utilities.py:217
IntegratedReportGeneratordetailed 报告装配器,逐小节调 analyze_topicreport_generator.py:33
CitationFormatter把正文 [n] 变成超链接 [[n]](url)text_optimization/citation_formatter.py:54
ExporterRegistry / 各 exporter把最终 Markdown 导成 PDF/ODT/LaTeX…exporters/registry.py:15

3. 核心机制一:引用处理器体系(选谁 + 防编造)

3.1 统一入口按 handler_type 选实现

要解决的小问题: 不同任务对"回答风格"要求不同——普通研究要详实、基准测试要一个准确答案。但调用方不想关心这些差异,只想说一句"帮我合成"。

思路: CitationHandler 是一层薄壳(facade)。它自己不做合成,只在构造时读 citation.handler_type 设置,import 并实例化真正干活的子类,然后把调用透传下去。

真实实现——按类型分派,认不出就退回 standard:

# citation_handler.py:38 _create_handler(handler_type)
if handler_type == "standard":
return StandardCitationHandler(...)
if handler_type in ["forced", "forced_answer", "browsecomp"]:
return ForcedAnswerCitationHandler(...) # 基准:逼出确定答案
if handler_type in ["precision", "precision_extraction", "simpleqa"]:
return PrecisionExtractionHandler(...) # SimpleQA:正则精抠
logger.warning(...) # 未知类型
return StandardCitationHandler(...) # 兜底退回默认

对外只暴露两个动词:analyze_initial(第一轮)和 analyze_followup(带着"既有知识"的后续轮),都只是 return self._handler.xxx(...) 的转发(citation_handler.py:92:98)。

三种实现的取舍:

实现面向关键手法拿不准时
Standard普通研究(默认)详实分析,可选跨源事实核查老实说"信息不足"
ForcedAnswerBrowseComp 基准提示词硬命令"绝不说无法确定"猜一个最可能的(_extract_direct_answer)
PrecisionSimpleQA 基准正则先抓姓名/年份/数字,再让 LLM 定夺挑出现最多的候选

精华: ForcedAnswer 有句直白的设计哲学——"A wrong answer is better than no answer for this task."(forced_answer_citation_handler.py:119)。这是为跑分特化的行为,和默认 Standard "无源就拒答"的谨慎正好相反。选错 handler 会让一个求稳的产品变成满嘴跑火车,所以它藏在设置里、默认关。

3.2 无源不调 LLM —— 防幻觉的硬门

要解决的小问题: 这是全章最重要的一条。只要 sources 段是空的,还去提示 LLM"请带 [1] [2] 引用回答",模型就会退回训练记忆、编出假引用。

思路: 不给模型这个机会。合成前先看有没有文档;没有就直接返回一段固定的"无源"说明,根本不发 LLM 请求

真实实现——底座提供统一的拒答出口:

# base_citation_handler.py:180 _no_sources_response(question)
logger.warning(f"[{...}] No sources available ... skipping LLM call "
f"to avoid fabricated citations")
content = ("No sources were found for this question. ... No answer was "
"generated because, without sources, it would have to rely on "
"the language model's built-in knowledge and could contain "
"fabricated citations.")
return {"content": content, "documents": []}

两个入口的门槛略有不同(这是细节,但很关键):

方法触发拒答的条件依据
analyze_initial没有任何文档standard_citation_handler.py:18
analyze_followup没有文档没有"既有知识"standard_citation_handler.py:55

后续轮之所以放宽:既有知识里已经带着上一轮的合法引用,即便本轮没搜到新东西,让模型基于旧引用继续答仍是可信的;只有当"新源和旧知识都空"时才拒答。

注意: 只有 Standard 装了这道门。ForcedAnswer 和 Precision 故意不装——它们的任务就是"哪怕没料也得挤个答案出来",所以直接走合成(forced_answer_citation_handler.py:21 建完文档就格式化,不检查空)。这再次说明基准 handler 不适合当日常默认。

3.3 精确抽取:先正则再 LLM(Precision)

要解决的小问题: SimpleQA 这类问题只要一个短答案(一个全名、一个年份、一场比分)。让 LLM 自由发挥容易带出多余信息或半截名字。

思路: 先用 _identify_question_type(precision_extraction_handler.py:143)按问句关键词判定类型(full_name / temporal / dimension / score …),LLM 答完后再用对应的正则 + 二次 LLM 抠把答案收紧。例如全名类会找出所有姓名变体、按最后一个词分组、每组取最长的当"完整名"(_extract_full_name,:224)。

这套逻辑是本章里**唯一大量用非 LLM 手段(正则、频次统计)**兜底的地方;它服务的是"要精确短答"的窄场景,不是通用报告。


4. 核心机制二:编号如何跨轮连续

4.1 编号的唯一真源:共享 dict 的 index 字段

要解决的小问题: 正文写 [5],结尾清单也得是同一个 [5] 指向同一个 URL;而且一份长报告里第 3 小节的 [5] 不能和第 1 小节的 [5] 撞车。

思路(呼应 01,此处从引用侧讲透): 每条搜索结果就是一个 dict。引用处理器在建 LangChain 文档时,直接往这个 dict 上写 index 字段。因为策略层把同一批 dict 对象同时放进了共享的 all_links_of_system,写进去的 index 就自动传播到了花名册——不需要另外同步。

真实实现——只在缺 index 时才写,且带偏移量:

# base_citation_handler.py:139 _create_documents(search_results, nr_of_links=0)
for i, result in enumerate(search_results):
if "index" not in result: # 已有就不覆盖
result["index"] = str(i + nr_of_links + 1) # 关键:加偏移
doc_index = int(result.get("index", i + nr_of_links + 1))
documents.append(Document(page_content=content,
metadata={"source": ..., "index": doc_index}))

_format_sources 随后就照着这个 index 把每段源文本标上 [index] 交给 LLM(base_citation_handler.py:172)。LLM 在正文里引用的编号,和喂进去的这个编号一致。

4.2 nr_of_links:让编号接着上一轮往下走

关键在 nr_of_links 这个偏移量。 假设上一轮已经收了 8 条源(花名册长度 = 8),这一轮新搜到 3 条:新的编号必须是 [9] [10] [11],不能又从 [1] 开始。策略层就把"本轮开始前的花名册长度"当偏移传进来。

真实实现——策略在合成前记下长度,合成后扩展花名册:

# advanced_search_system/strategies/source_based_strategy.py
total_citation_count_before_this_search = len(self.all_links_of_system) # :178
...
self.all_links_of_system.extend(final_filtered_results) # :452 先入册
final_citation_result = self.citation_handler.analyze_followup( # :477
query, final_filtered_results, previous_knowledge="",
nr_of_links=total_citation_count_before_this_search, # :481 传偏移
)

整条编号传播链(一次 analyze_topic 内):

本轮开始
│ 记下 len(all_links_of_system) = N (偏移量 nr_of_links)

新结果入册 all_links_of_system.extend(...) (同一批 dict 对象)

analyze_followup(..., nr_of_links=N)

_create_documents: result["index"] = i + N + 1 (写到 dict 上)

因为是同一批 dict → 花名册里这些条目也就有了 index

LLM 拿 [N+1..] 编号写正文 → 结尾清单读花名册同样的 index

跨多次 analyze_topic(detailed report 每个小节一次)时,花名册只有一份、只增不减,偏移量每次接着上次,于是全篇编号严格递增、永不重号

精华: 这里最巧的是"零拷贝同步"——index 不是算两遍再对齐,而是靠"正文文档"和"花名册条目"指向同一个 dict 对象,写一次两边都有。源码注释点破了这层(source_based_strategy.py:483-486:"these are the same dict objects ... indices propagate automatically")。


5. 核心机制三:两种产物(quick vs detailed)

5.1 分叉点:mode 决定走哪条路

研究服务按 mode 分叉(web/services/research_service.py:1088:2080):

产物是什么怎么来
quick summary一段综述文字 + 来源直接拿研究阶段的 current_knowledge,格式化引用即可
detailed report带目录、多小节的长文交给 IntegratedReportGenerator,逐小节再各自研究一遍

quick 便宜:研究循环产出的合成文本本身就带编号,套一层引用超链接、拼上来源段就完事。detailed 贵:它把报告拆成小节,每个小节都当成一次独立的深度研究重新跑。

5.2 detailed:逐小节多次调 analyze_topic

思路: IntegratedReportGenerator.generate_report(report_generator.py:88)分三步——① 让 LLM 定目录结构;② 逐小节研究 + 生成;③ 拼最终报告。

第 ② 步是重头戏:_research_and_generate_sections(report_generator.py:298)遍历每个小节,为每节造一个研究 query,再调一次 search_system.analyze_topic(subsection_query)(report_generator.py:491)。也就是说,一份 N 小节的报告要跑 N 次完整检索—合成。

关键设计:传入既有的 search_system 以复用花名册。 报告生成器不新建搜索系统,而是接住研究阶段那一个(research_service.py:2109search_system=search_system),这样所有小节共享同一本 all_links_of_system,§4 的编号连续性才跨小节成立。

5.3 累积上下文防止小节间重复

要解决的小问题: N 个小节各查各的,很容易车轱辘话——每节都从头讲一遍背景。

思路: 每写完一节就把内容存进 accumulated_findings,写下一节前,把最近几节的内容塞进 prompt,并硬加一句"这些已经写过,别重复"。

真实实现——两个常量卡住上下文规模:

# report_generator.py:16
DEFAULT_MAX_CONTEXT_SECTIONS = 3 # 只回看最近 3 节
DEFAULT_MAX_CONTEXT_CHARS = 4000 # 上下文最多 4000 字符(照顾小模型)

_build_previous_context(report_generator.py:261)取最近 max_context_sections 节拼起来,超长就按句子边界截断(_truncate_at_sentence_boundary,:224),外面裹上 === CONTENT ALREADY WRITTEN (DO NOT REPEAT) === 的分隔块。两个常量都可被 report.max_context_* 设置覆盖(:70:75)。

5.4 Sources 段的装配

第 ③ 步 _format_final_report(report_generator.py:542)拼目录 + 各节正文,末尾读整本花名册渲染来源:

# report_generator.py:580
utilities = importlib.import_module("local_deep_research.utilities")
formatted_all_links = utilities.search_utilities.format_links_to_markdown(
all_links=self.search_system.all_links_of_system) # 读整本花名册
...
final_report_content += "\n\n## Sources\n\n" + formatted_all_links # :602

边界(存储不变量): 内存返回的 detailed 报告 ## Sources 尾巴,给 MCP / 程序化 API 用;但存库时会被 format_document_split 剥掉,report_content 列只存"纯答案"——来源另存 research_resources 表,显示时再由 report_assembly_service.assemble_full_report(web/services/report_assembly_service.py:31)重新拼回。源码把这个不变量反复标注,防止有人把拼好的整块写回去(report_generator.py:586-601)。


6. 最终来源清单与导出

6.1 从结果里抽链接

extract_links_from_search_results(utilities/search_utilities.py:146)把搜索结果 dict 收敛成 {title, url, index, ...},并保留一大票引用相关字段(doi、authors、published、journal 等,:179-205),让它们能一路带到数据库,不在这步丢掉。

6.2 渲染来源段(去重 + 规范化 URL)

format_links_to_markdown(utilities/search_utilities.py:217)是来源段的渲染器,两个要点:

  • 按规范化 URL 去重:canonical_url_key 折叠尾斜杠、utm 参数、fragment、端口、大小写,同一来源即便被多次引用也只列一次,但保留它的多个编号
  • 按首次出现顺序输出,每条形如 [1, 7] 标题 (source nr: 1, 7)URL: 行(:275)。

渲染样例(示意):

[1, 4] Attention Is All You Need [Q1] (source nr: 1, 4)
URL: https://arxiv.org/abs/1706.03762

[2] OpenAI Blog (source nr: 2)
URL: https://openai.com/research

6.3 正文 [n] 变超链接

正文里的裸 [n]CitationFormatter 转成可点的 [[n]](url)format_document_split(text_optimization/citation_formatter.py:141)先找到 ## Sources 边界,把答案和来源切开,只对答案部分套超链接;若正文没有来源段,则走 apply_inline_hyperlinks(:191)用结构化源列表兜底。样式由 report.citation_format 设置选(number / domain / source-tagged 等模式,CitationMode,:32;工厂在 research_service.py:177)。

6.4 导出成文件

导出层是注册表 + 抽象基类的经典搭配:

  • BaseExporter(exporters/base.py:37)定义三个属性(format_name / file_extension / mimetype)加一个 export(markdown_content, options),输入统一是 Markdown 字符串,输出 ExportResult(content: bytes, filename, mimetype)
  • ExporterRegistry(exporters/registry.py:15)用 @register 装饰器登记各 exporter,get_exporter("pdf") 按名取单例(:55)。
  • 具体格式:PDF、ODT、LaTeX、Quarto、RIS 等(exporters/__init__.py 里逐个 import 触发注册)。基类还管 50MB 上限和安全文件名(base.py:63:118)。

也就是说:引用合成产出 Markdown → 导出层把这份 Markdown 转成任意格式。引用编号和来源段在 Markdown 阶段就已定型,导出只是换壳。


7. 边界与局限

  • 基准 handler 会主动编答案。 ForcedAnswer / Precision 为跑分特化,不装"无源拒答"门,拿不准就猜。当日常默认会把可信度换成命中率——它们默认关着是对的。
  • detailed 报告很贵。 N 个小节 = N 次完整检索—合成;小节多、每节 iteration 深时,LLM 调用与耗时线性放大(report_generator.py:298 的循环)。
  • 去重只按 URL,不按内容。 同一文章的两个不同 URL(镜像站、不同参数无法规范化时)会被当两条源、占两个编号(format_links_to_markdown 的 canonical 只处理 URL 形态,不比对正文)。
  • 上下文回看只有 3 节 / 4000 字符。 跨度大的报告里,第 10 节看不到第 1 节的细节,防重复是"近距离"的(report_generator.py:16-21)。
  • 编号连续依赖"同一个 search_system"。 若 detailed 路径没把研究阶段的 search_system 传进报告生成器,花名册就会断开、编号从头再来——所以 research_service.py:2109 显式传入。

8. 代码地图(导航索引)

主题文件路径符号名
引用统一入口 / 按类型分派citation_handler.pyCitationHandler_create_handler
引用底座:建文档 + 编号citation_handlers/base_citation_handler.pyBaseCitationHandler_create_documents_format_sources
无源防幻觉门citation_handlers/base_citation_handler.py_no_sources_response
默认实现 + 空源守卫citation_handlers/standard_citation_handler.pyStandardCitationHandleranalyze_initialanalyze_followup
逼出确定答案(基准)citation_handlers/forced_answer_citation_handler.pyForcedAnswerCitationHandler_needs_answer_extraction_extract_direct_answer
精确抽取(SimpleQA)citation_handlers/precision_extraction_handler.pyPrecisionExtractionHandler_identify_question_type_apply_precision_extraction
编号偏移传播advanced_search_system/strategies/source_based_strategy.pyall_links_of_systemanalyze_followup(nr_of_links=...)
detailed 报告装配report_generator.pyIntegratedReportGeneratorgenerate_reportget_report_generator
逐小节研究 + 防重复上下文report_generator.py_research_and_generate_sections_build_previous_contextDEFAULT_MAX_CONTEXT_SECTIONS/CHARS
最终报告 + Sources 尾report_generator.py_format_final_report
抽链接 / 渲染来源段utilities/search_utilities.pyextract_links_from_search_resultsformat_links_to_markdown
正文 [n] 超链接text_optimization/citation_formatter.pyCitationFormatterformat_document_splitapply_inline_hyperlinksCitationMode
存储侧重装来源web/services/report_assembly_service.pyassemble_full_report_build_sources_markdown
quick/detailed 分叉 + 引用格式工厂web/services/research_service.pyget_citation_formatter(:177)、mode 分支(:1088/:2080)
导出注册表 + 基类exporters/registry.pyexporters/base.pyExporterRegistryBaseExporterExportResult

相邻章节: 引擎主线与策略骨架看 01;搜索引擎两阶段检索看 02;LangGraph 智能体策略看 03;运行时底座看 05