夜雨聆风学习资料网

ARTICLE · 993705

手搓一个 Agent11:从文档问答到生产级智能体

手搓一个 Agent11:从文档问答到生产级智能体

✦ 干货分享 ✦

第11章 落地产物:文件读写工具与看得见的输出

上一章,我们给 Agent 装上了记忆,它能记住你们聊过的上下文了。但不知道你有没有一种隐隐的空虚感——就算它记住再多,聊得再好,它的"作业"还是躺在聊天框里,你一刷屏就没了。你让它"写一份这周的总结",它巴拉巴拉输出一大段漂亮的 Markdown 给你,然后呢?没了。复制、粘贴、存成 .md,这个过程还得你自己来。

这一章,我们就来解决 Agent 的痛点:让 Agent 真正交作业——把生成的周报、对比表、总结文档,直接落成真实文件。从"只会说"到"会写文件",这是你用 Agent 时体验上最爽的一次跳跃。

老规矩,先建目录。Let's go。


01一、开篇:从"会聊天"到"会交作业"

上一章结束后,你的 Agent 大概是这个状态:

  • 能多步调用工具脱 RAG 取日报;
  • 能记住上下文,跟你多轮对话;
  • 但产物永远只存在于聊天窗口里,转瞬即逝。

这一章我们要加两个工具,补上最后一块拼图:

  1. writeFile
    :写文件(升级版,支持指定目录);
  2. readFile
    :读文件(让 Agent 能读你丢给它的本地文件)。

有了这俩,你就能对 Agent 说一句话:"把这周的 3 份日报合并成一份周报,存到 reports/week.md",它自己调 RAG 取日报、自己生成内容、自己落盘。呼应第 9 章的多步编排——现在产物真的成文件了。

02二、writeFile + readFile:让 Agent 手能打字

先说核心思路。给 Agent 的工具就是 Spring 的一个 @Tool 注解方法。我们连写两个:一个写、一个读。

readFile:让 Agent 能"看"你给的本地文件

1@Tool(description = "读取指定路径的文本文件内容。路径必须是 workspace 目录内的相对路径。")

2public String readFile(String path) {

3    Path resolved = resolvePath(path);   // 后面会讲的校验

4return Files.readString(resolved, StandardCharsets.UTF_8);

5}

为什么需要 readFile?因为你可能丢一个本地文件给 Agent 说"帮我改改里面某段话",或者它写完周报后要检查一下有没有写成功。它能自己读回来看,比盲目信任自己写的结果强多了。

writeFile:支持指定目录的升级版

之前(如果第 3 或某章我出现过简化版)我们可能只在当前目录写。现在升级成"指定目录":

1@Tool(description = "把文本内容写到 workspace 内的指定路径文件。目录不存在会自动创建。适合把总结/周报/表格导出成 md 文件。")

2public String writeFile(String path, String content) {

3    Path resolved = resolvePath(path);   // 切到绝对路径、校验白名单

4    Files.createDirectories(resolved.getParent());

5    Files.writeString(resolved, content, StandardCharsets.UTF_8);

6return"{\"path\"\"" + resolved + "\",\"ok\":true}";

7}

注意两点:

  • createDirectories
    :自动建目录,否则 Agent 想写到 reports/week.md 但 reports/ 不存在会当场报错;
  • 返回值是个 JSON {"path": "...", "ok": true},后面"踩坑"小节我会细讲为什么不能只回一个 true

03三、读写权限边界:工作目录白名单

这里必须狠一点。你想想,如果 Agent 能随便写文件,它一个 ../ 就能写出你的 workspace,写到 C:\Windows 或者读取 /etc/passwd。第 6 章我们给计算器做了参数校验,道理一样——现在到了 File 级别,注入的破坏力大得多。

方案:工作目录白名单。我们就限定一个 workspace 目录(比如 ./workspace),Agent 拿到的文件名,一律先切到绝对路径,再 normalize 掉那些 ../,然后校验结果必须 startsWith 这个白名单。

1privatestaticfinal Path WORKSPACE = Path.of("./workspace").toAbsolutePath().normalize();

