Spring AI上下文记忆持久化:基于Redis的完整实现与避坑指南
做过去两三年聊天类应用的人大概都经历过这个尴尬瞬间用户和 AI 聊得正起劲刷新一下页面对话记录还在但 AI 已经把上一分钟说的话全忘了重新从“你好我是智能助手”开始。如果你用的是 Spring AI这个问题基本都出在同一个地方上下文记忆没有持久化。Spring AI 默认的记忆实现是放在 JVM 内存里的进程一重启或者会话一过期所有对话上下文全部归零。这在本地写 demo 无所谓可一旦上了生产尤其是 Spring Boot 餐饮 SaaS 这种需要按商户、按用户维度隔离会话的场景就是事故。这篇内容没有那些“官方文档式”的废话我按自己实际工程里的做法把 Spring AI 的上下文记忆持久化完整讲清楚先用什么方案、为什么用 Redis、代码怎么写、上线之后会踩哪些坑。适合正在集成 Spring AI 的后端开发也适合准备给现有 SaaS 系统加 AI 助手的团队参考。你不一定照抄我的代码但理解了背后的选型逻辑和坑换成 MySQL、PostgreSQL 或者其他存储都很容易。1. 先想清楚为什么上下文记忆必须持久化1.1 内存态对话上下文的三个局限Spring AI 和普通 HTTP 接口最大的区别在于AI 本身是“无状态”的。你发一句“帮我推荐三道菜”模型只会把它当成一个独立的问题如果下一句是“那再来三杯对应的饮品”模型根本不知道“对应”对应的是哪三道菜。所以 Spring AI 才会提供上下文记忆组件把历史消息重新塞给模型让对话显得像是有记忆的。但默认情况下这个记忆是存在内存里的。内存态方案有非常明显的三个局限第一进程重启即丢失。哪怕只是 Spring Boot 应用发布一个新版本滚动重启一个实例所有正在对话的用户都会发现 AI“失忆”。在餐饮 SaaS 场景里商户可能正在和 AI 助手沟通菜单配置突然断掉重来体验非常糟糕。第二横向扩容后无法共享。生产环境不可能只跑一个实例。当你部署两个节点用户第一次请求落在节点 A上下文存在节点 A 的内存里第二次请求负载均衡到了节点 B节点 B 找不到这段记忆AI 还是不认识用户。这个问题不解决一切多实例部署都是白搭。第三多租户隔离很难做。SaaS 系统里同时有几百个商户在用同一个 AI 服务A 商户的对话不能在 B 商户的会话里出现。内存里的 Map 虽然可以按 conversationId 做隔离但缺少数据维度的管理能力想按某个商家查询历史记录、做数据分析、清理过期会话都非常别扭。所以说把上下文从“内存态”变成“持久化”不是炫技而是从 demo 走向生产的必经一步。1.2 一个餐饮 SaaS 场景逼出来的需求我接手过一个餐饮 SaaS 项目里面要接入一个 AI 点餐助手。商户可以在后台配置自己的菜单、折扣、营业时间然后顾客在点餐页面和 AI 对话AI 会根据商户配置推荐菜品。一开始只做了“一次性问答”结果根本不可用。顾客问“今天有什么优惠”AI 回答之后顾客说“那帮我点这个套餐”AI 完全不知道“这个套餐”是什么。于是我们加上了 Spring AI 的上下文记忆但只存在内存里。测试阶段只有两三个人用看起来没问题上线第一天几十个商户同时使用应用一扩容就立刻暴露了会话错乱。后来我们重新设计把每个会话的上下文按“商户 ID 用户 ID”组成一个 conversationId统一落到 Redis。这样同一个顾客在同一个商户里的历史消息可以跨实例读取顾客明天再来也能接着聊商户后台还能看到完整对话记录用于复盘。这个方案才是真正贴合业务形态的上下文记忆持久化。1.3 持久化不只是“续聊”还有审计和运营价值很多人以为持久化是为了“用户下次还能接着聊”其实更重要的是数据本身的价值。传统 web 应用会把用户操作记录到数据库做审计AI 应用同样需要保留对话历史出了问题要能回溯运营要做分析商户要能看服务记录。如果上下文只存在内存里这些全都做不到。所以我的观点很直接凡是关键业务场景对话上下文都应该持久化只有纯体验型、无合规要求的场景才考虑允许临时丢记忆。2. 选型前的必读Spring AI 记忆抽象到底是什么2.1 ChatMemory 与 MessageChatMemoryAdvisor 的配合关系在写代码之前必须先把 Spring AI 里两个容易混淆的概念搞清楚一个是ChatMemory一个是MessageChatMemoryAdvisor。ChatMemory是记忆的存储层负责把消息按会话 ID 存起来、取出来、删掉。官方默认给的是内存实现适合演示不适合生产。MessageChatMemoryAdvisor是 Spring AI 里一种“顾问”机制在真正调用大模型前后插入额外逻辑。它负责从ChatMemory里取出历史消息组装到 prompt 里同时把本轮新消息写回记忆。你可以把它理解成一个站在 ChatClient 和大模型之间的“信使”不断把上下文搬运过去。实际开发里90% 的情况你只需要自定义ChatMemory的持久化实现MessageChatMemoryAdvisor用官方提供的即可。我不建议自己手写一套 prompt 拼接逻辑因为那要考虑消息截断、格式、系统提示词位置的问题很容易写坏。2.2 内存、Redis、关系型数据库三种落点选型做持久化第一步是选存储介质。我见过很多团队一上来就直接上 MySQL其实不一定划算。给你一个非常实际的对比内存实现。优点读最快代码最少适合本地测试和单元测试。缺点重启丢失、无法多实例共享、无数据模型。直接用默认InMemoryChatMemory就可以。Redis。优点读写快天然支持 TTL过期清理一条命令解决Redis 数据结构能比较优雅地表达“一个会话下多条消息”的关系多实例共享没问题。缺点需要额外部署 Redis如果 Redis 本身持久化配置不当重启仍可能丢数据。这是我们最终的方案。关系型数据库MySQL、PostgreSQL。优点数据绝对可靠支持 SQL 查询分析适合做对话回放、运营报表、合规审计。缺点高并发访问下频繁读写聊天记录会给数据库带来不必要的压力每次模型调用都要阻塞式读写历史RT 会升高。还有个容易忽略的选型变量你的应用之前有没有 Redis。餐饮 SaaS 系统基本都把 Redis 当作基础组件一定有现成的那就没必要为了聊天记录单独引入一套 MySQL 表结构。反过来如果你的业务强依赖 MySQL、并且对话量不大直接用数据库也没什么问题。所以我的结论是中小规模生产项目优先选 Redis对话量极大并且有分析诉求再考虑队列落库到数据库。Redis 负责“当前会话快读”数据库负责“历史归档”这是后期演进比较舒服的架构。2.3 关于 Spring AI 还是 LangGraph4j 的题外话最近经常有人问做 Agent 到底是选 Spring AI 还是 LangGraph4j。我的看法是如果你的系统本来就在 Java 生态、Spring Boot 里上下文记忆持久化这种需求Spring AI 是更自然的选择它和 Spring Data Redis、Spring 事务、配置中心的融合成本很低。LangGraph4j 的图编排思想在处理复杂分支、多 Agent 协作时更灵活但它算另一套心智模型团队需要重新适应。做记忆持久化这件事Spring AI 一点不虚。真正复杂的部分并非“调用大模型”而是把状态、会话、上下文这些系统设计问题处理好。2.4 数据模型设计要点不管选什么存储会话数据模型至少要包含三个要素会话 ID、消息角色、消息内容。如果以后要做分析最好还带上时间戳和业务标签。在 Redis 里我用的结构是一个有序集合ZSetkey 是会话 ID 对应的字符串member 是序列化后的消息 JSONscore 是消息时间戳。这么做有三个好处天然按时间排序不担心同一毫秒内的并发覆盖后续可以按时间范围截取某一段消息。比用 List 更稳也比 Hash 更适合顺序读。key 的命名要考虑多租户。比如ai:memory:tenant10001:user888这样既不会和其他业务 key 冲突又能按前缀批量清理某个租户的数据。千万别用什么conversation:1这种裸 key线上环境很快会乱成一锅粥。3. 实操基于 Redis 实现上下文记忆持久化3.1 准备工程与依赖先说正确的项目姿势建议从Spring Initializr生成一个 Spring Boot 3.x 工程依赖加上 Spring Web、Spring Data Redis、Spring AI。如果你用的是 Maven核心依赖大致是dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-spring-boot-starter/artifactId version1.0.0/version /dependency我这里以 Spring AI 1.0.x 的包结构为例。Spring AI 在 2024 到 2025 年间版本迭代很快早期里程碑版本里ChatMemory的接口位置和方法签名都有细微差异。你要是用 0.8 或者 1.0.0-M 系列以你本地 jar 包里的接口代码为准思路完全一样。Redis 连接配置不用多说spring: data: redis: host: 127.0.0.1 port: 6379 password: timeout: 3s这里注意一点spring-boot-starter-data-redis在 Spring Boot 3.x 下默认的客户端是 Lettuce配置项的 key 是spring.data.redis.*不是老的spring.redis.*。很多同学照抄网上旧教程配置一直没生效其实就错在这。3.2 自定义 RedisChatMemory 的完整实现我先直接给一个可运行的RedisChatMemory实现再拆解关键点。它实现了 Spring AI 的ChatMemory接口内部用StringRedisTemplate操作 Redis 的有序集合。Component public class RedisChatMemory implements ChatMemory { private static final String MEMORY_KEY_PREFIX ai:memory:; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; public RedisChatMemory(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { this.redisTemplate redisTemplate; this.objectMapper objectMapper; } Override public void add(String conversationId, ListMessage messages) { String key MEMORY_KEY_PREFIX conversationId; for (Message message : messages) { try { MapString, Object map new HashMap(); if (message instanceof UserMessage userMessage) { map.put(messageType, user); map.put(text, userMessage.getText()); } else if (message instanceof AssistantMessage assistantMessage) { map.put(messageType, assistant); map.put(text, assistantMessage.getText()); } else { throw new IllegalArgumentException(不支持的消息类型: message.getClass()); } map.put(timestamp, System.currentTimeMillis()); String json objectMapper.writeValueAsString(map); redisTemplate.opsForZSet().add(key, json, System.currentTimeMillis()); } catch (JsonProcessingException e) { throw new RuntimeException(消息序列化失败, e); } } } Override public ListMessage get(String conversationId) { String key MEMORY_KEY_PREFIX conversationId; SetString jsons redisTemplate.opsForZSet().range(key, 0, -1); if (jsons null || jsons.isEmpty()) { return List.of(); } ListMessage messages new ArrayList(); for (String json : jsons) { try { JsonNode node objectMapper.readTree(json); String messageType node.get(messageType).asText(); if (user.equals(messageType)) { messages.add(new UserMessage(node.get(text).asText())); } else if (assistant.equals(messageType)) { messages.add(new AssistantMessage(node.get(text).asText())); } } catch (JsonProcessingException e) { throw new RuntimeException(消息反序列化失败, e); } } return messages; } Override public void clear(String conversationId) { redisTemplate.delete(MEMORY_KEY_PREFIX conversationId); } Override public void delete(String conversationId) { redisTemplate.delete(MEMORY_KEY_PREFIX conversationId); } Override public SetString listConversationIds() { SetString keys redisTemplate.keys(MEMORY_KEY_PREFIX *); if (keys null || keys.isEmpty()) { return Set.of(); } return keys.stream() .map(key - key.substring(MEMORY_KEY_PREFIX.length())) .collect(Collectors.toSet()); } }这段代码是我刻意简化的版本可以把最常见的用户和助手消息落盘。先说几个容易被忽略的设计点为什么用StringRedisTemplate而不是RedisTemplateString, Object因为后者默认的 JDK 序列化器会把可读性搞得很差存入 Redis 的是一堆\xAC\xED...二进制排查问题的时候什么都看不出来。用StringRedisTemplate存 JSON至少可以redis-cli直接查看会话内容线上救急时能少掉不少头发。为什么用 ZSet 而不是 ListList 的leftPush也能做到顺序保存但 ZSet 的 score 用的是时间戳未来如果只想读取最近 10 分钟的消息一条rangeByScore就搞定了。List 想做时间范围截取就非常别扭。多实例并发写的时候ZSet 按 score 排序也不会乱。为什么我只处理了UserMessage和AssistantMessage因为MessageChatMemoryAdvisor实际上只会把对话往返产生的这两类消息交给ChatMemory。那些系统提示词、工具调用结果大多不会进来。这个假设在日常对话场景是成立的。如果你的业务里出现了SystemMessage或者带工具消息的复杂 Agent 场景就要改成基于 Spring AI 自带的类型转换器去做更完整的序列化。3.3 把 RedisChatMemory 注册为 Spring Bean有了实现类之后Spring Boot 会自动扫描到它。但为了显式告诉 Spring AI 用这个实现替换默认的内存实现建议单独建一个配置类Configuration public class ChatMemoryConfig { Bean public ChatMemory chatMemory(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { return new RedisChatMemory(redisTemplate, objectMapper); } Bean public MessageChatMemoryAdvisor messageChatMemoryAdvisor(ChatMemory chatMemory) { return new MessageChatMemoryAdvisor(chatMemory); } }这里有一个很多人会踩的坑如果你自定义了ChatMemorybean就不要再依赖 Spring AI 的自动配置给你注入内存版ChatMemory否则会出现“你自定义了 Redis 存储但 advisor 还在用内存存储”的情况。你可以在启动日志里确认自定义实现会被自动装配。MessageChatMemoryAdvisor为什么要单独注册因为它本身就是一种 AdvisorSpring AI 的ChatClient会自动把它应用到每次调用中。单独注册成 bean 之后后面所有用ChatClient.Builder创建的客户端都会自动带上记忆能力不用每个方法都手动加一遍。3.4 接入 MessageChatMemoryAdvisor 并按会话维度生效注册好 Advisor 之后创建ChatClientConfiguration public class ChatClientConfig { Bean public ChatClient chatClient(ChatClient.Builder builder, MessageChatMemoryAdvisor messageChatMemoryAdvisor) { return builder .defaultAdvisors(messageChatMemoryAdvisor) .build(); } }然后写一个面向业务的服务Service public class AiAssistantService { private final ChatClient chatClient; public AiAssistantService(ChatClient chatClient) { this.chatClient chatClient; } public String chat(String tenantId, String userId, String userMessage) { String conversationId tenantId : userId; return chatClient.prompt() .user(userMessage) .advisors(a - a.param( MessageChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID, conversationId )) .call() .content(); } }核心就是conversationId。在餐饮 SaaS 场景里一个商户底下可能有多个顾客同一个顾客又可能在不同商户里点餐所以用tenantId : userId能保证不同租户、不同用户之间的上下文完全隔离。如果你希望一个用户在一个商户里可以有多个独立会话那就在外面多加一个 sessionId拼成tenantId:userId:sessionId。这个 ID 设计直接决定你后台能不能精准找到某一段对话建议业务设计阶段就定好。CHAT_MEMORY_CONVERSATION_ID是 Spring AI 官方预留的参数名建议直接用常量而不是手写字符串。你在写.advisors(...)的时候IDE 会对参数名做检查直接把常量传进去最安全也不容易拼错。3.5 验证是否真的持久化写完上面的代码不要急着接大模型联调。先做一个可以“骗过自己”的最小验证启动一个内嵌模式或者本地 standalone 的 Redis执行redis-cli。调用一次接口发一句“我的餐厅叫老王私房菜主打川菜”。登陆 Redis执行KEYS ai:memory:*你应该能看到一个以ai:memory:开头的 key。再调用一次接口发一句“根据我餐厅的信息推荐三道招牌菜”看 AI 回答里是否包含了“老王私房菜”和“川菜”这些信息。关掉 Spring Boot 应用重新启动不走任何缓存预热再调用接口问“我们餐厅主打什么菜系”如果 AI 还能回答出“川菜”说明持久化生效了。我第一次做验证的时候只测到第 4 步就以为成功了结果漏了第 5 步。因为那次 Redis 没有开启 AOF 持久化Redis 进程重启后数据也丢了上下文照样断层。后来才意识到Redis 本身的持久化配置同样关键具体我放在第 4 部分讲。4. 常见问题与排查技巧实录4.1 记忆不生效先检查 Advisor 是否真的进入调用链我见过不少同事写完自定义ChatMemory后发现对话上下文还是记不住。排查下来大多是因为ChatClient创建的时候没有把MessageChatMemoryAdvisor加进去。Spring AI 的ChatClient.Builder不会自动应用项目里所有 Advisor bean只有通过defaultAdvisors显式指定或者你在构建时.advisors()手动指定记忆逻辑才会执行。所以一旦发现 memory 不生效第一件事不是去翻 Redis而是看你注入的ChatClient到底有没有带 advisor。日志是最快的证据。在RedisChatMemory.add方法里临时加一条log.info(save message to redis, conversationId{}, count{}, conversationId, messages.size());如果调用接口后一条日志都没有说明你的ChatClient压根没用这个内存。4.2 Redis 里数据还在但上下文断层了这类问题通常出在序列化和反序列化的类型上。Spring AI 1.0 的UserMessage、AssistantMessage内部可能带有media、metadata、toolCalls等扩展字段。我那个简化版只保留了text如果模型侧给出的消息类型和代码里判断的类型不一致反序列化就会失败。一个典型表现是Redis 里能看到历史消息但应用日志里出现IllegalArgumentException: 不支持的消息类型或者ClassCastException。避免这个问题的最佳方式不是手工拼 JSON而是让 ObjectMapper 带类型信息。如果你做的是生产级项目建议给 ObjectMapper 配置多态类型支持或者在存储层引入 Spring AI 提供的消息转换工具。网上的简化方案可以跑 demo但别直接上生产。另外要注意AssistantMessage在新版本里已经是不可变对象构造方式可能是new AssistantMessage(text)也可能存在带 metadata 的重载。拿不准的时候直接看 jar 包源码别靠猜。4.3 消息窗口无限膨胀的隐患很多新手以为记忆越多越好结果 Redis 里一个会话的 ZSet 成员数越来越大prompt 也越来越长。历史消息全部塞给大模型有两个问题一是 token 费用飙升二是超过模型上下文窗口后直接报错。MessageChatMemoryAdvisor其实有窗口大小参数一般参数名是CHAT_MEMORY_RETRIEVE_SIZE控制每次取多少条历史消息。如果你发现上下文老是被截断或者反过来过大优先调这个参数而不是在get方法里手动截断。我自己的经验是普通客服对话保留最近 10 到 20 条就够了复杂任务型对话开 20 到 40 条再长就要结合摘要记忆做分层了不能让原始消息无限累积。短期看 Redis 无所谓长期看成本和效果都吃不消。4.4 Redis 自身也会丢数据别忽略 AOF 和 RDB 配置前面提到过我自己的项目里就栽过一次Redis 节点直接在深夜被云厂商重启结果第二天一早商户发现 AI 又不认识他们了。原因是 Redis 默认持久化策略在很多人部署环境里是关闭的或者只开着 RDB 快照会丢失最近几分钟甚至更久的数据。聊天上下文这种高价值数据配置方式比较稳妥的是开启 AOF 持久化appendonly yes appendfsync everyseceverysec表示每秒刷盘一次最多丢一秒数据性能和可靠性平衡得好。如果你要求更高可以用always但写入吞吐会明显下降。上下文记忆这种场景everysec足够了。这里有个容易混淆的点我们做的“上下文记忆持久化”和“Redis 持久化”是两回事。前者是把对话消息从 JVM 内存搬到 Redis后者是防止 Redis 本身重启丢数据。两层都要做只做一层都不能叫真正意义上的持久化。4.5 排查问题速查表症状可能原因解决动作对话完全无上下文ChatClient 没加载 Advisor给 ChatClient 增加defaultAdvisors(messageChatMemoryAdvisor)换了实例后上下文断存储不共享确认用的 Redis且 key 一致Redis 有数据但报反序列化错误消息类型序列化不完整改用带类型信息的序列化方案上下文总是被截断消息窗口太小调大CHAT_MEMORY_RETRIEVE_SIZE上下文常被错误历史污染conversationId 冲突检查tenantId:userId拼接规则重启 Redis 后全没了Redis 持久化未配置开启appendonly yes4.6 上线前的自检清单最后给一张上线前可以照着勾的清单都是我踩过的坑累积换来的第一确认应用的多个实例连接的是同一个 Redis 实例或集群不是说都配了同一个spring.data.redis.host就行还要看有没有逻辑库不一致的问题。两个实例一个连 db0一个连 db1照样读不到彼此的数据。第二给AI:memory:*前缀的 key 设置合理的 TTL 策略。如果业务需要长期保留可以不设置 TTL如果只是想撑住短期会话可以在写入消息时设置过期时间。我建议至少保留 7 天方便出问题回溯。第三对listConversationIds方法里的KEYS命令要保持警惕。KEYS在高并发大 key 数量下会阻塞 Redis 单线程生产环境更推荐用SCAN代替。我示例里写keys是为了简洁真实环境强烈不建议直接照抄。5. 从记忆持久化出发还能往哪个方向扩5.1 分清“记忆”和“知识”给 RAG 留个位置上下文记忆持久化解决的是“记住用户聊了什么”而 RAG 解决的是“让模型知道业务知识”。两者经常被混在一起其实是两套东西。比如餐饮 SaaS 系统里商户的菜单、折扣、营业时间这些相对静态的数据更适合走 RAG让模型从向量库里检索而顾客这次点餐时说过“不要香菜”这是动态上下文应该走记忆。把这两个搞混了你的记忆层会塞进一堆不需要持久化的资料RAG 又要承担不该它负责的临时语境两头都会很别扭。我在项目里的做法是基础菜单和门店资料走向量检索顾客短期偏好走 Redis 上下文记忆长期的历史行为记录异步落库后续做个性化推荐。分层的职责边界越清晰越不容易出问题。5.2 从单轮记忆走向多轮会话管理现在这个方案已经能支持一个会话内连续对话以及跨实例、跨重启的“续聊”。但要是产品经理跟你说希望记录用户过去 30 天的口味偏好然后在一个新会话里也能用上那就不只是保存原始消息了。你需要对历史消息做摘要做用户画像提取生成一个“长期记忆”存在另一个 Redis key 或者数据库里。这种做法被称为“摘要记忆”或者“长期记忆层”。它的实现方式通常是在每次对话结束之后把本次对话的关键事实抽出来比如“用户不吃辣”“用户偏好靠窗位置”再合并进该用户的长青记忆里。短期记忆继续用原始消息长期记忆用结构化摘要两者配合才会既保证准确性又不撑爆 token 上限。5.3 多租户维度下的可观测性一旦上下文持久化做到生产级别就一定需要配套的可观测性。建议在上层加一个简单的会话追踪服务每次 ChatClient 调用时把 conversationId、消息条数、token 消耗、响应耗时统一打点。不要等到线上出问题才手动连 Redis 翻 key。这些指标不仅用于排查问题也是后续计费、运营分析的重要依据。SaaS 场景里不同商户的 AI 使用量差异可能很大有了会话记录你才能按租户维度做配额管理和成本分摊。写在最后的一点个人体验上下文记忆持久化这件事听起来只是加个 Redis但真正难的是明确数据边界和失败模式。我做了大半年 Spring AI 项目最深的体会是与其急着调模型参数不如先把自己的状态管理搞扎实。一个能扛住多实例、多租户、各种重启的上下文存储比一百条提示词技巧都值钱。你也不用一上来就追求完美。先跑通 Redis 版本再逐步加消息类型序列化、TTL 策略、长期摘要层每个阶段都稳扎稳打。只要把conversationId的设计和存储介质选对后面怎么扩展都不会偏。如果你也正在给自己的 Spring Boot 项目集成 AI建议今天就把这个验证在上线前做完。别等到用户来骂“AI 怎么死活记不住我说的话”再回来看这篇文章。