开源项目 OfficeCLI 的查询与转储:Agent 先读懂再动手
本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。
Agent 改文档翻车,绝大多数不是因为它不会写,而是因为它没看清就写了。 模型手里只有一段自然语言指令和一个文件名,它并不知道第 12 段是标题还是正文、B7 是硬编码数字还是公式、那张表是真的 Excel 表对象还是一片看起来像表格的单元格。缺了这层认知,任何一次写入都是在盲改。
这篇拆的是 OfficeCLI 这个开源项目的读侧。先说清楚它是什么:OfficeCLI 是 GitHub 上 iOfficeAI/OfficeCLI 仓库里的一个 Apache-2.0 开源项目,NOTICE 文件写明 Copyright 2026 OfficeCLI,由 goworm 创建维护。它编译成单个二进制,不需要机器上装 Office 就能读写 .docx / .xlsx / .pptx。它跟微软没有从属、授权或官方合作关系,下文出现 Word、Excel、PowerPoint 时指的是文件格式和对应的应用,不是这个项目的归属。仓库 README 把自己定位成给 AI Agent 用的 Office 套件,还自称是世界上第一个也是最好的一个——那是项目自己的说法,本文不替它背书,只看代码里能核实的机制。
站内另外三篇和这篇是分工关系:大模型幻觉 讲模型为什么会凭空生成不存在的东西,对手验证 讲产出之后怎么让另一个角色去挑错,browser-use 的结构化输出 讲的是浏览器场景里怎么把网页变成模型能吃的结构。这篇管的是动手之前那一段:文档这种二进制容器,怎么变成可寻址、可核对的形式。
一、先说清楚问题:Agent 缺的是一套坐标系
.docx / .xlsx / .pptx 这三种格式统称 OOXML。所谓 OOXML 包结构,一句话说就是:它们本质上是一个 zip 压缩包,里面装着若干份 XML 文件加上图片等二进制附件,正文、样式、编号、主题各占一份。你用文本编辑器打开一个 .docx,看到的是乱码,不是因为它加密了,而是因为你在看压缩包的字节。
这带来一个很实际的后果:Agent 想改文档,需要一种能指着说”就是这个”的语法。OfficeCLI 给的是类 DOM 的路径。src/officecli/CommandBuilder.GetQuery.cs 里 get 命令的 path 参数描述就写着它长什么样——/body/p[1] 这种形式,默认值是 /,也就是整个文档;--depth 控制展开几层子节点,默认 1。
officecli get report.docx '/body/p[3]' --depth 2 --json
officecli get slides.pptx '/slide[1]' --depth 1
officecli get data.xlsx '/Sheet1/B2' --json
路径不止一种写法。仓库根目录的 SKILL.md 里专门列了稳定 ID 寻址:有稳定标识的元素返回 @attr=value 形式的路径而不是位置下标,例如 /body/p[@paraId=1A2B3C4D]、/slide[1]/shape[@id=550950021]、/comments/comment[@commentId=1]。文档同时说明了为什么要优先用它:位置下标会在插入、删除后漂移,稳定 ID 不会。而 slide、run、tr/tc、row 这类没有稳定 ID 的元素,仍然只能靠位置下标。
这条差异对多步工作流是决定性的:Agent 第一步拿到 /body/p[12],第二步在前面插了一段,第三步再拿这个路径去改——改的已经不是同一段了。改动范围怎么跟 Agent 约定清楚,另见 Agent 改动边界的约定。
二、query 与 —compact:一个为读完整性设计的输出契约
get 是定点查看,query 是筛选。选择器是类 CSS 的写法,SKILL.md 给了真实例子:
officecli query report.docx 'paragraph[style=Normal] > run[font!=Arial]'
officecli query slides.pptx 'shape[fill=FF0000]'
真正值得细看的是 --compact 这个开关。它的行格式在 CommandBuilder.GetQuery.cs 的 FormatNodesCompact 方法上有一大段文档注释,开头就写着 STABILITY CONTRACT,核心格式如下(原注释里还有一行讲 --fields 怎么追加自定义列,这里略去):
{path}\t[{label}]\t"{text ≤60 chars, \t/\n/"/\\ escaped}"
{path}\t[table {R}x{C}] (tables fold; no text col)
{path}\t[{label}]\t(empty) (no text)
total: {N} of {M} elements / {K} slides (pptx)
total: {N} of {M} elements (docx)
一行一个元素,按文档顺序排;TAB 分隔;文本超过 60 字符截断并打省略标记;空文本写成 (empty);表格折叠成 [table RxC] 一行,不展开单元格。这些都不新鲜。新鲜的是最后那行 total。
注释里把它的语义写死了:N 恰好等于 total 行上方的元素行数,所以 lineCount - 1 == N 这个等式本身就是”我读完了”的证据;M 是文档里所有顶层框的数量,且与你的选择器无关。CountCompactDenominator 这个方法专门算 M——pptx 那边把 shape / picture / table / chart / connector / group 六种选择器各查一遍再按 path 去重(因为 title、textbox 本身也是 shape,会重复),docx 那边取 /body 的直接子节点数并排除 section(sectPr 是版式元数据,选择器够不着,算进分母会误导)。
这个设计的目标很明确:让 Agent 能自己回答”我到底看全了没有”。N 是我匹配到的,M 是总共有的,两个数字摆在同一行。少了这个分母,模型看到二十行输出就会默认这就是全部。工具返回值该怎么设计才能让调用方自我校验,这个话题本身可以单独展开,见 Agent 工具返回值设计。
契约的另一半是”不许改”。注释明确写了:列顺序、TAB 分隔符、省略标记、(empty)、[label] 的方括号形式、total 行的形状,只能往后加新列,不能改动或重排;任何变更都要进 CHANGELOG。docx 的 total 行没有容器段(pptx 才有 / K slides),这个”没有”本身也被冻结——以后补上去就算改了 total 行,契约不允许。
还有一处细节能看出这是被真实踩坑打磨过的。docx 的全量列举用选择器 *,注释解释了为什么不直接映射成 paragraph, table:paragraph 选择器会下钻到表格单元格里,--compact 下每个单元格段落都会跟折叠的 [table RxC] 行并列出现,N 和 M 就对不上了。而在这之前,docx 根本没有 *,选择器静默匹配零条,输出变成 total: 0 of 26 elements——注释原话是,这对 Agent 来说是最糟的形状,会被读成”这文档是空的”。
两个立刻能用的约束:--compact 和 --json 同时给会直接抛 CliException,code 是 invalid_value,提示你二选一;xlsx 不支持 --compact,FormatNodesCompact 一开头就判断 handler 是不是 ExcelHandler,是就抛错并指向 view text --range。
三、Excel 侧的读:路径解析器有多少特例
src/officecli/Handlers/Excel/ExcelHandler.Query.cs 里的 Get 方法值得单独看一遍,它能让你对”文档结构到底有多不规整”有个体感。这个方法是一长串正则分派:/namedrange、/sheet、/table 三个裸路径直接列举全部成员(注释说这是为了让 Agent 在还不知道名字的时候能先发现顶层集合);带下标的有 /Sheet1/row[N]、col[A]、cf[N]、dataValidation[N]、comment[N]、pivottable[N]、slicer[N]、sparkline[N]、rowbreak[N]、table[N]/columns[M]、chart[N]/series[K]、chart[N]/axis[@role=ROLE];还有 row[last()]、单元格 A1、区域 A1:D10、富文本 A1/run[N]。都不匹配的话,最后落到通用 XML 兜底,直接在工作表 XML 树上按路径导航。
读回来的也不只是单元格的值。光一个工作表节点,代码就依次尝试读冻结窗格、缩放、网格线、标签颜色、自动筛选、隐藏状态、工作表保护(连密码哈希和 algorithm / hash / salt / spinCount 都原样带出来,注释说明理由是让 dump 到 batch 的往返不会把保护强度悄悄降级成无密码)、打印区域、打印标题行列、页边距、页眉页脚、排序状态、分页符。
这里有个贯穿全项目的约定叫默认省略:默认可见的工作表不会输出 hidden 键,只有真的 Hidden 或 VeryHidden 才发。对 Agent 来说这是好事——输出里出现的每个键都携带信息,没出现就是默认值。
选择器这边有两个坑被代码显式挡住了,都很说明设计取向:
一个是漏写方括号。HasTopLevelComparison 会扫描选择器,看有没有 = 落在所有方括号、圆括号和引号之外。有的话直接抛错,提示你正确写法是 row[col.2024>150]。注释讲得很直白:这种选择器匹配不到任何元素类型,如果按老路走就是返回空列表加退出码 0,一个数据方向的用户会把这个”静默没有行”读成真实结果。只检查 = 是因为 > 和 < 兼任后代组合符(row > cell),拦了会误伤。
另一个是分隔符搞混。Excel 用 ! 作工作表前缀(Sheet1!cell[...]),PowerPoint 那套用 >。代码专门认了 Sheet1>ole 这种 PPT 式写法,在前缀看起来像表名、后缀又是已知 Excel 元素类型的时候,往 stderr 打一条警告指出正确形式,然后照常往下走。
还有一处别名归一化的坑值得记:Excel 的单元格选择器接受短别名,bold 映射到 font.bold、size 映射到 font.size。CommandBuilder.GetQuery.cs 里选 keyResolver 时用 ExcelHandler.SelectorTargetsCells 判断,注释写明这个判断必须先剥掉可选的工作表前缀(Sheet1!cell... 或 /Sheet1/cell...)再看元素名——否则带工作表作用域的单元格选择器会跳过别名归一化,匹配结果全部丢失。
四、dump:把子树吐成可回放的批处理脚本
src/officecli/CommandBuilder.Dump.cs 里的 dump 只干一件事:把文档的某个子树序列化成可回放的批处理脚本。命令描述里叫它 round-trip mechanism。
--format 目前只接受 batch,传别的直接抛错并把合法值列出来。输出是紧凑的单行 JSON 数组,注释说这就是 batch 的规范传输形式,batch run 直接消费,AI 工具链拿 jq 或 grep 过一道也不用管缩进。数组第一项由 BatchCompat.MetaItem() 插入,是个版本戳,作用是告诉回放端:在文本属性里,软换行是用 \v 而不是 \n 编码的。
子树路径可以精确到哪一级,命令的 path 参数描述里列全了:docx 支持 /、/body、/body/p[N]、/body/tbl[N]、/theme、/settings、/numbering、/styles;pptx 支持 /presentation、/slide[N]、/theme、/notesMaster、/slideMaster[N]、/slideLayout[N]、/noteSlide[N];xlsx 支持 /SheetName 和 /sheet[N]。
同一段描述紧接着写了这个机制最重要的免责声明:子树 dump 不包含兄弟路径上的资源——样式、编号、主题,pptx 的母版和版式,xlsx 的工作簿设置和命名区域,都不在里面。回放的目标文件必须已经定义了被引用的样式、numId 和版式(numId 是 Word 里指向某一套编号列表定义的编号 ID,正文段落只存这个号,真正的编号规则存在 /numbering 那一份 XML 里)。这不是缺陷,是范围界定,但 Agent 如果不知道,就会做出”dump 一段贴到空白文档就能复现”的错误假设。
三种格式各走各的发射器(WordBatchEmitter、PptxBatchEmitter、ExcelBatchEmitter),碰到不支持的元素不静默丢弃,而是产出警告,消息形如 skipped {element} at {path}: {reason}(pptx 那条把 at {path} 换成 on {slidePath},因为定位单位是幻灯片),code 统一是 unsupported_element。警告往哪儿走有讲究:只有 --json 模式,或者文本模式下 --out 把 JSON 导到了文件时,才往 stderr 打。注释解释了原委——文本模式下 dump 2>&1 | batch --input - 这种管道会把 stderr 混进 batch 的 JSON 标准输入,把解析搞崩;一旦 --out 把数组导去文件,stdout 只剩一个路径,stderr 就腾出来给人看了。而 --out - 按 Unix 惯例表示标准输出,代码里显式归一成 null,注释说在这之前它真的会在当前目录建出一个名叫 - 的文件。
这些部件分别在哪
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| get 命令装配 | path 参数、depth 上限截断、--save 抽二进制、selected 伪路径 | src/officecli/CommandBuilder.GetQuery.cs | 想定点看某一个节点 |
| query 与 compact 行格式 | 选择器过滤、--find、稳定行契约与 total 分母 | src/officecli/CommandBuilder.GetQuery.cs | 想一次扫全文并核对读全没 |
| dump 命令装配 | 子树序列化成 batch JSON、警告分流、--out 语义 | src/officecli/CommandBuilder.Dump.cs | 想把结构原样搬去另一个文件 |
| Excel 读路径解析 | 从 /Sheet1/A1 到 cf[N]、pivottable[N] 的全部分派与格式回读 | src/officecli/Handlers/Excel/ExcelHandler.Query.cs | 表格里定位不到元素 |
| Word 读路径解析 | 样式、编号、节、脚注、域、水印等非正文分支,以及内嵌二进制的宿主查找 | src/officecli/Handlers/Word/WordHandler.Query.cs | Word 路径找不到,或要抽内嵌对象 |
| 递归与体积上限 | MaxRecursionDepth 等硬常量 | src/officecli/Core/DocumentLimits.cs | 深嵌套文档读到挂起 |
| 批处理回放端 | dump 产物的消费方、原子回滚语义 | src/officecli/CommandBuilder.Batch.cs | 回放 dump 或批量改动 |
顺带说一个任何人都能当场数出来的结构特征:src/officecli/ 下共 469 个文件,其中 353 个 .cs;Word 处理器所在的 src/officecli/Handlers/Word/ 有 54 个文件,其中 46 个以 WordHandler. 开头,Excel 目录 47 个文件里有 44 个以 ExcelHandler. 开头(用 ls 加计数就能复现)。这些同名前缀的文件是 C# 的分部类——一个类的代码被拆到多个文件里,编译时合成一个。查询逻辑只是其中一小片,你要找读路径就直奔 .Query.cs,别在别的分片里翻。
五、边界、代价与风险
它不给语义判断。 这层机制能告诉你 /body/p[12] 的样式是 Heading1、文本是什么、字号多少,但完全不告诉你这一段该不该改、改了会不会破坏文档的论证结构。判断仍然全在模型那边,读侧只是把地基铺平。
深度是被硬截断的。 get 的 --depth 一进来就被夹到上限:
if (depth > DocumentLimits.MaxRecursionDepth)
depth = DocumentLimits.MaxRecursionDepth;
src/officecli/Core/DocumentLimits.cs 里 MaxRecursionDepth 是常量 256。注释给了理由:节点构建的递归里每个节点都要取 InnerText 和 OuterXml,代价是 O(n²),深嵌套文档配一个巨大的 depth 能把进程拖进几分钟的挂起或者栈溢出——而栈溢出是 .NET 里捕不住的,会直接杀掉长驻的 resident/watch 服务。所以这不是”最多给你 256 层”,而是”再多就不安全”。
query 的 JSON 只补一层子节点。 注释说明了原因:handler 构造查询结果时用 depth=0,避免昂贵的子树遍历,所以返回的节点 Children 是空的但 ChildCount 有值。--json 模式下会挨个调 Get(path, depth: 1) 补一层,让形状和 get --json 一致。想要更深,还得再发一次 get。
推断出来的表格不是真表格。 Excel 侧 table 选择器除了真的 ListObject(在 Excel 里执行「插入表格」生成的正式表对象,有名字、有表头、有独立的样式定义),还会带上启发式识别出的表格状区块,类型是 detectedtable、stable=false,路径用诚实的区域形式而不是伪造的 /table[N]。这个设计挺克制的,但 Agent 必须读 type 字段才能分清哪些是文档真的这么定义的、哪些只是看起来像。
compact 的文本是截断的,而且 xlsx 走另一条路。 60 个字符超出就打省略标记,它是做结构盘点和读完整性核对的,不是用来读内容的。xlsx 上 --compact 直接报错,紧凑视图是 view text 和 view text --range Sheet1!A1:C10;CommandBuilder.View.cs 里 text 模式对 xlsx 的描述是每个单元格渲染成 <A1>=<value> 制表符分隔,空单元格省略,所以稀疏行只列出有值的格子。
它动的是你磁盘上的真文件
读侧本身是安全的——dump 打开 handler 时显式传了 editable: false,get 和 query 也不写回。但同一个二进制的 set / add / remove 改的就是原文件本身,不是副本。你想留底就自己先复制一份,工具不会替你做。
常驻进程模式改变了”什么时候算落盘”。 officecli open <file> 会起一个常驻进程把文档留在内存里加速后续命令,get、query、dump 都会先探测有没有常驻进程持有这个文件,有就把请求转过去(dump 那边还留了注释说明不转会跟常驻进程抢文件锁,报”文件被另一进程占用”)。SKILL.md 把边界划得很清楚:officecli 自己的读永远看得到你最新的改动,所以工作流中间不用存;但在非 officecli 的程序读这个文件之前——python-docx、openpyxl、Word 本身、渲染器、上传交付——必须先跑 save(保留常驻)或 close(落盘并释放)。空闲会话会自动 flush,close 命令的描述写的是自适应 2 到 10 秒,可以用环境变量 OFFICECLI_RESIDENT_FLUSH 调成 each、auto、具体秒数或 off。也就是说,命令返回成功那一刻,磁盘上的字节可能还是旧的。
批量操作中途失败会留下什么。 默认是原子的:SKILL.md 写明每一项仍然都会跑并被报告(所以”N 成功 M 失败”依然有意义),但只要有任何一项失败,整批回滚,磁盘上的文件与批处理运行前逐字节一致,JSON 摘要里带 "atomicRolledBack": true。--best-effort 换回”能应用的就应用”的老语义,注释说这是给 dump 回放这种明知有不支持项的场景准备的——为一个元素丢掉整份复制品更亏。--stop-on-error 只决定停多早,不决定跑过的算不算数。
watch 会在本机开一个监听端口。 officecli watch <file> 起一个实时预览服务器,默认端口 26315,浏览器里点选元素,命令行用 officecli get <file> selected 把当前选中的路径读回来。这条链路本身很好用:
PATHS=$(officecli get deck.pptx selected --json | jq -r '.data.Results[].path')
for p in $PATHS; do officecli set deck.pptx "$p" --prop fill=FF0000; done
但要知道三件事:它是一个真实的本地 HTTP 监听面,在共享网络环境里随手起要掂量;文档写明所有连接的浏览器共享同一份选择状态,最后写入者胜;命令描述里说得很明确,这个预览只在 officecli 自己修改文档时刷新,外部编辑不检测。另外 get --save 会把图片、OLE、媒体节点背后的二进制负载写到你给的路径——OLE 指嵌在文档里的其他程序对象,比如一份贴进 Word 的 Excel 表格或一个内嵌 PDF,它在包里就是一坨完整的原始字节,WordHandler.Query.cs 的 TryExtractBinary 里目录不存在会自动 CreateDirectory——路径写错就是往磁盘上撒文件,不会拦你。
六、上手与避坑
一、用 total 行核对读全了没。 会踩是因为模型拿到二三十行输出就默认这是全部,尤其在 --compact 输出很整齐的时候。避法是把 lineCount - 1 == N 当成硬校验写进流程,再看 N 和 M 差多少——差得多说明你的选择器只覆盖了文档的一角。
二、docx 全量列举用 *,别用 paragraph, table。 会踩是因为后者看起来更”完整”。实际上 paragraph 会下钻表格单元格,跟折叠的表格行重复计数,N 和 M 的恒等式就废了,你反而失去了核对手段。
三、--compact 的两条互斥规则一起记。 会踩是因为习惯了”要结构化就加 json”、又在 docx 上用 compact 用顺手了。它跟 --json 同时给会抛 invalid_value,在 xlsx 上则会报错并指向 view text --range。这两处都是显式抛错,但脚本里错误被吞掉的话,就变成一次莫名其妙的失败。
四、Excel 选择器的谓词必须带方括号,分隔符必须是 !。 会踩是因为 Dept=IT 这种写法太自然,而 PowerPoint 那套用 > 做分隔、来回切换容易串。前者会被拦下抛错,后者只给一条 stderr 警告——stderr 在脚本里最容易被丢掉,别指望它救你,直接养成 Sheet1!row[Dept=IT] 的手感。
五、多步工作流一律用稳定 ID 路径。 会踩是因为第一次 query 拿到的位置路径看起来完全能用。只要中间有插入或删除,位置就漂了,而且漂移之后命令依然成功——改错了地方还报成功,这是最难查的一类。
六、dump 子树之前先确认目标文件已有资源。 会踩是因为”dump 出来能回放”这句话太容易被理解成自包含。子树里没有样式、编号、主题,回放目标缺一个 numId 就是另一副样子。要么整份 dump,要么先把 /styles、/numbering、/theme 单独 dump 过去。
七、文本模式的 dump 管道别加 2>&1。 会踩是因为调试时习惯性合并流。合了之后警告文本会混进 batch 的 JSON 输入把解析搞崩。要看警告就用 --out 导到文件,或者切 --json。
八、交给外部程序之前先 save 或 close。 会踩是因为 officecli 自己的读一直看得到最新状态,人很容易以为已经落盘了。下游是 Word、python-docx 或者上传脚本时,中间必须显式插一步。
收束
先读后写在文档自动化里是硬约束,原因很朴素:文档没有编译器。代码改错了跑一遍就报错,文档改错了它安静地保存成功,等下游某个人打开时才炸。所以读侧要提供的不只是能读到,而是能证明读全了、能指着同一个东西不漂移、能分清哪些是文档真的这么写的哪些是推断的。total 分母、稳定 ID 路径、detectedtable 的诚实标注,都是在回答这三个问题。
动手前的自检清单:目标节点是用稳定 ID 还是位置下标定位的?选择器覆盖了全文档的多少(N 比 M)?改的是原文件还是副本?常驻进程开着的话,下游程序读之前在哪一步 save?批量失败了是回滚还是留半截?
接着读哪个文件取决于你卡在哪:路径解析不对,去 src/officecli/Handlers/Excel/ExcelHandler.Query.cs 或 src/officecli/Handlers/Word/WordHandler.Query.cs 把分派分支顺一遍;输出格式对不上,看 src/officecli/CommandBuilder.GetQuery.cs 里 FormatNodesCompact 上方那段契约注释;要做往返复制,先读 src/officecli/CommandBuilder.Dump.cs 的 path 参数描述,再读 src/officecli/CommandBuilder.Batch.cs 关于原子回滚的那几段。这些文件的注释密度很高,多数是踩坑之后留下的,比读文档更省事。
本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 OfficeCLI 开源项目的选择器语法:三份解析实现与最常见的选错点 和 读开源项目 OfficeCLI 的源码:批量执行为什么快,以及失败时整批回滚这道坎。