开源项目 OfficeCLI 为何自研 Excel 公式引擎与求解器

2026-08-05

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

没装 Excel 的机器上,.xlsx 里那个公式的结果不会凭空出现——所以任何想让 Agent 独立读写表格的工具,都被逼着自己写一套公式引擎。 OfficeCLI 这个开源项目(仓库 iOfficeAI/OfficeCLI,Apache-2.0,NOTICE 写明 Copyright 2026 OfficeCLI,由 goworm 创建维护)把这条路走到了解析器、求值器、溢出数组、统计分布、迭代求根全部自己实现的程度。它不是微软出品,也不是”用命令行去驱动 Excel”,而是一个单二进制、不依赖本机安装 Office 的文档读写套件。这篇讲清它算到哪一步、哪一步故意不算,以及这两件事对你意味着什么。

先说清本篇与站内相邻几篇的分工:让 AI 写 SQL 讲的是让模型生成查询语句,AI 写数据分析脚本 讲的是让模型产出可执行的分析代码,Agent 输出约束的取舍 讲的是怎么约束模型的输出格式——那三篇的主角都是模型。本篇的主角是模型下游那个确定性工具:当 Agent 说”把这个公式写进去”,谁来算出结果、算错了会怎样。

一、绕不开的那个空格子

.xlsx 是一个 OOXML 包——说白了就是一个 zip,解开来是一堆 XML 文件。一个公式单元格在 XML 里是两块东西:<f> 存公式文本,<v> 存上次算出来的缓存值。真 Excel 打开文件时会重算并回填 <v>,所以人类用户永远看不到这个区别。

问题出在无头读取方。ExcelHandler.FormulaCache.cs 的文件头注释把这件事写得很直白:openpyxl 的 data_only=Truepandas.read_excel 这类读取方只信缓存,它们不会重算。你写进去一个 =SUMIFS(...) 却没有 <v>,Python 侧读到的是 None;缓存是旧的,读到的就是一个静悄悄的错数。而”下游是脚本而不是人”恰恰是自动化工具最需要伺候的那一类消费者。

所以自研公式引擎在这个项目里不是炫技,是准入条件。README 里对这块的说法是「350+ built-in Excel functions evaluated automatically on write」——写入即求值,写 =SUM(A1:A2) 然后 get 这个单元格,值已经在了。这是项目自己的定位表述;本文引用它只是为了说明设计意图,具体覆盖度仍以代码为准。

二、解析层:一个手写的递归下降解析器

Core/Formula/ 这个目录用 wc -l 数下来是 16 个 .cs 文件、合计 11185 行。其中 FormulaEvaluator 是一个 C# 的分部类(partial class,同一个类的实现拆到多个文件里,编译时合并),摊在 14 个文件上;解析和求值的主干在 FormulaEvaluator.cs

