读开源项目 OfficeCLI 的源码:批量执行为什么快,以及失败时整批回滚这道坎

2026-08-05

本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。

如果你只记一件事:在 OfficeCLI 这个开源项目里,把几十条修改合成一次 batch 调用,换来的不只是速度,还换掉了整套失败语义——默认情况下,只要有一项失败,前面成功的几十项也一并作废,磁盘上的文件跟批量跑之前一模一样。 这不是”顺手加的保护”,而是这条路径的默认值,你的重试逻辑必须按它来写。

先做一次消歧。OfficeCLI 是 GitHub 上 iOfficeAI/OfficeCLI 这个开源项目的专有名字,不是”用命令行操作 Office”这类泛指说法,也和微软没有任何从属、授权或官方合作关系。它是一个单二进制的命令行工具,在不安装 Office 的前提下读写 .docx / .xlsx / .pptx 这三种文件格式,许可证 Apache-2.0,NOTICE 文件写明 Copyright 2026 OfficeCLI,由 goworm 创建维护。下文提到 Word、Excel、PowerPoint 时,指的都是文件格式与对应应用,不是这个项目的归属。

站内已有三篇相邻的文章:Agent 并发编排 讲的是把多个任务铺到多条执行线上,重试与幂等 讲失败之后怎么安全地再来一次,工具返回设计 讲工具该把结果整理成什么样给模型看。本篇不重复这三件事,只盯住一个具体实现:当几十条修改被塞进同一次调用、同一个文件句柄里,“成功”和”失败”这两个词分别还剩下什么含义。

一、批量省掉的到底是哪几次开销

先说被优化掉的东西是什么。.docx / .xlsx / .pptx 本质上是 zip 压缩包,里面装着若干 XML 部件(part),文档正文、样式、主题各是一份。工具要改一句话,得先解包、把相关部件解析成内存里的文档树(源码里叫 DOM),改完再把部件重新序列化写回压缩包。

逐条调用的代价就摆在这里:改 40 处,就是 40 次进程启动、40 次开包解析、40 次整包写回。src/officecli/CommandBuilder.Batch.csRunNonResidentBatch 的注释把这件事说得很直白——它给 Word 处理器打上 DeferSave = true,让 N 条命令在最后统一序列化一次,而不是每条都存一次;注释把后者写成 O(N²) 量级的重复序列化(改一处就把整个部件重写一遍,改 N 处就写 N 遍越写越大的内容),并说它在大规模回放时是主要成本。批量的收益就是这两处叠加:一次进程 + 一次开包 + 一次落盘。

这里的分工有点意思:这层”延迟保存”故意没有做在共享的重放循环里。ApplyBatchItems 的注释解释了原因——保存时机和保护检查依赖处理器的生命周期,两类调用方不一样。用 using 管理、跑完即销毁的调用方(非常驻 CLI、MCP 服务端)把 DeferSave 一直开着,靠 Dispose 时的 FinalizeDeferredIds 做那一次刷盘;而长期活着的常驻进程必须自己保存并恢复 DeferSave,还要自己调 ReconcileGlobalIds。同一个循环,两种收尾。

二、一个批量项长什么样,以及最常踩的那个形状错误

batch 吃的是一个 JSON 数组。数组里每一项是一个对象,command 字段只放光秃秃的动词,动词的参数是这个对象的兄弟字段,而不是塞在 command 里的一整行命令行字符串。这一点在 CommandBuilder.Batch.cs 顶部的帮助文本里被单独点名,说这是最常见的批量错误——把整行 CLI 塞进 "command" 会以 Unknown command 失败。帮助文本给的示例是这样的:

[
  {"command":"add","parent":"/slide[1]","type":"shape","props":{"text":"Hi","x":"1cm","y":"2cm"}},
  {"command":"set","path":"/slide[1]/shape[1]","props":{"bold":"true"}},
  {"command":"remove","path":"/slide[2]/shape[3]"}
]

