☰
DeepSeek+Harness+LibreOffice:打通文档Agent最后一公里
2026/10/10 13:14:27 网站建设 项目流程

最近我在折腾一个把 DeepSeek 接进 Harness 的项目,做着做着发现一个特别现实的瓶颈:模型能写出很漂亮的内容,但生成不了“文件”。你说一个 Agent 分析完数据,最后回给你一坨 Markdown 文本,这在命令行里看看还行,真要交付给业务同事,人家要的是带页码、带目录、排版正常的 docx 或 PDF。所以我把 LibreOffice 整个塞进了 Harness 的工具箱,让文档 Agent 终于能补齐这最后一公里——直接吐出可交付的文档。这篇文章就聊聊我这个组合的架构思路、接入细节,以及实测中踩过的各种坑。

1. 这波折腾的背景:文档 Agent 的输出经常卡在“最后一公里”

1.1 真实工作流不认 Markdown

我最早做的几个 Agent 原型,输出全靠文本流。跑 RAG 问答还行,用户问一句,模型引着知识库答一段,终端里贴出来也像那么回事。但真正用到办公场景里就完了:你让 Agent 生成一份季度总结、整理一份合同初稿、把一堆数据变成报表,它输出给谁看?总不能让人手动复制进 Word 再调一天格式吧。

这个“最后一公里”我理解有两层意思。第一层是格式落地:内容要能落到真正的文档格式里,比如 docx、odt、PDF;第二层是流程落地:文档要能被后续环节直接使用,比如归档、打印、合并、盖章。之前很多 Agent 项目就死在第一层,因为大家默认“能生成文本就等于能生成文档”,忽略了下游消费方其实只认文件。

1.2 为什么偏偏是 LibreOffice

你要是只想要一份“像样”的文档,其实有很多方案,比如直接调在线文档服务,或者用 python-docx 拼 docx。但我的场景有几个硬约束:要离线局域网可用,要覆盖文字、表格、演示三类文档,要能被 Agent 当成一个工具频繁调用,还不能按次收费。市面上能同时满足这几点的,LibreOffice 几乎是唯一选择。

它本身就是开源办公套件,支持 docx、xlsx、pptx 这些常见格式的读写,带完整的无头模式(headless),可以在没有桌面环境的情况下作为一个排版服务来跑。更关键的是它自带 UNO API,可以程序化控制文档对象。这意味着我不光能“把文本灌进模板”,还能设置样式、插入表格、导出 PDF,整套操作都能封装成 Harness 里的工具函数。

2. 三层架构怎么搭:DeepSeek 出内容,Harness 做编排,LibreOffice 管排版

2.1 三个角色各干各的

这个项目里三个组件各司其职,缺一个都会变味。

DeepSeek 是大脑,负责理解用户的自然语言指令,拆解成可执行的步骤,并生成文档需要的核心内容。Harness 是手和骨架,负责管理 Agent 的执行流程、工具调用、上下文传递以及失败回退。LibreOffice 是车间,负责把内容变成真正的文档,处理排版、样式、分页、导出等工作。

你可能会问:让 DeepSeek 直接生成 docx 的 XML 结构不行吗?理论可行,但实操非常痛苦。docx 本质是 zip 包里的 XML 文件,模型生成这种结构很容易漏 namespace、图片关系、样式定义,一旦缺了就整个文件打不开。LibreOffice 的价值就在于它把“内容和排版之间”的脏活累活全包了,模型只需要告诉它“我要什么内容”,剩下的交给模板和样式。

2.2 一次调用链路的“慢动作”

我习惯把整个流程拆成一条链来看,这样出了问题好定位。

用户发来指令,比如“把这份销售数据整理成季度报告,带趋势分析和表格”。Harness 先把这条指令和可用工具列表一起交给 DeepSeek,模型返回一个执行计划,比如“读取数据文件,分析趋势,调用文档模板工具生成 docx,再转换 PDF”。Harness 按计划逐个调度工具,其中文档生成工具会启动 LibreOffice 服务,往模板里填内容,导出文件,最后把输出路径回传给模型。模型拿到路径之后可以判定任务是否完成,如果需要还能继续追加修改。

这条链路里有一个容易忽略的设计点:工具返回给模型的信息一定要精简。不要把整个文档内容回传,模型记不住那么长的上下文,传一个文件路径加文件大小的摘要就足够了。如果后续要修改,模型只需要基于用户最新指令再次调用工具,不需要重新知道全文。

3. LibreOffice 无头模式的接入细节:UNO 连接、模板填充、PDF 导出

3.1 无头模式安装与启动

我用的系统是 Ubuntu 类的 Linux 服务器,安装的时候不要整个套件都装,用 --no-install-recommends 可以省掉很多没用的东西。

sudo apt-get update sudo apt-get install -y libreoffice-writer libreoffice-calc --no-install-recommends

注意 LibreOffice 有不少命令,最关键的是 soffice。启动无头服务时我用的参数是:

