这个系列写到第三篇。前两篇聊了怎么用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实例又变成陌生人。
开发调试阶段用内存态完全没问题,跑通逻辑再换存储,成本很低。但生产环境要的是“跨重启、跨实例、跨会话”都稳定,这时候就必须把记忆放到外部存储里,也就是标题里说的“持久化”。
| 维度 | InMemoryChatMemory | Redis/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: 63793.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开没开。这个小习惯,已经帮我躲过了好几次深夜事故,今天也一并写给你。