仓库的技能包 skills/officecli/SKILL.md 里给了三种喂进去的方式:

echo '[
  {"command":"set","path":"/Sheet1/A1","props":{"value":"Name","bold":"true"}},
  {"command":"set","path":"/Sheet1/B1","props":{"value":"Score","bold":"true"}}
]' | officecli batch data.xlsx --json

officecli batch data.xlsx --commands '[{"op":"set","path":"/Sheet1/A1","props":{"value":"Done"}}]' --json
officecli batch data.xlsx --input updates.json --best-effort --json   # keep whatever succeeds even if some items fail

字段解析在 src/officecli/BatchTypes.csBatchItemConverter 里,手写的转换器,不是默认反射。它做了几件对调用方友好的事,也各自对应一段踩坑史:

opcommand 的别名,两个都收。query 项的过滤条件叫 selector,但 path 被接受为别名——ExecuteBatchItem 里的注释写了原因:通用字段表里写着”path 是 set/remove/get 的目标”,调用方顺手就带到 query 上,而早期忽略 path 会导致跑出一个空选择器,等于”全部命中”,源码原话把这称为最危险的那种错误数据。swap 的第二个路径叫 path2,也兼容写成 to

props 的解析更宽松。LenientStringDictionaryConverter 既收对象形式 {"bold":"true"},也收数组形式 ["bold=true"]。注释里说明了这个兼容是补出来的:单条命令的 --prop key=value 和 MCP 的 props 参数就是数组形状,模型很自然地把它复用到批量项里,而早期批量只认对象,结果是”某些模型的批量失败率 100%“。数值、布尔、null 也都会被规整成字符串。

宽松只到这里为止。未知字段是硬错误:BatchItem.KnownFields 是一份白名单,任何不在名单里的键都会让整个批量在执行前就报 batch item[i]: unknown field(s) ...,并把合法字段列表打给你。数组里出现 null 项也会被提前拒掉,报出具体下标。这两处都是”宁可早失败”的取向——反正整批要一起回滚,越早发现越省事。

还有两个容易忘的入口细节。--commands--input 互斥,同时给会直接报错;--input - 是显式读 stdin 的 Unix 惯例写法。以及 dump --json 的输出是带 {"success":..., "data":[...]} 外壳的,batch 会自动把 data 数组拆出来,所以 dump --json > out.json && batch --input out.json 这条管线不需要额外过一道 jq。

三、失败语义:整批回滚是默认值

这是本篇的重点。逐条调用时,“第 17 条失败了”意味着前 16 条已经落在磁盘上;批量不是这样。

先看非常驻这条路(CommandBuilder.Batch.cs)。只要这批里存在会改动文档的动词,且没加 --best-effort,就进入原子模式:

  1. 把目标文件路径先解析一次符号链接(symlink,指向另一个文件的”快捷方式”式条目),拿到最终目标,免得最后替换时替换掉链接本身。
  2. 用流式拷贝把原文件复制成同目录下的一个隐藏暂存文件(名字形如 .<stem>.batchprep-<guid><ext>),再改名成 .<stem>.batch-<guid><ext>。注释解释了为什么不用 File.Copy:那会连源文件的修改时间一起复制过来,而下面的清扫逻辑要靠新鲜的时间戳来判断”这份临时文件是不是活的”。扩展名保留在最后,因为处理器是按扩展名分发的。
  3. 所有修改都跑在这份副本上,原文件全程没有被以写模式打开过。
  4. 全绿才用 File.Replace 把副本提升为正式文件——同卷改名,一步换过去。任何一项失败,副本直接删除,原文件一个字节都没动。

