AI Agent 项目学习笔记(五):FileBasedChatMemory 文件式持久化记忆
1. 本期目标
上一篇文章分析了 ai_agent 项目中的多轮对话与 ChatMemory 机制。我们已经知道,大模型本身没有真正的长期记忆,项目需要在应用层保存历史消息,并在下一轮对话时重新交给模型。
这一期继续分析项目中自定义实现的记忆模块:
FileBasedChatMemory
它位于:
src/main/java/com/ai/aiagent/chatmemory/FileBasedChatMemory.java
项目 README 中也明确说明,ai_agent 提供了一个基于 Kryo 的文件式 ChatMemory 实现,可以按照 conversationId 写入本地文件,并支持服务重启后恢复历史会话。(GitHub)
本期主要解决几个问题:
1. 为什么需要文件式持久化记忆?
2. FileBasedChatMemory 和内存版 ChatMemory 有什么区别?
3. Kryo 在这里起什么作用?
4. conversationId 如何映射成本地文件?
5. add()、get()、clear() 分别如何工作?
6. getOrCreateConversation() 和 saveConversation() 的作用是什么?
7. 文件式记忆有什么优点和不足?
8. 如果后续要用于正式系统,应该怎么优化?
2. 为什么需要文件式记忆?
前面讲过,当前 LoveApp 默认使用的是:
MessageWindowChatMemory + InMemoryChatMemoryRepository
这种方式把对话消息存在内存里,适合学习和测试。问题也很明显:
服务重启后,历史对话会丢失。
例如:
用户上午问:我和女朋友总是因为沟通问题吵架。
AI 给出了一些建议。
服务下午重启。
用户继续问:那我今天晚上应该怎么开口?
如果只用内存记忆,系统重启后已经找不到上午的对话内容,模型就无法理解“怎么开口”指的是什么。
文件式记忆要解决的就是这个问题:
把对话历史写入本地文件,
让服务重启后仍然可以读取历史消息。
3. 当前 LoveApp 中如何预留文件式记忆?
在 LoveApp 构造函数中,可以看到项目已经预留了文件式记忆的切换入口:
String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory";
// ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
也就是说,当前默认使用内存版记忆,但只要取消注释并替换这一行,就可以切换为 FileBasedChatMemory。源码中确实定义了 fileDir = 项目根目录/tmp/chat-memory,并保留了 new FileBasedChatMemory(fileDir) 的注释代码。(GitHub)
可以理解为:
默认模式:
记忆存在内存中,重启丢失。
文件模式:
记忆存在 tmp/chat-memory 目录中,重启后可恢复。
4. FileBasedChatMemory 的整体定位
FileBasedChatMemory 实现了 Spring AI 的 ChatMemory 接口。
它不是一个普通工具类,而是可以直接作为 ChatMemory 使用。
它的核心思想很简单:
一个 conversationId 对应一个 .kryo 文件。
例如:
conversationId = chat_001
对应文件:
tmp/chat-memory/chat_001.kryo
这个文件中保存的是当前会话的历史消息列表。
所以整个设计可以概括为:
conversationId
↓
找到对应 kryo 文件
↓
读取历史 Message 列表
↓
追加新消息
↓
重新写回文件
5. 类结构概览
FileBasedChatMemory 的结构很简洁,核心字段和方法包括:
字段:
BASE_DIR
kryo
构造方法:
FileBasedChatMemory(String dir)
核心接口方法:
add(String conversationId, List messages)
get(String conversationId)
clear(String conversationId)
内部辅助方法:
getOrCreateConversation(String conversationId)
saveConversation(String conversationId, List messages)
getConversationFile(String conversationId)
源码中可以看到,它实现了 ChatMemory,内部通过 Kryo、Input、Output、FileInputStream 和 FileOutputStream 完成文件读写。(GitHub)
这说明它的代码并不复杂,重点是理解:
如何把会话消息列表序列化到文件,
以及如何再从文件反序列化回来。
6. BASE_DIR:记忆文件保存目录
类中有一个字段:
private final String BASE_DIR;
它表示所有会话记忆文件的根目录。
构造方法中会接收一个目录路径:
public FileBasedChatMemory(String dir) {
this.BASE_DIR = dir;
File baseDir = new File(dir);
if (!baseDir.exists()) {
baseDir.mkdirs();
}
}
这段代码的作用是:
第一,保存传入的记忆目录路径。
第二,判断目录是否存在。
第三,如果目录不存在,就自动创建。
所以,当 LoveApp 中传入:
项目根目录/tmp/chat-memory
系统就会确保这个目录存在。
对应目录结构可能是:
tmp/
└─ chat-memory/
├─ chat_001.kryo
├─ chat_002.kryo
└─ chat_003.kryo
7. Kryo 在这里起什么作用?
FileBasedChatMemory 使用 Kryo 完成序列化和反序列化。
Kryo 是一个 Java 二进制对象图序列化框架,官方说明它的目标是高速、体积小、易用,适合把对象持久化到文件、数据库或网络传输中。(GitHub)
在这个项目中,Kryo 的作用是:
把 Java 中的 List<Message> 转成二进制数据,写入 .kryo 文件;
再把 .kryo 文件中的二进制数据读回 List<Message>。
可以理解为:
Java 对象
↓
Kryo 序列化
↓
二进制文件
二进制文件
↓
Kryo 反序列化
↓
Java 对象
这里保存的不是普通文本,所以 .kryo 文件直接打开通常看不懂。
8. 为什么使用二进制序列化?
聊天记忆中保存的是 Spring AI 的 Message 对象,而不是单纯的字符串。
一条消息可能包含:
消息角色
消息内容
元数据
其他模型调用相关字段
如果用普通文本保存,就需要自己设计格式。
例如:
{
"role": "user",
"content": "我和女朋友吵架了"
}
这样虽然可读性好,但需要手动处理对象转换。
Kryo 的好处是:
直接保存 Java 对象结构,
读取时再恢复成 Java 对象。
所以文件式记忆的实现会更简单。
不过二进制序列化也有缺点:
文件不可读
不方便手动排查
类结构变化后可能存在兼容性问题
跨语言使用不方便
因此它适合轻量持久化,不一定适合作为正式系统的长期聊天记录格式。
9. Kryo 的初始化配置
FileBasedChatMemory 中有一个静态 Kryo 对象:
private static final Kryo kryo = new Kryo();
并在静态代码块中做了两个配置:
kryo.setRegistrationRequired(false);
kryo.setInstantiatorStrategy(new StdInstantiatorStrategy());
第一行:
setRegistrationRequired(false)
表示不强制要求提前注册所有要序列化的类。
这样做更灵活,因为 Message 及其内部对象可能来自 Spring AI,不一定都提前注册好了。
第二行:
setInstantiatorStrategy(new StdInstantiatorStrategy())
用于增强对象创建能力。某些对象在反序列化时可能没有标准无参构造函数,使用这个实例化策略可以提高反序列化成功率。
所以这两行配置的整体目的可以理解为:
降低 Kryo 序列化 Spring AI Message 对象时的使用门槛。
10. conversationId 如何变成文件名?
FileBasedChatMemory 中有一个方法:
private File getConversationFile(String conversationId) {
return new File(BASE_DIR, conversationId + ".kryo");
}
它把 conversationId 转换成文件路径。
例如:
BASE_DIR = D:/Project/ai_agent/tmp/chat-memory
conversationId = chat_001
最终得到:
D:/Project/ai_agent/tmp/chat-memory/chat_001.kryo
这就是文件式记忆的核心映射关系:
一个会话 ID
↓
一个本地文件
这种设计非常直观。
11. add():追加会话消息
add() 方法用于把新消息加入某个会话。
源码逻辑可以概括为:
public void add(String conversationId, List messages) {
List conversationMessages = getOrCreateConversation(conversationId);
conversationMessages.addAll(messages);
saveConversation(conversationId, conversationMessages);
}
它分成三步:
第一步:根据 conversationId 读取已有历史消息。
第二步:把本轮新消息追加到历史列表末尾。
第三步:把更新后的完整消息列表重新写回文件。
所以 add() 不是只写入本轮消息,而是:
读取旧消息
追加新消息
整体保存
例如:
chat_001.kryo 原来有 2 条消息:
1. 用户:我和女朋友吵架了
2. AI:建议先冷静沟通
新一轮新增 2 条消息:
3. 用户:那我应该怎么开口?
4. AI:可以先表达自己的感受
add() 后文件中保存 4 条消息。
12. get():读取会话消息
get() 方法用于读取某个会话的历史消息:
public List get(String conversationId) {
return getOrCreateConversation(conversationId);
}
它实际上直接调用:
getOrCreateConversation(conversationId)
也就是说:
如果文件存在:
读取文件中的消息列表。
如果文件不存在:
返回一个空列表。
这正好符合 ChatMemory 的使用逻辑。
第一次对话时,没有历史消息,返回空列表。
后续对话时,有历史文件,就把历史消息读出来交给 MessageChatMemoryAdvisor。
13. clear():清空某个会话
clear() 方法用于清除某个会话的记忆:
public void clear(String conversationId) {
File file = getConversationFile(conversationId);
if (file.exists()) {
file.delete();
}
}
它的逻辑很直接:
根据 conversationId 找到对应 .kryo 文件
↓
如果文件存在,就删除
例如:
clear("chat_001")
会删除:
tmp/chat-memory/chat_001.kryo
删除后,下一次再调用:
get("chat_001")
就会返回空列表。
这相当于清空了该会话的上下文记忆。
14. getOrCreateConversation():读取或创建会话
getOrCreateConversation() 是文件式记忆中最重要的内部方法之一。
它的作用是:
根据 conversationId 找到对应文件;
如果文件存在,就读取历史消息;
如果文件不存在,就创建一个空消息列表。
流程可以写成:
getConversationFile(conversationId)
↓
判断文件是否存在
↓
存在:用 Kryo 读取 ArrayList
↓
不存在:返回 new ArrayList<>()
这就是为什么 get() 和 add() 都可以安全调用它。
因为无论文件存不存在,它都会返回一个 List。
15. saveConversation():保存会话消息
saveConversation() 负责把消息列表写回文件。
它的核心逻辑是:
根据 conversationId 找到文件
↓
创建 FileOutputStream
↓
包装成 Kryo Output
↓
kryo.writeObject(output, messages)
也就是说,它把整个消息列表序列化后写入 .kryo 文件。
这里要注意:
每次保存都是保存完整消息列表,
不是只追加写入新增消息。
这种方式实现简单,但当会话消息越来越多时,文件会越来越大,每次写入的成本也会增加。
16. 完整读写流程
把 add()、get()、clear() 串起来看,可以得到完整流程:
第一次对话:
chat_001.kryo 不存在
↓
getOrCreateConversation() 返回空列表
↓
add() 追加本轮消息
↓
saveConversation() 创建 chat_001.kryo 文件
第二次对话:
chat_001.kryo 已存在
↓
getOrCreateConversation() 读取历史消息
↓
add() 追加新消息
↓
saveConversation() 覆盖保存完整列表
清空会话:
clear(chat_001)
↓
删除 chat_001.kryo
所以可以一句话理解:
FileBasedChatMemory 用 conversationId 找文件,用 Kryo 读写 List<Message>。
17. 文件式记忆如何接入 LoveApp?
当前 LoveApp 中默认代码是:
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
切换成文件式记忆时,可以改成:
String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory";
ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
然后仍然通过:
MessageChatMemoryAdvisor.builder(chatMemory).build()
接入 ChatClient。
也就是说,主链路不用改。
变化的只是:
ChatMemory 的具体实现
这体现了接口设计的好处。
18. 文件式记忆和窗口式记忆的一个重要区别
这里有一个需要特别注意的点。
MessageWindowChatMemory 是窗口式记忆,它可以限制进入上下文的消息数量。Spring AI 官方示例中也会通过 .maxMessages(10) 这类配置来控制记忆窗口大小。(Home)
但当前项目的 FileBasedChatMemory 是直接实现 ChatMemory,它的 get() 方法会返回该会话文件中的全部历史消息。源码中没有看到类似 maxMessages 的裁剪逻辑。(GitHub)
这意味着:
MessageWindowChatMemory:
默认关注窗口大小,避免上下文无限增长。
FileBasedChatMemory:
当前实现会保存并返回完整历史列表。
所以启用文件式记忆后,如果一个会话聊了很多轮,后续传给模型的上下文可能越来越长。
这会带来问题:
token 成本增加
上下文过长
回答速度变慢
可能超过模型上下文限制
因此,当前 FileBasedChatMemory 更像是一个“简单持久化版本”,还不是完整的生产级记忆管理方案。
19. FileBasedChatMemory 的优点
19.1 实现简单
它只依赖本地文件系统和 Kryo。
没有数据库,没有 Redis,也不需要额外服务。
对学习项目来说,这种方式很容易理解。
19.2 支持服务重启恢复
相比内存记忆,文件式记忆最大的优点是:
服务重启后,文件仍然存在。
只要 tmp/chat-memory 目录没有被删除,历史会话就可以继续读取。
19.3 一个会话一个文件,结构直观
每个 conversationId 对应一个 .kryo 文件。
这让会话隔离很清楚:
chat_001.kryo
chat_002.kryo
chat_003.kryo
删除某个会话也很简单,直接删除对应文件即可。
19.4 接口替换成本低
因为它实现了 ChatMemory 接口,所以可以直接替换内存版 ChatMemory。
LoveApp 的主对话代码不需要大改。
20. 当前实现中可以改进的地方
20.1 Kryo 静态实例存在线程安全风险
当前代码中使用的是:
private static final Kryo kryo = new Kryo();
但 Kryo 官方文档明确说明,Kryo、Input 和 Output 实例不是线程安全的;多线程环境中应该为每个线程提供自己的实例,或者使用池化机制。(GitHub)
而 Spring Boot Web 项目通常会同时处理多个请求。
如果多个用户同时触发文件式记忆读写,静态共享的 Kryo 实例可能产生并发问题。
更稳妥的方式是:
方案一:使用 ThreadLocal<Kryo>
方案二:使用 Kryo Pool
方案三:每次读写创建新的 Kryo 实例
学习阶段问题不大,正式系统需要重点处理。
20.2 conversationId 需要做文件名安全处理
当前文件名直接由:
conversationId + ".kryo"
拼接得到。
如果 conversationId 中包含特殊字符,例如:
../
/
\
:
*
可能导致路径异常,甚至产生目录穿越风险。
正式系统应该对 conversationId 做处理:
只允许字母、数字、下划线、短横线
或者对 conversationId 做 hash 后作为文件名
例如:
conversationId = user_1001_session_001
文件名 = sha256(conversationId).kryo
这样更安全。
20.3 缺少消息窗口裁剪
前面已经说过,当前文件式记忆会返回完整历史消息。
后续可以增加最大消息数,例如:
private static final int MAX_MESSAGES = 20;
保存前做裁剪:
如果消息数量超过 MAX_MESSAGES,
只保留最后 MAX_MESSAGES 条。
这样既能持久化,又能避免上下文无限增长。
20.4 文件读写缺少并发锁
同一个 conversationId 如果同时有多个请求进来,可能出现:
请求 A 读取旧文件
请求 B 读取旧文件
请求 A 写入新内容
请求 B 覆盖写入
最终可能丢失其中一部分消息。
可以考虑按会话加锁:
conversationId 级别锁
文件锁
synchronized
ReentrantLock
比较简单的做法是维护一个:
ConcurrentHashMap<String, ReentrantLock>
不同会话互不影响,同一会话串行读写。
20.5 异常处理过于简单
当前读取和保存文件时,如果出现 IOException,代码中主要是:
e.printStackTrace();
学习阶段可以接受,但正式系统建议:
使用日志框架记录错误
向上抛出业务异常
返回明确错误信息
避免静默失败
否则可能出现文件读取失败但系统继续返回空记忆的问题,排查起来比较困难。
20.6 文件不可读,不适合做聊天历史展示
.kryo 文件是二进制格式。
如果后续要做:
聊天历史列表
用户查看历史记录
后台审计
关键词搜索
导出聊天记录
那么 Kryo 文件就不太方便。
正式系统更适合使用:
MySQL
PostgreSQL
MongoDB
Redis
Spring AI 文档中也提供了多种 ChatMemoryRepository 方案,包括 JDBC、Neo4j、CosmosDB、MongoDB 等,其中 JDBC 版本适合需要持久化聊天记忆的关系型数据库场景。(Home)
21. 文件式记忆和数据库记忆怎么选?
可以这样理解:
内存记忆:
适合学习、调试、单机临时运行。
文件式记忆:
适合轻量持久化、小规模个人项目、本地 Demo。
数据库记忆:
适合正式系统、多用户、多实例部署、历史记录查询。
如果只是学习 ChatMemory 的原理,FileBasedChatMemory 很合适。
如果要做一个正式可用的 AI 对话平台,建议最终还是把会话和消息保存到数据库中。
22. 对这个项目的理解
我认为 FileBasedChatMemory 的价值不在于它是最完美的记忆方案,而在于它把“记忆持久化”这件事讲得很清楚。
它告诉我们:
ChatMemory 不一定只能存在内存里。
只要实现 ChatMemory 接口,
就可以把记忆保存到文件、数据库、Redis 或其他存储中。
这对学习 Agent 工程很重要。
因为很多智能体项目一开始只做到了:
当前会话能记住上下文
但进一步就要考虑:
服务重启后怎么办?
用户下次回来还能不能接着聊?
不同用户的会话怎么隔离?
历史消息如何管理?
FileBasedChatMemory 就是从“临时记忆”走向“持久记忆”的第一步。
23. 本期重点理解
这一期最重要的是理解 FileBasedChatMemory 的实现思路。
可以总结为五点:
第一,FileBasedChatMemory 实现了 Spring AI 的 ChatMemory 接口。
第二,它使用 conversationId 作为会话标识,并映射成本地 .kryo 文件。
第三,它使用 Kryo 把 List<Message> 序列化到文件中。
第四,add() 负责读取旧消息、追加新消息、重新保存完整列表。
第五,get() 负责读取历史消息,clear() 负责删除对应会话文件。
一句话概括:
FileBasedChatMemory 的作用,是把每个会话的历史消息保存为本地 Kryo 文件,从而让智能体具备服务重启后的会话恢复能力。
24. 本期小结
本期主要分析了 ai_agent 项目中的 FileBasedChatMemory 文件式持久化记忆。
项目默认使用内存版 MessageWindowChatMemory,但在 LoveApp 中已经预留了切换到 FileBasedChatMemory 的入口。FileBasedChatMemory 实现了 ChatMemory 接口,构造时指定本地保存目录,并确保目录存在。它通过 conversationId + ".kryo" 生成会话文件名,通过 Kryo 将 List<Message> 序列化到本地文件中。add() 方法会读取已有会话消息,追加本轮新消息,然后保存完整列表;get() 方法会读取对应会话文件,如果文件不存在则返回空列表;clear() 方法会删除指定会话对应的 .kryo 文件。
这一期可以用一句话总结:
FileBasedChatMemory 是 ai_agent 从“内存上下文”迈向“本地持久化会话记忆”的轻量实现。
下一期可以继续分析:
AI Agent 项目学习笔记(六):RAG 检索增强整体流程
下一期重点分析 LoveAppDocumentLoader、MarkdownDocumentReader、MyKeywordEnricher、LoveAppVectorStoreConfig、QueryRewriter 和 QuestionAnswerAdvisor,理解项目如何把本地 Markdown 文档变成可检索的知识库,并在对话时增强模型回答。
更多推荐

所有评论(0)