OfficeCLI 开源项目常驻模式:进程不退,文档改动何时落盘

2026-08-05

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

常驻模式真正改变的不是速度,而是”命令返回成功”这句话的含义。 在常驻模式下,一条 set 返回 0,只代表内存里那棵文档树被改了;磁盘上那个 .docx 可能几秒后才变,也可能要等到你显式发一次 flush 才变。你的 Agent 如果在改完之后立刻让另一个程序去读同一个文件,看到的很可能是改之前的内容——不是它没改,是还没写下去。这篇把这个时间差讲透。

OfficeCLI 是 GitHub 上 iOfficeAI/OfficeCLI 这个开源项目,Apache-2.0 许可证,NOTICE 文件写明 Copyright 2026 OfficeCLI,由 goworm 创建维护。它是一个单二进制的 Office 文档读写命令行工具,不装 Office 也能操作 .docx/.xlsx/.pptx,和微软没有从属、授权或官方合作关系;文中出现 Word/Excel/PowerPoint 时指的是文件格式和对应的桌面应用,不是这个项目的归属。

站内已有几篇相邻话题:Agent 状态机与自由发挥的取舍 谈的是 Agent 主循环该怎么被约束,常驻部署形态的选择 谈的是服务本身怎么长期跑着,AI 缓存策略 谈的是结果层复用。本篇只钉一件事:一个持有真实磁盘文件的常驻进程,它的内存状态和磁盘状态在什么时刻对齐、在什么时刻不对齐。

一、常驻要解决的成本,是反复解包

.docx/.xlsx/.pptx 这三种文件本质上是 zip 压缩包,里面装着一堆 XML 分片(这套格式叫 OOXML)。任何工具要改一个段落,都得先把包解开、把 XML 解析成内存里的一棵树、改、再序列化写回去。这个解包-解析的成本跟文件规模成正比,一个几十页的报表或者几千行的工作簿,单次开销就不是可以忽略的量级。

不开常驻时,每条命令都是一次完整的”开-改-关”。Agent 干活的典型形态恰恰是几十上百条小指令连续打,于是同一份文档被反复解析同样多次。更麻烦的是并发:多条命令同时摸同一个文件,谁都想拿写锁,冲突就来了。

常驻模式的做法是把这个循环拆开——起一个后台进程,把文档解析一次留在内存,之后所有命令通过进程间通道发进去,命令只是在这棵已经解析好的树上做增删改。写回磁盘这一步,被单独抽出来变成一个可以调节时机的动作。

skills/officecli/SKILL.md 里对使用者的建议是:每条命令在首次访问时都会自动拉起一个常驻进程(空闲 60 秒退出),文件锁冲突因此被自动规避;更长的会话建议显式 open / close,这时空闲窗口是 12 分钟。也就是说,你大概率已经在用常驻模式了,只是没意识到。

二、进程是怎么被拉起来的,谁在守着这个文件

