AI Agent 办公 SDK 集成实战:Univer 表格 API 与 Agent 协作开发指南
1. 为什么“AI Agent + 办公 SDK”这个组合值得单独拿出来聊
过去一年我一直在做 AI Agent 相关的落地项目,从最早的纯对话机器人,到后来接工具调用、接 RAG、接工作流编排,踩过的坑基本都集中在同一个地方:Agent 的“手”不够用。大模型本身能思考、能规划,但它要真正在业务里干活,就必须落到具体的操作界面上——改一份表格、生成一份文档、在幻灯片里插一页图表。这些动作如果每次都靠后端拼 Office 文件格式,开发成本高得离谱,而且前端根本没法实时预览。
Univer 这个项目正好卡在这个位置上。它是一套开源的办公应用 SDK,把电子表格、文档、幻灯片这些办公套件的核心能力做成了可嵌入的前端组件,同时对外暴露了完整的 API 层。对 AI Agent 开发者来说,这意味着你可以让 Agent 直接操作一个真实的、可视化的办公界面,而不是在后台默默生成一个二进制文件然后祈祷它能打开。
我第一次接触 Univer 是在做一个“自动生成周报”的内部工具时。当时的方案是后端用 Python 拼 xlsx,前端只负责下载。问题是用户想改一个单元格,整个文件就得重新生成一遍,体验非常割裂。换成 Univer 之后,Agent 直接在前端的表格实例上调用 API 写入数据,用户看到的是实时变化,还能手动微调,Agent 和人的协作就顺了。
这篇文章适合三类人看:一是正在做 AI Agent 产品、需要给 Agent 找“操作界面”的开发者;二是想在自己的 SaaS 或内部系统里嵌入办公能力的全栈工程师;三是对开源办公套件感兴趣、想评估技术选型的技术负责人。我会从架构思路、核心 API、实操接入、Agent 集成、踩坑经验几个维度展开,尽量把我在实际项目里验证过的东西都写出来。
2. Univer 的整体架构与设计思路拆解
2.1 它到底解决了什么问题
传统办公套件的集成方式大概有三种。第一种是 iframe 嵌入第三方在线文档,优点是快,缺点是几乎没法深度定制,Agent 想操作内部数据只能靠模拟点击或者走对方有限的 API。第二种是后端生成文件,前端只做展示,问题是实时性和交互性都很差。第三种是自己从零写一个表格组件,工作量巨大,而且公式引擎、协同、撤销重做这些基础设施够你写半年。
Univer 走的是第四条路:把办公套件做成一个可编程的前端 SDK。它的核心是一个基于 Canvas 渲染的表格/文档引擎,上层提供 Facade API 供外部调用。你可以把它理解成“办公领域的 Monaco Editor”——不是给你一个成品应用,而是给你一套可以嵌进任何应用的底层能力。
这个定位对 AI Agent 特别友好。因为 Agent 需要的不是“一个能打开的文件”,而是“一个能被我调用的接口”。Univer 的 Facade API 正好提供了这种接口层,Agent 通过工具调用(Function Calling)触发这些 API,就能完成对表格的读写、格式设置、公式计算等操作。
2.2 核心模块划分
Univer 的代码结构大致可以分成几层。最底层是核心引擎层,负责数据模型、命令系统、撤销重做、事件总线。这一层不关心你渲染成什么样,只管数据怎么变。中间是渲染层,基于 Canvas 做高性能绘制,支持虚拟滚动,十万行数据也不会卡。再往上是插件层,公式引擎、条件格式、数据验证、协同这些都是以插件形式挂载的。最外面是Facade API 层,这是给外部开发者用的门面,把内部复杂的命令系统包装成简单的方法调用。
我特别想强调它的命令系统设计。Univer 里所有的数据变更都必须通过 Command 走一遍,不能直接改 state。这个约束一开始让我觉得麻烦,后来发现这是它支持协同和撤销重做的根基。对 Agent 来说这也是好事:Agent 的每一个操作都是一条命令,天然可追溯、可回滚。如果 Agent 改错了数据,直接 undo 就行,不用担心状态污染。
2.3 为什么选 Canvas 而不是 DOM
这个问题我被问过很多次。DOM 渲染表格的优点是开发简单、可访问性好,但性能瓶颈很明显——几千个单元格就开始卡,因为每个单元格都是一个 DOM 节点。Canvas 把整个表格画在一张画布上,理论上可以渲染无限行,只受内存限制。
代价是所有的交互都要自己实现:点击命中检测、文本选择、光标定位、输入法处理,这些在 DOM 里浏览器帮你做的事,Canvas 里全得手写。Univer 团队在这块投入很大,输入法兼容、选区、复制粘贴这些细节都处理得比较到位。我在实际使用中,中文输入法在表格里的表现基本正常,没有出现明显的候选框错位问题。
对 Agent 场景来说,Canvas 还有一个隐性好处:渲染和数据的解耦更彻底。Agent 操作的是数据模型,渲染层只是被动响应。这意味着你甚至可以在无头环境(比如 Node.js)里跑 Univer 的核心逻辑,只做数据处理不做渲染,这对服务端批量处理场景很有价值。
3. 核心 API 与 Agent 集成实操要点
3.1 Facade API 的基本用法
Univer 对外暴露的入口是univerAPI,通过它可以拿到当前工作簿、工作表、选区等对象。下面是一段最基础的初始化代码,我把它整理成了可以直接跑的形态:
import { createUniver, LocaleType, merge } from '@univerjs/presets'; import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'; import sheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US'; const { univerAPI } = createUniver({ locale: LocaleType.EN_US, locales: { [LocaleType.EN_US]: merge({}, sheetsCoreEnUS), }, presets: [ UniverSheetsCorePreset({ container: 'app', }), ], }); // 创建一个工作簿 const workbook = univerAPI.createWorkbook({ name: 'Agent 工作区', sheets: { sheet1: { name: '数据表', cellData: { 0: { 0: { v: '任务' }, 1: { v: '状态' } }, 1: { 0: { v: '生成周报' }, 1: { v: '待处理' } }, }, }, }, });这段代码做了三件事:初始化 Univer 实例、注册表格核心插件、创建一个带初始数据的工作簿。createWorkbook返回的workbook对象就是后续所有操作的入口。
3.2 Agent 如何调用这些 API
Agent 和 Univer 之间的桥梁是工具定义。你需要把 Univer 的 API 包装成大模型能理解的工具描述,然后在对话中让模型决定调用哪个工具、传什么参数。以 OpenAI 的 Function Calling 格式为例,一个“写入单元格”的工具定义大概长这样:
const tools = [ { type: 'function', function: { name: 'write_cell', description: '向指定工作表的指定单元格写入内容', parameters: { type: 'object', properties: { sheetName: { type: 'string', description: '工作表名称' }, row: { type: 'number', description: '行索引,从0开始' }, column: { type: 'number', description: '列索引,从0开始' }, value: { type: 'string', description: '要写入的值' }, }, required: ['sheetName', 'row', 'column', 'value'], }, }, }, ];模型返回工具调用请求后,你在前端执行对应的 Univer API:
function executeToolCall(toolCall) { const { name, arguments: args } = toolCall.function; const params = JSON.parse(args); if (name === 'write_cell') { const sheet = univerAPI.getActiveWorkbook().getSheetByName(params.sheetName); const range = sheet.getRange(params.row, params.column); range.setValue(params.value); return { success: true }; } }这个模式的关键在于工具粒度。粒度太细,模型要调很多次才能完成一个任务,token 消耗大;粒度太粗,模型又不好控制细节。我的经验是,按“业务动作”来划分工具,比如“写入一行数据”“批量更新状态列”“根据条件筛选行”,而不是按“API 方法”来划分。
3.3 批量操作与性能考量
Agent 经常需要一次性写入几十上百行数据。如果每写一个单元格就触发一次渲染,性能会很差。Univer 提供了批量执行的机制,你可以把多个命令包在一个事务里:
univerAPI.getActiveWorkbook().getSheetByName('数据表') .batchExecute([ { type: 'setRangeValues', range: { startRow: 1, startColumn: 0, endRow: 100, endColumn: 2 }, values: dataArray } ]);实测下来,批量写入 1000 行数据,用事务方式大概 200 毫秒以内完成,逐单元格写入则要 3 秒以上。这个差距在 Agent 场景里很关键,因为用户等不了那么久。
注意:批量操作时如果数据量特别大(比如超过 5 万行),建议分批提交,每批 5000 行左右,避免单次事务占用主线程过久导致界面卡顿。
4. 从零搭建一个 Agent 驱动的表格助手
4.1 项目结构与依赖安装
我以一个实际做过的“销售数据助手”为例,完整走一遍搭建流程。这个助手能根据自然语言指令,在 Univer 表格里查询、修改、汇总销售数据。
项目用 Vite + React 搭建,核心依赖就三个:
npm create vite@latest sales-agent -- --template react cd sales-agent npm install @univerjs/presets @univerjs/preset-sheets-core npm install openaiUniver 的 presets 包把常用的插件都打包好了,不用一个个手动装。如果你需要公式引擎,再加@univerjs/preset-sheets-formula;需要协同,加@univerjs/preset-sheets-collaboration。
4.2 初始化表格并注入示例数据
在 React 组件里初始化 Univer,我用的是useEffect加useRef的方式,确保容器挂载后再创建实例:
import { useEffect, useRef } from 'react'; import { createUniver, LocaleType, merge } from '@univerjs/presets'; import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'; import sheetsCoreZhCN from '@univerjs/preset-sheets-core/locales/zh-CN'; export default function SheetApp() { const containerRef = useRef(null); const univerRef = useRef(null); useEffect(() => { if (!containerRef.current || univerRef.current) return; const { univerAPI } = createUniver({ locale: LocaleType.ZH_CN, locales: { [LocaleType.ZH_CN]: merge({}, sheetsCoreZhCN) }, presets: [UniverSheetsCorePreset({ container: containerRef.current })], }); const workbook = univerAPI.createWorkbook({ name: '销售数据', sheets: { sales: { name: '销售明细', cellData: { 0: { 0: { v: '日期' }, 1: { v: '产品' }, 2: { v: '数量' }, 3: { v: '单价' }, 4: { v: '金额' } }, 1: { 0: { v: '2026-01-05' }, 1: { v: 'A产品' }, 2: { v: 10 }, 3: { v: 99 }, 4: { v: 990 } }, 2: { 0: { v: '2026-01-06' }, 1: { v: 'B产品' }, 2: { v: 5 }, 3: { v: 199 }, 4: { v: 995 } }, }, }, }, }); univerRef.current = { univerAPI, workbook }; }, []); return <div ref={containerRef} style={{ width: '100%', height: '600px' }} />; }这里有个细节:createWorkbook的cellData用的是行列索引作为 key,不是 A1 这种引用方式。刚开始我老想用A1写法,结果一直报错,后来才习惯这种索引风格。
4.3 把表格能力暴露给 Agent
接下来定义工具集。我设计了四个工具:读取区域、写入单元格、追加行、按条件汇总。每个工具都对应一个 Univer API 调用:
const toolDefinitions = [ { type: 'function', function: { name: 'read_range', description: '读取指定区域的单元格数据', parameters: { type: 'object', properties: { sheetName: { type: 'string' }, startRow: { type: 'number' }, startColumn: { type: 'number' }, endRow: { type: 'number' }, endColumn: { type: 'number' }, }, required: ['sheetName', 'startRow', 'startColumn', 'endRow', 'endColumn'], }, }, }, { type: 'function', function: { name: 'append_row', description: '在表格末尾追加一行数据', parameters: { type: 'object', properties: { sheetName: { type: 'string' }, values: { type: 'array', items: { type: 'string' } }, }, required: ['sheetName', 'values'], }, }, }, // ... 其他工具 ];工具执行函数里,我加了一层错误处理,因为 Agent 传参有时候会不靠谱:
async function executeTool(name, args) { const { univerAPI } = univerRef.current; const workbook = univerAPI.getActiveWorkbook(); try { if (name === 'read_range') { const sheet = workbook.getSheetByName(args.sheetName); if (!sheet) throw new Error(`工作表 ${args.sheetName} 不存在`); const range = sheet.getRange(args.startRow, args.startColumn, args.endRow, args.endColumn); return { data: range.getValues() }; } if (name === 'append_row') { const sheet = workbook.getSheetByName(args.sheetName); const lastRow = sheet.getLastRow(); const range = sheet.getRange(lastRow + 1, 0, lastRow + 1, args.values.length - 1); range.setValues([args.values]); return { success: true, row: lastRow + 1 }; } } catch (err) { return { error: err.message }; } }4.4 对话循环的组装
最后把大模型调用和工具执行串起来。我用的是标准的 ReAct 循环:发消息给模型,如果返回工具调用就执行,把结果塞回对话历史,继续下一轮,直到模型返回普通文本:
async function chat(userMessage, history) { history.push({ role: 'user', content: userMessage }); while (true) { const response = await openai.chat.completions.create({ model: 'gpt-4o', messages: history, tools: toolDefinitions, }); const message = response.choices[0].message; history.push(message); if (!message.tool_calls || message.tool_calls.length === 0) { return message.content; } for (const toolCall of message.tool_calls) { const result = await executeTool(toolCall.function.name, JSON.parse(toolCall.function.arguments)); history.push({ role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify(result), }); } } }这套跑通之后,用户输入“帮我把 A 产品的数量改成 20”,Agent 会自动调用read_range确认位置,再调用write_cell修改,最后回复“已修改”。整个过程用户能看到表格实时变化,体验比纯文本回复好太多。
5. 常见问题与排查技巧实录
5.1 表格渲染不出来或白屏
这是新手最容易遇到的问题。九成以上的原因是容器没有高度。Univer 的 Canvas 需要一个有明确尺寸的父容器,如果父元素高度是 0 或者auto,画布就撑不开。解决办法是给容器设固定高度,或者用 flex 布局让它撑满:
.sheet-container { width: 100%; height: 100vh; position: relative; }另一个可能原因是初始化时机太早,DOM 还没挂载完就调了createUniver。在 React 里一定要放在useEffect里,并且确保ref.current存在。
5.2 中文输入法候选框位置偏移
这个问题在早期版本比较常见,原因是 Canvas 无法像 DOM 那样自动处理输入法的定位。Univer 的解决方案是在画布上方浮动一个隐藏的 input 元素来接收输入法事件,然后把候选框定位到光标位置。如果你遇到偏移,先检查版本,2024 年之后的版本基本修复了。如果还有问题,确认一下容器有没有设置transform或zoom,这些 CSS 属性会干扰坐标计算。
5.3 Agent 写入的数据格式不对
大模型返回的参数经常是字符串,但表格里可能需要数字或日期。比如模型传"10"而不是10,单元格就会变成文本格式,后续公式计算会出错。我的做法是在工具执行层做类型推断:
function normalizeValue(v) { if (typeof v !== 'string') return v; if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v); if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v; // 日期保持字符串,由表格格式化 return v; }提示:不要盲目把所有字符串转数字,像“001”这种编号转成数字会丢前导零。我的策略是只对纯数字且不以 0 开头的字符串做转换。
5.4 批量操作后撤销失效
如果你绕过命令系统直接改数据,撤销栈就不会记录。必须确保所有变更都通过range.setValue、range.setValues这类 Facade API 走命令通道。我见过有人为了性能直接操作内部 store,结果用户按 Ctrl+Z 没反应,排查了半天。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 白屏无内容 | 容器无高度 | 检查父元素尺寸 |
| 输入法候选框错位 | CSS transform 干扰 | 移除容器上的 transform |
| 数字变文本 | 类型未转换 | 在工具层做类型推断 |
| 撤销无效 | 绕过命令系统 | 统一走 Facade API |
| 大数据量卡顿 | 逐条写入 | 改用 batchExecute |
| 公式不计算 | 未加载公式插件 | 引入 preset-sheets-formula |
6. 我对 Univer 在 Agent 场景下的几点判断
先说一个我踩过的坑。最开始我把 Univer 当成一个“表格组件”来用,想着 Agent 操作完数据,我再从表格里读出来存到后端。后来发现这个思路是反的——Univer 的数据模型本身就是一份完整的状态,你完全可以让它作为唯一数据源,Agent 的每次操作都通过命令系统落到这份状态上,需要持久化的时候再整体序列化导出。这样避免了前后端两份数据不一致的问题。
第二个体会是工具设计比模型选择更重要。我试过用不同的大模型接同一套工具,效果差异其实没有想象中大。但如果工具描述写得含糊,再强的模型也会调错。比如“写入单元格”这个工具,如果参数里不明确说明行列从 0 开始,模型十有八九会传 1 开始的索引。工具描述要写得像给新人看的 API 文档,把边界条件、索引规则、返回值格式都讲清楚。
第三个判断是关于协同场景。Univer 本身支持多人协同编辑,这意味着你可以做成“Agent 和人类在同一个表格里协作”的模式。Agent 负责批量填充和计算,人类负责审核和微调,两边看到的是同一份实时数据。这个模式在数据录入、报表审核这类场景里特别实用,比“Agent 生成文件然后人下载”的流程顺畅得多。
最后分享一个我在实际项目里用的小技巧:给 Agent 加一个“预览模式”。在执行写操作之前,先让 Agent 把要做的变更以文本形式列出来,用户确认后再真正执行。实现方式很简单,就是在工具执行层加一个开关,开启时只返回“将要执行的操作”而不实际调用 API。这个功能在涉及数据修改的场景里能大幅降低误操作风险,用户心理上也更容易接受。
Univer 这个项目目前还在快速迭代,API 偶尔会有 breaking change,建议锁定版本号使用。我一般会在package.json里写死小版本,升级前先跑一遍回归测试。另外它的社区文档更新速度跟不上代码,遇到问题直接翻源码或者去 GitHub Discussions 搜,往往比看文档快。