Spring AI学习之旅:用ChatMemory + Redis让AI“记住我”
以下是基于 Redis 的 ChatMemory 实现类@Component// Redis 键的前缀@Autowired/*** 添加消息到 Redis* @param conversationId 会话ID* @param messages 消息列表*/@Override// 生成 Redis 键// 添加消息到 Redis// 设置过期时间 7 天/*** 获取 Redis 中的消息* @p
目录
💡 一次从“金鱼记忆”到“长期记忆”的实践之旅
一、为什么我开始研究 Spring AI 的对话记忆
最近在做一个 AI 问答 Demo 时,我遇到一个尴尬的问题:
每次我和AI聊得正开心,它突然忘了我是谁 😭。
这让我开始思考:
大模型能不能像人一样,记住我们之前说过的话?
于是我开始研究 Spring AI 框架。
它是 Spring 官方出的 AI 集成框架,不仅封装了调用大模型的细节,还引入了一个很妙的概念 —— ChatMemory(对话记忆)。
接下来,我就记录一下我的探索过程:
从理解理论到自己写出 基于 Redis 的持久化记忆系统。
二、理论准备
ChatMemory 是什么?
它像一个“会话管理器”,存储对话历史(List),确保每次调用 LLM 时注入上下文。原理基于:
-
上下文窗口(Context Window):LLM 有 Token 限制(e.g., 4096 Tokens),ChatMemory 负责截取最近 N 条消息,避免超出窗口。
-
消息角色分离:Spring AI 的 Message 接口有实现类:
-
SystemMessage:系统提示,定义 AI 角色。
-
UserMessage:用户输入。
-
AssistantMessage:AI 输出。
-
ChatMemory 存储这些 Message 对象,保持顺序(FIFO)。
-
-
设计模式:ChatMemory 是接口,遵循 Spring 的 SPI(Service Provider Interface),允许自定义实现(如内存、Redis)。它像 Spring Session 的存储后端,管理会话 ID(conversationId)。
原理上,ChatMemory 解决了“幻觉”(Hallucination)和“上下文丢失”问题:通过注入历史消息,让 LLM “回忆”对话,生成更准确的响应。
如何工作
ChatMemory 通过 Advisor 机制集成到 ChatClient 中。工作流程如下(结合 Spring AI 的源码分析):
1、接口定义(ChatMemory.java):
-
default void add(String conversationId, Message message) {
this.add(conversationId, List.of(message));
}:添加单条消息,但还是包装成 List<Message> 后调用核心的批量添加方法 -
add(String conversationId, List<Message> messages):追加消息到指定会话。
-
get(String conversationId, int lastN):获取最近 N 条消息。
-
clear(String conversationId):清理会话。
2、集成到 ChatClient:
-
使用 MessageChatMemoryAdvisor(一个 AOP-like 切面)。
-
在 ChatClient.prompt() 时:
-
get 历史消息,注入到 Prompt 中(作为 User/Assistant 角色)。
-
调用 LLM 生成响应。
-
add 新消息(用户输入 + AI 输出)到 ChatMemory。
-
示例流程:
用户请求 1:Prompt = System + User1。
AI 响应:Assistant1。
add(User1, Assistant1)。
用户请求 2:Prompt = System + User1 + Assistant1 + User2。
以此类推。
3、内置实现:
-
InMemoryChatMemoryRepository:默认使用,用ConcurrentHashMap来管理,简单但不持久,重启丢失。
-
JdbcChatMemoryRepository:它使用 JDBC 将消息存储在关系数据库中,支持 PostgreSQL、MySQL、SQL Server 等。
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
-
CassandraChatMemoryRepository:使用 Apache Cassandra 来存储消息,支持 TTL、时间序列审计
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-cassandra</artifactId>
</dependency>
-
Neo4jChatMemoryRepository:它使用 Neo4j 将聊天消息作为节点和关系存储在属性图数据库中,以图结构存储消息、会话、工具调用等。
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-neo4j</artifactId>
</dependency>
详细内存存储库见:官方内存存储库文档
Spring AI 默认实现的是 内存型 ChatMemory(程序重启就全忘光)。
但我希望能让它记在 Redis 里——这样即使服务重启,对话历史也不会丢失。
三、项目环境 & 依赖
| 组件 | 版本 |
|---|---|
| Spring Boot | 3.6.5 |
| Spring AI | 1.0.0-M6 |
| Redis | 6.2.6 |
| JDK | 17 |
pom.xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
<!--swagger doc 用于管理接口文档-->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.12</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
application.yml
spring:
application:
name: qwen-springai-demo
ai:
openai:
api-key: "换成你的秘钥即可"
base-url: https://dashscope.aliyuncs.com/compatible-mode # 这里使用的通义千问的模型,所以需要指定端点
chat:
options:
model: qwen-turbo # 指定模型名称
temperature: 0.7
data:
redis:
host: "替换成你的地址"
port: 6379
password: "redis开启了密码鉴权就输入你的密码即可,没有删除就行"
database: 1 "默认使用id为0的数据库,不需要指定数据库的话,删除即可"
四、配置 Redis 环境
Redis 是高性能的键值数据库,非常适合存储会话类数据。
在本项目中,我们通过 RedisTemplate 来操作 Redis,由于这里是要对Message进行序列化,在RedisTemplate模版中直接指定类型为Message即可
@Configuration
public class RedisConfig {
/**
* 创建一个RedisTemplate实例,并设置相应的序列化方式
* @param factory Redis连接工厂
* @param builder Jackson2ObjectMapperBuilder实例
* @return RedisTemplate实例
*/
@Bean
public RedisTemplate<String, Message> messageRedisTemplate(RedisConnectionFactory factory, Jackson2ObjectMapperBuilder builder) {
RedisTemplate<String, Message> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 使用String序列化器作为key的序列化方式
template.setKeySerializer(new StringRedisSerializer());
// 使用自定义的Message序列化器作为value的序列化方式
template.setValueSerializer(new MessageRedisSerializer(builder.build()));
// 设置hash类型的key和value序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new MessageRedisSerializer(builder.build()));
template.afterPropertiesSet();
return template;
}
/**
* 创建一个ObjectMapper实例,并注册JavaTimeModule以支持Java 8的日期和时间类型
* @return ObjectMapper实例
*/
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper().registerModule(new JavaTimeModule());
}
}
这里我们需要自定义Message序列化器,以便于序列化和反序列化
public class MessageRedisSerializer implements RedisSerializer<Message> {
private final ObjectMapper objectMapper;
private final JsonDeserializer<Message> messageDeserializer;
/**
* 创建一个 MessageRedisSerializer,使用指定的 ObjectMapper 对象进行序列化和反序列化。
* @param objectMapper 用于进行序列化和反序列化的 ObjectMapper 对象
*/
public MessageRedisSerializer(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.messageDeserializer = new JsonDeserializer<>() {
@Override
public Message deserialize(JsonParser jp, DeserializationContext ctx)
throws IOException {
ObjectNode root = jp.readValueAsTree();
String type = root.get("messageType").asText();
return switch (type) {
case "USER" -> new UserMessage(root.get("text").asText());
case "ASSISTANT" -> new AssistantMessage(root.get("text").asText());
default -> throw new UnsupportedOperationException("未知的消息类型");
};
}
};
}
/**
* 将 Message 对象序列化为字节数组。
* @param message 要进行序列化的 Message 对象
* @return 序列化后的字节数组
*/
@Override
public byte[] serialize(Message message) {
try {
return objectMapper.writeValueAsBytes(message);
} catch (JsonProcessingException e) {
throw new RuntimeException("无法序列化", e);
}
}
/**
* 将字节数组反序列化为 Message 对象。
* @param bytes 要进行反序列化的字节数组
* @return 反序列化后的 Message 对象
*/
@Override
public Message deserialize(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return null;
}
try {
return messageDeserializer.deserialize(objectMapper.getFactory().createParser(bytes), objectMapper.getDeserializationContext());
} catch (Exception e) {
throw new RuntimeException("无法反序列化", e);
}
}
}
五、自定义 RedisChatMemory 实现
以下是基于 Redis 的 ChatMemory 实现类
@Component
public class RedisChatMemory implements ChatMemory {
private static final String REDIS_KEY_PREFIX = "chat:history:"; // Redis 键的前缀
@Autowired
private RedisTemplate<String, Message> redisTemplate;
/**
* 添加消息到 Redis
* @param conversationId 会话ID
* @param messages 消息列表
*/
@Override
public void add(String conversationId, List<Message> messages) {
String key = REDIS_KEY_PREFIX + conversationId; // 生成 Redis 键
redisTemplate.opsForList().rightPushAll(key, messages); // 添加消息到 Redis
redisTemplate.expire(key, Duration.ofDays(7)); // 设置过期时间 7 天
}
/**
* 获取 Redis 中的消息
* @param conversationId 会话ID
* @param lastN 最后N条消息
* @return 消息列表
*/
@Override
public List<Message> get(String conversationId, int lastN) {
String key = REDIS_KEY_PREFIX + conversationId;
// 从 Redis 获取最新的 lastN 条消息
List<Message> serializedMessages = redisTemplate.opsForList().range(key, -lastN, -1);
if (serializedMessages != null) {
return serializedMessages;
}
return List.of(); // 如果没有消息,则返回一个空列表
}
/**
* 清空 Redis 中的消息
* @param conversationId 会话ID
*/
@Override
public void clear(String conversationId) {
redisTemplate.delete(REDIS_KEY_PREFIX + conversationId); // 删除 Redis 中的消息
}
}
💬 思路解析:
用 Redis 的 List 结构来保存消息历史,能够保持顺序并高效追加。
每个会话的 Key 是
chat:history:{conversationId},作为唯一标识,便于区分不同用户或会话。消息按顺序压入列表(保证对话顺序不乱)。
想清空会话时,只要删掉这个 Key。
六、整合到 Spring AI ChatClient
Spring AI 的 ChatClient 支持通过 Advisor 集成记忆模块。我们将 RedisChatMemory 注入其中
@Configuration
public class CommonConfig {
/**
* 创建一个 ChatMemory 实例
* @return ChatMemory 实例
*/
@Bean
public ChatMemory chatMemory() {
return new RedisChatMemory();
}
/**
* 创建一个 ChatClient 实例
* @param chatClientBuilder ChatClient.Builder 实例
* @param chatMemory ChatMemory 实例
* @return ChatClient 实例
*/
@Bean
public ChatClient chatClient(ChatClient.Builder chatClientBuilder, ChatMemory chatMemory) {
return chatClientBuilder
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))// 注入上下文记忆
.build();
}
}
这样,每次调用大模型时,它都会自动带上 Redis 里保存的上下文,实现真正的多轮对话记忆。
七、Controller 层接口设计
设计一个简单的聊天接口,支持根据 conversationId 管理上下文
@RestController
@Tag(name = "对话(含提示词模版)", description = "通义千问")
public class SmartChatController {
@Autowired
private ChatClient chatClient;
/**
* 流式输出由模型生成的内容
* @param userInput 用户输入
* @param language 语言
* @param conversationId 会话ID
* @return 模型生成的内容
*/
@Operation(summary = "流式输出由模型生成的内容")
@GetMapping(value = "/smart-chat-stream", produces = "text/html;charset=utf-8")
public Flux<String> streamResponse(
@RequestParam String userInput,
@RequestParam(defaultValue = "en") String language,
@RequestParam String conversationId
) {
String systemPrompt = """
你是一家科技公司的专业客服代理。
请用{language}语言,以礼貌和简洁的语调回复。
当被询问时,请提供关于Spring AI的准确信息。
""";
String userPromptTemplate = """
用户问题: {userInput}
请清晰且专业地回答这个问题。
""";
PromptTemplate promptTemplate = new PromptTemplate(userPromptTemplate); // 创建 PromptTemplate 对象
Map<String, Object> promptParams = Map.of(
"userInput", userInput,
"language", language.equals("zh") ? "Chinese" : "English"
); // 创建参数, 用于替换 PromptTemplate 中的占位符
// 使用 stream() 返回 Flux,实时输出
return chatClient.prompt()
.system(systemPrompt.replace("{language}", promptParams.get("language").toString())) // 设置系统提示,使用参数替换占位符
.user(promptTemplate.create(promptParams).getContents()) // 设置用户提示,使用参数替换占位符
.advisors(advisorSpec -> advisorSpec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId).param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10)) // 限制上下文为最近 10 条消息
.stream()
.content();
}
}
这里使用了Prompt模版,有利于Ai扮演一个特定角色,提升回复专业性和一致性,以及输出质量和风格一致性。
八、测试效果验证
第一次请求

第二次请求(同 ID)


到这里,Redis + ChatMemory 的对话记忆功能完美实现!
九、总结
Spring AI 不是“又一个 SDK”,而是一套让 AI 能像 Spring Boot 一样优雅运行的框架。
它帮我把“AI 记忆”从概念变成了代码,也让我重新理解了企业级 AI 开发该有的样子。
“让 AI 开发融入 Spring 生态,而不是另起炉灶。”
如果你觉得这篇文章对你有帮助,别忘了点赞 👍 收藏 ⭐ 支持一下!你的支持就是我学习最大的动力
更多推荐
所有评论(0)