soffice --headless --invisible --nodefault --norestore \ --accept="socket,host=127.0.0.1,port=2002;urp;" &

这里的核心是--accept参数,它打开了一个 UNO 监听端口。也就是说,LibreOffice 不再只是一个“每次转换完就退出”的一次性进程,而是常驻的服务,其他程序可以通过 socket 连上来控制它。--norestore很重要,如果不加,异常退出后它可能试图恢复上次会话,导致进程卡住。

装完之后,建议先手动测试一下服务是否正常,别急着上代码。用命令转一个小文件是最快的探活方式:

soffice --headless --convert-to pdf --outdir /tmp test.docx

如果这一步能正常输出,说明库依赖没问题;如果把 document 转换成 pdf 时报字体错误,那多半是系统缺字体,后面容器化那节我会专门讲。

3.2 用 UNO 做模板填充

我的方案不是让模型直接生成完整文档,而是先准备一套模板,再通过占位符填充。这么做的好处是格式稳定,且用户可控性更强。模板里有{{title}}、{{summary}}、{{table_content}}这样的占位符,Python 脚本连上 UNO 后打开模板,替换占位符,另存为新文件。

import uno from com.sun.star.beans import PropertyValue def connect(): ctx = uno.getComponentContext() resolver = ctx.ServiceManager.createInstanceWithContext( "com.sun.star.bridge.UnoUrlResolver", ctx) url = "uno:socket,host=127.0.0.1,port=2002;urp;StarOffice.ComponentContext" component_ctx = resolver.resolve(url) smgr = component_ctx.ServiceManager desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", component_ctx) return desktop def fill_template(template_path, output_path, replacements): desktop = connect() props = [] prop = PropertyValue() prop.Name = "Hidden" prop.Value = True props.append(prop) doc = desktop.loadComponentFromURL( f"file://{template_path}", "_blank", 0, tuple(props)) # 遍历全文并替换占位符 for key, value in replacements.items(): search = doc.createSearchDescriptor() search.SearchString = key found = doc.findFirst(search) while found: found.setString(value) found = doc.findNext(found.End, search) doc.storeToURL(f"file://{output_path}", ()) doc.close(True)

这里有个细节我一开始差点忽略:loadComponentFromURL的 URL 必须是file://开头的绝对路径,如果传成相对路径或普通路径,UNO 会直接报错。另外替换文本后,如果占位符本身带着表格的换行结构,需要额外处理,否则插入的内容会丢失格式。我实测下来,最简单的稳妥做法是每个替换值里只放纯文本,需要表格的地方单独用 API 创建,不要让模型把表格的可见文本硬塞进去。

3.3 命令行转换这条“安全通道”

UNO 适合精细控制,但它也是坑最多的路径。比如进程连接超时、端口被占用、连接无法释放,都会导致整个 Agent 卡住。所以我实际上准备了两套工具:一套是 UNO 模板填充,精细操作;另一套是纯命令行转换,快速稳妥。

soffice --headless --convert-to pdf --outdir /output report.docx

命令行转换的好处是简单、隔离性强、不容易污染常驻服务。缺点是控制粒度粗,只能做格式转换,不能改内容。我在 Harness 里把这两个工具分开了:需要精细排版走 fill_template,只需要产出 PDF 版本走 convert_pdf。实践中你会发现,绝大多数“最后一公里”问题用命令行转换就够了,UNO 是锦上添花。

4. 完整复现一次:从一句自然语言到一份可交付的 docx

4.1 先在 Harness 里注册两个文档工具

接下来是集成环节。我用的 Harness 支持注册自定义工具,说白了就是给每个函数写清楚名字、描述、参数,告诉模型“你有一个工具叫这个名字,输入这些参数,调用后返回这些信息”。我的工具注册表长这样:

{ "tools": [ { "name": "libreoffice_fill_template", "description": "使用LibreOffice打开模板文件,替换占位符,生成docx文档", "parameters": { "template_path": "string", "output_path": "string", "replacements": "object" } }, { "name": "libreoffice_convert_pdf", "description": "将docx或odt文件转换为PDF,用于最终交付", "parameters": { "input_path": "string", "output_dir": "string" } } ] }

这段注册信息会拼进系统提示词里,DeepSeek 看到之后才知道“哦,我可以用这两个工具”。我必须强调:工具的 description 要写清楚适用场景。最开始我写得太简单,模型经常在不需要转换 PDF 的时候也调用转换工具,白白浪费几次调用。后来把 description 改成“仅在用户要求PDF格式或最终交付需要时使用”,误调用率立刻降了下来。

4.2 DeepSeek 的规划与工具调用

我实际跑通的场景是:让 Agent 基于一份销售数据生成季度报告,要求“包含三个章节,最后附一个对比表格,导出为 docx 和 PDF 两个版本”。

Harness 把指令交给 DeepSeek 后,模型给出的规划大致是:

  1. 分析数据文件,提取各季度销售数字。
  2. 撰写报告文字,包括概述、趋势分析、下季度建议。
  3. 调用libreoffice_fill_template把内容填入模板。
  4. 调用libreoffice_convert_pdf生成 PDF 版本。