清扫逻辑也值得看一眼,因为它决定了”进程被强杀之后你的目录里会留下什么”。批量崩溃时来不及自清,下一次批量会扫同名模式的孤儿临时文件,但要同时过两道闸:能以独占方式打开(正在跑的批量握着自己的临时文件,所以活的不会被误删),并且创建时间和修改时间都在 15 分钟之前。真正的崩溃残留是几分钟到几天前的,更年轻的一律当成活的留给下一轮。这个 15 分钟是源码里写死的常量,你可以去 CommandBuilder.Batch.cs 里核。另外临时文件名会给原文件名加约 45 字节的前后缀,所以文件名主干会先被截到 180 字节以内(按 UTF-8 字节数、在字符边界处截),否则一个接近文件名长度上限的文档会出现”单条 set 能跑、batch 反而失败”的怪现象。

常驻模式(src/officecli/ResidentServer.csExecuteBatch)实现完全不同,但对外语义对齐。常驻进程一直把文档留在内存里,所以它没法靠”改副本”来保证原子性,它的做法是:

  • 进原子批量前先立一道刷盘屏障,把内存树写到磁盘,让磁盘状态等于批量前状态,这样磁盘本身就是回滚点。
  • 跑完发现有失败项,就把这份被污染的内存树标记为丢弃(不序列化),销毁处理器,重新从磁盘打开文件。回滚只在失败路径上付一次解析成本。
  • 回滚之后跳过 ReconcileGlobalIds,因为那批改动已经不存在了。

这里有一个必须知道的冲突:如果你设了 OFFICECLI_RESIDENT_FLUSH=off,也就是要求”只在显式 save/close/关闭时才写盘”,而此刻内存里又恰好压着还没落盘的改动,那道刷盘屏障就立不起来——立了违背 off 的承诺,不立则回滚时会把那批未落盘的改动一起丢掉。源码选择了报错而不是二选一地糊弄,抛出 flush_policy_conflict,并在错误消息里给出两条出路——先 save,或者改用 batch --best-effort。反过来,内存与磁盘本来就一致的会话不付这笔代价,屏障那一步直接跳过。

三个开关的分工要背下来,它们在 SKILL.md 里被明确区分过:

  • 默认(不加任何开关):每一项都会执行并被逐条报告,所以 N succeeded, M failed 这个数仍然有意义;但只要有失败,整批回滚。
  • --best-effort:恢复”成功的就留下”的旧语义。适合 dump → batch 这种有损回放——因为一个不支持的项就丢掉整份文档,比拿到一个部分结果更糟。
  • --stop-on-error:只改变”什么时候停”,不改变”跑过的算不算数”。剩余项计入 skipped。想要”遇错即停但保留已成功的”,得和 --best-effort 一起用。
  • --force 和以上三者无关,它只是 .docx 文档保护的绕过开关。

判决怎么传出来也很清楚。JSON 模式下外层信封的 success 只有全部成功才为真,单项判决在 data.results[].success 上——同一份 JSON 里有两个 success,靠路径区分。被回滚的批量,summary 里会多一个 "atomicRolledBack": true;文本模式则在那行汇总后面追加 (atomic: no changes were applied)。失败项带机器可读的 code,还会把原始项 item 原样带回来,方便你直接改了重投。退出码和外层判决保持一致:有失败项返回 1;只有”无法识别的 LaTeX 命令”这类告警时返回 2;连不上常驻进程、批量投不进去时返回 3。

四、这套东西由哪几块拼成

