设计一个超长文档的总结和问答服务

深入系统设计场景题约 9 分钟读完

一句话回答

整体是一条异步流水线:文件直传对象存储,提交任务后进入队列,Worker 分阶段处理:解析(文本型 PDF 直接提取,扫描件走 OCR,表格保留结构)→ 按章节结构切块 → 两条支路并行:一条建立检索索引支持问答,一条做 Map-Reduce 分层总结(分块摘要 → 章节摘要 → 全文摘要)。每个阶段、每个分块的结果都持久化,失败时只重试失败的部分,可以断点续跑;按文件哈希缓存结果;进度实时推送,先给目录和已完成的章节摘要,索引建好就能开始问答,不必等全文总结完成。

详细解析

第一步:澄清需求和估算

  • 文档:类型(合同、财报、书籍、论文)、格式(PDF、Word、扫描件)、最大页数
  • 功能:全文摘要、章节摘要、基于文档的问答(答案带页码引用),是否需要跨文档分析
  • 时效:用户能接受等几分钟,但希望尽快看到部分结果
  • 隐私:企业内部文档能不能发给云端模型

估算(全部是假设值,实际以测量为准):300 页的文档,假设每页约 500 token(取决于排版、语言和分词器),全文约 15 万 token,切成约 80 块:

  • Map:80 次调用,输入共约 15 万 token 再加上每次调用的指令开销;假设每块输出 300 token,共约 2.4 万 token
  • Reduce:生成章节摘要和全文摘要,再调用十几次,输入是上一层的摘要
  • 时间:假设并发 10 个、每次调用 15 秒,Map 要 8 轮,两分钟左右;扫描件的 OCR 可能比模型调用还慢
  • 费用:各阶段的 token 数乘以所用模型的单价

第二步:整体架构

文本
前端 ──① 申请上传凭证──► API 服务(返回预签名 URL)
  ├──② 直传文件────────────────────────────► 对象存储
  ├──③ 提交任务(文件 ID)──► API 服务 ──► 任务表 + 消息队列
  │
  │      Worker(按阶段拆分,各自扩缩容,每个阶段可以单独重试)
  │        解析:文本提取 / OCR / 表格识别 → 章节树(标题层级、页码)
  │        切块:按章节切,过长再按 token 切,每块带章节路径和页码
  │          ├─► Embedding → 向量库 + 全文索引(问答用)
  │          └─► Map:分块摘要(限并发)→ Reduce:章节摘要 → 全文摘要
  │                               │
  └◄──④ 进度推送(SSE 或轮询)◄── 任务状态、各阶段的结果

第三步:核心模块和数据模型

上传:前端拿预签名 URL 直传对象存储,不经过业务服务器;大文件分片上传、断点续传,见大文件上传。上传完成后校验真实的文件类型(看文件头而不是扩展名)、大小和页数上限,计算文件哈希,用于去重和缓存。

解析:

  • 文本型 PDF 直接提取文字和版面信息;某一页几乎提取不到文字,就判定为扫描页,走 OCR
  • 表格要识别出行列结构,转成 Markdown 表格,不能压成一行文字
  • 去掉页眉页脚,保留标题层级和页码,后面的切块和引用都依赖它们
  • 版式复杂的页面可以交给多模态模型解析,效果好但成本高,只用在少数页面上
  • 可以选开源工具(例如 MinerU、Docling)或云厂商的文档解析服务

数据模型:

  • documents:文件哈希、存储路径、页数、状态、解析结果的路径
  • jobs:文档 ID、当前阶段(parsing / indexing / summarizing / done / failed)、进度、错误信息、心跳时间
  • chunks:文档 ID、序号、章节路径、页码范围、文本
  • chunk_summaries:文档 ID、分块序号、版本(模型 + Prompt)、摘要;三者组成唯一键,是断点续跑的依据

分层总结:Map 阶段每块单独总结,Prompt 里带上全文目录和本块的章节路径,让模型知道这一块在整体中的位置;Reduce 阶段先把同一章的分块摘要合并成章节摘要,再合并成全文摘要,输入太长就分组递归合并。每条摘要保留页码范围,结论能追溯到原文。Refine、层级摘要等方法的对比见文档太长的处理方法。

问答:同一批分块建向量索引和全文索引,用混合检索回答细节问题,答案标注页码,点击可以跳到原文位置。"这份报告的主要结论是什么"这类全局问题检索不到具体片段,直接用章节摘要和全文摘要回答,也可以把各层摘要一起放进索引。

第四步:关键难点

