跳转至

STORM:按章节检索,再写长文

用户想要一份完整的杭州茶文化旅游攻略。直接把所有资料塞进一个 prompt 让模型写出来,结果是:结构松散、证据混在不同段落、引用难以审计。

STORM 的做法:先列大纲,每个章节单独检索、单独写,最后编辑合成。

一句话

STORM 把"一次检索一次写"变成"大纲 → 按章节检索 → 按章节写 → 编辑合成",让长文的结构和证据可控。

它修什么失败

问题 表面看起来 实际风险
一次性生成长文 结构松散
所有资料塞一个 context 信息很多 证据跨章节混用
没有编辑环节 内容存在 重复、语气不一致

它引入什么复杂度

负责什么
大纲步骤 定义章节
章节检索器 只检索该章节需要的资料
章节写作者 基于该章节证据写内容
编辑/组装器 合并、去重、统一语气

和其他模式的关系

flowchart LR
  ARAG["Agentic RAG"] -.->|"长文"| STM["STORM"]
  RL["Retrieval Loop"] -.->|"长文"| STM
  STM -.->|"事实验证"| COVE["CoVe"]
  STM -.->|"质量审查"| MC["Maker-Checker"]
  • Agentic RAG / Retrieval Loop:STORM 的每个章节内部就是一次 Retrieval Loop。
  • CoVe:写完后可以对每个章节的断言做 CoVe 验证。
  • Maker-Checker:每个章节或最终文章可以过一轮 Maker-Checker。

完整实现:杭州茶文化旅游攻略

"""storm_travel.py — 按章节检索和写作的长文生成"""
from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any


# ── 极简运行时 ──────────────────────────────────────────

@dataclass(frozen=True)
class Message:
    role: str
    content: str

class MockLLM:
    def __init__(self, responses: list[str]):
        self._responses = list(responses)
        self._idx = 0
    def complete(self, messages: list[Message]) -> str:
        if self._idx >= len(self._responses):
            raise RuntimeError("MockLLM 回复用完了")
        out = self._responses[self._idx]
        self._idx += 1
        return out

@dataclass(frozen=True)
class Document:
    doc_id: str
    text: str

class SimpleSearchIndex:
    def __init__(self, docs: list[Document]):
        self._docs = docs
    def search(self, query: str, k: int = 3) -> list[Document]:
        terms = self._tokenize(query)
        if not terms:
            return []
        scored = []
        for doc in self._docs:
            doc_tokens = self._tokenize(doc.text)
            score = sum(1 for t in terms for d in doc_tokens if t == d)
            if score > 0:
                scored.append((doc, score))
        scored.sort(key=lambda x: x[1], reverse=True)
        return [doc for doc, _ in scored[:k]]
    @staticmethod
    def _tokenize(text: str) -> list[str]:
        tokens, buf = [], ""
        for ch in text.lower():
            if ch.isalnum() or '\u4e00' <= ch <= '\u9fff':
                buf += ch
            else:
                if buf:
                    tokens.append(buf)
                    buf = ""
        if buf:
            tokens.append(buf)
        return tokens


# ── STORM 数据结构 ──────────────────────────────────────

@dataclass
class Section:
    title: str
    content: str
    evidence_ids: list[str] = field(default_factory=list)

@dataclass
class Article:
    topic: str
    sections: list[Section]
    final_text: str


# ── STORM 核心逻辑 ──────────────────────────────────────

def storm_write(
    model: MockLLM,
    *,
    topic: str,
    index: SimpleSearchIndex,
    top_k: int = 3,
) -> Article:
    """
    1. 列大纲
    2. 每个章节:生成 query → 检索 → 写
    3. 组装最终文章
    """
    # 步骤 1:列大纲
    raw_outline = model.complete([
        Message(role="system", content="为主题创建简短大纲。只返回 JSON。"),
        Message(role="user", content=f"主题:{topic}"),
    ])
    outline = json.loads(raw_outline)["sections"]
    print(f"[大纲] {len(outline)} 个章节:{outline}")

    # 步骤 2:逐章节检索和写作
    sections: list[Section] = []
    for title in outline:
        # 2a:生成检索 query
        raw_query = model.complete([
            Message(role="system", content="为这个章节生成检索 query。只返回 JSON。"),
            Message(role="user", content=f"主题:{topic}\n章节:{title}"),
        ])
        query = json.loads(raw_query)["query"]

        # 2b:检索
        docs = index.search(query, k=top_k)
        evidence_ids = [d.doc_id for d in docs]
        notes = "\n".join(f"- [{d.doc_id}] {d.text[:100]}" for d in docs)
        print(f"[章节 '{title}'] query='{query}', 命中 {len(docs)} 条")

        # 2c:写章节
        content = model.complete([
            Message(
                role="system",
                content="用证据写这个章节。引用来源 [doc_id]。",
            ),
            Message(
                role="user",
                content=f"主题:{topic}\n章节:{title}\n\n证据:\n{notes}",
            ),
        ])
        sections.append(Section(title=title, content=content, evidence_ids=evidence_ids))

    # 步骤 3:组装
    sections_text = "\n\n".join(f"## {s.title}\n{s.content}" for s in sections)
    final = model.complete([
        Message(role="system", content="把各章节组装成完整文章。统一语气,去重。"),
        Message(role="user", content=f"主题:{topic}\n\n{sections_text}"),
    ])
    print(f"[组装] 完成")

    return Article(topic=topic, sections=sections, final_text=final)


# ── 运行 ────────────────────────────────────────────────

