对话记忆持久化-Redis热与MySQL冷

对话记忆持久化: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 都在本机,跑了一遍完整流程:

  1. 第一轮对话后,Redis 里该会话 llen=2,ttl=604800 秒(7 天),MySQL 历史 2 条;
  2. 手动删掉 Redis key,llen=0,模拟缓存失效;
  3. 第二轮对话,回复明确承接了第一轮内容,说明成功回源 MySQL;
  4. 对话结束后 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 负责不丢,中间加一层回源逻辑兜底。

三个动作记牢:写的时候先落库,读的时候先查缓存,缓存坏了要能降级。做到这三点,重启不再失忆,缓存挂了也不慌。

先看日志,别慌,问题不大。

相关推荐
晚安日记wanna1 小时前
MySQL 回表为什么这么慢?5 种优化手段逐个拆解
数据库·mysql·性能优化
晚安日记wanna1 小时前
索引建了却不走?7 个失效原因逐层排查
数据库·mysql·性能优化
传奇开心果编程1 小时前
【Compose Multiplatform 跨端开发学与练】第7课 平台适配与互操作
android·windows·学习·ui·ios·kotlin·composer
mmsx1 小时前
Android 地图数据链路:从 GDAL、KML 到多引擎适配
android·kotlin
2601_968900771 小时前
大模型版本回归评估实战:从能力指标到额度口径的完整链路
android·数据挖掘·回归
ShineWinsu3 小时前
对于Redis:主从复制的解析
linux·数据库·c++·redis·缓存·面试·主从复制
ao-weilai4 小时前
MySQL数据库:基本查询
android·数据库·mysql
java1234_小锋4 小时前
【技术专题】Mysql8 数据库 - Mysql8 查询数据
数据库·mysql
事圆则缓4 小时前
Kotlin 泛型方差实战:out、in、星投影与类型擦除
android·开发语言·kotlin
ShineWinsu6 小时前
对于Redis:事务的解析
数据库·redis·mysql·缓存·面试·事务·acid