ARTICLE · 993705
手搓一个 Agent11:从文档问答到生产级智能体
✦ 干货分享 ✦
第11章 落地产物:文件读写工具与看得见的输出
◆
上一章,我们给 Agent 装上了记忆,它能记住你们聊过的上下文了。但不知道你有没有一种隐隐的空虚感——就算它记住再多,聊得再好,它的"作业"还是躺在聊天框里,你一刷屏就没了。你让它"写一份这周的总结",它巴拉巴拉输出一大段漂亮的 Markdown 给你,然后呢?没了。复制、粘贴、存成 .md,这个过程还得你自己来。
这一章,我们就来解决 Agent 的痛点:让 Agent 真正交作业——把生成的周报、对比表、总结文档,直接落成真实文件。从"只会说"到"会写文件",这是你用 Agent 时体验上最爽的一次跳跃。
老规矩,先建目录。Let's go。
01一、开篇:从"会聊天"到"会交作业"
上一章结束后,你的 Agent 大概是这个状态:
能多步调用工具脱 RAG 取日报; 能记住上下文,跟你多轮对话; 但产物永远只存在于聊天窗口里,转瞬即逝。
这一章我们要加两个工具,补上最后一块拼图:
- writeFile
:写文件(升级版,支持指定目录); - 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 的执行链:
识别意图 → 需要读 3 份日报; 调 RAG 检索工具,拿到 3 份日报原文; 在脑子里把日报总结、合并成周报文本; 调 writeFile("reports/week.md", 周报内容); - resolvePath
校验通过,建目录,落盘; 返回 {"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 轻装上阵,继续陪你聊下去。敬请期待。