失败重试和断点续跑:

  • 任务按阶段、按分块拆成幂等的子任务,结果按(文档、分块、版本)持久化,重跑时跳过已完成的部分
  • 消息处理完成后才确认,Worker 中途崩溃,消息会重新投递给其他 Worker
  • 429、5xx、超时按指数退避重试,超过次数进入死信队列;个别分块始终失败(比如被内容审核拦截)就标记后跳过,在结果里注明哪些页没有覆盖
  • 用户取消任务时,停止派发新的子任务

并发和限流:Map 阶段很容易瞬间打满上游的 TPM。按单个文档、租户、全局三级限制并发,避免一个用户的几千页文档让其他人的小文档排队很久。

缓存:用"文件哈希 + 流水线版本 + 模型 + Prompt 版本"作为缓存的 key,同一份文件不重复解析和总结;模型或 Prompt 升级后版本号变化,旧缓存自然失效。不同用户上传同一份文件时,只有公开文件才能共享结果,私有文档按租户隔离。

体验:

  • 解析完成就展示目录,用户可以先浏览原文
  • 章节摘要按完成的顺序陆续显示,不等全文摘要
  • 索引建好就开放问答,这时总结可能还在进行
  • 按阶段展示进度(如"总结中 45/80")和预计剩余时间,完成后发通知

第五步:扩展与优化

  • 成本:Map 阶段用便宜的小模型,Reduce 和问答用能力更强的模型;对时效要求不高的任务,可以用供应商的批处理接口
  • 多文档:比如对比几年的财报,在单文档的结果上再做一层跨文档的 Reduce
  • 质量评估:根据原文出一批问题,看只读摘要能不能答对,以此衡量摘要的覆盖度

代码示例

Map 阶段:限制并发,按分块持久化结果,重跑时跳过已完成的块:

TypeScript
import pLimit from 'p-limit'

const limit = pLimit(10) // 单个文档的并发上限,全局还有一层限流

async function mapChunks(doc: Doc, chunks: Chunk[], version: string) {
  return Promise.all(
    chunks.map((chunk) =>
      limit(async () => {
        const done = await db.chunkSummaries.find(doc.id, chunk.index, version)
        if (done) return done // 断点续跑:已完成的块直接复用

        const summary = await withRetry(() =>
          llm.chat({ messages: mapPrompt(doc.outline, chunk) }), // 带上目录和章节路径
        )
        const saved = await db.chunkSummaries.save(doc.id, chunk.index, version, {
          summary,
          pages: chunk.pages, // 保留页码,结论可以追溯到原文
        })
        await reportProgress(doc.id, 'summarizing')
        return saved
      }),
    ),
  )
}

面试官可能追问

模型的上下文窗口越来越长,为什么不直接把整份文档放进去?

文档能放进窗口时,直接用长上下文确实更简单,对全局的理解也更好。但有几个限制:超过窗口的文档还是要拆;每次问答都重新发送整份文档,成本和延迟都高(对同一文档多次提问时,提示缓存能缓解);长上下文中间部分的信息容易被忽略;分块方案还能给出精确的页码引用。实践中常把两者结合:中等长度的文档直接放进上下文,超长文档走分块的流水线。

Map-Reduce 总结会丢掉什么?怎么改进?

会丢掉跨块的联系(前文的定义、后文的结论)和全局结构,还可能重复或前后矛盾。改进方法:按章节而不是按固定长度切块;Map 时带上目录和章节路径;Reduce 按章节分组,保持文档原有的结构;要求保留关键数字和结论并标明页码;最后用"根据原文出题、只看摘要作答"的方式评估覆盖度。

任务处理到一半,Worker 进程崩溃了,怎么办?

消息在处理完成后才确认,崩溃后消息会在超时后重新投递给其他 Worker。因为每个分块的结果都已经持久化,新的 Worker 只会处理还没完成的块。任务表里记录每个阶段的心跳时间,长时间没有更新的任务由定时任务重新派发。

扫描件识别质量很差,怎么办?

先判断质量:OCR 一般会给出置信度,低于阈值的页面标记出来。对这些页面换更强的识别方式(比如用多模态模型直接读图),仍然不行就在结果里明确告诉用户"第几页识别质量差,相关内容可能不准确",而不是让模型基于错乱的文字编出一段摘要。

易错点

  • 在上传接口里同步做解析和总结,大文件必然超时
  • 按固定字数切块,把表格和段落从中间切断,还丢了页码,引用无法回溯
  • 只有任务级别的重试,一个分块失败就让整份文档从头再来
  • 把所有分块一次性并发发给模型,触发上游限流,所有任务一起失败

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。