1. 为什么要把文档、表格、智能体和工作流塞进同一个桌面窗口
大多数人现在的桌面状态是这样的:浏览器开了十几个标签页,飞书或钉钉里挂着几个表格,本地文件夹里散落着几十个 Word 和 PDF,另外还开着某个 AI 聊天窗口用来临时问问题。每次要处理一份合同或者整理一批数据,流程都是"打开文档 → 复制内容 → 切到 AI 窗口 → 粘贴 → 等结果 → 再复制回来 → 手动填进表格"。这套动作重复三次以上,人就开始烦躁,重复十次以上,就一定会出错。
我最初做这个开源项目的动机特别朴素:能不能有一个桌面工作区,左边是文件树,中间是文档或表格的编辑区,右边挂一个智能体面板,底下再放一条工作流编排区,让"读文档、抽信息、填表格、跑流程"这四件事在同一个窗口里闭环完成。不用来回切应用,不用手动搬运数据,所有中间结果都留在工作区里可追溯。
这个想法听起来像"又一个 All-in-One 工具",但实际做下来会发现,它和传统的笔记软件、在线表格、低代码平台有本质区别。笔记软件的核心是"记录",在线表格的核心是"协作",低代码平台的核心是"搭应用",而这个桌面工作区要解决的核心问题是**"让非结构化的文档内容,经过智能体的处理,变成结构化的表格数据,并且这个过程可以被工作流自动触发和复用"**。
举个具体场景你就明白了。假设你是一个做招聘的,每天收到几十份简历,格式五花八门,有 PDF、有 Word、有图片扫描件。传统做法是逐份打开看,手动把姓名、学历、工作年限、期望薪资敲进 Excel。用这个工作区,你可以把简历文件夹拖进来,挂一个"简历筛选工作流",工作流里第一步调用文档结构化解析智能体把 PDF 转成文本,第二步调用信息抽取智能体按字段提取,第三步把结果写进工作区里的表格,第四步根据预设规则打分排序。整个过程你只需要点一次"运行",剩下的交给工作流。
所以这篇文章不是泛泛地讲"AI 桌面工作区有多好",而是把我从零搭这套东西的过程中,关于文档结构化解析、表格动态生成、智能体编排、工作流调度这四个核心模块的真实设计取舍、踩过的坑、以及可以直接抄的配置方案,全部摊开讲清楚。适合有一定编程基础、想自己搭一套类似工具的人,也适合只是想理解"智能体和工作流到底怎么落地"的产品和运营同学。
2. 文档结构化解析:从 PDF 到可编辑表格的完整链路
2.1 为什么不能直接调一个大模型就完事
很多人第一反应是:文档解析有什么难的,把 PDF 丢给大模型,让它输出 JSON 不就行了。我一开始也是这么想的,实测下来问题一大堆。
第一个问题是长文档截断。一份 50 页的 PDF,转成文本可能有 8 万字,直接塞进模型上下文,要么被截断,要么成本高得离谱。第二个问题是表格结构丢失。PDF 里的表格转成纯文本后,行列关系全乱了,模型根本分不清哪个是表头哪个是数据。第三个问题是扫描件和图片。纯文本提取工具对扫描件无能为力,必须走 OCR 路线。
所以正确的做法是分层处理,而不是一步到位。我的方案是把文档解析拆成四个阶段:
| 阶段 | 处理内容 | 技术选型 | 输出 |
|---|---|---|---|
| 格式识别 | 判断是文本型 PDF 还是扫描型 | 读取 PDF 内嵌字体信息 | 类型标记 |
| 内容提取 | 文本型走解析库,扫描型走 OCR | pdfplumber / PaddleOCR | 原始文本 + 坐标 |
| 结构还原 | 根据坐标还原表格和段落 | 自定义行列聚类算法 | 结构化 JSON |
| 语义抽取 | 按业务字段提取信息 | 大模型 + 字段模板 | 业务对象 |
这个分层的好处是,每一层都可以单独替换和调试。比如你发现表格还原不准,只需要调第三层的聚类参数,不用动 OCR 和模型部分。
2.2 表格还原的坐标聚类算法怎么设计
这是整个解析链路里最容易被低估的部分。PDF 里的表格本质上是一堆带坐标的文本块,没有"行"和"列"的概念。要还原成表格,核心是两步:行聚类和列聚类。
行聚类的逻辑是:把所有文本块的 y 坐标拿出来,如果两个文本块的 y 坐标差值小于某个阈值(通常是文本高度的 0.5 倍),就认为它们在同一行。列聚类同理,用 x 坐标。但这里有个坑,跨行合并单元格会导致某一行的文本块数量明显少于其他行,如果直接用"列数最多的行"作为标准列数,合并单元格就会错位。
我的处理方式是:先统计所有行的文本块数量,取众数作为基准列数,然后对文本块数量不足的行,根据 x 坐标的间隙判断哪些列被合并了。具体代码逻辑大概是这样:
def cluster_columns(blocks, base_col_count, x_threshold=5): # 收集所有 x 坐标 x_coords = sorted(set(round(b['x0']) for b in blocks)) # 合并相近的 x 坐标 merged = [] for x in x_coords: if not merged or x - merged[-1] > x_threshold: merged.append(x) # 如果合并后的列数多于基准,说明有噪声,取前 base_col_count 个 if len(merged) > base_col_count: merged = merged[:base_col_count] return merged实测下来,这套逻辑对规整的财务报表、简历表格、出入库单的还原准确率能到 90% 以上。剩下的 10% 主要是手写体扫描件和极度不规整的排版,这种就只能靠人工兜底或者上更强的 OCR 模型。
2.3 文档结构化解析的字段模板怎么配
结构还原之后,你拿到的是一个二维数组,但业务上你需要的是"姓名、学历、工作年限"这样的字段。这一步我用的是字段模板 + 大模型抽取的组合。
字段模板就是一个 JSON 配置,定义你要抽哪些字段、每个字段的类型和描述。比如简历筛选的模板:
{ "template_name": "resume_screening", "fields": [ {"name": "candidate_name", "type": "string", "desc": "候选人姓名"}, {"name": "education", "type": "enum", "options": ["大专", "本科", "硕士", "博士"]}, {"name": "work_years", "type": "number", "desc": "工作年限,取整数"}, {"name": "expected_salary", "type": "number", "desc": "期望薪资,单位千元"} ] }把模板和文档文本一起给模型,让它按模板输出 JSON。这里的关键技巧是在 prompt 里明确要求模型对不确定的字段返回 null,而不是瞎猜。我踩过的坑是,早期没加这个约束,模型会把"期望薪资面议"硬编成一个数字,导致后续排序全乱。
提示:字段模板不要一次配太多字段,超过 10 个字段后模型的抽取准确率会明显下降。如果业务字段确实多,拆成多个模板分次抽取,比一次性抽完更稳。
3. 表格模块:动态创建、合并与格式转换的实操细节
3.1 为什么表格要用 JS 动态创建而不是写死
工作区里的表格不是静态的,它的列是根据智能体抽取的字段动态生成的。今天你抽简历,列就是姓名、学历、工作年限;明天你抽发票,列就变成发票号、金额、开票日期。所以表格必须支持运行时动态创建列和行。
前端我用的是原生 JS 操作 DOM,没有上重型表格库。原因是重型表格库虽然功能全,但动态改列结构的时候性能开销大,而且和智能体的数据流对接起来很别扭。自己写反而更可控。
动态创建表格的核心逻辑:
function renderTable(container, columns, rows) { const table = document.createElement('table'); const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); columns.forEach(col => { const th = document.createElement('th'); th.textContent = col.label; th.dataset.field = col.field; headerRow.appendChild(th); }); thead.appendChild(headerRow); table.appendChild(thead); const tbody = document.createElement('tbody'); rows.forEach(row => { const tr = document.createElement('tr'); columns.forEach(col => { const td = document.createElement('td'); td.textContent = row[col.field] ?? ''; tr.appendChild(td); }); tbody.appendChild(tr); }); table.appendChild(tbody); container.innerHTML = ''; container.appendChild(table); }这段代码看起来简单,但有两个细节值得说。第一,td.textContent而不是innerHTML,防止文档内容里的特殊字符破坏页面结构。第二,row[col.field] ?? ''用空字符串兜底,避免undefined直接渲染出来。
3.2 动态创建的表格怎么做单元格合并
单元格合并是动态表格里最烦人的部分。因为列是动态的,你没法在 HTML 里写死rowspan和colspan,必须在渲染时根据数据计算。
我的做法是在数据层加一个合并描述,比如:
const mergeConfig = [ { field: 'department', rowspan: 3 }, // 该列连续 3 行合并 { field: 'category', colspan: 2 } // 该列横向合并 2 列 ];渲染的时候,遇到需要合并的单元格,先检查它是不是合并组的第一个,如果是就正常渲染并设置rowspan,如果不是就跳过不渲染。这里的关键是维护一个已合并单元格的坐标集合,避免重复渲染。
function renderWithMerge(tbody, rows, columns, mergeConfig) { const merged = new Set(); rows.forEach((row, rowIndex) => { const tr = document.createElement('tr'); columns.forEach((col, colIndex) => { const key = `${rowIndex}-${colIndex}`; if (merged.has(key)) return; const td = document.createElement('td'); td.textContent = row[col.field] ?? ''; const config = mergeConfig.find(c => c.field === col.field); if (config && config.rowspan) { td.rowSpan = config.rowspan; for (let i = 1; i < config.rowspan; i++) { merged.add(`${rowIndex + i}-${colIndex}`); } } tr.appendChild(td); }); tbody.appendChild(tr); }); }实测下来,这套逻辑能覆盖 90% 的合并场景。剩下的 10% 是"不规则合并",比如某一行合并了 3 列,下一行只合并了 2 列,这种就需要更复杂的合并矩阵来描述,一般业务里很少遇到,遇到了建议直接让用户手动调整。
3.3 表格导出:Markdown 转 Excel 和 HTML 转 WPS 的坑
工作区里的表格最终要导出给其他人用,最常见的需求是导出 Excel。这里有两个转换路径:Markdown 表格转 Excel和HTML 表格转 WPS 表格。
Markdown 转 Excel 相对简单,因为 Markdown 表格结构规整,用|分割就行。但要注意转义字符,如果单元格内容里本身有|,必须先转义成\|,否则会多切出一列。我踩过的坑是,从文档里抽出来的地址字段经常带|,导出后列全错位了。
HTML 转 WPS 表格的坑更多。WPS 对 HTML 表格的解析和浏览器不完全一致,特别是rowspan和colspan的处理。我的经验是,导出前先把 HTML 表格"拍平",也就是把所有合并单元格展开成独立单元格,再交给 WPS 解析。虽然丢失了合并信息,但至少数据不会错位。如果必须保留合并,建议直接生成.xlsx文件,用 SheetJS 这类库来写,比走 HTML 中转可靠得多。
| 导出方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Markdown 转 Excel | 实现简单,结构清晰 | 不支持合并单元格 | 纯数据表格 |
| HTML 转 WPS | 保留样式 | 合并单元格易错位 | 带格式的报表 |
| 直接生成 xlsx | 最可靠,支持合并 | 需要引入库 | 正式交付文件 |
4. 智能体编排:让抽取、判断、生成各司其职
4.1 单智能体 vs 多智能体:什么时候该拆
工作区里的智能体不是越多越好。我一开始设计的时候,恨不得每个功能都做一个智能体,结果就是工作流里挂了七八个节点,调试的时候根本不知道是哪一步出的问题。
后来我总结了一个判断标准:如果一个任务可以用一段 prompt 描述清楚,就用单智能体;如果需要多个不同角色的判断,才拆多智能体。
比如"从简历里抽信息"是单智能体任务,一段 prompt 就够了。"简历筛选"则是多智能体任务,因为需要先抽取信息,再根据规则打分,再生成面试建议,这三个步骤的 prompt 差异很大,拆开更清晰。
我的工作区里目前固定了四类智能体:
- 解析智能体:负责把非结构化文本转成结构化 JSON,prompt 里强调"严格按模板输出,不确定返回 null"。
- 判断智能体:负责根据规则做分类和打分,prompt 里强调"给出判断理由,理由要引用原文"。
- 生成智能体:负责写文案、写总结、写建议,prompt 里强调"语气和格式要求"。
- 校验智能体:负责检查前三个智能体的输出是否符合格式要求,不符合就打回重跑。
这四类智能体的分工,基本覆盖了文档处理的所有场景。你不需要为每个业务单独造智能体,只需要换 prompt 和字段模板。
4.2 智能体之间的数据传递怎么设计
多智能体协作最大的坑是数据格式不统一。解析智能体输出的是 JSON,判断智能体可能想要的是纯文本,生成智能体又想要带上下文的 JSON。如果每个智能体都自己定义输入输出格式,工作流就会变成一团乱麻。
我的方案是统一用 JSON 作为智能体之间的传递格式,并且定义一个最小约定:
{ "task_id": "唯一标识", "input": { "原始数据或上一步输出" }, "context": { "全局上下文,比如用户配置、历史结果" }, "output": { "本步骤的输出" }, "status": "success | failed", "error": "失败时的错误信息" }这个约定看起来简单,但它让工作流的每个节点都可以独立测试。你可以手动构造一个 input 丢给某个智能体,看它输出什么,不用跑完整条工作流。
注意:context 字段不要塞太多东西,超过 2000 字后模型会开始忽略前面的内容。如果确实需要大量上下文,拆成多个字段,在 prompt 里明确引用。
4.3 智能体技能里的敏感变量怎么处理
工作区里的智能体经常需要访问一些配置,比如 API 地址、模型名称、超时时间。这些配置如果直接写在 prompt 里,一是容易泄露,二是改起来麻烦。
我的做法是把配置抽成变量,存在工作区的配置层,智能体通过变量名引用。比如 prompt 里写{{model_name}},运行时替换成实际值。这样换模型只需要改一处配置,不用动所有智能体的 prompt。
对于真正敏感的变量,比如访问凭证,我做了两层保护:一是存在本地加密文件里,不随工作区导出;二是智能体运行时才解密注入,日志里只记录变量名不记录值。这个设计参考了常见的密钥管理思路,实测下来既安全又不影响调试。
5. 工作流调度:从手动点击到自动触发的完整实现
5.1 工作流引擎的最小可用设计
工作流引擎听起来很复杂,但如果你只需要支持"顺序执行 + 条件分支 + 循环"这三种结构,核心代码量并不大。
我的引擎设计是这样的:每个工作流是一个节点数组,每个节点有type、config、next三个属性。type决定这个节点是调智能体、调脚本还是做判断。next指向下一个节点的 ID,如果是条件分支,next就是一个映射表。
const workflow = { nodes: [ { id: 'parse', type: 'agent', config: { agent: 'parser', template: 'resume' }, next: 'judge' }, { id: 'judge', type: 'agent', config: { agent: 'judger', rule: 'score > 60' }, next: 'branch' }, { id: 'branch', type: 'condition', config: { field: 'score', operator: '>', value: 60 }, next: { true: 'generate', false: 'end' } }, { id: 'generate', type: 'agent', config: { agent: 'writer' }, next: 'end' }, { id: 'end', type: 'terminal' } ] };执行的时候从第一个节点开始,根据next跳转,直到遇到terminal节点。这个设计的好处是工作流可以序列化成 JSON 存下来,也可以从 JSON 加载,方便分享和复用。
5.2 循环处理批量文档的正确姿势
批量处理文档是工作流最常见的场景,但循环处理有个坑:如果一份文档处理失败,整个工作流是中断还是跳过。
我的默认策略是跳过并记录,而不是中断。因为批量场景下,一份文档的失败不应该影响其他文档。具体实现是在循环节点里加一个on_error配置,默认是skip,可选abort。
async function runLoop(items, processor, onError = 'skip') { const results = []; for (const item of items) { try { const result = await processor(item); results.push({ item, result, status: 'success' }); } catch (err) { results.push({ item, error: err.message, status: 'failed' }); if (onError === 'abort') break; } } return results; }实测下来,处理 100 份简历,通常会有 3 到 5 份失败,主要是扫描件质量太差或者格式太特殊。这些失败的会单独列出来,用户可以手动处理或者调整参数重跑。
5.3 工作流编码:把常用流程固化成模板
工作流搭好之后,如果每次都要从头配一遍,效率太低。所以我把常用流程固化成了模板,比如"简历筛选工作流""发票录入工作流""合同审查工作流"。用户选一个模板,改几个参数就能用。
模板的本质就是一个预置的 JSON 工作流定义,加上一份参数说明。比如简历筛选模板的参数是"最低学历要求""最低工作年限""是否要求特定技能"。用户填完参数,工作流自动把参数注入到对应节点的 prompt 里。
这里有个经验:模板的参数不要超过 5 个。超过 5 个参数,用户配置的成本就接近从头搭了,模板的价值就没了。如果业务确实复杂,拆成多个模板,让用户组合使用。
| 模板名称 | 核心节点 | 参数数量 | 适用场景 |
|---|---|---|---|
| 简历筛选 | 解析 → 判断 → 生成 | 4 | 招聘初筛 |
| 发票录入 | 解析 → 校验 → 写表 | 3 | 财务报销 |
| 合同审查 | 解析 → 判断 → 生成 | 5 | 法务初审 |
| 出入库登记 | 解析 → 写表 | 2 | 仓储管理 |
6. 实测中暴露的问题与我的处理方式
6.1 大文档处理超时怎么办
一份 200 页的 PDF,走完整条解析链路可能要 3 到 5 分钟。如果工作流是同步等待的,前端就会一直转圈,用户体验很差。
我的处理方式是异步化 + 进度推送。工作流启动后立即返回一个任务 ID,前端通过轮询或者长连接获取进度。每个节点执行完就更新一次进度,用户能看到"正在解析第 3 章""正在抽取字段"这样的实时状态。
这个改动看起来简单,但它是工作区能不能处理大文档的关键。没有进度反馈,用户会以为程序卡死了,直接关掉重来,反而更慢。
6.2 智能体输出格式不稳定的兜底方案
大模型的输出格式不稳定是常态。即使你在 prompt 里千叮咛万嘱咐"只输出 JSON",它还是可能给你加一句"好的,以下是结果"。
我的兜底方案是三层校验:第一层用正则提取 JSON 块,第二层用 JSON 解析器尝试解析,第三层如果解析失败,调用校验智能体让它把输出修成合法 JSON。三层都失败,才标记为失败。
function extractJSON(text) { // 第一层:正则提取 const match = text.match(/\{[\s\S]*\}/); if (!match) return null; try { // 第二层:直接解析 return JSON.parse(match[0]); } catch { // 第三层:交给校验智能体修复 return null; } }实测下来,第一层能解决 80% 的问题,第二层再解决 15%,剩下 5% 才需要第三层。这个兜底链路让整个工作流的成功率从 70% 提升到了 95% 以上。
6.3 表格数据量大了之后前端卡顿
工作区里的表格如果超过 1000 行,直接渲染 DOM 会明显卡顿。我的处理方式是虚拟滚动,只渲染可视区域内的行。
虚拟滚动的核心是维护一个"可视窗口",根据滚动位置计算当前应该渲染哪些行。这个技术在前端领域很成熟,但和动态列结合的时候要注意:列宽变化会导致行高变化,行高不固定,虚拟滚动的计算就会出错。我的做法是固定行高,如果内容太长就截断加省略号,鼠标悬停时显示完整内容。
这个取舍牺牲了一点展示效果,但换来了流畅的滚动体验。对于工作区这种以数据处理为主的场景,流畅比好看重要。
7. 我对这套工作区后续扩展的一些想法
这套东西我从去年开始搭,中间重构了两次,现在算是能稳定跑通"文档进、表格出"的完整链路。但说实话,它离"好用"还有距离。
最大的问题是智能体的判断准确率还不够高。抽取字段的准确率能到 90% 左右,但判断类的任务,比如"这份简历是否匹配岗位",准确率只有 70% 出头。这意味着用户还是需要人工复核,工作流的价值就打折了。我目前的思路是把判断类任务拆得更细,不要让一个智能体做综合判断,而是拆成多个单一维度的判断,最后用规则汇总。这样每个智能体的准确率能到 85% 以上,汇总后的结果也更可解释。
另一个问题是工作流的调试体验。现在调试一条工作流,只能看每个节点的输入输出日志,如果某个节点输出不对,很难定位是 prompt 的问题还是上游数据的问题。我打算加一个"单步执行"模式,让用户可以暂停在工作流的任意节点,手动修改输入再继续,这样调试效率会高很多。
最后分享一个我在实际使用中觉得最有价值的经验:不要追求一步到位的工作流,先手动跑通一遍,再把跑通的步骤固化成工作流。我见过太多人一上来就想搭一个全自动的流程,结果因为某个环节的准确率不够,整个流程都跑不起来。正确的做法是先把每个环节单独调稳,再串起来。手动跑十遍,比自动跑一百遍但结果全错,有价值得多。