组成部分它负责什么仓库位置你什么时候会碰到它
batch 命令与原子提升解析入参、拷贝副本、跑完提升或删除、打印结果src/officecli/CommandBuilder.Batch.cs从命令行或脚本发批量时
ApplyBatchItems 共享重放循环逐项 try/catch、记录每项判决、--stop-on-error 中断同上文件内想搞清”某一项失败后循环怎么走”时
BatchItem / BatchResult 与转换器字段白名单、别名、props 宽松解析、结果序列化src/officecli/BatchTypes.cs拼 JSON 报 unknown field 时
项级动词分发 ExecuteBatchItem把一项翻译成 get/set/add/remove/query 等实际操作src/officecli/CommandBuilder.cs排查某个动词的参数校验时
BatchExecutor 进程内入口给直接链接本项目的宿主用,输出与 CLI 逐字节一致src/officecli/Core/BatchExecutor.cs把它嵌进自己的服务而不是起进程时
常驻模式批量刷盘屏障、内存回滚、跳过批内 open/closesrc/officecli/ResidentServer.cs用常驻进程跑多步流程时
dump 版本兼容老 dump 的换行语义改写src/officecli/Core/BatchCompat.cs回放很早以前导出的 dump 时
刷盘策略常驻模式什么时候把内存写回磁盘src/officecli/Core/ResidentFlushPolicy.csOFFICECLI_RESIDENT_FLUSH

顺带解释一个 C# 概念:CommandBuilder 是个”分部类”(partial class),也就是同一个类的代码被拆到多个文件里写,所以你会看到 CommandBuilder.Batch.csCommandBuilder.Set.cs 这一串同前缀文件,它们其实是一个类。整个 src/officecli/ 下有 469 个文件、其中 353 个是 .cs(这是你自己 ls 就能数出来的结构性数字),批量相关的核心逻辑集中在上面这几个里。

五、边界与代价:它明确不管什么

原子默认不是白拿的,代价很具体:

磁盘占用会翻倍。 每次会改动文档的批量都会在同目录下先复制一整份文件。文档大、目录配额紧的场景要自己算这笔账。全部由只读动词(getqueryviewvalidatedumpraw)组成的批量会跳过复制,但这个名单是封闭的——不在名单里的动词,包括未来新增的,一律按”会改动”处理。这是刻意的失败取向:宁可多复制一次,也不让新动词被悄悄当成只读。

原子性依赖同卷替换。 提升那一步是同目录改名,跨卷不适用;替换失败(目标被改成只读、被人从底下删掉、文件系统怪癖)会在任何成功输出之前作为命令错误抛出,原文件保持原样。

它不是数据库事务。 没有隔离级别,没有多文档事务。原子性的作用域就是”这一个文件、这一次批量”。两个批量同时打同一份文档,靠的是临时文件独占句柄和时间闸这类文件系统层面的约定,不是锁管理器。

它不判断你改得对不对。 回滚只在”某一项抛异常”时触发。一批语法完全合法、语义完全错误的修改会全绿通过并落盘。选择器写宽了、把整篇文档的字体都改了——工具照做。

常驻模式下批量不落盘。 这一点最容易出事:批量项在常驻进程里只作用于内存,磁盘写入被推迟到 save / close / 空闲自动保存。自动保存的间隔是自适应的,在 2 秒到 10 秒之间取值(下限和上限是 ResidentFlushPolicy.cs 里的常量)。也就是说,批量返回成功之后,你去读那个 .docx,可能读到的还是老内容。想要确定性,设 OFFICECLI_RESIDENT_FLUSH=each,让批量返回前就落盘。本工具自己的读命令走的是同一份内存树,所以它立刻能看到改动——但绕过它的第三方读取器看不到。

它改的是你磁盘上的真实文件。 不是沙箱,不是副本留档。原子模式下中间过程走临时副本,但最终替换的就是原文件本身(且经过符号链接解析,替换的是链接指向的真身)。--best-effort 模式下更直接:跑在原文件上,成功几项就留几项。批量不会缩小单条命令的能力边界——单条命令能碰到的东西(磁盘文件、按参数拉取的外部资源),批量里同样会碰,而且一次几十条会把这些副作用一次性放大到你不容易复核的规模。让模型生成整批 JSON 时,这一点值得在提示里写死。

六、上手清单:每条都写清为什么会踩

1. 别把整行 CLI 塞进 command 会踩,是因为你脑子里的心智模型是”批量 = 命令行的多行版本”,而实际模型是”结构化项的数组”。避法:command 只放动词,参数一律作为兄弟字段;写完拿帮助文本里那三行示例对一遍形状。

