让 AI 读懂一份文档:从六块积木,到敢于做减法
我们天天让 AI 吃文件,却很少停下来想:AI 读一份 Word、一份 Excel、一张 PPT,它到底读到了什么?
这篇文章不聊"怎么调 API",聊的是藏在解析库底层的一套架构思路,以及我做自己的工具时,最想分享的那个判断。
开篇:二十年的文档,和一夜之间要读懂它的 AI
过去二十年,人类用 Office 写了无数文档。合同、报告、简历、论文、产品说明书------它们散落在硬盘、网盘、公司服务器里,格式五花八门:doc、docx、xls、xlsx、ppt、pdf、csv。
这些格式有一个共同点:
它们是给"人眼看"设计的,不是给"机器读"设计的。
在 AI 出现之前,这从来不是问题。可当 AI Agent 开始需要"吃文件"时,问题突然摆上台面:你总不能让 AI 像个实习生一样,点开 Word 一份份肉眼读完再总结吧?
于是问题变成了:AI 读一份 Word 文档,它到底读到了什么?
不是「标题、段落、列表」这些你一眼扫过去就看到的东西。它读到的是:
xml
<w:p><w:r><w:t>这是一段文字</w:t></w:r></w:p>
一长串平铺在 XML 里的标签,没有层级,没有语义,像一条被打散的句子。一份 500 字的文档,背后是上千行 XML;而「这是一段引用」「这是第 2 级列表」这些信息,散落在三个互不挨着的文件里,需要你自己去拼。
换一份 Excel、一张 PPT,只会更糟:单元格里是坐标,幻灯片里是文本框,语义藏得比 Word 还深。
💡 什么是 OOXML?
2006 年微软把 Office 的私有格式开放成国际标准 OOXML,Word 的
.docx、Excel 的.xlsx、PPT 的.pptx本质都是它------一个压缩包里塞着几十个 XML 文件。机器读的就是这些 XML,而不是你屏幕上看到的"漂亮的排版"。所以"让 AI 读文档",第一步永远是"把这些 XML 里的语义捞出来"。
拿编号来说。OOXML 规范里,光列表的编号格式 numFmt 就定义了 40 多种:十进制、零填充十进制、中文数字、序数词、带圈数字、字母和罗马数字变体......
但等你真做下来会发现,人类真正关心的其实只有四件事:这是不是圆点、是不是数字、是不是字母、是不是罗马。其余几十种,全是同一个「十进制 +1」换了层显示皮。
所以我们把 40 多种协议值,收成 4 类语义,剩下的全落进「数字」兜底。
协议有多啰嗦,实现就该有多小气。
这就是整个项目全部的哲学,浓缩成一句话:格式是方言,语义是通用语。
中间那个把方言翻译成通用语的东西,我们把它做成了一个沙漏------一头进来六种格式,一头出去一种输出,而窄腰只有六块积木:标题、段落、列表、表格、引用、代码。

↑ 整个架构的全貌:左边六种格式,右边一种 Markdown 输出,中间那个窄腰就是承载一切的核心。
一、为什么不能直接「XML → Markdown」
你可能会想:这不过是个格式转换,写个正则,把 <w:p> 换成换行、把 <w:b> 换成 **,不就完了?
问题在数量,不在难度。
假设你要支持六种格式,每种都直接写一个「转 Markdown」的转换器:
- 今天只要一种输出 → 6 个转换器
- 明天再出一个 HTML 版 → 12 个
- 后天再来个 PDF 版 → 18 个
格式数 × 输出数,是乘法,不是加法。
💡 为什么是"乘法"?
"理解一种格式"和"产出一个输出"是两件正交 的事。六个格式、三个输出,就是
6×3=18种组合;每多一个格式或输出,都是成倍地加。而这个 18,背后还是同一套"怎么理解标题、怎么处理列表"的逻辑在反复重写------纯属浪费。
沙漏把这个乘法压成了加法。中间放一个所有人都同意的「中间模型」:
- 六种格式各自翻译成它(6 次)
- 输出端各自从它翻译出去(1 次,以后变 2 次、3 次)
6 × M 变成了 6 + M。沙漏不是优雅洁癖,是一道数学题。
两种思路的差别,一张表说清:
| 直接「格式 → Markdown」 | 沙漏(中间模型) | |
|---|---|---|
| 加一个新格式 | 每个输出各写一遍 | 只写一次「格式 → 模型」 |
| 加一种新输出 | 每个格式各写一遍 | 只写一次「模型 → 输出」 |
| 新增成本 | 乘法(格式数 × 输出数) | 加法(格式数 + 输出数) |
| 理解"标题"的逻辑 | 每个转换器各存一份 | 只存一份,大家共用 |
那这个公约数到底有多大?六块。
二、六块积木:少,是刻意的
标题、段落、列表、表格、引用、代码块。就这六种。