def main() -> None:
    docs = [
        Document("tea_history", "龙井茶始于宋代,盛于明清。西湖龙井是中国十大名茶之一。"),
        Document("tea_museum", "中国茶叶博物馆,龙井路 88 号,免费,9:00-17:00,展示中国茶文化历史。"),
        Document("tea_village", "龙井村可体验采茶,春季最佳,步行友好,约 2 小时。"),
        Document("tea_ceremony", "杭州多家茶馆提供茶艺表演,推荐湖畔居、青藤茶馆。"),
        Document("tea_food", "龙井虾仁是杭州名菜,用龙井茶嫩芽炒制。龙井茶酥也是特色茶点。"),
        Document("weather", "杭州春季多雨,建议备伞。4-5 月气温 15-25 度。"),
    ]
    index = SimpleSearchIndex(docs)

    model = MockLLM([
        # 大纲
        json.dumps({
            "sections": ["龙井茶的历史", "茶文化体验", "茶主题美食"]
        }, ensure_ascii=False),

        # 章节 1:query
        '{"query":"龙井茶历史起源"}',
        # 章节 1:写
        "龙井茶始于宋代,盛于明清,是中国十大名茶之一 [tea_history]。中国茶叶博物馆详细展示了这段历史 [tea_museum]。",

        # 章节 2:query
        '{"query":"杭州茶文化体验采茶"}',
        # 章节 2:写
        "到龙井村可以亲手采茶,春季最佳,步行约 2 小时 [tea_village]。市区内湖畔居、青藤茶馆有茶艺表演 [tea_ceremony]。",

        # 章节 3:query
        '{"query":"杭州茶美食龙井虾仁"}',
        # 章节 3:写
        "龙井虾仁用龙井茶嫩芽炒制,是杭州名菜 [tea_food]。龙井茶酥也值得尝试 [tea_food]。",

        # 组装
        (
            "# 杭州茶文化旅游攻略\n\n"
            "## 龙井茶的历史\n"
            "龙井茶始于宋代,盛于明清,是中国十大名茶之一 [tea_history]。"
            "中国茶叶博物馆(龙井路 88 号,免费)详细展示了这段历史 [tea_museum]。\n\n"
            "## 茶文化体验\n"
            "到龙井村可以亲手采茶,春季最佳,步行约 2 小时 [tea_village]。"
            "市区内湖畔居、青藤茶馆有专业茶艺表演 [tea_ceremony]。\n\n"
            "## 茶主题美食\n"
            "龙井虾仁用龙井茶嫩芽炒制,是杭州必尝名菜。"
            "龙井茶酥也值得尝试 [tea_food]。"
        ),
    ])

    article = storm_write(model, topic="杭州茶文化旅游攻略", index=index)
    print(f"\n{'='*50}")
    print(article.final_text)
    print(f"{'='*50}")
    print(f"\n章节证据:")
    for s in article.sections:
        print(f"  {s.title}: {s.evidence_ids}")


if __name__ == "__main__":
    main()

运行日志

[大纲] 3 个章节:['龙井茶的历史', '茶文化体验', '茶主题美食']
[章节 '龙井茶的历史'] query='龙井茶历史起源', 命中 2 条
[章节 '茶文化体验'] query='杭州茶文化体验采茶', 命中 3 条
[章节 '茶主题美食'] query='杭州茶美食龙井虾仁', 命中 1 条
[组装] 完成

==================================================
# 杭州茶文化旅游攻略

## 龙井茶的历史
龙井茶始于宋代,盛于明清,是中国十大名茶之一 [tea_history]。中国茶叶博物馆(龙井路 88 号,免费)详细展示了这段历史 [tea_museum]。

## 茶文化体验
到龙井村可以亲手采茶,春季最佳,步行约 2 小时 [tea_village]。市区内湖畔居、青藤茶馆有专业茶艺表演 [tea_ceremony]。

## 茶主题美食
龙井虾仁用龙井茶嫩芽炒制,是杭州必尝名菜。龙井茶酥也值得尝试 [tea_food]。
==================================================

章节证据:
  龙井茶的历史: ['tea_history', 'tea_museum']
  茶文化体验: ['tea_village', 'tea_ceremony', 'tea_museum']
  茶主题美食: ['tea_food']

调试:trace 里看什么

要看的 怎么看 说明什么
大纲质量 章节标题是否有意义 太泛的标题 → 检索也会泛
每章节 query trace 里的 query query 和章节不匹配 → 证据跑偏
证据隔离 每个章节的 evidence_ids A 章节引用了 B 章节的来源 = 串味
最终文章 vs 章节 对比 final_text 和各 section 组装时丢了引用或加了幻觉
章节间重复 读最终文章 编辑步骤没去重

工程笔记

成本公式

大纲 = 1 次调用
每章节 = 1(query)+ 1 次检索 + 1(写) = 2 次模型调用
组装 = 1 次调用
总调用 = 1 + n_sections × 2 + 1 = 2 + 2n

3 个章节 = 8 次模型调用。10 个章节 = 22 次。

常见坑

现象 修法
大纲太浅 每个章节都很泛 写完大纲先审查
证据串味 A 章节引用 B 章节的来源 按章节隔离证据账本
context 爆炸 所有文档全进最终 prompt 按章节摘要
假引用 引用不存在 强制要求检索器返回的 doc id
组装太弱 拼接但没编辑 明确要求组装步骤统一语气、去重

什么时候用

  • 输出是文章、报告、攻略。
  • 章节结构很重要。
  • 证据要按章节隔离。
  • 最终编辑环节有用。

什么时候别用

  • 用户要一句话答案。
  • 预算不支持多次检索和写作。
  • 不需要引用。
  • 大纲不确定,应该先让人列好。

读完以后

STORM 是长文写作的检索工作流。

如果是动态问答而不是长文,读 Agentic RAG。 如果写完后要逐条验证事实,读 CoVe。 如果只需要简单检索,退回 RAG