DeepSeek 把 LibreOffice 塞进 Harness,文档 Agent 开始补齐最后一公里
最近我在搭一个"文档 Agent"模拟项目,越搭越觉得有意思:模型聊天、规划、拆解任务这些能力已经够用了,真正让人头疼的是"交付物"——用户不会满足于屏幕上的一段 Markdown,他们要的是能下载、能打印、能发给客户的 docx、PDF、ODT。换句话说,Agent 的前 99 公里是推理和对话,最后一公里是真实文件。
我最后选用的组合是 DeepSeek 负责语言理解和工具调用规划,LibreOffice 以无头模式充当文档渲染引擎,外面再套一层 Harness 来控制工具注册、权限、超时和错误恢复。这套东西跑通之后,Agent 才算是真正"能交差"了。这篇就把我踩过的坑和最终的实现思路完整拆出来,给同样在折腾文档 Agent 的人当个参考。
1. 文档Agent的"最后一公里"究竟卡在哪
1.1 聊天很顺,交付物却一塌糊涂
我最早搭的 Agent 版本,本质上就是个"加强版聊天机器人"。用户说"帮我写一份月度汇报",它能输出结构完整的文字,甚至能给出好看的排版建议。但只要用户追问一句"导出成 PDF 发我一下",整个链路就露馅了:要么模型只会生成一段 Markdown,用户还得自己复制到编辑器里去转格式;要么我硬写一段 Python 脚本直接把字符串塞进 docx,结果没有样式、没有目录、没有分页,交付物像一份未经排版的长文。
这不是模型能力问题,而是工程架构问题。模型擅长的是生成连续令牌,它并不擅长也不需要直接操作二进制文件。文档 Agent 的"最后一公里",本质上指的是从文本生成到文件交付这中间的所有工程环节:格式转换、模板填充、嵌入图表、生成页码目录、导出 PDF、兼容老旧 Office 格式等。没有这一公里,Agent 充其量是个聪明的文本生成器,算不上文档生产力工具。
1.2 最后一公里不是生成文本,而是生成文件
我习惯把文档 Agent 的能力拆成三层看:
- 语言层:理解用户意图、生成内容、拆解多步任务,这是模型的事;
- 规划层:把"写一份带数据图表的调研报告"拆成若干子任务,决定先后顺序,这也是模型配合 Harness 完成的;
- 执行层:真实打开文档、写入内容、套用模板、转换格式、导出文件,这是工具链的事。
大多数项目死在执行层。原因也很现实:文本生成有现成的模型能力兜底,文件操作却没有一个通用的"万能模型"能直接输出 PDF 二进制流。模型最多输出一个工具调用意图,剩下的必须交给外部程序去干。LibreOffice 在这条链路里就是最顺手的执行层引擎。
1.3 DeepSeek在这条链路里的位置
选 DeepSeek 做语言层,我自己是这么权衡的:上下文窗口足够长,适合让它读完整份模板和素材再规划;工具调用的指令遵循能力不错,能稳定输出结构化的 JSON 工具请求;部署成本可控,既可以用 API,也可以本地跑量化版本,这对需要处理敏感文档的场景很重要。
但这里要强调一个边界:DeepSeek 再聪明,也不会自己打开 LibreOffice 去转 PDF。它要做的是把用户需求理解成"工具动作序列",剩下的事交给 Harness 去执行。模型管想,LibreOffice 管做,Harness 管调度。三者各管一段,链路才干净。
2. 为什么偏偏选中 LibreOffice:它是Agent少有的"全能文件引擎"
2.1 LibreOffice在Agent里的真实角色
很多人在朋友圈里听到"把 LibreOffice 塞进 Harness",第一反应是"又一个套壳工具"。实际用下来,LibreOffice 在 Agent 体系里的角色更像一个"文件渲染内核"。
它可以无头运行,不需要图形界面,适合放在服务器上;它原生支持 ODT、DOCX、XLSX、PPTX、PDF 等几十种格式,能把老旧的 .doc、.xls 也救回来;它还提供 UNO 编程接口,允许你从 Python 里启动它、打开文档、修改内容、另存为其他格式。对我来说最重要的一点是:它把"文档格式"这个复杂问题收敛成了一个"本地服务",Agent 只需要和这个服务打交道。
2.2 直接写文件 vs 交给LibreOffice渲染
我之前也试过用 Python 库直接生成 docx,比如 python-docx。简单文档没问题,但一旦涉及模板、跨格式转换、老版本兼容就非常吃力。我整理过一张对账表,当时选型就看到很直观:
| 场景 | Python直写 | LibreOffice无头服务 |
|---|---|---|
| 新建简单docx | 方便 | 略繁琐 |
| 套用复杂模板要保留样式 | 麻烦,样式容易丢 | 通过ODT模板填充后导出,保留度高 |
| doc转docx/pdf | 基本不可行 | 一条命令 |
| xlsx公式重算 | 需要额外库模拟计算 | 启动计算链后自动重算 |
| 带批注/修订的文档处理 | 很难 | UNO接口可以操作 |
| 老格式甚至 .wps | 不支持 | 转换兜底能力强 |
这些对比说明一个核心问题:文档不是纯文本,它里面有节、段、样式、页眉页脚、批注、修订、公式、嵌入对象。用"读写 XML 的库"去模拟这些能力,等于重新造半个 Office。LibreOffice 已经把这些功能实现了二十多年,直接把它当引擎调用,是最省力的解法。
2.3 Harness与LibreOffice的关系
接下来解释一下 Harness。在 Agent 工程里,harness 指的是包在模型外层的"调度总成":它负责维护多轮上下文、管理可用工具列表、执行工具调用、限制权限、处理超时和错误恢复。你可以把它理解成给模型配的操作系统——模型只负责发指令,具体进程管理、参数校验、文件读写都由 harness 接住。
LibreOffice 本身不认识 DeepSeek,它只认 UNO 调用和命令行参数。所以二者的连接器就是 harness:模型说"我要把这个 docx 转成 PDF,放在输出目录",harness 负责把这句话规范化成一条合法的 LibreOffice 命令并执行,再把结果回传给模型,让它能接着规划下一步。没有这层管理,模型直接去拼 shell 命令,既危险又不可控。
3. 无头模式三件套:安装、常驻服务与Profile隔离
3.1 最小安装与第一次转换
在 Linux 服务器上安装 LibreOffice,不必装完整版全家桶。我的最小集是:
sudo apt update sudo apt install -y libreoffice-core libreoffice-writer libreoffice-calc fonts-noto-cjklibreoffice-core 提供基础引擎,libreoffice-writer 处理文字文档,calc 处理表格,字体包先装上中文,避免后面转 PDF 时中文全部变成方框。装完先跑一次最小验证:
libreoffice --headless --convert-to pdf --outdir /tmp/test /tmp/test.docx这条命令的意思是:无头模式启动,把 test.docx 转成 PDF,输出到 /tmp/test 目录。第一次跑会慢一些,因为要初始化用户配置目录,但成功后你能立刻在 /tmp/test 下看到 PDF。这一步通了,后面就可以考虑常驻服务方案了。
3.2 UNO常驻服务:让Agent告别冷启动
每次调 libreoffice 命令都会启动完整进程,文档稍大一点,光冷启动就要三五秒,任务一多就非常难受。更好的方式是启动一个常驻的 UNO 服务,让 Agent 通过 Python 连接:
soffice --headless --invisible --norestore --nofirststart \ --accept="socket,host=127.0.0.1,port=2002;urp;" &之后 Python 侧用 uno 模块连接过来,就能直接打开文档、调用接口:
import uno from com.sun.star.beans import PropertyValue local_context = uno.getComponentContext() resolver = local_context.ServiceManager.createInstance( "com.sun.star.bridge.UnoUrlResolver") ctx = resolver.resolve( "uno:socket,host=127.0.0.1,port=2002;urp;StarOffice.ComponentContext") smgr = ctx.ServiceManager desktop = smgr.createInstance("com.sun.star.frame.Desktop") input_url = "file:///data/reports/weekly_report.docx" doc = desktop.loadComponentFromURL(input_url, "_blank", 0, ()) # ... 对文档做修改、重算、导出等操作 doc.close(False)连接常驻服务后,转换和编辑的速度会快很多,因为省去了进程启动和组件加载时间。这才是 Agent 真正可用的形态:工具调用延迟稳定,不再随文档大小大幅波动。
3.3 多实例隔离:每个任务一个干净Profile
常驻服务的坑也随之而来:LibreOffice 默认会锁用户配置目录,多个进程或多次并发调用抢同一个 profile 时,会出现"already running"或者直接拒绝连接。我踩过几次之后,方案改成——每个任务都指定独立的 profile 目录:
soffice --headless \ -env:UserInstallation=file:///tmp/lo_profile_task_123 \ --accept="socket,host=127.0.0.1,port=2002;urp;"这样每个任务都有干净的字体缓存、扩展配置、锁文件。任务结束之后把临时 profile 目录一并清理,不会互相污染。这个习惯养成了,你的文档 Agent 才能扛住并发请求。
3.4 中文字体与依赖:提前装好,别等出方框再哭
很多人第一步转换就发现 PDF 里的中文全是方框。这不是 LibreOffice 的问题,而是系统缺中文字体。除了前面提到的 fonts-noto-cjk,还可以装 fonts-wqy-zenhei 作为后备字体。装好后在终端里执行 fc-list | grep -i "noto|wqy" 检查字体是否被识别。字体问题属于"不遇到不知道,遇到一次就浪费半天"的类型,环境准备阶段多花两分钟,后面能省一整晚。
4. 在Harness里把文档工具管起来:注册、调用与容错
4.1 先想清楚:Agent到底需要哪些文档能力
在写代码之前,我先规划了工具清单。文档 Agent 最常见的执行需求其实就这么几个:
- create_doc_from_template:基于模板 + 变量生成新文档;
- convert_format:把一种格式转成另一种,比如 docx 转 pdf,或老 doc 转 docx;
- export_pdf:带封面、目录、页码设置导出 PDF;
- read_doc_text:从 docx/pdf 里抽取文本,供模型阅读;
- fill_table:往指定的表格区域填结构化数据;
- docx_to_text:把上传的文档先转成文本,给模型"看"。
这六个工具基本覆盖了日常文档操作的 80%。工具数量不建议一开始搞太多,每多一个工具,模型误调用的概率就高一分。先把核心链路跑通,再逐步加。
4.2 工具注册与模型输出的映射
在 Harness 里,每个工具都要注册清晰的 Schema。我拿 export_pdf 举例,它接收的参数要保持很"窄":
{ "tool": "export_pdf", "params": { "source": "/data/reports/weekly_report.docx", "outdir": "/data/output", "cover_page": true, "toc": true, "page_numbers": true } }DeepSeek 在收到用户一句"把这周的报告整理成带封面和目录的 PDF"之后,会把它映射成上述 JSON 请求。Harness 拿到参数后,先做路径校验,再调用 LibreOffice 服务,等命令结束,把产物路径和日志摘要返回给模型。
整个过程里,模型不需要知道 LibreOffice 装在哪个目录、用什么端口、有哪些启动参数。它只负责把自然语言翻译成工具请求,工具的复杂度全部封装在 harness 侧。这也是 Agent 工程里比较健康的边界。
4.3 参数校验与路径沙箱
模型的输出不能盲信。我会在 harness 里强制做这几项校验:
- 路径必须是白名单前缀,比如 /data/input 和 /data/output,其他路径直接拒绝;
- 文件名必须做合法化处理,禁止包含 .. 或 shell 特殊字符;
- 输出目录不存在时先创建,但只在允许的范围内创建;
- 文件类型后缀必须和转换目标匹配,不能出现"把 PDF 当模板填数据"这种操作。
这些规则看起来是常识,但实际跑起来你会发现,模型在生成参数时出错的概率比想象中高。路径多了个引号、少了个反斜杠、把目录传到了文件字段里,这些都会让工具调用失败。靠模型自觉不如靠 harness 硬校验,这一步省不得。
4.4 超时与失败回退:不能因为一个文件卡死整个任务
文档转换有时会长时间无响应,原因可能是文件损坏、临时文件过大、或者 LibreOffice 进程僵死。我会给每个工具调用设置超时,比如默认 30 秒,超过就强制终止并清理进程:
timeout 30 libreoffice --headless --convert-to pdf --outdir /data/output /data/input/bad_file.docx如果超时,harness 返回给模型的错误信息必须是"可行动的":文件路径、失败原因、剩余空间、进程日志片段。模型可以基于这些信息决定下一步——可能是重试一次,可能是让用户换文件,或者降级成"先抽取文字供阅读"。重要的是,单个文件的失败不能拖垮整个 Agent 任务,该放弃就放弃,该重试就重试。
5. 实测踩坑记录:字体方框、并发锁与格式漂移
5.1 中文字体变成方框,问题不在LibreOffice而在系统字体
第一次在服务器上跑转换,所有中文字符全部渲染成方框,往 PDF 里嵌字体也溅不起水花。排查到最后,发现系统里只有英文字体,LibreOffice 找不到中文字体就全部拿"默认空字形"代替。装上 fonts-noto-cjk 之后症状消失。经验就是:无头服务器的字体环境和普通桌面不同,桌面一般自带一堆字体,服务器真就是"裸奔"。环境准备阶段把常用中英文字体都装一遍,能杜绝后面一大半格式问题。
5.2 并发调用时的Profile锁与僵尸进程
后面我试着同时跑三个转换任务,结果一个成功、两个报错,错误日志是用户配置目录被占用。原因就是我前面说的 profile 锁。LibreOffice 只允许一个进程操作同一 profile,多任务并发必须隔离。用 -env:UserInstallation 指定独立目录之后,问题解决。另外要注意,headless 模式在某些异常情况下会留下僵尸 soffice 进程,定期用 pkill -f soffice 清理,或者让每次转换都在独立 profile 里跑,结束后自动清理,能避免进程堆积。
5.3 大文档转换超时与任务回滚
一份 200 页带大量高清图片的文档,转换耗时可能超过一分钟。如果 Agent 层面不给超时,模型会一直等在那里,占住窗口不释放。前后端任务都卡死,体验极差。我的处理是在 harness 里加了动态超时:普通文档默认 30 秒,超过 10MB 的文档放宽到 120 秒,并且转换过程轮询文件产物是否出现,而不是死等进程退出。一旦超时,明确告诉用户"文档过大或格式异常,建议拆分成小文件重试"。
5.4 模板填充后格式漂移:从"在XML里硬改"到"先转ODT再导出"
原本想在 docx 里直接做模板变量替换,用 Python 改 document.xml,结果变量替换成功但整个段落样式全乱了——因为 docx 的 XML 结构里,样式信息分散在多个关系文件里,手动改一个节点很容易破坏其它引用。
后来换成更稳妥的套路:先把模板转成 ODT,在 ODT 里用占位符标记,比如 {{client_name}},再用 UNO 接口搜索替换,最后无头导出为 docx 或 PDF。ODT 的结构比 docx 的松散 XML 更规整,LibreOffice 对自家格式的读写保真度最高,替换结束后样式、分页、字体很少出现漂移。这也是我强烈建议"模板文件先用 LibreOffice 生成或净化一遍"的原因。
5.5 文件名带空格/中文时:参数解析失效
还有一个小坑:文件名或路径里带空格和中文时,直接用字符串拼接 shell 命令会出各种怪问题。最稳妥的办法是不走 shell,直接用 Python 的 subprocess 传入参数列表,让系统帮你处理转义。或者用 UNO 打开文件时把路径统一转成 file:// URL 格式,中文和空格都会被正确编码。千万不要为省事把文件名拼接进命令字符串,这一点在无头环境里尤其致命。
6. 跑通之后:从"能生成"到"能交差"的几个扩展方向
6.1 实测一档:一条指令生成带封面和目录的PDF
整个链路跑通之后,我做了个端到端测试:给 Agent 说一句"把季度财务说明生成 PDF,带封面、目录和页码,封面标题用大号粗体"。DeepSeek 将它拆成三个子任务——填充数据到模板、生成目录、导出 PDF;Harness 依次调度,LibreOffice 完成所有排版;最终产物是一个 12 页、带封面和可点击目录的 PDF。整个过程从用户提问到文件返回大概三四十秒,对付大多数办公场景已经足够。
要说哪里还值得优化,就是目录页码最后重新加载一次文档才能正确刷新。这个小问题不算大,但如果用户经常要改内容再重新导出,就会多出一次空转。所以我也在工具里加了"导出前是否刷新字段"的开关,让模型根据具体情况决定。
6.2 扩展方向:表格重算、PDF反向抽取、多格式分发
跑通基础链路后,后续可以继续扩展的方向也很多:
- 表格重算:让 Agent 修改 xlsx 里的公式参数,再调用 LibreOffice 重算,输出带最新结果的表格;
- PDF 反向抽取:把 PDF 转回文本或结构化数据,喂给模型做摘要和问答;
- 多格式分发:一份内容同时导出 PDF、docx、txt,方便不同终端用户使用;
- 批量水印与页眉:通过 UNO 接口批量操作,这一点对合同、标书的场景非常实用。
这些方向的核心仍然是同一个模式:模型负责理解和规划,LibreOffice 负责所有真正麻烦的文件级操作,harness 在外围做控制和兜底。模式一旦跑通,换场景只是多注册几个工具而已。
6.3 我最后留下的稳定组合
如果你也想照着搭,我最后留一个当前比较稳的配置参考:
| 组件 | 选型建议 |
|---|---|
| 语言模型 | DeepSeek,API 或本地部署均可 |
| 调度层 | 自研 Harness 工程,轻量即可,重点做好工具注册与超时 |
| 文档引擎 | LibreOffice 7.x 及以上,headless + UNO 常驻服务 |
| 字体 | fonts-noto-cjk + fonts-wqy-zenhei |
| 并发隔离 | 每个任务独立 UserInstallation profile |
| 超时策略 | 普通文档 30 秒,超大文档 120 秒,超时即回收 |
| 路径规则 | 白名单目录 + 参数合法化校验 |
组合并不复杂,但每一环都有存在的理由。模型再聪明,最终也要落到"文件真的能打开、格式真的不变"这个朴素的标准上。文档 Agent 的竞争力不在聊天,而在交付。
最后说点我自己的体会:做文档 Agent,最容易低估的就是"文件操作"这部分的工程量。模型把话说得漂亮很容易,把文件做得漂亮很难。LibreOffice 的价值不是某个花哨功能,而是它能稳定接住那些脏活累活——不管输入是老格式、带模板、还是带公式,它都能给你一个可预测的产物。
如果你正在搭类似的文档 Agent,我的建议是先把"一条最简单的转换链路"跑穿,再谈复杂功能。第一次看到 Agent 自主生成一份排版完整的 PDF 时,那种"终于不是纸老虎"的感觉,值得你折腾到今天这一步。