ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

AI操作文档的工程化:文档运行时与工具接口设计

AI操作文档的工程化:文档运行时与工具接口设计 上个月旁听了一场内部分享主题叫「AI 自动生成经营报表」。演示的时候很热闹模型几十秒吐出一张格式整齐的表格投屏上看着挺像回事。可等主讲人把内容贴进真正的电子表格里问题全冒出来了——数字全成了文本求和结果是 0合并单元格被冲散原本该是公式的列变成了写死的值源数据一改报表就再也不动了。那一刻我脑子里蹦出来的判断很直接这不是 AI 的能力问题是文档的接口问题。生成一段看起来像表格的文本和真正在一份文档里操作状态中间隔着整整一层东西。前者是说后者是做。而做需要文档本身提供一个可编程、可回滚、可校验的运行时——这正是 univer 这类文档引擎值得被重新审视的地方。后面我想聊的就是这件事当 AI 不再满足于往文档里塞内容而是开始真的用文档我们需要在工程上准备什么。适合正在做 AI 工具调用、文档协作、低代码报表这类产品的前端和数据方向的同学也适合单纯好奇AI 到底怎么点表格的读者。1. 「生成内容」和「操作文档」之间差着一整层状态模型很多人第一反应是把这事理解成提示词工程只要让模型输出合法的表格结构问题不就解决了我一开始也这么想直到把同一套提示词换到不同的文档底座上跑结果差异大到不像同一个模型干的活。差别不在模型在底座有没有把文档的状态暴露出来。1.1 一个反直觉的现象模型能力涨了卡点反而更靠下早期模型笨瓶颈确实在理解层——它连把 B 列改成百分比这句话都解析不清楚。现在这层基本被抹平了模型能准确说出它要做什么甚至能自己规划分几步。可一旦它伸手去改你会发现新的瓶颈全部堆在文档这一侧改完之后单元格的行列索引对不上、公式没重算、格式被覆盖、改错了没法一步撤回、多个会话同时改同一张表互相踩。换句话说AI 从想不明白进化到了想明白了但下不去手。这时候继续优化提示词是无效功得把文档从一个给人看的界面改造成一个给程序调用的运行时。这个视角的转换是我认为整个讨论里最关键的一步。1.2 为什么纯前端渲染的老路走不通传统电子表格的实现思路是界面即状态单元格是 DOM 节点值挂在节点上选中靠 CSS 类名改动靠事件监听。这套东西对人很友好对机器极其不友好——因为没有一份脱离视图的、可序列化的数据真相。举个具体的例子。人看到的是第 3 行第 2 列现在是 12800但程序要去改它得先知道这是数据行还是表头行上面有没有隐藏行这张表是不是被冻结了合并单元格的左上角锚点在哪当前用户是不是正在编辑这个格子这些问题在纯 DOM 方案里全是隐式的而 AI 调用是没有上下文感知能力的它只知道自己拿到了一个坐标。只要有一个隐式假设没对上写进去的位置就偏了。1.3 文档运行时至少要提供三样东西在我实际用过的几套底座里能真正撑起AI 操作文档这个场景的都得同时具备三件事缺一不可一份脱离视图的状态模型工作簿 → 工作表 → 行列表 → 单元格能整体快照、能增量 patch、能序列化成 JSON 落库。一条命令通道不是直接改数据而是发一条有语义、有参数校验、天然可撤销的命令。一个可回滚的边界一次 AI 操作对应一个事务出错能整体退回去而不是留下半张改坏的表格。这三样东西日常给人用的表格软件其实都偷偷实现了只是没对外开放成稳定的 API。univer 让我觉得有意思的点在于它从设计上就是围绕这三个东西长出来的——它把自己定位成文档引擎而不是表格组件这个定位差异后面会越来越重要。2. 拆开 univer这个文档运行时里到底跑着什么我不打算把 univer 的每个包都念一遍包名和版本一直在演进写死了反而误导人。我按实际调试时的心智模型来讲它内部可以粗略切成四层每层在 AI 场景里承担的角色完全不同。理解了这个分层你就知道该把 AI 接在哪一层、以及为什么不能接在别的地方。2.1 四层结构数据、公式、渲染、命令层主要职责AI 场景下的角色该不该直接碰数据模型层工作簿/工作表/单元格/富文本/样式/行列结构唯一真相快照来源只读投影不直改公式引擎层依赖图、计算链、重算调度保证 AI 读到的是算完的值间接依赖必须触发重算渲染层基于画布的绘制、视口、选区负责展示不产生状态完全不碰命令与 UI 插件层命令注册、参数校验、撤销栈、协作广播AI 的入口全部从这里进这个表里最容易被忽略的是第三列和第四列的对比。很多团队做 AI 表格功能时图省事直接往数据模型层塞值——反正改完渲染层会自己刷新看着是通的。但这么干等于绕过了命令层也就同时绕过了参数校验、撤销栈和协作广播。单机 Demo 完全看不出问题一上多人协作就变成我这边改了对面不刷新用户按 CtrlZ 撤销的是别人的操作这种灵异现象。2.2 Facade 是给人和简单脚本用的Command 才是给 AI 用的univer 对外有两套调用方式我调试的时候两套都试过。一套是 Facade写法很像常见的链式 API比如拿到当前工作簿、拿到某个工作表、再拿一个区域对象、然后设值——读起来非常顺写 Demo 特别快。另一套是命令需要你带上命令名和结构化参数扔给命令服务去执行比如批量设置区域的值、插入行列、设置公式这些。给 AI 用我一定选命令。原因不在于命令更底层而在于三个具体差别可撤销。走命令通道的改动会自动进撤销栈这意味着 AI 改错了用户可以像撤销自己的操作一样撤掉它。Facade 直调很多时候不进栈或者进栈粒度不对。可校验。命令执行前有参数 schema 校验AI 传个不存在的 sheet 名、传个越界坐标会在执行前被拦下来并报错而不是悄悄写到一个幽灵位置。可协同。协作场景下命令是要广播给其他端的直改数据模型不会广播或者只广播了一半。还有一点很实际命令天然是幂等设计友好的。你给命令带一个唯一的 requestId重复执行时可以在入口处拦掉。这在 AI 场景下不是锦上添花——模型调用超时重试太常见了没有幂等保护重试一次就多插一行数据用户看到的就是报表里凭空多出几条重复记录。2.3 公式引擎AI 最容易忽略的隐式依赖这一层我想单独拎出来说因为它是我踩得最深的一个坑。AI 读表格的时候它读到的往往是单元格的当前显示值而这个值可能是公式算出来的。如果底层公式引擎还没完成重算你给模型的是一份过期快照模型基于过期数据做决策改出来的东西自然不对。更麻烦的是反向的问题AI 写入一批原始数据之后依赖这些数据的公式单元格需要重算重算结果又可能被 AI 在下一步读取。这条链路如果没接好你会看到模型反复自我怀疑——它读了三次数据得到三个不同的值然后在思考过程中开始道歉。我在实践里的处理办法是把「写」和「读」严格分开成两个阶段写完先触发一次全量重算并等待完成再让模型去读。牺牲一点点时延换来的是模型看到的数字和用户看到的数字绝对一致。这个一致性比省那两百毫秒重要得多。2.4 快照与事务给 AI 准备的「后悔药」univer 内部有快照能力可以把整个工作簿的状态序列化出来。这个能力在 AI 场景里有一个非常实用的用法在每一次工具调用循环开始前打一个快照循环结束后做一次校验校验不过就直接用快照还原。这比让模型自己改回来可靠一个数量级。我试过让模型在发现改错后输出一条撤销上一步的操作成功率大概七成——它经常撤销得过多或者过少因为它对自己刚刚做了什么的理解是有偏差的。而用快照还原是确定性的百分之百回到原点。代价是内存。一份几千行的工作簿完整快照在几十毫秒到几百毫秒之间序列化出来可能几 MB。所以我的做法不是每步都打快照而是按任务打一个用户请求进来打一次任务结束释放。中间步骤靠命令级别的撤销栈兜底。这两层保护叠在一起基本能做到随便改坏了都能回来。3. 把文档包装成工具接口设计上必须做的五个取舍到这一层问题从文档能不能被程序操作变成了怎么操作才不让模型翻车。这两件事的难度差很远。前者是工程问题后者是设计问题而且后者没有标准答案只有一堆取舍。下面五条是我在真实项目里反复调过的每一条都附上我最后选了什么、以及为什么。3.1 给 AI 的 API 不该照抄给人用的 API人用的 API 讲究灵活一个 setValue 可以传单值、可以传二维数组、可以传公式字符串、可以带样式。给 AI 用的 API 必须窄每种意图一个独立工具参数少、语义单一、失败原因明确。我最终的工具有这么几个读区域、批量写值、插入行、插入列、设置公式、设置数字格式、合并单元格、删除行。就这八个覆盖了九成以上的报表操作需求。工具越少模型选错的概率越低参数越扁平模型填错的概率越低。我一开始贪心做了十六个工具结果模型有相当比例的错误是选错了工具而不是填错了参数收敛到八个之后错误率明显下降。另外工具描述里一定要写清楚坐标系。是 0-based 还是 1-based行号和列号哪个在前A1 表示法是绝对还是相对。这些在人看来是常识的东西模型会一本正经地搞混而且搞混的方式很隐蔽——它不会报错它只会写到一个相邻的位置上。3.2 读取比写入难怎么给模型一份够用又不爆 token的视图写是明确的读是模糊的。一张两万行的工作表全量序列化给模型token 直接爆炸而且模型在超长上下文里的定位精度会下降。但你要是只给个摘要它又没法定点修改。我现在用的是三段式投影先给结构工作簿里有哪些 sheet、每个 sheet 的行列数、前两行内容当作表头猜测、每列的粗略类型推断数字/日期/文本/公式。再给窗口只给模型实际会被问到的那个区域加上上下各三行的缓冲。最后给线索如果有公式列单独列出公式文本让模型知道这一列是算出来的不能直接写值。这套投影把一次典型请求的输入 token 从几万压到了两千以内。压下来的不只是成本更重要的是模型的表现明显变稳了——上下文越短它越不容易在中途忘了自己要干什么。3.3 事务粒度决定你能不能全身而退一个用户请求模型可能规划出五步操作。这五步应该是一个事务还是五个事务我两种都试过结论是按语义单元切不要按调用次数切。举个例子「给这张表加一列环比、并调整成百分比格式」。这在语义上是一个单元用户的心智是我要一列环比不是我要先插列再写公式再改格式。如果按调用次数切成三个事务用户在中间一步失败后点撤销只会撤掉格式留下一个半成品列。按语义单元切失败就整体回滚用户看到的是没改成而不是改了一半。实现上就是在工具循环外层包一层事务循环内所有命令共享同一个 undo 边界。univer 的命令服务支持这种组织方式具体 API 名字各版本有差异但思路是通用的进循环前开边界出循环时根据校验结果决定提交还是回滚。3.4 校验与自愈让模型自己发现「改错了」我对模型的一个基本假设是它会认为自己改对了。所以校验不能靠模型自省得靠程序断言。我在每次写入后跑一组固定检查区域尺寸是否和预期一致防止合并单元格把范围吃掉数值列里有没有混进文本用类型判断不看显示值公式单元格有没有产生错误值常见的是除零和引用失效关键汇总行是否等于明细的聚合结果这一条最能抓到逻辑错误任何一条不过我不直接把错误抛给用户而是把断言结果作为一条新的消息回灌给模型让它自己重试一次。实测下来第一次校验失败率大概在两成左右回灌一次之后能修好其中的大部分。剩下的才升级给用户而且带上明确的哪一步没通过说明用户手动改起来也快。这个自愈循环有个上限我设的是两次。超过两次还在同一个断言上失败说明是这个任务的描述本身有歧义继续让模型试只是在烧钱。3.5 并发与协作多人加多 Agent 同文档时的冲突这块是最容易被低估的。一旦文档进入协作模式同一个格子上可能同时有用户 A 的手动编辑、用户 B 的粘贴、以及一个 AI Agent 的批量写入。三方并发谁来赢我采取的策略是给 AI 留出存在感AI 的每一次写入都走正常命令通道带上操作者标识因此它产生的变更是能被其他端看到的、能被别人撤销的、也能被别人覆盖的。听起来有点残酷——AI 写的东西可能被人随手覆盖——但这恰恰是对的因为在协作语境下AI 就是一个普通参与者它不该有特权。真正需要额外处理的是AI 基于过期视图做决策这个问题。用户在 AI 思考的这几秒里改了数据AI 醒来后按老数据去写就会覆盖掉别人的改动。我的解法是在提交前做一次轻量的版本比对如果目标区域在 AI 读取之后被改动过就中止并让模型重新读一遍。这个检查很便宜但省掉了大量我的改动怎么没了的投诉。4. 一次跑通的链路让 Agent 给销售看板补一列环比讲原理讲多了容易空我把一次真实跑通的流程完整拆开。场景不复杂一张按周汇总的销售明细表列是「周次、区域、销售额」需求是加一列周环比负数标红最后加一行合计。4.1 输入准备与权限收窄第一步不是写代码是收窄权限。我给这个 Agent 的工具集里删除行、删除列、清空区域这三个高危工具全部没开。原因很直白模型在规划阶段有可能把加一列理解成先删一列腾位置这种理解在它的世界里是合理的但对用户来说是灾难。高危操作不开放比事后校验便宜太多。同时我构造读取视图只取有数据的区域把表头行单独拎出来做列名映射让模型不用自己数第几列是销售额。4.2 工具定义与实际代码工具声明我用的是常规的函数调用格式参数尽量扁平。下面是简化后的核心部分// 工具声明喂给模型的 schema节选 const tools [ { name: sheet_read_range, description: 读取指定区域的值。坐标 1-basedrow 为行号col 为列号从 1 开始。, parameters: { type: object, properties: { sheetName: { type: string }, startRow: { type: number }, endRow: { type: number }, startCol: { type: number }, endCol: { type: number } }, required: [sheetName, startRow, endRow, startCol, endCol] } }, { name: sheet_write_formula_column, description: 向某一列批量写入公式。行范围不含表头。公式中用 {row} 占位当前行号。, parameters: { type: object, properties: { sheetName: { type: string }, col: { type: number }, startRow: { type: number }, endRow: { type: number }, formulaTemplate: { type: string } }, required: [sheetName, col, startRow, endRow, formulaTemplate] } } ];派发层是这套东西的核心所有模型输出最终都要在这里被翻译成命令并且在这里做参数兜底async function dispatch(call: ToolCall, ctx: TaskContext) { // 1. 幂等同一个 requestId 只执行一次 if (ctx.seen.has(call.requestId)) return ctx.seen.get(call.requestId); // 2. 白名单校验sheet 必须存在 const wb univerAPI.getActiveWorkbook(); const sheet wb.getSheetByName(call.args.sheetName); if (!sheet) { return { ok: false, error: 工作表 ${call.args.sheetName} 不存在可选${listSheetNames(wb)} }; } // 3. 越界校验不信任模型给的坐标 const range normalizeRange(call.args, sheet.getMaxRows(), sheet.getMaxColumns()); if (!range) { return { ok: false, error: 坐标越界请先调用 sheet_read_range 确认区域 }; } // 4. 统一走命令通道带上操作者标识 const result await executeViaCommand(call.name, { ...call.args, ...range }, { operator: agent, requestId: call.requestId }); ctx.seen.set(call.requestId, result); return result; }这段代码里我最想强调的是第 2 步和第 3 步。看起来是两层很无聊的防御性编程但它们拦掉的问题占了我在线上观察到的一半以上。模型编造一个不存在的 sheet 名或者给出一个超出实际行数的范围这两种情况极其常见。而错误信息里带上「可选xxx」这一点小设计让模型的自愈成功率提升得很明显——它看到合法选项后基本都能纠正过来看不到就只能瞎猜。4.3 执行、校验、提交的完整循环整个循环的形状是这样的开事务边界打一次快照。把结构视图和用户需求一起给模型让它输出第一批调用。逐个派发收集结果。跑断言新列的数值类型对不对、公式有没有错误值、合计行是否等于明细求和。断言不过把错误回灌回到第 2 步最多两轮。断言通过提交事务任何环节超时或异常快照还原。这里有个细节值得说断言里不能用模型给的期望值。比如模型说合计应该是 128400你不能拿这个数去断言因为它自己就是猜的。断言必须来自文档本身——合计行用公式写然后检查公式的计算结果是否等于明细区域的聚合。让文档自己证明自己这是唯一靠得住的校验。4.4 实测的一些数字同一张约 1200 行的表跑二十次这个任务我记录下来的大致情况指标数值说明单次任务平均耗时815 秒含一到两次模型往返输入 token单轮15002500投影后的结构视图一次通过率约 78%首轮断言全过一次回灌后通过率约 94%剩余为歧义任务需要快照还原的比例约 3%基本都是坐标理解错误命令级重试次数平均 0.4 次主要是越界后被纠正这些数字不是基准测试只是我自己记录的经验值环境和表结构一变就会漂。但那个一次通过率不到八成、回灌一轮到九成以上的形状在后来几个不同场景里都很稳定。它说明一件事模型的第一次输出不值得信任但它的第二次输出质量已经够用了。所以自愈循环不是可选项是必需品。5. 踩坑记录Demo 里看不见、上线才炸的几件事下面这些是真正让我半夜爬起来看日志的问题。它们有个共同点在小数据集上、单人场景下、手工点几下就能验证的路径里全都不会出现。5.1 合并单元格是坐标系统的黑洞我以为坐标问题很好解决直到遇到带合并单元格的表。合并之后被合并的那些格子在数据模型里其实是空的只有左上角的锚点单元格有值。AI 读的时候看到一片空就会认为这里没数据写的时候往合并区域中间写值会被丢弃或者错位。更麻烦的是行列偏移。一张表里有三处合并表头用户视角的第 5 行和模型拿到的第 5 行可能不是同一行因为中间有隐藏行。我最后的处理方式是投影时把合并区域显式标出来写成「A3:C3 合并值销售额」这样的注释附在结构信息里写入前检查目标区域是否与任何合并区域相交相交就拒绝并返回提示。这个检查加进去之后这类问题基本绝迹。5.2 循环引用不是报错是静默死循环公式引擎遇到循环引用不同实现的行为不一样。我遇到过一次特别难查的模型把环比公式写成了引用自己所在列形成了环。界面上没报错只是那一列全显示为空。模型读到空值以为是数据缺失又写了一版公式还是环还是空。它在两轮里反复横跳最后给我一个该列数据源缺失无法计算的结论。事后我在断言里加了一条任何写入的公式在提交前做一次依赖检查检测是否形成环。这个检查必须由程序做因为模型对自己写的公式有没有问题是没有判断力的——它觉得C2/C2-1完全合理。5.3 逐格写入会触发渲染风暴早期我是让模型一次返回一个单元格的修改然后循环调用。一千二百行一千二百次命令每次命令都触发一次重算和一次重绘。结果页面卡了将近十秒用户以为崩了。改成批量命令之后一次命令带一个二维数组重算一次、重绘一次同样的数据量压到了一百多毫秒。这个教训很朴素AI 的调用频率不等于人的操作频率。人能一秒点两下模型可以一秒返回五十个操作。所有按人的节奏设计的接口在 AI 面前都会变成性能陷阱。5.4 模型会很有礼貌地编造一个 sheet 名这个坑我印象最深因为它的表现形式特别合理。用户说在汇总表里加一行模型调用时传的 sheetName 是「汇总表」而实际的表名叫「汇总」或者「Summary」。命令失败模型看到错误然后它做的事情是——换了个名字再试比如「汇总表1」。它不会先问它会一路试下去。解法就是前面代码里那个「可选xxx」的错误信息。把合法选项直接喂回去它立刻就能选对。这条经验可以推广成一个通用原则给模型的报错信息要包含足够让它自我纠正的上下文而不是只描述错误本身。5.5 重试机制把同一行数据插了三遍这个是最危险的因为它破坏的是数据正确性而不是显示。我们的调用链路上有超时重试模型侧也有 SDK 自带的重试。两边叠加一次插入操作被执行了三次。因为插入行这个操作本身不幂等——插两次就是两行。后来所有写操作都强制带 requestId在派发层做去重表同一个 id 只认第一次结果。另外把插入行这类结构性操作尽量改写成写入到已存在的空行这种幂等操作。能用幂等操作表达的意图就别用非幂等操作。6. 这条路往哪走文档运行时接下来的三个变化把 AI 接进文档这件事我现在看到的还只是很浅的一层——本质上是把人的点击翻译成了 API 调用。再往下走我认为会有三个方向的变化而且这些变化会反过来影响文档引擎本身的设计。6.1 从命令 API 到语义层现在的接口还是太机器了。模型要理解第 3 行第 4 列这种坐标语义而这恰恰是它最容易出错的地方。下一步应该是文档自己提供语义层告诉调用方这张表有一个叫销售额的列而不是第 4 列。列名、表头、数据区域、汇总行这些概念应该成为一等公民由文档引擎维护而不是每次让模型去猜。univer 这种把文档模型完整暴露出来的引擎做这件事有天然优势——它内部本来就知道什么是行、什么是列、什么是公式只是现在这些信息还需要调用方自己去拼。6.2 文档变成可以跑的东西现在我们把文档当数据看读写它的单元格。但文档里其实已经有一整套计算能力了——公式引擎就是一个图灵完备的计算环境只是它的输入输出都困在格子里。我试过一个很小的实验把一段业务规则写成公式让模型只负责填参数不负责计算。结果准确率比让模型直接算高出一大截因为计算这件事本来就该交给确定性的引擎。这个方向往大了说就是文档从一个展示容器变成一个轻量执行环境——规则、校验、聚合都在文档里跑AI 只做它擅长的部分理解意图、填参数、处理异常。6.3 可审计会成为硬需求一旦 AI 能改文档这次改动是谁做的、为什么做、中间试了几次就变成了必须留痕的东西。我现在给每个 AI 任务都记一条操作日志任务 id、用户原始表述、模型规划出的步骤、每一步的命令参数、断言结果、最终是否提交或回滚。这份日志的价值在多轮之后才显现出来。用户说上周那个报表怎么少了一行你能顺着日志一路查到是哪次 AI 任务、因为什么断言没过、回滚到了哪个快照。没有这份东西AI 改过的文档基本等于不可维护。最后分享一个我自己用了很久的小习惯每次给文档加新的 AI 能力我会先手动把那个操作做一遍然后问自己我刚才这一下对应的是哪条命令。如果我说不出对应的命令说明这个能力还没准备好开放给模型——因为模型比我更容易在模糊地带栽跟头。这个笨办法帮我在上线前拦下了好几个本来会很难看的问题。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表