对话记忆持久化:Redis 当冰箱,MySQL 当仓库
记录人:老码
一、开场:重启一次,模型就失忆
用内存保存对话记忆,开发阶段很爽:代码少,跑得快。
然后服务重启,用户回来接着聊,模型一脸茫然地重新问「请问您遇到什么问题」。用户以为你把他拉黑了。
内存记忆适合演示,不适合生产。要解决这个问题,得把记忆搬到外部存储里。
二、人话解释:记忆存哪里,取决于一个接口
Spring AI 把记忆抽象成了三个东西:
ChatMemory:最小接口,只有add、get、clear三个方法;ChatMemoryRepository+MessageWindowChatMemory:官方仓储抽象,自带窗口裁剪;MessageChatMemoryAdvisor:挂在 ChatClient 上的执行者,负责请求前取历史、请求后存消息。
所以换存储不用改业务代码,只需要换 ChatMemory 的实现。业务层该传的还是那个 sessionId。
这里有个坑要提前避开:ChatMemoryRepository 的 saveAll 语义是覆盖整个会话 ,而记忆窗口只保留最近 N 条。直接拿它落库,超过窗口的历史会被覆盖掉。想要全量历史,就自己实现 ChatMemory,把「存全量」和「喂窗口」分开。
三、生活类比:冰箱与仓库
Redis 是冰箱,拿东西快,但空间小、放不久;MySQL 是仓库,容量大、能长期放,但每次取货慢一点。
正常做法是:常用的放冰箱,仓库留全量。冰箱空了,去仓库补一次货。

四、方案怎么选
| 方案 | 持久化 | 多实例共享 | 读延迟 | 适合场景 |
|---|---|---|---|---|
| 内存 | 重启即丢 | 不共享 | 最低 | 本地开发 |
| 文件 | 单机持久 | 不共享 | 低 | 单机工具 |
| MySQL | 持久 | 共享 | 中 | 生产默认、要查询和审计 |
| Redis | 可持久化 | 共享 | 极低 | 高并发、多实例、短会话 |
| 对象存储 | 持久 | 共享 | 高 | 冷数据归档 |
官方目前提供 JDBC、Cassandra、Neo4j 的仓储 starter,没有 Redis 版 ,想用 Redis 得自己实现 ChatMemory。这也正好,Redis 方案本来就需要自定义读写策略。
五、落地:Redis 热 + MySQL 冷
5.1 配置
Spring Boot 3 的 Redis 配置前缀是 spring.data.redis,写老版本的 spring.redis 不会生效,只会安静地连不上。
yaml
spring:
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
timeout: 10s
love:
memory:
type: ${LOVE_MEMORY_TYPE:tiered} # mysql | redis | tiered
window-size: 20
redis-ttl-days: 7
window-size 决定喂给模型多少条历史,redis-ttl-days 决定热数据放多久。两个值都和成本直接相关。
5.2 写:先落库,再更新缓存
java
@Override
public void add(String conversationId, List<Message> messages) {
cold.add(conversationId, messages); // 事实源先落库
try {
hot.add(conversationId, messages); // 缓存失败不影响主流程
} catch (RuntimeException e) {
log.warn("Redis 写入对话记忆失败,已降级为仅 MySQL:{}", e.getMessage());
}
}
顺序不能反。先写 Redis 再写 MySQL,中间挂一次,这段对话就永久丢了。先落库,缓存最坏情况只是没更新,下一次读的时候会从 MySQL 回填。
Redis 里用 List 存窗口,写完裁剪、顺手续期:
java
redisTemplate.opsForList().rightPushAll(key, payloads);
redisTemplate.opsForList().trim(key, -windowSize, -1);
redisTemplate.expire(key, ttl);
5.3 读:命中即返回,未命中回源
java
@Override
public List<Message> get(String conversationId) {
try {
List<Message> cached = hot.get(conversationId);
if (!cached.isEmpty()) {
return cached;
}
} catch (RuntimeException e) {
log.warn("Redis 读取对话记忆失败,回源 MySQL:{}", e.getMessage());
}
List<Message> messages = cold.get(conversationId);
if (!messages.isEmpty()) {
try {
hot.add(conversationId, messages); // 回填,下次就快了
} catch (RuntimeException e) {
log.warn("Redis 回填对话记忆失败:{}", e.getMessage());
}
}
return messages;
}
这段代码的价值在于:Redis 挂了,聊天照样能用,只是慢一点。缓存故障不该升级成业务故障。
5.4 一个 bean 切换三种策略
把选择权放到配置里,换方案不用改代码:
java
@Bean
public ChatMemory chatMemory(LoveMessageMapper messageMapper,
StringRedisTemplate redisTemplate,
ObjectMapper objectMapper,
LoveMemoryProperties properties) {
MySQLChatMemory cold = new MySQLChatMemory(messageMapper, properties.getWindowSize());
RedisChatMemory hot = new RedisChatMemory(
redisTemplate, objectMapper,
properties.getWindowSize(),
Duration.ofDays(properties.getRedisTtlDays()));
return switch (properties.getType()) {
case "mysql" -> cold;
case "redis" -> hot;
default -> new TieredChatMemory(hot, cold);
};
}
六、实测结果
Redis 和 MySQL 都在本机,跑了一遍完整流程:
- 第一轮对话后,Redis 里该会话
llen=2,ttl=604800秒(7 天),MySQL 历史 2 条; - 手动删掉 Redis key,
llen=0,模拟缓存失效; - 第二轮对话,回复明确承接了第一轮内容,说明成功回源 MySQL;
- 对话结束后 Redis 重新回填为
llen=4,TTL 续到 7 天,MySQL 历史 4 条。
验证命令就三条,自己复现也简单:
bash
redis-cli keys "love:chat:memory:*"
redis-cli llen love:chat:memory:<sessionId>
redis-cli ttl love:chat:memory:<sessionId>
七、坑点提醒
- 前缀写错。 Spring Boot 3 用
spring.data.redis,spring.redis是 Boot 2 的写法,配置不报错但也不生效。 - 别把 Redis 当唯一存储。 只用 Redis 模式(
type=redis)时,Redis 挂了聊天直接失败;要稳就用 tiered。 - 注意淘汰策略。 Redis 内存吃紧时如果配了 LRU,聊天记录会被当普通缓存淘汰。给会话 key 设 TTL 比等它被挤掉更可控。
- 窗口大小是成本开关。 20 条约等于 10 轮对话,调大不仅费 token,还会让模型更容易被无关历史带偏。
- 表会一直涨。 MySQL 存全量,只增不删早晚要归档。规划好保留期,或者定期把冷会话导出到对象存储。
- 隐私要当回事。 聊天内容属于敏感数据,Redis 要设密码、限制访问来源,日志里别打印消息正文。
八、老码的总结
对话记忆持久化,说白了就是把「快」和「不丢」分开:Redis 负责快,MySQL 负责不丢,中间加一层回源逻辑兜底。
三个动作记牢:写的时候先落库,读的时候先查缓存,缓存坏了要能降级。做到这三点,重启不再失忆,缓存挂了也不慌。
先看日志,别慌,问题不大。