你可能会觉得:这是不是做得不全?Word 里还有脚注、文本框、目录、批注、页眉页脚......呢?
这六块不是「只做了六种」,而是「刻意只留六种 」。分类的本质是省略:你把一百种东西归成六类,靠的不是把一百种都装进去,而是判断哪六类值得留下,其余全部降级。
比如:
- 「文本框」→ 不是第七块,内容被拆出来、塞进正文流(降级,用已有的六块去表达)
- 「脚注」→ 不是第七块,但也没降级:正文留一个
[^n]引用,脚注内容存到文档级,文末再输出定义(旁路,保留它本来的语义)
六块积木之外的「特殊」,只有两种命运:要么降级成六块,要么走旁路保留语义。判断依据是------它是否真的需要保留自己的结构。
为什么是这六块,而不是五块或八块? 因为它是下游的公约数。渲染器拿到一个 Block,闭着眼睛都能写:
- 标题加
# - 引用贴
> - 代码包 fence
- 列表算缩进
模型越少,下游越不用思考。多一种积木,渲染器就要多写一个 case------又回到乘法爆炸去了。
对你的 Agent 意味着什么:如果一个解析库输出的不是这六种干净的语义块,而是二十种混着格式信息的"半成品",你的 Agent 就得自己去判断"这是引用还是代码块"------这个判断本来不该是 Agent 的活,是解析层没做完的活。
所以「六」是一个经过计算的数字:刚好覆盖人类文档的基本语义,又少到能让所有下游轻松穷尽。
三、折叠:解析层收,渲染层放
全项目最反直觉的一个词,叫「折叠」。
XML 里的列表不是一棵树,是一条条平的段落 :第一项、第二项、第三项......中间混着层级标记,但结构是平的。而模型里,它是一个嵌套结构:一个 List,里面套着 Items,每个 Item 里还能再套子 List。

↑ 同一份内容,进来时"收拢"成树,出去时"摊开"成行------像一把折叠伞。
解析器要做的动作,就是把这堆平的东西收拢成一棵树。 它不能看到一个列表项就当场造一个 Block 出来,因为它不知道这个列表到哪结束------它得先攒着,等边界出现,再一次性打包。
如果解析器没做这一步,你的 Agent 拿到的就是一堆散的项目符号,不知道哪些属于同一个列表、哪些是嵌套的子列表。看起来内容都在,但结构全丢了。
有意思的是,到了渲染层,动作正好反过来:模型里的树,要重新摊平成一行一行的 Markdown。
解析层收,渲染层放。 中间那个收拢成树的东西,就是模型存在的意义:树才能表达"谁套在谁里面",平的东西表达不了。
四、编号:整份协议里最硬的骨头
编号是 docx 里最复杂的一段。它难,难在三层。
如果你只是想让 Agent 读文档、不打算自己做解析,这一节可以跳过;但记住一个结论------好的解析器不会把 40 种编号格式硬塞给你,而是收成 4 类语义交出去。
第一层:一个编号,藏了三个地方

