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

对话记忆持久化:Redis 当冰箱,MySQL 当仓库

记录人:老码

一、开场:重启一次,模型就失忆

用内存保存对话记忆,开发阶段很爽:代码少,跑得快。

然后服务重启,用户回来接着聊,模型一脸茫然地重新问「请问您遇到什么问题」。用户以为你把他拉黑了。

内存记忆适合演示,不适合生产。要解决这个问题,得把记忆搬到外部存储里。

二、人话解释:记忆存哪里,取决于一个接口

Spring AI 把记忆抽象成了三个东西:

  • ChatMemory:最小接口,只有 addgetclear 三个方法;
  • ChatMemoryRepository + MessageWindowChatMemory:官方仓储抽象,自带窗口裁剪;
  • MessageChatMemoryAdvisor:挂在 ChatClient 上的执行者,负责请求前取历史、请求后存消息。

所以换存储不用改业务代码,只需要换 ChatMemory 的实现。业务层该传的还是那个 sessionId

这里有个坑要提前避开:ChatMemoryRepositorysaveAll 语义是覆盖整个会话 ,而记忆窗口只保留最近 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=2ttl=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.redisspring.redis 是 Boot 2 的写法,配置不报错但也不生效。
  • 别把 Redis 当唯一存储。 只用 Redis 模式(type=redis)时,Redis 挂了聊天直接失败;要稳就用 tiered。
  • 注意淘汰策略。 Redis 内存吃紧时如果配了 LRU,聊天记录会被当普通缓存淘汰。给会话 key 设 TTL 比等它被挤掉更可控。
  • 窗口大小是成本开关。 20 条约等于 10 轮对话,调大不仅费 token,还会让模型更容易被无关历史带偏。
  • 表会一直涨。 MySQL 存全量,只增不删早晚要归档。规划好保留期,或者定期把冷会话导出到对象存储。
  • 隐私要当回事。 聊天内容属于敏感数据,Redis 要设密码、限制访问来源,日志里别打印消息正文。

八、老码的总结

对话记忆持久化,说白了就是把「快」和「不丢」分开:Redis 负责快,MySQL 负责不丢,中间加一层回源逻辑兜底。

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

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

相关推荐
冰暮流星1 小时前
mysql多表练习2
数据库·sql·mysql
1570925113410 小时前
Android进阶之光:HTTP协议原理深度解析
android·网络协议·http
风哥2号10 小时前
数据库教程FGMT27‑MySQL数据库基础知识与体系架构
数据库·mysql
终端安全笔记10 小时前
安卓五品牌通道差在哪:监管通道拆解
android
v_348360876211 小时前
Android native端 堆栈打印
android·算法
一笑的小酒馆12 小时前
AndroidKMP之导航和WebView
android
夕除13 小时前
redis--015
redis
风哥2号14 小时前
数据库教程FGMT43‑MySQL性能分析与优化调整
数据库·mysql