2. 想要”部分成功”必须显式说。 会踩,是因为很多人从别的工具带来”跑到哪算哪”的预期,结果一项失败拿回来的是零改动,然后去文件里找那”应该已经改好的前 30 条”。避法:需要部分进度就加 --best-effort;不加就当”全成或全不成”来设计上游逻辑。

3. 别拿 --stop-on-error 当”保留已成功项”用。 会踩,是因为名字听起来像”停在这儿,前面的算数”。它只控制何时停止扫描,回滚与否由是不是 --best-effort 决定。避法:两个都要就两个都写。

4. 常驻模式下批量返回成功 ≠ 文件已改。 会踩,是因为流水线下一步往往是另一个进程去读那个文件,而它读的是磁盘。避法:批量之后显式 save,或者把刷盘策略设成每次落盘;把这一步写进流水线,别指望自动保存的时间窗。

5. 把每项的 code 存下来再决定重试。 会踩,是因为失败原因分布很宽——路径不存在、属性不支持、输入里混了 NUL 字节等等,混在一起重试等于瞎试。避法:按 code 分桶,路径类错误改路径、输入类错误改数据、保护类错误走 --force 决策。这一步和站内 失败分类 讲的是同一件事,只是这里的分类信号是现成的。

6. 批量输出可能不在 stdout 里。 会踩,是因为大批量的 JSON 结果超过 8192 字节就会被溢写到临时文件,stdout 上留下的是精简信封,只带 outputFileoutputSize、每项的 index/success 和失败信息。你的解析器如果只认完整结构,会在大批量上突然拿不到 output。避法:解析时先判断有没有 outputFile,有就去读那个文件。

7. 别同时喂两个输入源。 会踩,是因为脚本里既写了 --commands 又恰好有重定向进来的 stdin。--commands--input 同时给会直接报错;--commands/--input 与管道 stdin 同时存在会打一条”stdin 将被忽略”的警告(工具会用一次很短的探测判断管道里是不是真有内容,避免在 CI、cron 这类非交互环境里误报)。避法:只留一个源;确实需要静音就设那个专门的环境变量。

8. 回放老 dump 前确认换行语义。 会踩,是因为项目把 .docx 的换行编码改过一版:现在 \n 表示分段、\v 表示段内换行,而更早的 dump 用 \n 表示段内换行。直接回放会把每个软换行炸成一次分段。避法:老 dump 在数组开头放一个声明旧版本的 meta 项,兼容层会把文本字段里的 \n 改写回 \v;这个改写只对 .docx 生效,不声明版本的批量按当前语义执行(这一点是刻意的——手写批量本来就没有 meta 项,不能让兼容逻辑对普通输入开火)。

收尾:投一批之前问自己四句

第一,这批里有没有会改动文档的动词?有就意味着一次全量文件复制,磁盘和耗时都要算进去。第二,我要的是全成或全不成,还是尽量多成?答案决定 --best-effort 写不写。第三,这个文件此刻有没有常驻进程握着?有的话,批量成功之后还差一步落盘。第四,失败回来我拿什么重试?如果答案不是”按 code 分桶 + 用回传的 item 重投”,那重试逻辑还没写完。

想继续往下读源码,建议的顺序是:src/officecli/CommandBuilder.Batch.cs 从上到下走一遍(帮助文本、原子提升、清扫逻辑都在这一个文件里),然后跳到 src/officecli/BatchTypes.cs 看字段契约,最后对照 src/officecli/ResidentServer.csExecuteBatch 看同一套语义在长生命周期进程里是怎么换一种方式实现的。三个文件读完,批量这条路上你会遇到的行为基本都有出处可查。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 开源项目 OfficeCLI 的查询与转储:Agent 先读懂再动手OfficeCLI 开源项目常驻模式:进程不退,文档改动何时落盘

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。