↑ 同一个"有没有编号"的问题,OOXML 给出了三种不同的回答方式。
OOXML 的编号是三态的:
| 情形 | 含义 |
|---|---|
| 段落上直接写了 numId | 就用直接的 |
| 没写,引用了某个样式 | 从样式继承 |
numId=0 |
显式取消------哪怕样式说该编号,这里也强制不编 |
这就是很多解析器列表出错的根源:只看了最表面那层,漏掉了继承链;或者把"显式取消编号"当成"没有编号"处理,结果多出一堆不该有的项目符号,或者该有的编号消失了。
一个编号到底是圆点还是数字,可能要到三个不同的 XML 文件里各翻一次才能确定。
第二层:四十种格式,四种语义
协议给了 40 多种 numFmt,我们只认四类:
| 语义 | 例子 | 本质 |
|---|---|---|
| 无序 | bullet |
不递增 |
| 数字 | decimal、chineseCounting、ordinal...... |
都是十进制 +1,只是显示皮不同 |
| 字母 | lowerLetter、upperLetter |
按字母表递增 |
| 罗马 | lowerRoman、upperRoman |
按罗马规则递增 |
4 个语义大类,落成 6 个 MarkerKind 枚举(字母和罗马各分大小写)。类别少,枚举穷尽,下游不用猜。
第三层:记规则,还是记结果
模型里的 List,只存两件事:符号类型 + 起点。渲染时现场算:
go
list.Marker.Label(list.Start + uint64(i)) // 起点 + 序号 = 当前编号
为什么不直接存「1.」「2.」「3.」这些字符串? 因为 docx 的列表可以不从 1 开始------一个 startOverride 就能让列表从 3 起跳,变成「3. 4. 5.」。如果你把编号存成字符串,遇到从 3 开始的列表,要么存错,要么存一堆随时会算错的冗余数据。
精确复现编号,靠的不是「记下每个编号」,而是「记下规则,渲染时重算」。
存结果会错、会臃肿;存规则永远正确、永远最小。
这也是评估一个解析工具的角度:它交给你的是"规则"还是"字符串"? 如果是字符串,遇到非标准编号就会出问题。
五、不止 Word:其他格式,逻辑一样吗?
上面讲的编号、折叠、降级,全是以 Word 为例。那 Excel、PPT、PDF、CSV 呢?
答案是:骨架完全一样,难点各不相同。
| 格式 | 它的"方言" | 主要落到哪块积木 | 解析难点 |
|---|---|---|---|
| Excel | 表格是主角,坐标即结构 | Table |
合并单元格、跨 Sheet 引用 |
| PPT | 浮动文本框,无阅读顺序 | 标题 / 段落 / 列表 | 按顺序拼回文本流 |
| CSV | 最平,无嵌套 | Table |
分隔符、引号、编码边界 |
| 打印出来的"样子",无结构 | 优先旁路,兜底进模型 | 无结构语义,默认绕开解析层 |
Word 的难点在"隐藏的语义"(编号、样式),其他格式的难点在"怎么把各自的结构装进这六块积木"。
先看它是怎么被认出来的
无论什么文件,进来的第一步不是解析,而是认出它是什么。这靠的是「魔数」------每种格式写在文件头部的身份标记:
| 格式 | 身份标记(魔数) |
|---|---|
开头 %PDF- |
|
| 老版 doc/xls/ppt | OLE 复合文档签名 D0CF11E0... + 内部流名 |
| docx/xlsx/pptx | ZIP 头 PK\x03\x04 + 包内 [Content_Types].xml |
| CSV | 没有魔数------靠扩展名兜底 |
💡 为什么叫"魔数"?
它是写死在文件最开头的那几个字节,一看就知道"这文件是谁生的"。好比人一眼能从口音判断对方是哪里人------文件系统也靠这几个字节,在打开前先判断"你是 docx 还是 pdf"。
检测只认身份,不猜内容。 CSV 天生没有魔数,检测层对它永远返回"认不出",于是交给扩展名兜底。检测认不出不是错误,是「交给下一层」。
⚠️ 认得出 ≠ 支持转换:魔数表里出现了 xls/ppt 等老格式,但检测层认出它们,主要是为了给出明确的「不支持」错误------而不是静默失败。静默失败对 Agent 来说更危险:你不知道内容有没有被丢掉。
认出格式后,进一个分发点:
go
func ToDocument(data []byte, format string) (*model.Document, error) {
switch strings.ToLower(format) {
case "csv": return csv.ToDocument(data)
case "docx": return docx.ToDocument(data)
case "xlsx": return xlsx.ToDocument(data)
case "pptx": return pptx.ToDocument(data)
case "pdf": return pdf.ToDocument(data)
case "doc": return pkgdoc.ToDocument(data)
default: return nil, model.NewUnsupportedError(...)
}
}
这就是格式路由的统一收口:所有格式在这一处汇聚,加一个新格式(比如 RTF),只需要在这里加一个 case,扩展名映射加一行,检测加一个判断------已写好的 parser、模型、渲染器一行都不用动。
Excel:表格是主角
Word 的世界里,段落是主角、表格是配角;Excel 恰好反过来------整个文件就是一张张表格。
xlsx 的 parser 把一个个 Sheet 收拢成模型里的 Table 块,把坐标 A1、B2 翻译成「第几行第几列」,把合并单元格、跨 Sheet 引用这些"Excel 特有的戏法"降级成模型能装下的样子。
对你的 Agent 意味着什么:一个 Excel 文件到了模型里,就是一张结构清晰的表格。你的 Agent 不需要理解"A1 是什么",它拿到的就是第一行第一列的值。
PPT:拆散再拼回
一张幻灯片不是一个"文档",而是一堆浮动的文本框------有坐标、有层级,但没有"从上到下读"的顺序。
pptx 的 parser 把散落在各张 slide 里的文本框内容按阅读顺序拆出来、拼回一条文本流,再把它们依次归进标题、段落、列表。
顺序错了,AI 读到的就是错乱的句子。 这不是 PPT 内容的问题,是解析顺序的问题。
CSV:最简单,也最"平"
CSV 是这六种里最简单的格式------没有 XML,没有魔数,没有嵌套。它天生就是一张扁平的表格。
csv 的 parser 几乎不需要"折叠":读一行,按分隔符切开,放进一个 Table 块,完事。它的难点全在"方言"的边界上:
- 分隔符是逗号还是分号?
- 字段带不带引号?
- 有没有换行藏在引号里?
- 编码是 UTF-8 还是 UTF-16?
同样是映射到"表格"这一块积木,CSV 和 Excel 的代码几乎无法共享------因为它们难在不同地方。
PDF:真正的"旁路"
PDF 是这里最特殊的一个,因为它默认绕开模型。
PDF 不是办公格式,它是一份"打印出来的样子"------没有段落、没有列表、没有结构语义,只有文本块、坐标、字体。硬要把 PDF 塞进"六块积木"的模型里,等于逼它撒谎。
所以 PDF 走了旁路:读字节 → 认出是 PDF → 优先走 pdf2md 直接吐 Markdown;只有当 pdf2md 不可用时,才回退到内置解析器,把每页文本装进段落和表格。