2

3private Path resolvePath(String rawPath) {

4// 1. 拼到 workspace 下、切成绝对路径、normalize 去掉 ../ 和 ./

5    Path resolved = WORKSPACE.resolve(rawPath).toAbsolutePath().normalize();

6

7// 2. 校验:normalize 之后必须还在 workspace 内

8if (!resolved.startsWith(WORKSPACE)) {

9thrownew IllegalArgumentException("路径越界被拒绝: " + rawPath);

10    }

11return resolved;

12}

这个 normalize() + startsWith() 是业界标准防路径穿越的姿势。../etc/passwd 经过 resolve + normalize 后,前缀不再是 WORKSPACE,直接抛异常。就这么几行,坑就堵死了。

04四、不同格式怎么生成:选最少依赖

现在能写文件了,那写什么格式的?我把这章的范围划清楚:Markdown 是绝对主推,PDF 和 Excel 各给一个最小片段点到为止,绝不展开成"报表生成大全"——那样就跑题了,读者是来学 Agent 的,不是来学报表的。

Markdown:直接写字符串(主推)

1String md = "# 本周周报\n\n## 周一\n- ...\n";

2writeFile("reports/week.md", md);

为什么主推?因为零依赖、零编码问题、人人能打开。Agent 生成 Markdown 是最自然的——它天然就擅长输出结构化的文字。整个教程的主产物就是 Markdown。

PDF:中文字体是最大的坑(踩坑点)

用 iText 生成 PDF 前,先打个预防针:iText 的默认字体不含中文字符——你直接生成,中文全变方块 □□□。要绕,有两条路:

  • 嵌入一个中文字体文件(比如 Noto Sans CJK),麻烦;
  • 最简单:生成 HTML → 用浏览器直接"打印成 PDF",中文字体完美,零代码。

1// 最小片段:iText 要显示中文必须嵌入字体,否则乱码

2BaseFont bf = BaseFont.createFont(

3"C:/Windows/Fonts/msyh.ttc"// 微软雅黑

4    BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);

记住这条路就行,真要出高保真 PDF,推荐 HTML 转 PDF,而不是硬刚 iText 字体。

Excel:Apache POI 最小片段

先给 pom 依赖:

1<dependency>

2<groupId>org.apache.poi</groupId>

3<artifactId>poi-ooxml</artifactId>

4<version>5.2.5</version>

5</dependency>

最小的生成片段(两行叫板):

1try (Workbook wb = new XSSFWorkbook()) {

2    Sheet sheet = wb.createSheet("对比表");

3    Row row = sheet.createRow(0);

4    row.createCell(0).setCellValue("指标");

5    row.createCell(1).setCellValue("方案A");

6    row.createCell(2).setCellValue("方案B");

7try (OutputStream os = Files.newOutputStream(resolvePath("compare.xlsx"))) {

8        wb.write(os);

9    }

10}

Excel 的坑在于:中文字体默认也会有编码问题,必要时要设置单元格字体。但这章就点到为止,记住"要 Excel 用 POI,写个最小 demo 快速验证"即可。

说结论: 实战里 90% 的场景,Markdown 就够了。PDF/Excel 属于"用户真点名要了才上"的选项,别一上来就铺开。

05五、工具描述怎么写,让 Agent 知道该用它

工具装好了,但 Agent 怎么知道"用户说导出/保存/存成文件"该用 writeFile?关键在 @Tool 的 description 里。

你注意到前面我写的那段 description 了吗:

1@Tool(description = "把文本内容写到 workspace 内的指定路径文件。目录不存在会自动创建。适合把总结/周报/表格导出成 md 文件。")

精髓在这句"适合把总结/周报/表格导出成 md 文件"。Spring AI 会把这段描述连同方法一起喂给大模型,模型读到这句,就知道:用户说"存下来""导出""保存成文件",就该调它。描述写得好不好,直接决定 Agent 会不会用这个工具。同一个 writeFile,description 写个干巴巴的"写文件",和写了使用场景,Agent 的调用准确率天差地别。