这里值得注意的是 DeepSeek 能根据模板结构自动决定替换哪些字段。模板里的{{chapter1_title}}、{{chapter1_body}}、{{sales_table}}这些字段,模型会照着章节内容去填。不过,sales_table这个字段不能直接填 Markdown 表格,因为 LibreOffice 的文本替换不会自动把竖线文本变成表格。我的做法是让工具函数内部识别“表格占位符”,然后单独用 UNO API 插入真正的表格对象。

4.3 我踩过的几个小坑

第一个坑是 LibreOffice 进程的并发安全。当我同时跑多个文档任务时,多个 UNO 客户端连同一个服务会互相干扰。最典型的报错是 “connect failed” 和 “no such element in collection”。二次看日志,发现是并发调用同一个 soffice 进程导致的。我的解决办法是把 UNO 连接封装进一个带锁的队列,保证同一时刻只有一个填充任务在执行。

第二个坑是中文字体缺失。默认容器里没有中文字体,生成 PDF 的时候,所有汉字变成方块,整体排版直接崩了。这个问题排查起来最烦,因为 docx 在 LibreOffice 里打开看着是对的,转 PDF 才出错。后来我在系统里装好了 Noto CJK 字体,并且在模板里把字体显式设置为“Noto Sans CJK SC”,问题才彻底解决。字体问题对中文文档 Agent 来说几乎绕不开,后面容器化章节我会再展开。

第三个坑是 Harness 的上下文窗口被工具返回信息撑爆。最初我把整个模板填充后的校验结果都回传给模型,文本很长。后来学乖了,工具只返回“生成成功,文件路径:xxx,大小:xxKB”,模型只需要这个结论就够了。你真需要让模型知道文档内容时,它可以再开一个读取工具去读文件,而不是在工具返回里塞全文。

5. 从“能用”到“抗造”:容器化、并发控制、失败回退里的实战心得

5.1 批量生成时别让 LibreOffice 进程打架

我把这套东西跑出单次流程之后,第一个想做的就是批量生成:几十份周报,一次性跑完。结果并发一开,问题立刻暴露。

LibreOffice 的无头模式其实有两个用法。一种是我前面说的常驻服务模式,快,但单个进程扛不住并发。另一种是每次调用临时启动一个soffice --convert-to命令,用完就退出,慢但隔离性强。批量任务里,我建议混合用:转换 PDF 这种“无状态”操作,走临时进程,多个并行没关系,前提是你控制了机器资源;模板填充这种“有状态”操作,走常驻服务,但必须排队。

我实测下来的经验是,四核机器上同时跑六个--convert-to进程是没问题的,再往上内存就开始吃紧。模板填充服务我最多给它留一个并发名额,宁等勿乱。排队的实现直接用了 Python 的线程锁,简单粗暴,但后面跑了几千次文档任务,一次没因为资源冲突崩溃过。

5.2 容器镜像里的字体与 locale 问题

容器化是“抗造”的关键一步。我的 Docker 镜像基于 Debian,就安装文字处理相关组件和字体,加上中文字体包,同时把 locale 也设置好,避免生成日期或数字格式时不按中文习惯显示。这一套下来镜像看起来有点大,但换来的是环境完全可控,再也不用担心换一台机器生成出来的文档样式不一致。

这里还有一个容易忽略的点:时区。默认容器的时区是 UTC,文档里如果带日期时间戳,很可能比你的本地时间早八个小时。我一开始没设时区,连续两次看到文档里时间不对,还以为是数据源的问题,最后才查出来是容器的锅。

5.3 代码回退与失败重试的设计

最后一个让我真正觉得“可以交付”的功能,是回退机制。文档生成过程里,最让人头大的是“生成到一半失败”。比如模板里有模型没见过的占位符,或者 LibreOffice 进程突然挂了,如果 Harness 不处理,用户只能拿到一个半成品文件路径,体验会很差。

我的处理方案分两层。第一层是 Harness 级别的重试:失败之后,Harness 从报错信息里提取关键词,自动决定是重新调用工具还是调整一次参数再试。比如转换 PDF 失败,大概率是输入文件的问题,那就原样重试一次,如果还失败就果断放弃,不要再让模型空转。第二层是模板级别的回退:每个模板文件都保留一个备份,填充前先备份,填充失败就用备份恢复,确保原始模板不会被污染。

这些设计做完之后,我最大的感觉是整个 Agent 从“偶尔能出文档”变成了“稳定产出文档”。至少在离线局域网的环境下,DeepSeek 负责理解指令,Harness 负责任务编排,LibreOffice 负责排版输出,一套既不需要联网又不需要付费的文档 Agent 工作流就闭环了。

如果你也想搭类似的东西,我的建议是不要一上来就追求复杂功能。先把“自然语言到纯文本”跑通,再单独接一个 LibreOffice 转换工具,最后再加模板填充和批量并发。每一步都验证完再往前走,踩坑的频率会小很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询