【从零理解 Spring AI · 07】对话记忆:让模型记住上一句说了什么
写在开头
第 02 篇我们验证过:模型是无状态函数,"记忆"的本质是每轮把历史重新发一遍。
当时用的还是两个独立接口,各自发各自的------所以模型"失忆"。那正确姿势是什么?每次调用前,把历史消息手动拼进消息列表。问题是这样写又臭又长:
java
// 手工管理记忆的样子(能跑,但谁维护谁头疼)
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是助教"));
messages.add(new UserMessage("我叫张三,负责订单系统"));
messages.add(new AssistantMessage("好的张三"));
messages.add(new UserMessage("我叫什么名字?"));
// 每轮都要:读历史 → 拼接 → 发送 → 把新问答存回去
存、读、拼、截断、隔离、持久化------六个动作每处都要自己写。Spring AI 把这套东西做成了组件,这篇讲清楚它的用法和每个配置背后的取舍。
一、先分清两个词:Memory 和 History
官方文档专门强调过这对概念的区别,因为混淆它们是最常见的埋雷方式:
| 概念 | 是什么 | 给谁看 | 怎么管 |
|---|---|---|---|
| ChatMemory | 维持对话上下文所需的那部分消息 | 给模型看 | Spring AI 的 Advisor 自动管理 |
| ChatHistory | 完整的对话记录(所有消息) | 给人看(界面回显、审计) | 老老实实用 Spring Data 自己存 |
为什么要分开?因为模型需要的和产品要展示的,往往不是一回事:模型只需要窗口内的最近若干轮(多了反而占上下文),而产品要展示完整聊天记录、支持用户翻历史。
把完整记录塞给模型? 浪费窗口、成本飙升、老话题干扰新回答。把窗口内的内容当历史存? 用户刷新页面,聊天记录没了。
各管各的,别互相客串。 有了这个共识,再看 Spring AI 的设计就顺理成章了。
二、最小可用:三行开启记忆
2.1 直接用自动配置的 Bean
Spring AI 启动时自动装配好了一个 ChatMemory:默认用内存存储 + 消息窗口策略(保留最近 20 条)。直接注入就能用。
真正要写的只有两处:把记忆 Advisor 挂到 ChatClient 上,每次调用带上会话 ID。
java
@Service
public class ChatService {
private final ChatClient chatClient;
public ChatService(ChatClient.Builder builder, ChatMemory chatMemory) {
this.chatClient = builder
.defaultSystem("你是一个面向 Java 初学者的助教。")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
public String chat(String conversationId, String message) {
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
}
}
2.2 效果验证
用同一个会话 ID 连发两轮:
java
String cid = "user-1:conv-1";
chat(cid, "我叫张三,负责订单系统");
chat(cid, "我叫什么名字?负责什么系统?"); // 这次它能答对了
第二轮的完整流程是:
收到"我叫什么名字?"
→ 记忆 Advisor 拦截:按 conversationId 从 ChatMemory 读出历史
→ 把历史拼进消息列表:[system, user(我叫张三...), assistant(好的...), user(我叫什么名字?)]
→ 发给模型 → 模型这次"看得见"张三 → 正确回答
→ Advisor 把这轮的问答追加回 ChatMemory
和第 02 篇手工版的对照:六个动作(存、读、拼、截断、隔离、持久化)里,前五个全被 Advisor 接管了,业务代码只剩"传一个会话 ID"。
2.3 一个必须记住的硬规则
CONVERSATION_ID 是必传参数。 不传的话,运行时直接抛 IllegalArgumentException------框架不会猜一个默认会话给你。
这其实是好事:会话归属是业务决策,框架替你猜才是事故源。
三、窗口策略:怎么留、怎么丢
MessageWindowChatMemory 是默认的记忆实现,核心机制就是第 02 篇说的"滑动窗口":

窗口怎么留、怎么丢、怎么隔离
三个关键行为:
① 只留最近 N 条。默认 20 条,可调:
java
MessageWindowChatMemory memory = MessageWindowChatMemory.builder()
.maxMessages(10)
.build();
② SystemMessage 永远保留。角色设定被逐出了,模型的"人格"就没了------框架默认保它。
③ 按"整轮"逐出,不把一轮切一半 。这个细节很贴心:逐出点如果落在一轮的中间(比如助手回答还没删、工具交互还没删),框架会把切点推进到下一个用户消息,保证留下来的窗口永远从完整的一轮开始。
为什么强调"整轮"?因为半轮历史比没有更糟------模型看到"用户问了一句"却没有回答,会误解语境。
窗口大小怎么定? 不是越大越好:窗口越大,每次请求携带的内容越多(费用、延迟上升),且老话题可能干扰新回答。经验做法:从默认 20 条起步,根据业务的平均对话长度调。 单轮任务(查个天气)窗口小点没关系;长咨询类对话给足空间。
四、多用户隔离:conversationId 怎么设计
conversationId 是记忆的唯一隔离边界------同一个 ID 的历史归拢在一起,不同 ID 完全互不可见。
绝对不能犯的错:所有用户共用一个写死的 ID。张三和李四的历史混在一起,张三能"看到"李四说了什么------这不是 bug,是安全事故。
推荐做法:在服务端从当前用户推导,不要从前端传:
java
// 登录态里有用户,会话里有 sessionId
String conversationId = currentUser.getId() + ":" + httpSession.getId();
chatClient.prompt()
.user(userInput)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
三层建议(来自官方文档的原话精神):
- 每个用户一个独立 ID;一个用户可以有多个会话时,每个会话一个 ID;
- 在服务端推导,而不是信任前端传的值;
- 列出、删除会话时,只操作属于当前用户的那些 ID。
五、历史存哪里:四种存储的选型
内存存储重启就丢。Spring AI 提供了一组 ChatMemoryRepository 实现,换个 starter 就换存储:
| 实现 | 一句话定位 | 适合 |
|---|---|---|
| InMemory(默认) | ConcurrentHashMap 存内存 |
开发、demo、单机玩具 |
| JDBC | 关系库,支持 PostgreSQL / MySQL / MariaDB / SQL Server / Oracle 等 | 大多数业务的默认选择 |
| Redis | Redis Stack 存储,低延迟,支持 TTL 过期和高级查询 | 高并发、历史需要过期 |
| Cassandra / Neo4j / MongoDB | 各有专长(时序 TTL / 图关系 / 文档) | 已有对应技术栈的团队 |
以 JDBC 为例,换存储只需两步:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
java
// 自动配置会检测到 JDBC 仓库并优先使用它,也可以显式组装:
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository) // 注入的 JDBC 仓库
.maxMessages(20)
.build();
建表脚本默认只对嵌入式数据库自动执行;连 MySQL/PostgreSQL 时要显式配置:
properties
spring.ai.chat.memory.repository.jdbc.initialize-schema=always
(生产配合 Flyway/Liquibase 时设为 never,把建表脚本纳管进迁移体系。)
⚠️ 一个提前预告的坑 :JDBC、Mongo、Cassandra 这几个存储不支持持久化工具调用类的消息(保存时会被静默过滤)。平时没影响,等第三辑上了工具调用,多步工具交互的记忆就会缺片段------官方推荐用社区项目 Spring AI Session 解决。这里先埋个点,工具调用篇回收。
六、进阶:长期记忆的另一种思路
滑动窗口解决"最近几轮",但跨天、跨会话的偏好记忆("用户喜欢简洁回答")窗口装不下。
Spring AI 还有一个 VectorStoreChatMemoryAdvisor:把历史存进向量库,每次请求时检索相关片段、拼进系统消息。它和滑动窗口是互补关系:
- 窗口记忆:管最近几轮的完整上下文,逐字带上;
- 向量记忆:管久远的相关信息,按相关性捞出摘要拼上去。
详细机制牵扯向量库和检索(第四辑 RAG 的核心内容),这里记住分工即可。
七、这篇的四个要点
- Memory 和 History 是两个东西------给模型看的窗口上下文用 Spring AI 管;给人看的完整记录用 Spring Data 自己存。别混。
- 三行开启记忆 ------注入自动配置的
ChatMemory→ 挂MessageChatMemoryAdvisor→ 每次调用传CONVERSATION_ID。这个参数必传,不传抛异常。 - 滑动窗口的三个行为------默认留 20 条、SystemMessage 永远保留、按整轮逐出不切半。
- 存储按场景选 ------开发用内存,业务默认 JDBC(注意
initialize-schema),高并发短历史用 Redis;工具调用消息的持久化有已知限制。
延伸思考
到这里,"模型记住上下文"解决了。但还有个更隐蔽的问题:同一个问题换个问法,答案质量忽高忽低;或者让模型写长文,开头华丽结尾拉胯。
模型输出质量的稳定性,除了参数(温度)之外,最大的杠杆在提示词本身怎么写。第二篇说过提示词是"第一层控制手段",下一篇把它从"控制"升级成"工程"。
下一篇
《提示词工程:几个好用的框架》------CO-STAR 六要素填空法、少样本示例为什么比十条规则都管用、思维链什么时候有效什么时候纯属浪费、以及这些技巧在 ChatClient 里的落点。
第二辑收官,也是进入第三辑(工具调用与 MCP)前的最后一块拼图。
本专栏按 Spring AI 当前版本讲解。