欢迎光临
我们一直在努力

Spring AI ChatMemory持久化Redis实战与踩坑

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以内,供各位参考。

    赞(0)
    未经允许不得转载:171主机测评 » Spring AI ChatMemory持久化Redis实战与踩坑
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址