Spring AI ChatMemory持久化到Redis:从踩坑到生产级落地
一、问题背景
在构建企业级AI Agent应用时,多轮对话的记忆管理是绕不开的核心问题。Spring AI框架提供了ChatMemory抽象接口,官方默认实现InMemoryChatMemory仅将对话存储在JVM内存中,这在生产环境中存在致命缺陷:
- 服务重启即丢失:所有用户的对话上下文归零
- 无法水平扩展:多实例部署时,用户请求被负载均衡到不同节点,记忆不共享
- 内存泄漏风险:无过期机制,长期运行后堆内存持续膨胀
本文记录我们在生产项目中将ChatMemory迁移到Redis的完整过程,包括踩过的坑和最终的最佳实践方案。
二、环境准备
2.1 依赖版本
<dependencies>
<!– Spring AI –>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
<!– Spring Data Redis –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!– Jackson for serialization –>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
2.2 Redis配置
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: your_password
database: 0
timeout: 5000ms
lettuce:
pool:
max-active: 50
max-idle: 20
min-idle: 5
三、核心实现
3.1 自定义RedisChatMemory
Spring AI的ChatMemory接口定义了三个核心方法:
public interface ChatMemory {
void add(String conversationId, List<Message> messages);
List<Message> get(String conversationId, int lastN);
void clear(String conversationId);
}
我们的Redis实现如下:
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Component
public class RedisChatMemory implements ChatMemory {
private static final String KEY_PREFIX = "chat:memory:";
private static final int DEFAULT_EXPIRE_HOURS = 24;
private static final int MAX_MESSAGES_PER_CONVERSATION = 50;
private final RedisTemplate<String, Object> redisTemplate;
public RedisChatMemory(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void add(String conversationId, List<Message> messages) {
String key = buildKey(conversationId);
// 使用RPUSH追加消息,保持顺序
for (Message message : messages) {
redisTemplate.opsForList().rightPush(key, message);
}
// 裁剪超出上限的历史消息(保留最新的MAX_MESSAGES条)
Long size = redisTemplate.opsForList().size(key);
if (size != null && size > MAX_MESSAGES_PER_CONVERSATION) {
redisTemplate.opsForList().trim(key,
size – MAX_MESSAGES_PER_CONVERSATION, -1);
}
// 刷新过期时间
redisTemplate.expire(key, DEFAULT_EXPIRE_HOURS, TimeUnit.HOURS);
}
@Override
public List<Message> get(String conversationId, int lastN) {
String key = buildKey(conversationId);
// LRANGE获取最近lastN条消息
List<Object> rawMessages = redisTemplate.opsForList()
.range(key, -lastN, -1);
if (rawMessages == null || rawMessages.isEmpty()) {
return new ArrayList<>();
}
List<Message> messages = new ArrayList<>(rawMessages.size());
for (Object obj : rawMessages) {
if (obj instanceof Message msg) {
messages.add(msg);
}
}
return messages;
}
@Override
public void clear(String conversationId) {
String key = buildKey(conversationId);
redisTemplate.delete(key);
}
private String buildKey(String conversationId) {
return KEY_PREFIX + conversationId;
}
}
3.2 RedisTemplate序列化配置(关键踩坑点)
这是第一个大坑。默认的RedisTemplate使用JDK序列化,存储的数据是不可读的二进制,且Message子类(如UserMessage、AssistantMessage)没有实现Serializable接口,直接抛异常。
必须配置JSON序列化:
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> chatMemoryRedisTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// Key使用String序列化
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// Value使用JSON序列化 —— 这里需要特殊处理多态类型
ObjectMapper objectMapper = new ObjectMapper();
// 启用默认类型信息,解决Message反序列化时不知道具体子类的问题
objectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
⚠️ 踩坑警告:如果不配置activateDefaultTyping,反序列化时会报Cannot construct instance of org.springframework.ai.chat.messages.Message错误,因为Message是接口,Jackson不知道还原成哪个具体实现类。开启默认类型后,JSON中会嵌入@class字段标识具体类型。
3.3 注入到ChatClient
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory; // 对比用
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AiAgentConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder,
RedisChatMemory redisChatMemory) {
return builder
.defaultSystem("你是一个专业的技术支持助手")
.build();
}
// 注意:Spring AI 1.0.0-M6中,ChatMemory需要通过Advisor机制注入
@Bean
public MessageChatMemoryAdvisor messageChatMemoryAdvisor(
RedisChatMemory redisChatMemory) {
return new MessageChatMemoryAdvisor(redisChatMemory);
}
}
实际调用时使用conversationId区分不同会话:
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@PostMapping
public Map<String, String> chat(@RequestBody ChatRequest request) {
String response = chatClient.prompt()
.user(request.getMessage())
.advisors(advisor -> advisor
.param(ChatMemory.CONVERSATION_ID, request.getConversationId()))
.call()
.content();
return Map.of("reply", response);
}
}
四、生产级增强
4.1 滑动窗口 + Token预算控制
简单的"保留最近N条消息"不够精细,长对话可能超出模型context window。我们实现了基于Token估算的截断策略:
import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator;
import org.springframework.ai.tokenizer.TokenCountEstimator;
@Override
public List<Message> get(String conversationId, int lastN) {
String key = buildKey(conversationId);
List<Object> allRaw = redisTemplate.opsForList().range(key, 0, -1);
if (allRaw == null || allRaw.isEmpty()) {
return new ArrayList<>();
}
// 从最新消息往前累加,直到超过Token预算
int tokenBudget = 3000; // 根据模型调整
int usedTokens = 0;
List<Message> result = new ArrayList<>();
TokenCountEstimator estimator = new JTokkitTokenCountEstimator();
for (int i = allRaw.size() – 1; i >= 0; i–) {
if (!(allRaw.get(i) instanceof Message msg)) continue;
int msgTokens = estimator.estimate(msg.getText());
if (usedTokens + msgTokens > tokenBudget) {
break; // 超出预算,停止添加更早的消息
}
usedTokens += msgTokens;
result.add(0, msg); // 保持正序
if (result.size() >= lastN) break;
}
return result;
}
4.2 分布式锁防止并发写入冲突
同一conversationId下多个请求并发写入Redis List可能导致消息顺序错乱:
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
@Override
public void add(String conversationId, List<Message> messages) {
String lockKey = "lock:chat:memory:" + conversationId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 等待3秒,持有锁5秒自动释放
if (!lock.tryLock(3, 5, TimeUnit.SECONDS)) {
throw new RuntimeException("获取对话记忆锁超时");
}
doAdd(conversationId, messages);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("写入对话记忆被中断", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
4.3 监控指标埋点
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
private final Counter memoryHitCounter;
private final Counter memoryMissCounter;
private final Timer memoryReadTimer;
public RedisChatMemory(RedisTemplate<String, Object> redisTemplate,
MeterRegistry meterRegistry) {
this.redisTemplate = redisTemplate;
this.memoryHitCounter = Counter.builder("chat.memory.hit")
.description("对话记忆命中次数")
.register(meterRegistry);
this.memoryMissCounter = Counter.builder("chat.memory.miss")
.description("对话记忆未命中次数")
.register(meterRegistry);
this.memoryReadTimer = Timer.builder("chat.memory.read.duration")
.description("对话记忆读取耗时")
.register(meterRegistry);
}
五、常见报错排查清单
| SerializationException: cannot serialize | Message未实现Serializable / JDK序列化 | 切换为GenericJackson2JsonRedisSerializer |
| Cannot construct instance of Message | Jackson多态类型信息缺失 | 配置activateDefaultTyping |
| WRONGTYPE Operation against a key holding the wrong kind of value | 同一个key之前存了String类型数据 | 清理旧key或更换key前缀 |
| Connection refused | Redis连接池耗尽 | 调大lettuce.pool.max-active,检查是否有连接泄漏 |
| 对话顺序错乱 | 并发写入List未加锁 | 引入Redisson分布式锁 |
六、总结与思考
不要直接用InMemoryChatMemory上生产,哪怕是小流量项目,一次Full GC或Pod重启就会让所有用户的对话上下文消失,体验灾难级。
序列化方案选择:相比Kryo、Protobuf等二进制方案,JSON虽然体积更大但调试友好,Redis Desktop Manager可以直接查看内容,对于对话记忆这种低频读写场景完全够用。
Token预算比消息条数更重要:一条包含大段代码的AssistantMessage可能消耗2000+token,而十条短寒暄才几百token。纯按条数截断在生产中几乎一定会超context。
Spring AI版本迭代快:1.0.0-M系列API变动频繁,ChatMemory的注入方式从直接传参演进到Advisor模式,升级时务必阅读Release Notes。
这套方案在我们日均10万+轮对话的生产环境中稳定运行半年,P99延迟控制在15ms以内,供各位参考。