前台命令的装配都在 src/officecli/CommandBuilder.cs(这个类是 C# 的分部类,也就是一个类的代码被拆到多个文件里写,CommandBuilder.Save.cs 这类文件都是它的一部分)。里面注册了一个隐藏子命令 __resident-serve__,注释直接写着 do not call directly——它就是后台进程的入口。open 和自动起常驻走的都是 TryStartResidentProcess,fork 出当前可执行文件、带上这个隐藏动词和文件路径,然后最多等 5 秒,轮询探活直到确认能连上。

连接靠的是命名管道(同一台机器上两个进程之间的一条带名字的双向通道)。管道名的算法在 ResidentServer.GetPipeName:把文件绝对路径取 SHA256、取十六进制前 16 位,拼成 officecli-<hash>;Windows 和 macOS 上还会先把路径转大写再哈希,因为这两个平台的路径大小写不敏感。所以”哪个进程持有哪个文件”这件事,是靠路径哈希对齐的,不需要注册中心。

关键是这里开了两条管道:主管道跑业务命令,另一条名字后缀 -ping 的管道只跑三个内部命令 __ping____set-idle-timeout____close__。分开的理由写在代码注释里:主管道上的命令是被一把命令锁串行化的,一条大 batch 跑着的时候后面全排队;而探活和关闭必须随时能答,不能被业务命令堵死。

__set-idle-timeout__ 这个 RPC 存在的原因很具体:create 自动起的常驻只给 60 秒空闲窗口,随后你显式 open 时,它不会重启进程,而是发这条 RPC 把窗口升到 12 分钟。合法区间是 1 到 86400 秒,越界拒绝。启动时也可以用环境变量 OFFICECLI_RESIDENT_IDLE_SECONDS 指定,同一套边界。

还有一个容易被忽略的部件:单例锁文件。探活失败就 spawn 这个流程天生有竞态——N 个客户端同时探同一个没人持有的文件,会各自起一个常驻。代码注释记录了这个事故的形态:每个常驻各持一份完整内存副本,落盘时整文件覆写,于是并发写只有最后一个 flush 的活下来(观测到 40 个并行 set 最终盘上只剩 0 到 2 个单元格,而且每条都报成功)。修法是在临时目录里用 pipeName + ".lock" 开一个 FileShare.NoneDeleteOnClose 的独占文件,抢不到的进程安静退出,它们的客户端会重新探活连到赢家。

组成部分它负责什么对应仓库位置你什么时候会碰到它
常驻服务端持有文档句柄、串行执行命令、跑两个看门狗(到点自检并采取动作的后台循环)、管有序关停src/officecli/ResidentServer.cs命令明明成功了,磁盘文件却没变
客户端探活、发业务命令、发 save/close 的 RPC、管道收发src/officecli/ResidentClient.cs命令报”could not be delivered”
落盘策略解析四种 flush 模式、按实测保存耗时算自适应间隔src/officecli/Core/ResidentFlushPolicy.cs想把落盘时机改成确定性的
命令装配与进程孵化open / close / 隐藏的 __resident-serve__、自动起常驻、单例锁src/officecli/CommandBuilder.cs想知道后台进程是谁拉起来的
save 子命令只 flush 不退出,没有常驻时是幂等成功src/officecli/CommandBuilder.Save.cs交付前手动落一次盘
技能文档给 Agent 的使用约定与边界提示skills/officecli/SKILL.md想看项目自己推荐的用法

三、你的文件到底什么时候变

这是本篇的核心。常驻进程内部有两个布尔状态:一个标记句柄是不是可写,一个标记内存里有没有还没写回磁盘的改动。

第一个状态的默认值是”不可写”。只服务读命令(view/get/query/raw/validate/dump)的常驻,从头到尾不会把文件写回去。第一条会改动文档的命令(set/add/remove/move/swap/refresh/raw-set/add-part/batch)进来时,会走一个共同前置动作:把只读句柄释放掉、以可写模式重新打开、把”可写”这个标记锁死到进程生命周期结束。也就是说,升级成可写是一次性的、不可逆的,从此这个常驻退出时一定会写盘。

同一个前置动作还做两件事:把”有未落盘改动”这个标记置真;对 Word 处理器打开延迟保存开关。延迟保存的理由在注释里算过账——常驻本来就把文档留在内存,每次改动都做一次全量序列化是纯浪费,长会话里会累积成平方级的开销。

那么什么时候真的写盘?看 Core/ResidentFlushPolicy.cs 定义的四种模式,用环境变量 OFFICECLI_RESIDENT_FLUSH 选(旧变量 OFFICECLI_RESIDENT_IDLE_SAVE_SECONDS 在新变量没设时仍被认):

  • each:每条改动命令返回之前先 flush。命令成功就等于磁盘已经是新的,这是确定性最高的一档,代价是每条命令都摊一次全量序列化。batch 内部仍然合并成一次,不会退化。
  • auto(默认):空闲防抖。没有新命令进来、静默够一段时间之后才 flush,进程继续活着。
  • <N>:空闲防抖,但间隔固定为 N 秒,就是自适应之前的老行为。
  • off:只有显式 save/close/进程关停才写盘。

auto 这一档的间隔不是拍脑袋定的,公式写在策略文件里:clamp(4 × EMA(保存耗时), 2s, 10s)。EMA 是指数移动平均,就是拿历史值和新样本按权重混合出一个平滑估计。这里的平滑是不对称的:保存变慢时权重 0.7,估计值几乎立刻抬上去;保存变快时权重只有 0.2,慢慢降回来。设计意图很直白——4 倍这个系数把后台保存占用的墙上时间压在四分之一以内,普通文档基本贴着 2 秒下限走,一个保存要好几秒的重工作簿会自动把间隔往 10 秒退,不会退化成保存风暴。

空闲这件事有两个独立的计时器,都被命令活动重置:短的那个到点做一次落盘、进程继续跑;长的那个到点是关停——落盘加释放文件。注意一个细节:自动落盘触发不会重置关停计时器,所以常驻仍然在最后一条命令之后按配置的时间退出。另外关停计时器到点时还会做一次实际检查,如果此刻还有命令在跑(比如一个跑得比空闲窗口还长的大 batch),它会重新计时而不是把进程拆掉——注释里解释过这个坑:中途拆掉会让那条命令的回复被取消、客户端报”没送达”,可退出时的落盘却又把结果写进去了,等于一次假失败盖住一次真写入。

显式 save 走的是同一套状态:句柄还没升级成可写,或者当前没有未落盘改动,都直接返回 No pending changes for <文件名>,不做任何 I/O。所以交付前保险性地敲一次 save 几乎是免费的。

close 走的是有序关停,注释里把顺序和不变量都写死了:先停主管道不再接新命令(此时探活管道还活着),踢一下主管道唤醒阻塞的等待,排空正在跑的那条命令,然后释放文档句柄——这一步才把内存树写回磁盘并松开文件锁,最后才停探活管道。整套顺序服务于一条不变量:探活能应答 ⇔ 常驻仍然持有这个文件。任何客户端探到活就绝不该自己去直接开文件,探不到才安全。

四、边界与代价:它放弃了什么

放弃了”命令成功即磁盘为真”。 默认 auto 模式下,你在 Agent 里写完一段就调 python-docx 或者 openpyxl 去读同一个文件,读到的是旧内容的概率不低。技能文档给的边界很清楚:officecli 自己的读命令永远看得到最新改动,需要 flush 的只有”非 officecli 程序要读这个文件”这一个时刻——外部库、桌面 Office、渲染器、上传交付。

放弃了纯内存沙箱。 这个工具改的是你磁盘上那个原文件,不是副本。常驻只是把写入时刻推后了,不是把写入取消了。会话中途你在文件管理器里把文件删了或改名,关停时的落盘会把内容按原路径重建出来,并在 close 的返回里给一条警告(改名的情况下新位置还会留一份旧副本);如果释放句柄这一步本身抛了异常,close 会以非零退出并明说 data may be lost。这两种情况都不是”静默成功”,但你得真的去看 stderr。

放弃了随手 kill 的安全性。 进程注册了 SIGTERM、SIGINT、SIGQUIT、SIGHUP 四个信号,以及一个进程退出兜底钩子,走这些路径退出会先执行落盘。捕获不到的强杀不在这个列表里——内存里那棵树就没了。另外关停本身有一个 10 分钟的看门狗,超时会强制退出并打警告。

和原子 batch 有一处硬冲突。 原子批处理的回滚点就是”磁盘上的批前状态”——一批命令中途失败时,它靠丢掉内存里那棵被改坏的树、从磁盘重新读回来实现回滚,所以磁盘必须先是批前的样子。为此它在开跑前会做一次 flush 屏障:只有当此刻确实攒着未落盘改动时才真的写一次,会话本来就干净的话这一步不花钱。而 OFFICECLI_RESIDENT_FLUSH=off 承诺的恰恰是不做隐式写盘。两者撞在一起时代码选择了失败关闭:直接抛错,错误码 flush_policy_conflict,并给两条出路——先手动 save,或者改用 batch --best-effort。它不替你偷偷选一个。

它明确不管的事: 不管跨进程的多写者协调(同一台机器上另一个 Agent 会话对同一文件另起常驻,仍会互相争抢,xlsx 技能文档把这条列为已知风险);不管把命令重发一次是否安全——客户端的重试只覆盖连接阶段,一旦命令写进管道就绝不重发,因为 add/remove/move/swap/batch 都不是幂等的,宁可报”没送达”让你自己判断,也不肯冒双写的风险。这个取舍和 Agent 失败重试的边界 里讨论的是同一类问题。

五、上手与避坑清单

1. 别把”命令返回 0”当成交付完成。 会踩是因为默认 auto 模式下写盘是异步的,而流水线的下一步往往紧接着就是上传或者用另一个库校验。避法:在流程里把”交给非 officecli 程序”这个边界显式标出来,边界前面加一条 save(保留常驻)或者 close(落盘并释放)。没有常驻时这两条都是幂等成功——幂等是指同一条命令重复执行结果不变,这里具体表现为:没有常驻会话可 flush 时,save 不当成错误,而是直接告诉你文件已经是磁盘上的样子并返回成功。所以这两条可以无脑加。

2. 需要确定性就把 flush 改成 each,而不是到处插 save。 会踩是因为有人靠”多睡几秒”来赌自动落盘完成。避法:OFFICECLI_RESIDENT_FLUSH=each,让每条改动命令返回时磁盘就是新的;代价是每条命令一次序列化,写得密就明显。批量场景更划算的是保持默认、只在边界 flush。

3. 用 off 之前先确认你不跑原子 batch。 会踩是因为 off 看起来像”性能最优档”,但它和原子回滚的前提直接打架:只要开批时手上攒着未落盘的改动,批处理开头就直接抛错,而这恰恰是 off 模式下的常态。避法:要么保持 auto,要么在跑批前手动 save 一次,要么显式用 best-effort 放弃原子性——注意 best-effort 意味着失败的批可能留下改了一半的文档。

4. 交付前别用 kill -9 收尾。 会踩是因为很多脚本习惯用强杀清理后台进程。避法:用 close 让它按顺序落盘退出;实在要清理,也走能被捕获的终止信号。

5. 同一份文件不要让两个会话各起一个常驻。 会踩是因为单例锁只在同一台机器的启动竞态里生效,而两个 Agent 会话各自持久占用是另一回事,xlsx 技能文档明确提示这种情况会出现非确定性挂起。避法:让文档的所有权归一个会话;换会话前先 close。这本质上是 改动边界的约定问题

6. 遇到退出码 3 别重发同一条改动命令。 会踩是因为 3 看起来像普通失败。它的含义是”常驻活着但命令没送达”,而客户端在连接成功之后不会重试,正是为了避免双写。避法:先用读命令确认当前文档状态,再决定补哪一条;实在卡住就 close 之后重来。

7. 大 batch 别指望空闲计时器帮你兜底。 会踩是因为一条命令跑得比空闲窗口还长时,直觉上会担心进程被拆掉。实际实现里关停前会检查在飞命令数并续等,所以不用为此调参;真正需要警惕的是相反方向——batch 跑完之后你如果立刻读盘,改动可能还在内存里。

收束

判断要不要开常驻,其实只有一个问题:这份文档接下来是被 officecli 自己连续操作,还是马上要交给别人读? 前者开常驻只有好处,后者要在交接点显式 flush。

给自己留一张自检清单:这条流水线里,非 officecli 程序读文件的时刻在哪几处?每一处前面有没有 save 或 close?当前 flush 模式是什么,是不是有人在环境变量里改过?会话结束时是靠 close 收尾,还是靠空闲超时?失败路径上(批处理中途报错、进程被杀)文件会停在什么状态?

想继续往下读代码,顺序建议是:先 src/officecli/ResidentServer.cs 顶部那一大段注释——两个取消令牌(.NET 里用来通知一组后台任务”该停了”的开关,这里一个管业务命令循环、一个管探活与计时器,分开正是为了让探活活到最后)、两个计时器、脏标记的语义全在那儿;再 src/officecli/Core/ResidentFlushPolicy.cs 看自适应间隔的完整公式;最后 src/officecli/ResidentClient.cs 看客户端为什么把重试严格限制在连接阶段。这三个文件加起来不长,但常驻模式所有让人困惑的行为,答案都在里面。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 读开源项目 OfficeCLI 的源码:批量执行为什么快,以及失败时整批回滚这道坎开源项目 OfficeCLI 的 watch 预览:让 Agent 改文档时你在浏览器里同步看到

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