↑ 六种格式里,五条进模型、一条优先绕道(兜底仍进模型)------对外观感是一致的:都是"给一份文件,回一段 Markdown"。
这是沙漏结构里最诚实的部分:不是所有格式都值得被翻译成通用语,有的格式,直接转换比强行装进模型更诚实。
六、模型干净,脏活全在前端
走完这一圈,能看出一个更深的取舍:模型从不迁就输入,输入迁就模型。
两个例子:
- 模型里的标题
Heading没有「编号」字段。遇到「1. 引言」这种带编号的标题怎么办?不扩字段,而是把编号「1.」当普通文本插进标题内容里------模型没有的,就降维成模型有的。 - 模型不允许「标题里套文本框」。遇到标题里嵌了文本框怎么办?不破规则,而是把文本框里的内容拆出来、变成标题之外的普通段落,标题只保留纯文本------模型装不下的,就退到装得下的形态。
Excel 的合并单元格、PPT 的浮动文本框,都是这个逻辑:脏活在各自的 parser 里先解决,把「特殊」降级成六块装得下的样子。
而脚注走的是另一条路------它不降级,而是走文档级的旁路:正文留 [^n] 引用,脚注内容存到文档级 Notes 容器,文末再输出定义。因为脚注值得保留自己的结构,降级成普通段落反而会丢掉引用关系。
不管是哪种格式带来的"特殊",要么降级成六块 Block,要么走旁路保留语义------模型的核心永远是那六块干净的 Block,旁路的内容(脚注、资源、警告)放进模型里独立的容器,绝不混进这六块。
落到工程上,是三条纪律:
- 类型封闭 --- Block 就这六种,外部不能凭空造出第七种"四不像"
- 穷尽检查靠测试 --- Go 的 type switch 没有编译期穷尽检查,漏一个 case 会在运行时静默丢内容。所以六种类型各有测试覆盖,加新类型时测试会点名所有没处理的地方------这是工程纪律,不是编译器保障
- 构造器做规范化 --- 提供构造函数处理边界(比如
span < 1时强制为 1),推荐用构造器而非手搓结构体,减少非法状态的入口
这三条翻译成人话:Go 不会替你把穷尽检查和防非法状态做掉------那就用类型封闭挡住一部分,剩下的交给测试和构造器,把风险压到最低。
结尾:格式是方言,语义是通用语
Word、Excel、PPT、PDF,是不同的方言。它们各自有一套冗长、繁琐、甚至互相矛盾的词汇表:一个编号能藏三个文件,一个样式名既可能映射成引用,也可能映射成代码块,一个单元格带着坐标,一张幻灯片躺着文本框。但它们想说的,是同一件事:这里有个标题,这里有个列表,这里有一段引用,这里有一张表格。
六块积木,就是它们共通的通用语。沙漏,就是那个翻译官。把方言翻译成通用语这件事本身没有魔法,魔法在于:承认格式很啰嗦,同时坚信语义很简单。
这二十年里人类攒下的文档,现在轮到 AI 来读了。要读懂它们,第一步不是堆算力、不是上模型,而是先解决"机器根本读不懂这些格式"这个最朴素的前提。而这一切的起点,就是从一堆 <w:p>、一张 <sheetData>、一叠 <p:sp> 里,捞出那六块积木。
六,是被六种格式共同验证过的六。
附注:这套「做减法」的设计,落地于开源工具 minidoc------Go 实现、MIT,六种格式转 LLM-ready Markdown,内置 HTTP 服务。架构思路参考了 Firecrawl AnyDoc(Rust 原生文档解析库)。