☰
Spring AI上下文记忆持久化:ChatMemory、Advisor与Redis实战
2026/10/1 14:24:43 网站建设 项目流程

这个系列写到第三篇。前两篇聊了怎么用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目前常见的选择有三条路线,各有各的脾气。

方案优点缺点适合场景
Redis(RedisChatMemory)读写快,List结构天然适合追加消息,TTL可直接控制会话过期需要额外维护Redis,数据可查性弱大多数互联网业务的默认选择
MySQL/PostgreSQL(JdbcChatMemory)强一致、方便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> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencyManagement> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-memory-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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: 6379

3.2 编写ChatMemory和Advisor装配代码

Spring AI的Redis memory starter不会自动帮你把一切配好,你需要自己声明ChatMemory的Bean,再把MessageChatMemoryAdvisor挂到ChatClient上。

一个常见的坑是RedisTemplate的泛型和序列化器。如果你的工程里已经有别的RedisTemplate,尽量单独建一个给AI记忆专用的,避免key/value序列化方式互相干扰。

@Configuration public class AiMemoryConfig { @Bean public RedisTemplate<String, String> chatRedisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, 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(RedisTemplate<String, 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叫MessageHistoryChatMemoryAdvisor,1.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?conversationId=room-test" \ -H "Content-Type: text/plain" \ -d "我叫小王,我来自杭州" curl -X POST "http://localhost:8080/chat?conversationId=room-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 yes+appendfsync 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派生conversationId
A用户能聊到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开没开。这个小习惯,已经帮我躲过了好几次深夜事故,今天也一并写给你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询