
做AI应用做了一段时间最戳心的一件事是用户上一秒还在告诉你我叫小张家里打印机型号是HP LaserJet M227最近老是卡纸下一秒换个话题聊两句再问那我打印机的具体型号是什么它居然说不出来。这不是模型笨是大模型API天然无状态——每一次请求都像失忆重启。今天这篇快速入门第3篇把上下文记忆持久化这件事彻底聊透从Spring AI的ChatMemory接口到底层存储选型再到把记忆从内存挪到Redis的完整落地最后是我在实际项目里踩过的几个坑。适合刚入门Spring AI、正打算把对话Demo做成真正可运营产品的开发者。1. 无状态API决定了AI注定失忆记忆的价值不在模型层1.1 一次对话就是一次遗忘很多人第一次接触ChatClient时都会想当然既然叫对话那模型应该记得我上一句说了什么吧真实情况是每次调用ChatClient.prompt().user(...).call()发到模型接口的就是一个独立的HTTP请求。你说的话、模型的回复全都在请求结束后被丢弃了。这个失忆不是模型能力不够而是架构决定的。模型本体只负责根据你给它的一段文本预测下一段最合适的文本。它没有任何会话状态这个概念。我见过不少刚转过来的后端同学第一反应是那我在服务端缓存一个session然后把之前聊天记录拼起来发过去不就行了——思路完全正确这恰恰就是上下文记忆的实现本质。1.2 所谓记忆就是每次请求前重新拼一次上下文既然模型没有记忆应用层的任务就是替它记然后在每次请求前把记下来的内容重新塞回Prompt里。用生活化的类比对话历史就像你点外卖时填的备注栏店家本身不记得你上礼拜点过什么你只能每次下单都把偏好重新写一遍。写多了店家看不过来响应变慢单子也可能超时——这对应的就是Token成本和上下文窗口上限。所以上下文记忆这事儿拆开看就两件事存储把历史对话按会话维度存起来内存、数据库、Redis都行。注入每次调用模型前把最近的历史消息取出来拼到本次请求的上下文里。1.3 Spring AI解决记忆问题的三层结构Spring AI对这件事的抽象我认为值得表扬它把上面说的两件事拆成了三层第一层是ChatMemory接口提供add、get、clear这类存储语义你可以把它理解成记忆仓库的统一门面。换底层存储不需要动业务代码。第二层是实现类比如最基础的InMemoryChatMemory、带窗口裁剪的MessageWindowChatMemory还有后面要说的Redis版、JDBC版。第三层是MessageChatMemoryAdvisor它是一个Advisor顾问挂在ChatClient的调用链上。模型调用前自动从ChatMemory里取历史调用后自动把新对话写入ChatMemory。这个设计很像Spring MVC里的拦截器你不需要自己在业务代码里手动做读历史、拼上下文、存新消息这一套重复劳动Advisor帮你全部包掉了。理解了这个三层结构后面所有代码其实就是在往这三层里填具体实现。提示快速入门阶段别急着上高深的向量记忆、知识图谱记忆先把消息队列式的历史读写跑通这才是地基。2. 先把内存版跑通ChatMemory接口与Advisor核心机制2.1 ChatMemory接口到底在管什么先看一眼接口的核心方法Spring AI 1.0.x的常见形态不同小版本可能略有差异add(String conversationId, ListMessage messages)给某个会话追加一批消息。get(String conversationId, int lastN)取某个会话最近N条消息。clear(String conversationId)清空某个会话的记忆。deleteById(String conversationId)删除会话记录个别版本才有如果IDE提示实现它直接委托给clear就行。注意这里的关键维度是conversationId——所有记忆都按会话维度隔离。同一个用户如果你每次传不同的conversationId那它天然就是失忆的传同一个历史才会被捞起来。2.2 最小可运行示例从无记忆到有记忆我用的是Spring AI 1.0.x OpenAI兼容接口这套组合先加核心依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency然后配置模型密钥这里是简化配置实际按你自己的模型服务商来spring: ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini关键配置类长这样Configuration public class ChatConfig { Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); } }然后在Service层调用时显式传入会话IDService public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient chatClient) { this.chatClient chatClient; } public String chat(String userId, String message) { return chatClient.prompt() .user(message) .advisors(a - a.param(chatMemory_conversationId, userId)) .call() .content(); } }加上一个最简Controller就能测了RestController public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService chatService; } GetMapping(/chat) public String chat(RequestParam String userId, RequestParam String message) { return chatService.chat(userId, message); } }测的时候连续访问/chat?userIdu1message我叫小张我的打印机是HP LaserJet M227再访问/chat?userIdu1message我打印机什么型号你会发现它能答出来了。但你要是换成userIdu2它立刻忘记小张是谁——这就是conversationId隔离的效果。2.3 窗口裁剪MessageWindowChatMemory与token成本控制内存版能跑但有个隐患如果对话无限累积每次请求往Prompt里塞的历史越来越多。假设单条消息平均300 token100条历史就是3万token成本、时延、上下文窗口全部告急。这就是MessageWindowChatMemory存在的原因——它给底层存储套了一层只保留最近N条的贪心策略Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .chatMemory(new InMemoryChatMemory()) .maxMessages(30) .build(); }.maxMessages(30)就是窗口大小超过30条时最老的消息会被丢弃。这个数字怎么定没有一个万能答案取决于你的业务场景客服机器人用户问题短、意图分歧大30条足够多了反而稀释重点。技术问答需要追踪很长的上下文50到100条也常见。高频交易辅助只要最近几条5到10条就够。我的经验是先把窗口设小跑通后再根据用户反馈慢慢调大。窗口翻倍单次请求的Token消耗也差不多翻倍这是个线性增长的成本项。3. 持久化落地自研RedisChatMemory把记忆从内存搬到远端内存版有个致命伤——进程一重启全部记忆归零。Demo阶段无所谓生产环境就不行了。用户昨晚刚说过设备型号今天你发个版本重启一下它又不记得了这在产品上就是事故。要持久化最简单可靠的落地方式就是Redis。3.1 为什么选Redis而不是数据库有人可能会问我用MySQL存对话记录不行吗行但你是把关系型数据库当队列用读写吞吐和结构灵活性都不划算。对话历史的存取模式非常固定按会话ID追加消息、按会话ID拉最近N条、按会话ID清空。这是典型的List或Hash操作Redis的RPUSH和LRANGE语义天然覆盖。加上字符串追加、过期时间这些能力Redis做这件事几乎是量身定做。当然如果你已经有强一致性的业务诉求或者需要拿历史对话做统计报表MySQL/PG这样的存储依然有价值。但作为记忆缓存层Redis是当前踩过坑之后我最推荐的首选。3.2 存储结构设计List加JSON最简单也最可靠我用的方案是每个会话一个ListList里每个元素是一条消息序列化后的JSON字符串。spring-ai:chat-memory:{conversationId} - ListString具体到Redis里长这样spring-ai:chat-memory:user:123 - [{\type\:\user\,\content\:\我叫小张\}, {\type\:\assistant\,\content\:\你好小张\}]为什么用List而不是Hash因为List能保证消息的先后顺序LRANGE key -30 -1就能直接拿到最近30条一条命令解决问题。用Hash虽然也能存但取值后你还得自己在应用层排序。别在这个地方过度设计。3.3 完整的ChatMemory实现代码自己实现一个RedisChatMemory最核心的点是消息序列化。Spring AI的Message是一个接口比如UserMessage、AssistantMessage、SystemMessage直接用Jackson序列化接口类型很容易踩坑。我的办法是定义一个带类型标记的record序列化前把Message转成record反序列化时再根据type还原Component public class RedisChatMemory implements ChatMemory { private static final String MEMORY_PREFIX spring-ai:chat-memory:; private static final ObjectMapper MAPPER new ObjectMapper() .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); private final StringRedisTemplate stringRedisTemplate; public RedisChatMemory(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } Override public void add(String conversationId, ListMessage messages) { if (conversationId null || messages null || messages.isEmpty()) { return; } String key MEMORY_PREFIX conversationId; ListString jsonList messages.stream() .map(this::serialize) .toList(); stringRedisTemplate.opsForList().rightPushAll(key, jsonList); // 每次写入都刷新过期时间长期活跃的记忆不会中断 stringRedisTemplate.expire(key, Duration.ofDays(30)); } Override public ListMessage get(String conversationId, int lastN) { String key MEMORY_PREFIX conversationId; ListString jsonList stringRedisTemplate.opsForList().range(key, -lastN, -1); if (jsonList null || jsonList.isEmpty()) { return List.of(); } return jsonList.stream().map(this::deserialize).toList(); } Override public void clear(String conversationId) { stringRedisTemplate.delete(MEMORY_PREFIX conversationId); } // 个别版本的接口里有deleteById加上这个实现方法最保险 public void deleteById(String conversationId) { clear(conversationId); } private String serialize(Message message) { try { MapString, Object metadata message.getMetadata(); ChatMessageRecord record new ChatMessageRecord( resolveType(message), message.getContent() null ? : message.getContent(), metadata null ? Map.of() : metadata); return MAPPER.writeValueAsString(record); } catch (JsonProcessingException e) { throw new RuntimeException(序列化对话消息失败, e); } } private Message deserialize(String json) { try { ChatMessageRecord record MAPPER.readValue(json, ChatMessageRecord.class); MapString, Object metadata record.metadata(); if (metadata null) { metadata Map.of(); } return switch (record.type()) { case user - new UserMessage(record.content(), metadata); case assistant - new AssistantMessage(record.content(), metadata); case system - new SystemMessage(record.content()); default - throw new IllegalStateException(未知的消息类型: record.type()); }; } catch (JsonProcessingException e) { throw new RuntimeException(反序列化对话消息失败, e); } } private String resolveType(Message message) { if (message instanceof UserMessage) return user; if (message instanceof AssistantMessage) return assistant; if (message instanceof SystemMessage) return system; throw new IllegalStateException(暂不支持的消息类型: message.getClass()); } record ChatMessageRecord(String type, String content, MapString, Object metadata) { } }补充几个这个实现里的关键决策都是踩出来的用StringRedisTemplate而非RedisTemplate因为JsonString在Redis里用string存取最直观不用额外配置RedisSerializer。每条记录必须带type字段否则反序列化时你根本不知道这是一段用户消息还是助手回复。SystemMessage在还原时我没保留metadata因为系统消息一般不需要带额外数据省一层麻烦。如果你的场景涉及工具调用Function Calling这里serialize和deserialize还得扩展ToolResponseMessage等类型的处理否则模型能力一旦用起来历史里夹带一条工具调用消息就会还原失败。3.4 注册Bean并替换内存版有了这个组件把配置类里的Bean换掉即可Configuration public class ChatConfig { private final RedisChatMemory redisChatMemory; public ChatConfig(RedisChatMemory redisChatMemory) { this.redisChatMemory redisChatMemory; } Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(redisChatMemory)) .build(); } }别忘了引入Redis依赖和配置连接信息dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependencyspring: data: redis: host: localhost port: 6379注意Spring Boot 3.x的Redis配置前缀是spring.data.redis不是老的spring.redis。网上不少旧文章和博客用的是后者照抄会直接静默不生效。4. 跨会话验证重启应用AI仍记得昨天的事4.1 场景模拟与预期结果持久化的价值必须用重启验证来体现。我搭个最小验证闭环第一步启动应用访问/chat?userIduser:xiaozhangmessage我家打印机型号是HP LaserJet M227最近老是卡纸 /chat?userIduser:xiaozhangmessage你觉得最可能是哪里的问题第二步不管回答如何直接CtrlC停掉应用再重新启动。第三步再次访问/chat?userIduser:xiaozhangmessage你还记得我打印机型号吗如果一切正常它能准确说出HP LaserJet M227。如果用的是InMemoryChatMemory这一问它会一脸茫然——这就是持久化和非持久化的核心差异。我还会顺手测试记忆隔离/chat?userIduser:lisimessage你知道我打印机什么型号吗正确行为是完全不知道。现实业务里不同用户、不同业务线的会话必须互相隔离否则就是严重的数据泄漏事故。4.2 conversationId的设计别裸用userId这里有一个我在项目里踩过的设计坑直接用数据库主键userId当conversationId。问题是你的应用里可能不止一个对话入口——用户在客服页面聊在工单页面聊在你内部测试后台也聊。如果两个业务共用一套userId历史会互相串味用户下午问天气的对话记录混进早上的售后工单上下文里。我的建议是区分业务场景conversationId加业务前缀客服会话service:user:123工单会话:ticket:user:123调试会话debug:u123同时如果你要在Web场景里从请求头取会话ID可以实现ConversationIdProvider接口把从Header取ID的逻辑收口到一处而不是在Controller里散落一堆字符串拼接。4.3 性能与容错检索大小、TTL与过期策略记忆中比较容易被忽略的一个参数是chatMemory_retrievalSize。它控制每次请求实际从存储里捞多少条历史。即使底层Redis存了几百条你每次调用时也可以只取最近20条public String chat(String userId, String message) { return chatClient.prompt() .user(message) .advisors(a - a .param(chatMemory_conversationId, userId) .param(chatMemory_retrievalSize, 20)) .call() .content(); }这和窗口裁剪MessageWindowChatMemory的区别是窗口是存的时候只留30条retrievalSize是取的时候只取最近20条。两者可以同时存在也可以只选一个。我的做法是存储层全量保留一段时间读取时控制单次消耗这样既保证记忆的完整性又控制单次成本。TTL策略也值得花时间设计。我上面的代码里固定30天但实际上不同业务差别很大业务场景建议TTL理由售后客服30天问题跟踪周期长用户可能隔几周再来电商导购7天购物决策窗口短太久了反而带来噪音调试后台1天只是临时排查留太久占空间敏感业务不落盘或24小时隐私合规优先及时清理Redis的EXPIRE每次写入都刷新这个语义其实就是滑动过期只要用户还在持续对话记忆就一直续期停止对话超过阈值记忆自动消亡。5. 真实生产环境避坑序列化、并发与版本迁移5.1 消息序列化是重灾区自定义实现还有一个非常隐蔽的素质问题metadata里放了什么Spring AI的UserMessage、AssistantMessage构建时都可以带一个MapString, Object metadata。我见过有人把某个业务对象整个塞进metadata然后序列化时直接抛出一堆Jackson不认识的类型。序列化的黄金法则是只放本身能JSON化的数据。反序列化的大坑则是接口类型无法实例化。Spring AI官方的Redis集成方案正是因为要jason一个ListMessage接口类型处理起来非常啰嗦。我的record方案把类型信息显式化本质上就是避开这个坑。如果你拿到了官方实现的源码看到里面有一堆Message反序列化处理器不要慌直接拿我这个精简版方案替换少掉一半头发。5.2 高并发下先读后写的顺序问题还有一个在压测时才暴露的问题MessageChatMemoryAdvisor的调用顺序是先读历史 → 调模型 → 再写新消息。如果同一个用户同时发来两条消息A和B请求A读到的是空历史请求B读到的也是空历史最后写入顺序可能变成用户A、助手A、用户B、助手B看起来没问题但极端情况下可能交错成用户A、用户B、助手A、助手BAI就把用户B的问题和A的上下文揉在一起了。对绝大多数业务场景这个概率很低后果也不严重。但如果你的产品是强实时协作类比如多人共用一个会话就需要在会话维度加一把分布式锁。用Redis的SETNX做一个简单的会话锁或者直接引入Redisson把读历史 → 调模型 → 写历史包成一个原子区间。5.3 Spring AI 2.0迁移时的差异点写这篇文章的时候Spring AI 2.0已经在社区走热了。我自己的迁移经验是ChatMemory、MessageChatMemoryAdvisor这套抽象没有消失但包路径和部分starter坐标可能有变化。比如官方Redis集成相关的依赖1.0.x和2.0.x的坐标不一样如果你照着1.0.x的文章直接抄dependency启动时很容易遇到NoClassDefFoundError这类问题。迁移建议很直接优先看官方发布说明里的Breaking Changes章节里面通常会列一个旧坐标到新坐标的映射。把自定义实现类作为缓冲层由于我上面写的RedisChatMemory是自己实现的并没有绑定官方Redis starter的内部类所以升级时只需要处理接口方法名的微调底层存储逻辑完全不用动。Spring AI 2.0对Agent的支持明显加强如果你的记忆方案要服务于Agent建议在消息类型里提前预留type扩展位——Agent的中间推理步骤、工具调用结果都会出现在历史消息里只认user/assistant两个type的存储方案撑不了太久。最后再分享一个实操习惯我会在RedisKey的设计上把版本号和业务线都带进去比如spring-ai:chat-memory:v2:service:user:123。别小看这个前缀一旦需要换序列化方案、清空某一类历史或做灰度迁移这个前缀能让你直接针对某一条业务线做SCAN加DEL不用在线上环境里裸奔操作全部数据。上下文记忆持久化这块越早把存储结构、会话隔离和过期策略想清楚后期重构的成本就越低。