跳到主要内容

02 · 知识采集:多视角模拟对话(STORM 的发动机)

这是 STORM 名字里 “Multi-perspective Question Asking” 和 “Retrieval” 的实现,也是整个项目工程含量最高、最值得读的一章。

1. 核心洞察:难点是“问问题”,不是“写”

README 把话说得很直白:STORM 认为自动化调研的核心,是自动想出好问题;而直接让 LLM 提问效果不好——问出来的都是泛泛、表层的问题。

STORM 用两招把问题问得又深又广:

  • 视角引导提问(Perspective-Guided): 不让 LLM 用“一个泛泛的我”去问,而是先发现这个主题下有哪些不同视角/角色(通过看相似主题的维基页面),让每个视角各自去问。
  • 模拟对话(Simulated Conversation): 让“维基编辑”和“联网专家”多轮对话——专家的回答会进入编辑的下一轮提问,从而问出有上下文的追问。

2. 顶层图:Writer ↔ Expert 的模拟对话

怎么读这张图:左边的 Writer(编辑)和右边的 Expert(专家)交替发言,共 max_turn 轮;每轮专家都要先“拆查询→检索→据资料作答”。

┌─────────────────────────────────────────────────────────┐
│ ConvSimulator.forward (循环 max_turn 次) │
└─────────────────────────────────────────────────────────┘

persona(某个视角)


┌──────────────┐ question(带视角的问题) ┌──────────────┐
│ WikiWriter │ ─────────────────────────▶ │ TopicExpert │
│ (问问题的人) │ │ (联网答题) │
│ │ ◀───────────────────────── │ │
└──────────────┘ answer(带 [1][2] 引用) └──────────────┘
▲ │
│ ├─ ① QuestionToQuery:问题→搜索词
└──────── dlg_history 累积,喂回下一轮 ◀──────┤─ ② retriever.retrieve:联网搜
└─ ③ AnswerQuestion:据 snippet 作答

对应符号:ConvSimulatorWikiWriterTopicExpert 全在 knowledge_storm/storm_wiki/modules/knowledge_curation.py

3. 机制一:视角(persona)怎么生成

它要解决的小问题: 一个主题该从哪些角度去研究?

思路: 别凭空想,去看相似主题的维基页面长什么样——它们的章节标题就暗示了常见视角。

真实实现分两步(knowledge_storm/storm_wiki/modules/persona_generator.py):

  1. FindRelatedTopic(persona_generator.py:48-53)让 LLM 列出若干相关维基页面 URL;
  2. 代码用 get_wiki_page_title_and_toc(persona_generator.py:10-45)真的去抓这些页面的标题 + 目录(TOC),作为灵感;
  3. GenPersona(persona_generator.py:56-65)据这些目录生成“一组各有侧重的维基编辑”。

关键细节: StormPersonaGenerator.generate_persona 一定会在最前面塞一个默认视角 “Basic fact writer”(persona_generator.py:152-153),保证就算视角生成失败也有人去问基础事实。这是个朴素但稳的兜底。

# 示意,源码精简自 persona_generator.py
default_persona = "Basic fact writer: ...broadly covering the basic facts..."
considered_personas = [default_persona] + personas.personas[:max_num_persona]

4. 机制二:Writer 怎么带着视角提问

它要解决的小问题: 怎么让提问“记得”自己的视角、也“记得”刚才聊了什么、还别重复问?

真实实现 WikiWriter.forward(knowledge_curation.py:95-125):

  • 视角非空就用 AskQuestionWithPersona,否则退化成 AskQuestion(两个 dspy Signature,knowledge_curation.py:128-152);两个 prompt 都明确要求“一次只问一个问题、别问问过的、没问题了就说 ‘Thank you so much for your help!’”。
  • 喂给 LLM 的对话历史做了压缩:只有最近 4 轮保留专家完整回答,更早的轮次把专家答案替换成 “Omit the answer here due to space limit.”(knowledge_curation.py:103-110)——既保上下文又省 token。
# 示意,源码精简自 knowledge_curation.py:103-110
for turn in dialogue_turns[:-4]: # 早期轮次:只留自己问的,答案省略
conv.append(f"You: {turn.user_utterance}\nExpert: Omit the answer here due to space limit.")
for turn in dialogue_turns[-4:]: # 最近 4 轮:保留完整答案
conv.append(f"You: {turn.user_utterance}\nExpert: {remove_citations(turn.agent_utterance)}")

对话怎么终止: ConvSimulator.forward(knowledge_curation.py:47-81)里,若 Writer 说出以 “Thank you so much for your help!” 开头的话、或问出空字符串,就 break。

5. 机制三:Expert 怎么联网作答(Identify→Search→Answer)

它要解决的小问题: 收到一个问题后,怎么变成可信、有出处的回答?

TopicExpert.forward(knowledge_curation.py:204-244)分三步,注释里写得清清楚楚:

  1. 拆查询: QuestionToQuery 把一个问题拆成多条 Google 搜索词,清洗掉前导 -/引号,截到 max_search_queries 条。
  2. 检索: retriever.retrieve(set(queries)),并把 ground_truth_url 排除(评测时防止“答案泄漏”)。
  3. 作答: 把检索结果拼成 [1]: snippet 形式、截到 1000 词,用 AnswerQuestion 生成回答。

两个诚实性设计(防幻觉):

  • 检索啥也没搜到时,专家不许编,直接回 “Sorry, I cannot find information for this question.”(knowledge_curation.py:238-240)。
  • AnswerQuestion 的 prompt 要求“每句话都要有检索资料支撑;答不了就明说 ‘I cannot answer this question based on the available information’ 并解释缺口”(knowledge_curation.py:167-178)。
  • 生成后用 remove_uncompleted_sentences_with_citations 砍掉因 token 上限被截断的半截句子(knowledge_curation.py:232-234)。

6. 机制四:多视角并行 + 信息表汇总

并行: 每个视角是一场独立对话,_run_conversation 用线程池并发跑所有视角的对话(knowledge_curation.py:286-345),max_workers = min(max_thread_num, 视角数)。还专门处理了 Streamlit 前端的线程上下文(add_script_run_ctx)。

汇总成信息表: 所有对话的检索结果汇进 StormInformationTable。建表时 construct_url_to_infoURL 去重并合并 snippet(storm_dataclass.py:65-80):

# 示意,源码精简自 storm_dataclass.py:65-80
for persona, conv in conversations:
for turn in conv:
for storm_info in turn.search_results:
if storm_info.url in url_to_info:
url_to_info[storm_info.url].snippets.extend(storm_info.snippets) # 同源合并
else:
url_to_info[storm_info.url] = storm_info
for url in url_to_info:
url_to_info[url].snippets = list(set(url_to_info[url].snippets)) # snippet 去重

这张表就是后续大纲/成文阶段的“资料库”。它怎么被语义检索,见 01 章 §404 章

7. 这章带走什么

STORM 的全部“魔法”就是:用 persona 把提问者多样化 + 用模拟对话把提问深度化 + 用检索把回答事实化。后面三个阶段(大纲/成文/润色)只是把这些采访记录工程化地变成文章。Co-STORM 则把“人”和“一张思维导图”加进这场对话——见 03 章