尧图精选

Spring AI上下文记忆持久化:ChatMemory、Advisor与Redis实战

🕒 发布时间:2026/10/1 14:24:44 📁 来源:尧图网络
这个系列写到第三篇。前两篇聊了怎么用ChatClient把大模型接进Spring Boot项目以及怎么用提示词模板和结构化输出让AI按规矩办事。但有一个坎几乎每个做AI应用的人都会撞上AI聊着聊着就把前面的话全忘了。你刚告诉它“以后这个项目的技术栈是Java 17 Spring Boot 3”下一轮再问它像第一次见你一样礼貌反问“你说的是哪个项目”。真不是模型傻是我们压根没给它装记忆。这一篇就专门解决“上下文记忆持久化”这件事。我会从原理拆起讲清楚Spring AI里ChatMemory、Advisor、conversationId这几个角色各管什么然后对比内存态和持久化方案再带你把Spring AI Redis这套组合从头到尾搭一遍最后把我在生产环境里踩过的坑一次说清楚。适合刚做完“能对话的Demo”、想往生产级AI应用走一步的Java后端同学。1. 先搞清楚LLM天生的“记忆缺陷”和Spring AI的补法1.1 无状态API聊完即焚的真相很多人第一次用Spring AI的时候都会疑惑ChatClient看起来就是个API封装为什么我自己连续调两次模型完全不记得第一次说了什么这里有个底层事实大模型接口本质上是无状态的。你调OpenAI或者通义千问的ChatCompletion接口时是把你希望模型看到的全部对话内容放在一个messages数组里一次性传过去模型只根据这次传进去的内容生成回答它不会主动回想“上次你跟我聊过什么”。后端服务端不会替每个用户缓存历史除非你用的是专门的Threads/Assistants这类状态化接口但Spring AI默认的ChatClient走的是最朴素的Completion路径。也就是说你在业务层感受到的“连续对话”完全是调用方自己拼出来的上一轮模型说了什么这一轮你把它加进messages里一起再发一遍。从前的ChatGPT网页版是这样你现在用Spring AI做多轮对话也得自己搞定这件事。拿生活里的事打个比方每次问路都等于换了一个新导游你上一句话他根本没听见。要想让他记住就得给他配一个随身速记员。速记员把你们之前的对话都记在本子上每次开口前先把本子翻出来给他看。这就是上下文记忆的本质。1.2 ChatMemory、Advisor、conversationId三位一体Spring AI把“补记忆”这件事拆成了几个清晰的角色理解了它们后面写代码就不迷糊。第一个是ChatMemory它管的是“话放哪儿”。这是一个存储接口核心方法就是往某个会话ID下面追加消息、按会话ID取最近N条历史。它的实现可以很简单比如InMemoryChatMemory数据塞进一个ConcurrentHashMap就完事也可以很生产化比如JdbcChatMemory存数据库、RedisChatMemory存Redis。第二个是Advisor它管的是“什么时候取、什么时候存”。你可以把它理解成Spring里的拦截器或AOP切面。在真正调用模型之前Advisor会根据当前conversationId把历史消息从ChatMemory里捞出来拼到这次请求的prompt里等模型返回之后它再把“用户问的 模型答的”写回ChatMemory。这套自动流程省掉了你自己拼messages的重复劳动。第三个是conversationId它管的是“谁是这间聊天室的主人”。同一个conversationId下面的消息会被当成同一段对话历史。换个ID就是换了一间房谁都不认识谁。三者串起来代码长这样String answer chatClient.prompt() .user(我叫小王请记住我的名字) .advisors(a - a.param(conversationId, room-001)) .call() .content();如果你在构建ChatClient时配了一个MessageChatMemoryAdvisor那么这一轮请求会自动经历“取历史 - 拼进prompt - 调模型 - 把新消息写回ChatMemory”的完整链路。你只需要盯住conversationId别传错。1.3 内存态 vs 持久化分水岭在哪Spring AI开箱即用的InMemoryChatMemory最省事但两个硬伤很快就暴露JVM一重启所有对话记录烟消云散应用多实例部署时每个实例各存各的用户在A实例聊完请求落到B实例又变成陌生人。开发调试阶段用内存态完全没问题跑通逻辑再换存储成本很低。但生产环境要的是“跨重启、跨实例、跨会话”都稳定这时候就必须把记忆放到外部存储里也就是标题里说的“持久化”。维度InMemoryChatMemoryRedis/JDBC持久化存储位置JVM堆内ConcurrentHashMap外部存储系统应用重启全部丢失不丢多实例部署各实例数据互相独立共享同一份数据定位本地调试、单元测试生产环境、正式功能我见过不少团队拿着内存态方案直接上了生产线上用户一多就出“AI失忆”事故。说白了内存态解决的是“能把代码跑起来”持久化解决的才是“能不能上线”。2. 持久化载体怎么选Redis、关系库、向量库的取舍2.1 三种载体横向对比确定要持久化之后下一个问题就是用谁存。Spring AI目前常见的选择有三条路线各有各的脾气。方案优点缺点适合场景RedisRedisChatMemory读写快List结构天然适合追加消息TTL可直接控制会话过期需要额外维护Redis数据可查性弱大多数互联网业务的默认选择MySQL/PostgreSQLJdbcChatMemory强一致、方便SQL审计能直接查某用户的完整历史高并发写入有IO压力消息量大要分表强审计、合规要求高的业务向量数据库能按语义检索历史比如“上次讨论的结论是什么”精确回放弱多一套组件要运维长期个人助理、企业知识型Agent我的建议很直接大部分项目从Redis起步就够了。会话历史本质上是“短TTL、高频率追加的流水日志”Redis的List操作、过期机制、跨实例共享简直是为这个场景量身定做的。等你做的是那种必须保留完整对话、随时要按用户/时间查记录的合规业务再考虑换JDBC。2.2 为什么我优先推Redis谈Redis适合存对话历史不能光说“快”得看数据结构。历史消息是一个只往后追加的序列Redis里的RPUSH就是干这个的每次对话结束应用把消息往List尾部一推下次要取历史用LRANGE拿最近几十条。不用自己管递增ID不用考虑分页语义上完全对得上。再一个好处是TTL。一个普通用户的对话历史留个30天、90天足够过期了让Redis自己清掉不用写定时任务。这一点在餐饮SaaS这类多租户场景里特别省心——不同租户的会话策略不同可以直接给不同conversationId前缀设不同TTL。还有个隐蔽优势大多数Spring Boot项目本来就已经引入Redis在扛缓存、分布式锁、验证码了。顺手把AI记忆也放进去不增加任何新组件运维成本几乎为零。你要是为了存对话历史再单独上一个MongoDB或向量库光是版本管理、容量规划、备份策略就够喝一壶。有一条红线必须提前说Redis快不等于Redis持久。默认配置下Redis可能根本不往磁盘写或者只按快照策略低频写盘。应用把记忆写进Redis只是“外置”了Redis自己要是没配持久化重启一次照样忘光。这层坑我放到第三章专门讲。2.3 顺带聊聊Spring AI和LangGraph4j的选型焦虑最近总有人拿“Spring AI”和“LangGraph4j”比问现在到底该学哪个。我的看法比较务实。Spring AI 1.0发布之后的定位是“Java生态里的AI应用基础设施”它把ChatClient、提示词模板、记忆Advisor、RAG流程、模型评估这些东西都做成了Spring风格组件你写的代码跟平时的Service、Controller、JdbcTemplate长得一模一样团队上手几乎没有额外负担。LangGraph4j则更像一个“图状态机”擅长表达多步骤、多分支、需要人工介入的工作流比如多个Agent协作、带条件回环的复杂任务。它的学习曲线明显更陡。如果你的需求是“把大模型接进Spring Boot搞定多轮对话、RAG、Agent的基本链路”Spring AI的性价比高得多。真等业务复杂到需要显式画状态图、管循环和分支的时候再叠加LangGraph4j也不迟。框架选型别为了“看起来高级”提前引入复杂度记忆设计这个基本功才是绕不过去的。3. 亲手搭一遍Spring AI Redis实现上下文记忆持久化3.1 环境准备与依赖配置我这边用到的环境是JDK 17、Spring Boot 3.4.x、Spring AI 1.0稳定版、Redis 7.x。Spring AI的版本迭代很快API在1.0.0-GA前后有过调整建议在dependencyManagement里锁住spring-ai-bom版本避免子模块版本漂移。Maven依赖长这样dependencyManagement dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencyManagement dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-memory-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency如果你用的是国内模型把spring-ai-starter-model-openai换成对应的DashScope starter接口代码基本不变只要改模型配置就行。Spring AI Alibaba这条线在中文场景下的NL2SQL、Agent实践挺成熟值得后面单开一篇细聊。application.yml里最核心的配置spring: ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.7 data: redis: host: localhost port: 63793.2 编写ChatMemory和Advisor装配代码Spring AI的Redis memory starter不会自动帮你把一切配好你需要自己声明ChatMemory的Bean再把MessageChatMemoryAdvisor挂到ChatClient上。一个常见的坑是RedisTemplate的泛型和序列化器。如果你的工程里已经有别的RedisTemplate尽量单独建一个给AI记忆专用的避免key/value序列化方式互相干扰。Configuration public class AiMemoryConfig { Bean public RedisTemplateString, String chatRedisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplateString, String template new RedisTemplate(); template.setConnectionFactory(connectionFactory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(new StringRedisSerializer()); template.afterPropertiesSet(); return template; } Bean public ChatMemory chatMemory(RedisTemplateString, String chatRedisTemplate) { return new RedisChatMemory(chatRedisTemplate); } Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); } }这里有个版本细节要留意早期Spring AI版本里这个Advisor叫MessageHistoryChatMemoryAdvisor1.0.0 GA后改成了MessageChatMemoryAdvisor。你要是从老版本升级编译报错找不到类多半就是名字变了。有了defaultAdvisors之后所有通过这个ChatClient发出去的请求都会被自动套上记忆逻辑。然后写一个最简单的接口验证RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient chatClient; } PostMapping(/chat) public String chat(RequestParam String conversationId, RequestBody String message) { return chatClient.prompt() .user(message) .advisors(a - a.param(conversationId, conversationId)) .call() .content(); } }3.3 用一套REST接口测通多轮对话启动应用后先用同一个conversationId连续问两个问题curl -X POST http://localhost:8080/chat?conversationIdroom-test \ -H Content-Type: text/plain \ -d 我叫小王我来自杭州 curl -X POST http://localhost:8080/chat?conversationIdroom-test \ -H Content-Type: text/plain \ -d 我叫什么名字如果第二轮的回复里带着“小王”“杭州”这些信息说明历史被加载进来了。然后换一个ID再问“我叫什么名字”它不会记得这就证明记忆是按conversationId隔离的。我习惯顺手去Redis里看一眼真实数据redis-cli keys *room-test*你会看到形如runtimectx:room-test:message的key前缀以当前版本源码为准再用LRANGE看List里的消息内容就能直观感受到“持久化”到底存了什么。看到数据落盘的那一刻记忆持久化的概念才算真正落地。3.4 Redis持久化机制RDB和AOF到底怎么配应用把对话写进Redis只完成了一半“持久化”的最后一公里是Redis自己把数据写到磁盘。这块不弄明白Redis一重启前面的功夫照样白费。Redis的持久化就两大件RDB和AOF。RDB是周期性快照到时间点把全内存数据打成一份二进制文件。优点是文件紧凑、恢复极快缺点是两次快照之间的数据可能全丢。默认配置大致是“3600秒内至少有1次写操作就存一次快照300秒内100次、60秒内10000次”也就是说在极端情况下你可能丢几分钟的数据。AOF是追加日志每条写命令先追加到文件里。appendfsync三个档位always每条命令都刷盘最安全但性能最差everysec每秒刷一次最多丢一秒数据no交给操作系统决定最猛但丢得最多。对话记忆这种业务everysec是性价比最高的档位丢一秒几乎无感性能损失也小。场景推荐配置理由本地Demo、非核心缓存默认RDB即可丢了也无所谓省IO生产环境AI记忆、会话状态appendonly yesappendfsync everysec最多丢一秒重启可恢复强合规、不可丢失appendfsync always每条都刷盘但QPS会明显下降Redis 4.0之后支持混合持久化开启aof-use-rdb-preamble yes后AOF文件头部是RDB快照、后面追加增量命令兼顾重启速度和数据完整性。我的生产建议是appendonly yes、appendfsync everysec、开启混合持久化配合定期RDB保存。这套组合在绝大多数对话场景下够稳。注意修改redis.conf之后记得重启Redis或用CONFIG SET动态调整。别配了不生效上线才傻眼。4. 踩坑实录与生产落地清单4.1 记忆不生效、串号、丢数据的排查速查表这一段是我在实际项目里遇到最高频的问题汇总按“现象 - 原因 - 解法”的格式整理成表你可以直接当排查手册用。现象原因解法上下轮对话完全不连贯前端每次传了不同的conversationId统一由后端从登录态/会话Cookie派生conversationIdA用户能聊到B用户的上下文conversationId写死成“default”用租户ID:用户ID:场景ID拼key避免全局共享重启应用后记忆全没了用的是InMemoryChatMemory换成Redis或JDBC实现Redis重启后记忆没了Redis自身没开持久化检查appendonly yes、appendfsync配置启动后反序列化报错RedisTemplate序列化器和存储数据不一致统一用String序列化或给ChatMemory建独立RedisTemplate长会话越聊越慢、token超限历史消息没有裁剪外层再套MessageWindowChatMemory或TokenWindowChatMemory多实例部署时上下文时有时无记忆被内存态或本地缓存分片确认所有实例连同一个Redis并检查连接池配置这里特别说一下窗口裁剪的问题。很多人做完记忆就以为万事大吉结果聊了二三十轮之后每次请求会把所有历史全塞给模型token消耗越来越大甚至直接超限报错。MessageChatMemoryAdvisor不是只能配裸的ChatMemory你可以在外面包一层窗口逻辑比如只保留最近20条或者按token上限截断。记忆不是越多越好够用、可控才是生产标准。4.2 餐饮SaaS多租户场景conversationId怎么设计讲个我实际接触过的场景一个面向连锁餐饮品牌的SaaS平台商家要AI助手能回答“我们品牌上个月华东区营业额最高的门店是哪家”这种查询类问题背后往往需要NL2SQL能力把自然语言转成SQL同时还要记住“我只想看直营门店的数据”这种偏好。这个场景里conversationId如果只传一个UUID问题不大但格局不够。正确的做法是把租户维度融进去conversationId tenantId : userId : sceneId比如chunhegu:9527:pos-query。chunhegu是品牌租户9527是运营人员pos-query是经营分析场景。这样天然做了三件事不同租户之间的对话历史物理隔离A品牌的记忆不会串到B品牌同一个用户不同场景各聊各的点餐助手和经营分析互不污染后续要做权限控制或TTL分级直接按前缀扫Redis或者设置过期时间就行。再往深一层走如果你想做的是“跨会话积累的长期偏好助手”光靠Redis里的原始消息还不够。可以把历史消息定期向量化转到向量库做RAG让AI在回答前先检索“这个用户/租户之前聊过什么”。这就是Spring AI生态里RAG和记忆的典型结合方式。对话记忆管“短期事实”RAG管“长期知识”两者不冲突反而是互补。4.3 记忆的瘦身、过期与安全最后说三个很多人容易忽略的生产要点。第一个是给记忆设置过期策略。对话历史不是越久越好尤其是餐饮SaaS这种C端流量入口会话量大得惊人。我建议按业务价值分层当天的完整语境保留历史明细按月归档超过阈值的直接让TTL清掉。Redis的TTL配置在RedisChatMemory的使用场景下特别顺手给不同前缀的conversationId设置不同过期时间就行。第二个是敏感信息问题。对话内容里经常夹带手机号、地址、支付信息这些如果原样落Redis等于把用户隐私明文摆在那。上生产之前一定要想清楚要么在写入前脱敏要么对存储内容做加密要么至少做好Redis的访问控制和网络隔离。我曾见过把包含真实手机号的客服对话原样存Redis还没设密码这属于事故级别的隐患。第三个是日志别打印全量对话。开发时图方便在日志里输出完整messages列表一旦上线就是敏感信息泄露。我给团队的规矩是日志里只打conversationId、消息条数和token数正文内容一律不打。最后分享一个印象特别深的经历。我第一次给AI助手加Redis记忆时以为把数据写进Redis就万事大吉结果某天Redis一重启所有会话全忘干净。后来才反应过来应用外置到Redis只是第一步Redis自己的持久化配置才是最后一公里。现在不管接什么项目只要方案里出现“记忆”两个字我一定顺手检查appendonly开没开。这个小习惯已经帮我躲过了好几次深夜事故今天也一并写给你。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →