Spring AI (第二章)大模型对话上下文记忆
文章目录
- [Spring AI (第二章)大模型对话上下文记忆](#Spring AI (第二章)大模型对话上下文记忆)
- 一、章节目的
-
- [1.1 需求分析](#1.1 需求分析)
- [1.2 方案一:内存存储(In-memory)](#1.2 方案一:内存存储(In-memory))
- [1.3 方案二:文件存储(Kryo 序列化)](#1.3 方案二:文件存储(Kryo 序列化))
- [1.4 方案三:MongoDB(或类似文档数据库)](#1.4 方案三:MongoDB(或类似文档数据库))
- [1.5 选型建议](#1.5 选型建议)
- 二、代码
-
- [2.1 上下文聊天记忆的大模型对象](#2.1 上下文聊天记忆的大模型对象)
- [2.2 基于本地文件存储的方式实现代码(可选)](#2.2 基于本地文件存储的方式实现代码(可选))
- [2.3 测试代码](#2.3 测试代码)
一、章节目的
1.1 需求分析
- 基于第一章实现的内容;
- 当前对话仅按单轮请求-响应执行,模型不会利用历史消息,导致上下文丢失、回答前后不一致、重复确认较多。
- 目标:为每个会话维护"对话历史",在下一轮请求时一并提交,从而实现连贯、多轮的上下文记忆。

1.2 方案一:内存存储(In-memory)
- 官方提供了基于计算机内存存储上下文记忆的依赖、会把用户和大模型的上下文存储在内存当中、可以解决上下文记忆的问题;
- 优点:实现最简单、延迟最低、无外部依赖,开发/调试成本低;
- 缺点:进程退出或重启即丢失、无法跨实例共享;数据量大时占用内;
- 适用场景:PoC、单机小流量、对持久化不敏感的场景
1.3 方案二:文件存储(Kryo 序列化)
- 通过Kryo序列化对象来实现数据的文件写入、文件读取、根据用户聊天会话ID去写入和读取文件、可以解决上下文记忆的问题;
- 优点:重启不丢失,部署简单,不依赖外部服务。
- 缺点:小文件众多(碎片化)、目录遍历和清理成本高、并发写入需加锁;跨机无法共享;备份/检索能力有限。
- 适用场景:单机生产/测试环境,需要一定持久化但不想引入数据库。
1.4 方案三:MongoDB(或类似文档数据库)
- 将会话与消息存为文档,按会话ID/时间索引查询与追加。
- 优点:持久化可靠、天然支持多实例共享与水平扩展、易于做查询、统计与归档;备份/副本集可靠。
- 缺点:需运维数据库(推荐使用 Docker),引入网络延迟与成本。
- 适用场景:生产环境、多副本部署、需要可观测性与扩展性。
1.5 选型建议
- 三种方案选择一种即可;
- 快速落地/教学演示:选内存存储;配上限与TTL,最快见效。
- 轻量持久化(单机):选文件存储;注意分桶与锁。
- 正式生产/多实例/高并发:选 MongoDB(或 Redis Streams/Postgres JSONB),推荐开启 TTL、归档与监控。
二、代码
2.1 上下文聊天记忆的大模型对象
java
import lombok.extern.slf4j.Slf4j;
import org.exi.exiaiagent.chatmemory.FileBasedChatMemory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Component;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY;
/**
* 初始化可以存储上下文聊天记忆的大模型对象
*/
@Component
@Slf4j
public class ChatMemoryApp {
private final ChatClient chatClient;
/**
* 构造函数 初始化ChatClient
* @param dashscopeChatModel
*/
public ChatMemoryApp(ChatModel dashscopeChatModel) {
/**
* 1 = 内存存储
* 2 = 计算机资源文件存储
* 3 = MongoDB数据库存储
*/
int route = 1;
ChatMemory chatMemory;
if (route == 1) {
chatMemory = UseMemoryChatMemory();
} else if (route == 2) {
chatMemory = UseFileChatMemory();
} else {
chatMemory = UseMemoryChatMemory(); // 默认为基于内存存储
}
chatClient = ChatClient.builder(dashscopeChatModel)
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory) // 选择消息格式分层存储的拦截器
)
.build();
}
/**
* 基于本地文件存储的方式 实现读取、写入聊天上下文
* @return
*/
public ChatMemory UseFileChatMemory(){
// 项目根目录、定义对话存储文件路径
String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory";
// 创建自定义文件存储的对象
ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
return chatMemory;
}
/**
* 基于计算机内存存储、实现读取、写入聊天上下文
* @return
*/
public ChatMemory UseMemoryChatMemory(){
/**
* 默认使用:MessageWindowChatMemory
* 存储位置: JVM 内存 (InMemoryChatMemoryRepository)
* 数据持久化: 不持久化(应用重启后丢失)
* 消息窗口: maxMessages = 20(默认,可配置)
*/
InMemoryChatMemory chatMemory = new InMemoryChatMemory();
return chatMemory;
}
/**
* 基于MongoDB数据库存储、实现读取、写入聊天上下文
*/
public void UseMongoDBChatMemory(){
}
/**
* 根据用户消息和用户的聊天ID进行回复
* @param message
* @param chatId
* @return
*/
public String doChat(String message, String chatId) {
ChatResponse chatResponse = chatClient.prompt()
.user(message) // 用户消息
.advisors(advisorSpec -> advisorSpec
.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId) // 指定聊天记忆存储ID
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10) // 获取最近10条聊天记忆
)
.call()
.chatResponse();
String content = chatResponse.getResult().getOutput().getText(); // 获取chatResponse的结果数据
log.info("【对话请求】用户消息:{}", message);
log.info("【模型响应】内容:{}", content);
return content;
}
}
2.2 基于本地文件存储的方式实现代码(可选)
Kryo序列化依赖
xml
<!-- Kryo高性能序列化框架依赖、作用:提供快速、高效的Java对象序列化和反序列化功能 -->
<!-- 在自定义对话记忆存储对象序列化中使用到了 -->
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
<version>5.6.2</version>
</dependency>
实现代码
java
import com.esotericsoftware.kryo.Kryo;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;
import org.objenesis.strategy.StdInstantiatorStrategy;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 参考内存数据存储InMemoryChatMemory自定义存储实现ChatMemory
*/
public class FileBasedChatMemory implements ChatMemory {
private String BASE_DIR; // 会话文件存储路经
/**
* 创建一个Kryo序列化对象
*
* 问题:Message对象有多种子类型(UserMessage/SystemMessage/AssistantMessage),
* 字段结构不同,JSON序列化难以统一处理
*
* 解决:使用Kryo二进制序列化,自动处理不同Message子类,自动处理复杂对象结构的序列化
* 保持完整类型信息,提供高性能序列化
*
* 总结:可以通过Kryo解决不同子类字段不一致的存储和读取序列化问题
*
* 通俗解释:
* - 就像有3种不同的盒子(UserMessage、SystemMessage、AssistantMessage)
* - 每个盒子里装的东西都不一样(字段不同)
* - JSON就像用文字描述盒子内容,很难统一描述
* - Kryo就像直接拍照保存盒子,不管里面装什么都能完整保存
* - 读取时也能完整还原出原来的盒子
*/
private static final Kryo kryo = new Kryo();
static {
// 不要求注册类,允许序列化任意类
kryo.setRegistrationRequired(false);
// 设置实例化策略
kryo.setInstantiatorStrategy(new StdInstantiatorStrategy());
}
// 构造对象时、初始化存储目录、没有则创建
public FileBasedChatMemory(String dir) {
this.BASE_DIR = dir;
File baseDir = new File(dir);
if (!baseDir.exists()) {
baseDir.mkdirs();
}
}
/**
* 根据会话ID 添加会话列表
* @param conversationId
* @param messages
*/
@Override
public void add(String conversationId, List<Message> messages) {
// 根据会话ID、获取之前的消息列表
List<Message> conversationMessages = getOrCreateConversation(conversationId);
// 添加新的消息列表
conversationMessages.addAll(messages);
// 根据会话ID、写入最新的消息列表
saveConversation(conversationId, conversationMessages);
}
/**
* 根据会话ID获取会话列表数据
* @param conversationId
* @param lastN
* @return
*/
@Override
public List<Message> get(String conversationId, int lastN) {
List<Message> messageList = getOrCreateConversation(conversationId);
// 根据lastN获取最近存储的消息列表
return messageList.stream()
.skip(Math.max(0, messageList.size() - lastN))
.toList();
}
/**
* 根据会话ID、删除会话
* @param conversationId
*/
@Override
public void clear(String conversationId) {
File file = getConversationFile(conversationId);
// 如果文件对象存储则删除
if (file.exists()) {
file.delete();
}
}
/**
* 根据会话ID创建或获取会话列表
* @param conversationId
* @return
*/
private List<Message> getOrCreateConversation(String conversationId) {
// 获取本地存储的kryo序列化文件对象
File file = getConversationFile(conversationId);
List<Message> messages = new ArrayList<>();
if (file.exists()) {
try (Input input = new Input(new FileInputStream(file))) {
// 通过kryo读取文件输入流并转为ArrayList.class格式
messages = kryo.readObject(input, ArrayList.class);
} catch (IOException e) {
e.printStackTrace();
}
}
return messages;
}
/**
* 把会话消息列表通过kryo序列化存储至文件当中
* @param conversationId
* @param messages
*/
private void saveConversation(String conversationId, List<Message> messages) {
File file = getConversationFile(conversationId);
try (Output output = new Output(new FileOutputStream(file))) {
kryo.writeObject(output, messages);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 根据会话ID获取本地存储的文件对象
* @param conversationId
* @return
*/
private File getConversationFile(String conversationId) {
return new File(BASE_DIR, conversationId + ".kryo");
}
}
2.3 测试代码
测试代码
java
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class ChatMemoryAppTest {
/**
* @Resource 注解的作用是先根据字段名后根据类型 来自动匹配注入对应的 Bean
*/
@Resource
private ChatMemoryApp chatMemoryApp;
@Test
void doChat() {
String chatId = "a5daa1bb-e5e2-427e-9a74-68650459511f";
String message = "我有两个朋友:张三和李四,张三喜欢性感火辣的、李四喜欢斯文儒雅的";
String answer = chatMemoryApp.doChat(message, chatId);
message = "我有两个朋友,你知道他们分别是谁不、各自喜欢的女生类型";
answer = chatMemoryApp.doChat(message, chatId);
}
}
测试结果