词法层先把公式切成 token,token 类型枚举 TT 覆盖数字、字符串、单元格引用、区间、跨表引用、数组字面量、错误字面量、名字等十几种。真正吃功夫的是那些不讲道理的边界,代码里每一处都留了原因:

  • TRUE / FALSE 是布尔字面量,但后面紧跟 ( 时得当函数调用放行,否则会剩下一个谁也消费不掉的 ()
  • LOG10 长得完全符合单元格引用的正则 ^[A-Z]{1,3}\d+$(LOG 列第 10 行)。规则是:紧跟 ( 的标识符一律是函数名。
  • 'Sheet Name'!A1 里的表名内部单引号按 ECMA-376 用两个连写来转义,收尾引号必须是”后面没有跟另一个引号”的那个。
  • {1,2;3,4} 数组常量按 ECMA-376 §18.17.7.282 解析:逗号分列、分号分行;且拆分时要跳过双引号内部的分隔符,否则 {",",";"} 自己就把自己劈了。
  • A:A 整列、1:1 整行不会真去展开 1048576 行,而是先扫出该工作表的已填充行/列区间再裁剪。

定义名(工作簿里给区域起的别名)分两种走法:名字体是纯字面区域的,直接换成一个引用 token;名字体是公式的,把它的 token 流原地内联,并且强制在两侧补一对括号——因为 MyName = A1+B1 若按文本替换进 MyName*2,会变成 A1+B1*2,运算优先级直接错掉。

表达式层是标准的递归下降解析——每一档运算优先级对应一个函数,函数内部调用下一档更紧的优先级,靠调用栈的深浅天然表达”谁先算”,所以 1+2*3 不需要额外的优先级表就能算对。这里从比较到连接、加减、乘除、乘方、一元、后缀、原子逐级下降。这里埋了一处很值得抄的防护:每个 ( 都会重新进入 ParseConcat,一个嵌套几万层的括号炸弹会触发 StackOverflowException——而这个异常在 .NET 里不可捕获,进程直接死,对常驻服务就是一次拒绝服务。它的处理是双保险:硬上限取自 Core/DocumentLimits.csMaxRecursionDepth = 256,外加运行时栈探测 RuntimeHelpers.TryEnsureSufficientExecutionStack(),任一触发就返回一个可见的 #NUM!

跨单元格方向同理:同表引用链有 MaxSameSheetDepth = 1000 的兜底加栈探测,跨表链深度超过 20 返回 #NUM!;循环引用由 _visiting 集合识别,命中时返回 0(对齐 Excel 迭代计算的初值),同时把 CircularHits 计数加一——这个计数的唯一用途是告诉缓存层”这个结果依赖于你从哪儿进的环,不许记忆化”。

三、值模型与广播:错误也要有形状

求值结果统一是 FormulaResult 这个记录类型,可以是数字、字符串、布尔、错误、一维数组、二维区域、Lambda,还有一个专门的空白态。空白态存在的理由写在注释里:=OFFSET(A1,5,0)&"x" 在 Excel 里是 "x" 而不是 "0x",空白在算术里当 0、在字符串拼接里当空串,只有单独建一个态才能同时满足两边。

二元运算走 ApplyBinaryOp,任一操作数带形状就产出二维区域:单行或单列会广播——广播是指把一行或一列沿着缺的那个方向重复铺开去匹配对方的尺寸,比如一列数乘一行数就铺成一张乘法表;配不上的位置填 #N/A逐元素的错误被原样保留在格子里。这一条是有代价的取舍——保留形状和逐格错误,聚合函数才能跳过错误格,INDEX 才能寻址,SUM 才能把错误往上传。

类型强制那一层也全是实测出来的规则:文本 "2024-08-01" 参与减法要按日期序列号强制转换;但纯时间文本 "12:00" 必须先走 TimeSpan 解析成 0.5,因为 DateTime.TryParse 会给它拼上”今天”,得到的序列号既错误又不可复现——同一份输入在不同日期跑出不同结果,这是自动化里最难查的一类 bug。

写回单元格时还有两道防线:结果按 15 位有效数字四舍五入,避免 25300000.000000004 这种浮点毛刺;NaN±Infinity 在 OOXML 的 <v> 里没有合法写法,硬写进去 Excel 会拒绝打开文件,所以它们一律转成 #NUM!

重复计算这块靠一个会话对象串起来:整表扫描时共享一个 FormulaEvalSession,里面有按 Sheet!A1 键的单元格结果记忆化(memoization,把算过一次的结果按键存下来,同一轮里再问同一个键就直接给答案)、矩形区域记忆化、每表的已填充行列区间缓存。记忆化的准入条件写得很克制:LET/LAMBDA 绑定活跃时不记(被引用单元格会看见调用方的绑定)、本次求值命中过循环引用不记、结果是 Lambda 不记。三条排除项的共同点是同一个键在不同上下文下会给出不同答案——记下来就等于把一次偶然的上下文固化成”事实”,而这类错误在下一次读取时完全看不出破绽。

四、溢出数组与求解器:两块必须自己啃的骨头

溢出数组(spill)是 Excel 2016 之后的能力:一个公式返回一整片区域,自动”溢”到旁边的格子里。FormulaEvaluator.Spill.cs(520 行)把 SEQUENCE、TRANSPOSE、SORT、SORTBY、UNIQUE、FILTER、TAKE、DROP、CHOOSEROWS/COLS、TOCOL/TOROW、EXPAND、HSTACK/VSTACK、WRAPROWS/WRAPCOLS、TEXTSPLIT 全部归一到”二维网格进、二维网格出”,配一组 ToGrid / PickRows / PickCols / PickRC / Flatten 的小工具。FormulaEvaluator.SpillLambda.cs 在此之上接 MAP / BYROW / BYCOL / SCAN / MAKEARRAY,把 LAMBDA 值逐元素、逐行、逐列地套用。生成类函数带硬上限:行列相乘超过 1048576 直接 #NUM!

真正的设计决断在写盘。Handlers/Excel/ExcelHandler.DynamicArray.cs 的注释交代得很清楚:只写锚点单元格,不物化溢出出来的”幽灵”格子。要让 Excel 365 真的溢出,锚点必须同时带 t="array" 的数组公式标记,和一个 cm="1" 的单元格元数据索引,指向 xl/metadata.xml 里那条 XLDAPR 动态数组记录——缺了元数据,Excel 会把 t="array" 当成老式 CSE 数组锁死在单格里,不溢出。这里的 CSE 数组指的是老式的 Ctrl+Shift+Enter 数组公式:结果被锁在你手工选中的那片区域里,不会自己往外扩。选择把幽灵格子留给 Excel 的收益是:不会覆盖用户已有的单元格、不会留下需要回收的陈旧格子、#SPILL! 冲突检测也归 Excel 管。代价是引擎自己顶层只塌陷出锚点值(左上角那一个)。FormulaEvaluator.Spill.cs 的文件头注释连验证方法都写了:要探溢出区内部,得把整个溢出包在 INDEX / SUM / COUNT 这类标量归约里去读。

另一件必须自己做的事是函数名限定ModernFunctionQualifier.cs 维护了一张表:Excel 不认裸的现代函数名,SEQUENCE(5) 写进 XML 会变 #NAME?,必须写成 _xlfn.SEQUENCE(5);FILTER 更特殊,走的是工作表专属命名空间 _xlfn._xlws.。Excel 展示给用户时会把前缀脱掉,所以这层对人是隐形的,对写文件的程序不是。

求解器是全套里最小也最见功力的一块:FormulaEvaluator.Solver.cs 只有 88 行,服务所有”值是方程的根而非闭式解”的函数——RATE、IRR、XIRR,以及 FormulaEvaluator.Securities.cs 里 YIELD 一族的债券收益率。主方法是牛顿法配中心数值导数,步长 h = max(1e-7, |x|·1e-7) 随量级缩放,最多 100 轮,容差 1e-10。牛顿失败(导数消失、迭代跑出收敛域)就落到二分兜底:下界钳在 -0.999999,因为利率的定义域是 (-1, ∞),再低 (1+r)^n 就没意义了;上界从猜测值向外扩张,最多 200 次找符号变号区间,找不到才返回空、由调用方给出 #NUM!。注释里点明了兜底的价值:一个永远回不了本的现金流序列真解在 -42% 附近,朴素的有界牛顿会冲过头,只有兜底能把它捞回来。

统计分布族(FormulaEvaluator.Statistics.cs / .Statistics2.cs / .Statistics3.cs / .SpecialFunctions.cs)同样是算而不是查表:正态走误差函数 erf,gamma、卡方、泊松走正则化不完全 gamma 积分,beta、t、F 走正则化不完全 beta。所谓”算而不是查表”,是指这些分布的累积概率靠级数与连分式当场逼近,而不是像旧式统计手册那样存一张离散的分位数表再插值——好处是任意参数都给得出值,代价是精度取决于逼近的收敛判据(不完全 gamma 那一支的注释里写的是收敛到约 1e-12)。

组成部分它负责什么仓库位置你什么时候会碰到它
词法 + 递归下降解析 + 单元格解析把公式文本变成结果,含环检测与深度防护src/officecli/Core/Formula/FormulaEvaluator.cs写任何公式
函数实现与分发各函数体,本目录最大的一份(2285 行)src/officecli/Core/Formula/FormulaEvaluator.Functions.cs某个函数结果不对时
溢出数组网格化的动态数组函数族src/officecli/Core/Formula/FormulaEvaluator.Spill.cs用 FILTER / SORT / UNIQUE
Lambda 驱动的溢出MAP / BYROW / BYCOL / SCAN / MAKEARRAYsrc/officecli/Core/Formula/FormulaEvaluator.SpillLambda.cs写 LAMBDA 组合
迭代求根牛顿加二分兜底,88 行src/officecli/Core/Formula/FormulaEvaluator.Solver.csRATE / IRR / XIRR / YIELD
债券与日算基准付息日程与 30/360、实际/实际等基准src/officecli/Core/Formula/FormulaEvaluator.Securities.cs做固定收益测算
现代函数名限定_xlfn. / _xlfn._xlws. 前缀与动态数组识别src/officecli/Core/Formula/ModernFunctionQualifier.cs写 2016 之后的新函数
溢出锚点写盘t="array" 加 XLDAPR 元数据src/officecli/Handlers/Excel/ExcelHandler.DynamicArray.cs排查”为什么没溢出”
保存期缓存清扫陈旧 <v> 的 L1/L2 处置与时间预算src/officecli/Handlers/Excel/ExcelHandler.FormulaCache.csPython 侧读到 None 时
递归与资源上限MaxRecursionDepth = 256 等硬化常量src/officecli/Core/DocumentLimits.cs处理不可信文档时
LaTeX ↔ OMML 转换Word 数学公式,与 Excel 公式无关src/officecli/Core/Formula/FormulaParser.cs写 Word 里的数学式

最后一行值得单独提一句:同一个 Core/Formula/ 目录下,FormulaParser.cs 这个名字很容易被当成 Excel 公式的解析器,它其实是 Word 数学公式的 LaTeX 与 OMML(Office Math Markup Language)双向转换器。按目录名猜文件职责,在这个仓库里会猜错。

五、边界与代价:它明确不管的事

这套设计最克制的地方在保存那一刻。ExcelHandler.FormulaCache.cs 定义了两档策略:L2(当前默认生效)在发现自己算出来的值与磁盘缓存不一致时,不写新值,而是<v> 直接删掉,让 openpyxl 读到 None——一个明确的”没算”,好过一个错数——同时置上 fullCalcOnLoad,让能重算的应用打开时自己刷新。L1 才是写入新鲜值,但它的准入名单 FormulaCacheL1Allowlist 是一个空集合,注释写明:函数要通过 officeshot 的差分比对、与真 Excel 逐字节一致,才准进这张表。换句话说,作者自己都不让未经差分验证的计算结果覆盖磁盘上的缓存。

明确不管的还有这些:

  • 没有跨单元格依赖图。每个公式只在它自己被写入的那一刻算一次。先写 =SUMIFS(Data!...) 再导入 Data 数据,那个公式保留的是精算时(precedents 还是空的)算出的 0。保存时的清扫是补救,不是依赖图。
  • 清扫有时间预算,默认 5 秒(可用 OFFICECLI_FORMULA_SWEEP_BUDGET_SECONDS 调整)。超时就地停下,剩下的靠 fullCalcOnLoad。理由也写在注释里:清扫跑在常驻服务的保存路径上,无界扫描会变成”保存卡住、被 SIGKILL、编辑丢失”。
  • 数组公式单元格的 <v> 一概不碰,那片区域归 Excel 管。引用了不存在工作表的公式也跳过,避免制造假的”不一致”。
  • 循环引用不报错,返回 0。这是对齐 Excel 迭代计算的选择,但对自动化意味着一个逻辑错误会以合法数字的形式流下去。

风险面也要说清楚。这个工具直接读写你磁盘上的真实 .docx / .xlsx / .pptx,改的是原文件而不是副本,写之前该有的备份得你自己做——这属于典型的改动边界约定问题,交给 Agent 之前先把可写目录圈出来。常驻模式把文档留在内存里,磁盘写入是延后的:README 说明它在空闲后自适应 2–10 秒自动落盘,也可以 save / close 手动刷、或设 OFFICECLI_RESIDENT_FLUSH=each 让每次改动返回前都落盘;在此之前,任何非 OfficeCLI 的程序读到的都是旧文件。批量操作中途失败时,落盘的是已经应用到内存文档并被刷写的那部分,不是一个原子事务。此外 watch 模式会起一个本地 HTTP 预览服务,等于在本机开了一个能读到文档内容的端口,共享环境下要留意。

六、上手与避坑清单

1. 别把它当 Excel 的替身去终验财务模型。 会踩是因为”值已经算好了”太容易让人默认它等于 Excel。L1 名单为空这件事本身就是作者给出的置信度声明。做法:关键结论单元格(尤其 IRR、XIRR、YIELD 这类走迭代求根的)用真 Excel 或等价工具复核一遍再对外。

2. 先写数据、后写公式。 会踩是因为按人的习惯是先搭表头和公式再灌数。没有依赖图,顺序反了缓存就是 0。做法:把 import 放在 set 公式之前;确实反了,就依赖保存期清扫,但要知道它有 5 秒预算,大表可能扫不完。

3. 现代函数别自己拼前缀。 会踩是因为你在网上看到”要加 _xlfn.”就手动加,结果 FILTER 实际需要的是 _xlfn._xlws.,加错了 Excel 一样报 #NAME?。做法:交给工具的限定层去加,你写规范的函数名。

4. 别指望在工具里读到整片溢出结果。 会踩是因为 SEQUENCE 在 Excel 里明明铺开了一片。引擎顶层塌陷成锚点值,且幽灵格子根本没写进文件。做法:要验证内部,按仓库注释的路子把溢出包进 INDEX / SUM / COUNT 再读。

5. 常驻模式下别让别的程序直接读文件。 会踩是因为命令返回成功给人”已经写完”的错觉。做法:交接给 Python 脚本或渲染程序之前先 save,或者整条流水线设 OFFICECLI_RESIDENT_FLUSH=each。这本质上是工具返回值该传达什么的问题——“命令成功”和”磁盘已更新”是两个状态,别让 Agent 把它们混为一谈。

6. 把”没算出来”和”算出错误值”当两件事处理。 引擎自己就是这么分的。它给公式单元格吐三个键:cachedValue 是磁盘上那个 <v> 的原样(当作 XML 状态信任,不代表重算核对过);computedValue 是引擎此刻按当前工作簿状态算出来的值;evaluated 是一个布尔裁决——两者至少有一个在才为真,都没有就是假。做法:下游别去正则匹配显示文本,读 evaluated 决定值可不可信,拿 cachedValuecomputedValue 相互一比就能定位陈旧缓存;至于 #REF! 这类,它是”算出来了,结论是公式本身有问题”,和”压根没算”要走不同分支。

收束:三条自检,两个文件

交付前的三条自检:磁盘上有没有非官方程序正在读这个文件(有就先 flush);关键数字单元格的 <v> 是有值、被删掉了、还是压根没写过;用到的函数里有没有走迭代求根或统计分布的,有就安排一次外部复核。

想继续往下读,两个文件的性价比最高:Core/Formula/FormulaEvaluator.csTokenize 往下看到 ParseAtom,一遍就能摸清整条解析链路的形状;Handlers/Excel/ExcelHandler.FormulaCache.cs 只看文件头那段注释,它把”一个自研引擎该在多大程度上相信自己”这个问题回答得比多数设计文档都干脆。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 OfficeCLI 开源仓库:Excel 处理器 47 个文件怎么分组开源项目 OfficeCLI 的 Excel 透视表实现:五块难点拆解

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