Spring AI 指南:如何自主构造 ChatMemory
在构建基于大语言模型(LLM)的对话式应用时,上下文管理是决定对话质量和连贯性的核心。用户不会希望模型在每一轮对话时都“忘记”之前说过的话。在 Spring AI 框架中,这个关键任务由 ChatMemory
接口来承担。
官方虽然提供了一些开箱即用的实现(如 InMemoryChatMemory
),但在实际的生产环境中,我们往往有更复杂的需求,例如:
- 持久化存储:将对话历史保存到数据库(如 Redis, MongoDB, JDBC 数据库)或文件中,以防应用重启后丢失。
- 自定义记忆策略:实现更复杂的对话历史裁剪策略,比如只保留最近的 N 条对话、保留最近 M 分钟内的对话,或者基于 Token 数量进行控制。
- 多租户/多会话隔离:为不同的用户或会话提供独立的记忆存储。
本文将深入解析 ChatMemory
接口,并从零开始,创建一个将对话历史持久化到本地文件的自定义 ChatMemory
实现。
1. ChatMemory 核心接口解析
ChatMemory
接口的定义非常简洁,它主要包含以下几个核心方法:
void add(String conversationId, Message message)
- 作用:向记忆中添加一条新的消息。每当用户提问或模型回答后,对应的
Message
对象就应该被添加到记忆中。 - 实现要点:这是最核心的写入操作。需要在这里实现将消息存入选择的后端存储(文件、数据库等)的逻辑。
- 注意:现在这个方法已经可以不用重写了,只需要重写一次性添加多条消息的
add
- 作用:向记忆中添加一条新的消息。每当用户提问或模型回答后,对应的
void add(String conversationId, List<Message> messages)
- 作用:一次性添加多条消息。
- 实现要点:默认实现是遍历列表并逐一调用
add(Message)
。通常情况下,不需要重写此方法,除非后端存储支持批量写入以优化性能。
List<Message> get(String conversationId)
- 作用:获取当前记忆中存储的所有对话历史。在构建下一次对模型的请求时,Spring AI 会调用此方法来获取完整的上下文。
- 实现要点:这是最核心的读取操作。需要在这里实现从后端存储读取并返回消息列表的逻辑。
void clear(String conversationId)
- 作用:清空当前会话的所有记忆。
- 实现要点:实现从后端存储删除所有相关消息的逻辑。
理解了这个接口,我们的目标就非常明确了:创建一个类,实现这几个方法,并使用我们选择的存储方式来完成消息的增、删、查操作,本质上就是做 CRUD。
2. 实战:构建一个基于文件的 FileBasedChatMemory
让我们来创建一个具体的实现:FileBasedChatMemory
。它的功能是将特定对话的全部历史记录持久化保存到一个文件中。每个对话实例都将对应一个唯一的文件。
1. 项目准备
由于使用 JSON 来序列化会存在很多报错,我们可以选择高性能的 Kryo 序列化库。
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
<version>5.6.2</version>
</dependency>
2. 创建 FileBasedChatMemory 类
我们来定义这个类,它需要实现 ChatMemory
接口。
package com.lumibee.aiagent.chatmemory;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.objenesis.strategy.StdInstantiatorStrategy;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import com.esotericsoftware.kryo.Kryo;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;
/**
* 基于文件的聊天记忆实现类
* 使用Kryo序列化库将聊天消息持久化到本地文件系统
* 实现Spring AI的ChatMemory接口,提供对话历史的存储和检索功能
*/
public class FileBasedChatMemory implements ChatMemory {
/** 存储聊天记录的基础目录路径 */
private final String BASE_DIR;
/** Kryo序列化器实例,用于对象的序列化和反序列化 */
private static final Kryo kryo = new Kryo();
/**
* 静态初始化块,配置Kryo序列化器
*/
static {
// 禁用注册要求,允许序列化未注册的类
kryo.setRegistrationRequired(false);
// 设置实例化策略,使用Objenesis的StdInstantiatorStrategy
// 这允许Kryo创建没有无参构造函数的对象
kryo.setInstantiatorStrategy(new StdInstantiatorStrategy());
}
/**
* 构造函数
* @param dir 存储聊天记录的基础目录路径
*/
public FileBasedChatMemory(String dir) {
this.BASE_DIR = dir;
File file = new File(dir);
// 如果目录不存在,则创建目录结构
if (!file.exists()) {
file.mkdirs();
}
}
/**
* 添加消息到指定对话
* @param conversationId 对话ID,用于标识不同的对话
* @param messages 要添加的消息列表
*/
@Override
public void add(String conversationId, List<Message> messages) {
// 获取或创建对话的消息列表
List<Message> conversationMessages = getOrCreateConversation(conversationId);
// 将新消息添加到现有消息列表中
conversationMessages.addAll(messages);
// 保存更新后的对话到文件
saveConversation(conversationId, conversationMessages);
}
/**
* 获取指定对话的所有消息
* @param conversationId 对话ID
* @return 该对话的所有消息列表的不可修改副本
*/
@Override
public List<Message> get(String conversationId) {
List<Message> allMessages = getOrCreateConversation(conversationId);
// 返回不可修改的消息列表副本,防止外部修改影响内部状态
return allMessages.stream().toList();
}
/**
* 清除指定对话的所有消息
* @param conversationId 要清除的对话ID
*/
@Override
public void clear(String conversationId) {
File file = getConversationFile(conversationId);
// 如果文件存在,则删除该文件
if (file.exists()) {
file.delete();
}
}
/**
* 获取或创建指定对话的消息列表
* 如果文件不存在,则创建新的空列表
* @param conversationId 对话ID
* @return 该对话的消息列表
*/
private List<Message> getOrCreateConversation(String conversationId) {
File file = getConversationFile(conversationId);
List<Message> messages = new ArrayList<>();
// 如果文件存在,则从文件中读取消息
if (file.exists()) {
try (Input input = new Input(new FileInputStream(file))) {
// 使用Kryo反序列化消息列表
messages = kryo.readObject(input, ArrayList.class);
} catch (IOException e) {
// 如果读取失败,打印错误信息并返回空列表
e.printStackTrace();
}
}
return messages;
}
/**
* 保存对话消息到文件
* @param conversationId 对话ID
* @param messages 要保存的消息列表
*/
private void saveConversation(String conversationId, List<Message> messages) {
File file = getConversationFile(conversationId);
try (Output output = new Output(new FileOutputStream(file))){
// 使用Kryo序列化消息列表到文件
kryo.writeObject(output, messages);
} catch (FileNotFoundException e) {
// 如果文件创建失败,抛出运行时异常
throw new RuntimeException(e);
}
}
/**
* 获取指定对话对应的文件对象
* @param conversationId 对话ID
* @return 对应的文件对象,文件扩展名为.kryo
*/
private File getConversationFile(String conversationId) {
// 文件名格式:{conversationId}.kryo
return new File(BASE_DIR, conversationId + ".kryo");
}
}
代码解析:
- 构造函数: 接收存储目录路径,并确保该目录存在。如果目录不存在,会自动创建目录结构。
- add(String conversationId, List
messages) : 这是核心的写入逻辑。它首先调用getOrCreateConversation()
获取当前完整的消息列表,然后将新消息添加到该列表中,最后将整个更新后的列表使用 Kryo 序列化并写入到文件中。 - get(String conversationId): 首先检查文件是否存在。如果存在,则读取文件内容,并使用 Kryo 的 Input 流将二进制数据反序列化为 List
对象。如果文件不存在,返回一个空的 ArrayList。 - clear(String conversationId): 逻辑最简单,直接删除对应的 .kryo 文件即可。
- 线程安全: 请注意,这个简单的实现不是线程安全的。如果多个线程同时调用 add 方法操作同一个对话,可能会导致数据丢失或文件损坏。在生产环境中,需要使用
synchronized
关键字或java.util.concurrent.locks.Lock
来保护对文件的读写操作。
3. 使用自定义 ChatMemory
使用起来非常简单。当需要与 ChatClient
交互时,不再使用默认的记忆体,而是实例化自己的 FileBasedChatMemory
。
public App(ChatClient.Builder builder,
MysqlChatMemoryRepository mysqlChatMemoryRepository,
@Value("classpath:/prompts/love-prompt.st") Resource lovePromptResource) {
String fileDir = System.getProperty("user.dir") + "/chat-memory";
ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
this.lovePromptTemplate = new PromptTemplate(lovePromptResource);
this.chatClient = builder
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory)
.build())
.build();
}
现在,每次调用 chatClient.call()
,框架都会自动:
- 调用
fileBasedChatMemory.get()
获取历史记录。 - 将历史记录和当前问题一起发送给 LLM。
- 在收到回答后,调用
fileBasedChatMemory.add()
将用户的新问题和模型的回答保存到文件中。
3. 思考:从 ChatMemory 到 MessageStore
虽然我们成功实现了一个自定义的 ChatMemory
,但 Spring AI 还提供了一个更底层的抽象:MessageStore
。
MessageStore
: 它的职责更纯粹,只关心如何根据一个key
(通常是会话ID)来存储和检索List<Message>
。它不关心ChatMemory
的那些运行时逻辑。ChatMemory
的实现可以委托给MessageStore
: Spring AI 提供了一个InMemoryChatMemory
的构造函数,它接收一个MessageStore
。这意味着你可以只实现一个JdbcMessageStore
或RedisMessageStore
,然后把它包装在InMemoryChatMemory
中,即可获得一个功能完备且持久化的ChatMemory
,而无需自己处理记忆裁剪等逻辑。
选择哪种方式?
- 如果需要完全控制记忆的所有行为(包括如何添加、如何检索以及复杂的裁剪逻辑),直接实现
ChatMemory
接口,如我们的文件示例所示。 - 如果主要目标只是更换后端存储(例如从内存换到 Redis),并且满足于 Spring AI 提供的默认记忆管理逻辑,那么实现
MessageStore
接口并将其注入到InMemoryChatMemory
中是更推荐、更省力的方式。
评论区
请登录后发表评论