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,内部通过 KryoInputOutputFileInputStreamFileOutputStream 完成文件读写。(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 官方文档明确说明,KryoInputOutput 实例不是线程安全的;多线程环境中应该为每个线程提供自己的实例,或者使用池化机制。(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 检索增强整体流程

下一期重点分析 LoveAppDocumentLoaderMarkdownDocumentReaderMyKeywordEnricherLoveAppVectorStoreConfigQueryRewriterQuestionAnswerAdvisor,理解项目如何把本地 Markdown 文档变成可检索的知识库,并在对话时增强模型回答。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