多写一句"什么时候用这个工具(以及传什么参数)",比写十句"这个工具接受几个参数"有用得多。

06六、成品:一句话,Agent 交付真文件

现在把前面第 9 章的多步编排 RAG,和本章的写文件串起来。你对 Agent 说一句:

"

把这 3 份日报合并成一份周报,存到 reports/week.md。

"

Agent 的执行链:

  1. 识别意图 → 需要读 3 份日报;
  2. 调 RAG 检索工具,拿到 3 份日报原文;
  3. 在脑子里把日报总结、合并成周报文本;
  4. 调 writeFile("reports/week.md", 周报内容);
  5. resolvePath
     校验通过,建目录,落盘;
  6. 返回 {"path":"...","ok":true},报告"周报已保存"。

你回头打开 ./workspace/reports/week.md,一份真实的周报躺在那里。从"只会说"到"会写",这个跨越,就是本章的意义。

07本章踩坑

坑1:文件路径穿越注入

现象: Agent(或你传的文件名)里带 ../,比如 "reports/../../etc/passwd",结果写到/读到了 workspace 之外的敏感路径。轻则写坏系统文件,重则泄露配置文件内容。

根因: 对路径没做任何校验,让文件名裸写了出去。这是文件系统版的注入攻击,和第 6 章计算器注入同一个妈生的。

解法: 工作目录白名单 + Path.normalize + startsWith 校验,就是前面那段 resolvePath。把它当成所有文件读写工具的"身份证",统一走这一个入口,别让每个工具各自为政(否则有个工具漏了校验,就是千人千面里漏网的那个)。

坑2:中文文件、中文内容乱码

现象: 写出来的 .md 文件用记事本打开是乱的,或者 Agent 读你给的本地中文文件读出一堆乱码。

根因: Windows 默认编码是 GBK,而 Java 里 Files.writeString(path, str) 不带编码时用的是平台默认编码;读文件同理。两边编码对不上,中文必乱。

解法: 凡读写,一律显式指定 UTF-8:

1Files.writeString(resolved, content, StandardCharsets.UTF_8); // 写

2Files.readString(resolved, StandardCharsets.UTF_8);           // 读

别依赖平台默认编码。打开文件时也要确认编辑器是 UTF-8 保存的。

坑3:PDF 中文字体缺失

现象: 用 iText 生成中文 PDF,中文全变成 □□□ 方块。

根因: iText 的默认字体是标准 PDF 内置字体(Helvetica 等),本身不含中文字符集,渲染不出中文。

解法: - 内嵌中文字体文件(如 msyh.ttc / Noto Sans CJK),如前面 BaseFont.createFont(...) 片段; - 或者干脆别用 iText,生成 HTML 再用浏览器"打印为 PDF",浏览器自带中文字体,零乱码,最省心。

这坑只要知道了就不亏,别等生产环境报一堆方块才头皮发麻。

坑4:工具产物要不要返回路径给 Agent

现象:writeFile 只返回就一个 "ok" 或者干脆 void,Agent write 完就抓瞎,不知道写没写成功、更不知道写到哪去了,没法往下链式操作。

根因: 你没告诉 Agent 结果好不好、文件在哪。Agent 是瞎子摸象,你给的信息越少它越蒙。

解法: 返回结构化结果,把路径带回去:

1return"{\"path\"\"" + resolved + "\",\"ok\":true}";

这样 Agent 知道"写成功了,路径在 reports/week.md",它就能接着说"我给你读出来看看"——readFile("reports/week.md") 接力,实现链式操作。要让 Agent 能自检、能接力,路径这个返回值必须给。

08结尾预告

这一章 Agent 终于会交作业了。但新的问题也在慢慢逼近:对话越来越长,记忆越积越多,最后上下文塞爆了怎么办?那些前面聊过的细节,全都得记住吗?

别急,下一章,第 12 章,我们来聊聊记忆的收缩:长对话的摘要压缩——怎么在对话太长了之后,把它"压缩"成一段摘要,让 Agent 轻装上阵,继续陪你聊下去。敬请期待。

相关学习资料

返回首页浏览学习资料