版本:Spring AI 2.0.1
目标:弄清对话为什么要「记住」、记忆分几类,以及窗口、向量长期记忆、用户偏好表怎么接。

ChatClient 不存会话。多轮能接上,是 Advisor 在调用前后帮你读写记忆——窗口记最近原文,向量捞相关旧片段,偏好表放过敏原这类硬字段。
7.1 为什么需要记忆
没有记忆时,每一轮对模型都是新开场:
用户:我叫 Tom,请记住。
助手:好的。
用户:我叫什么名字?
助手:……(Prompt 里没有上一轮,它答不上来)
要把第二轮接上,得在发给模型的内容里带上历史,或带上相关的旧片段。Spring AI 用记忆类 Advisor 做这件事,而不是塞进 ChatClient 内部。
接下来要选的是:记最近几轮原文,还是按语义捞旧话;落进程内还是 JDBC / Redis / 向量库;过敏原这类硬字段要不要和闲聊分开存。先分清类型,再谈装配。
7.2 记忆的分类

| 层 | 典型实现 | 记住什么 |
|---|---|---|
| 本轮 | 当前 Prompt / ToolContext | 这一次请求里的东西 |
| 短期窗口 | MessageChatMemoryAdvisor + MessageWindowChatMemory | 最近若干轮原文 |
| 长期语义 | VectorStoreChatMemoryAdvisor + VectorStore | 按相似度召回的历史片段 |
| 用户偏好表 | 业务表(或专用集合) | 过敏原、默认地址等硬字段 |
窗口和向量是日常最常碰到的两条路:

客服追问、强顺序多轮,窗口就够。会话很长、窗口裁掉了但仍要「想起相关旧话」,再叠向量记忆。过敏原、默认地址这类不能猜错的,单独落一张用户偏好表。公司手册是 RAG;相似问直接短路是语义缓存;图执行断点恢复是另一套状态——都别跟对话记忆搅在一起。
本轮上下文就是当前请求,下面只展开窗口、向量长期记忆和用户偏好表。
7.3 窗口记忆
窗口路径记的是 Message 原文,按条数裁剪。结构是三层:

| 层 | 职责 |
|---|---|
MessageChatMemoryAdvisor | 调用前读历史、调用后写本轮 |
ChatMemory | add / get / clear |
ChatMemoryRepository | 按会话 id 真正落库 |
MessageWindowChatMemory 是常用的 ChatMemory 实现:在 Repository 上限制 maxMessages。
契约与会话键
public interface ChatMemory {
String CONVERSATION_ID = "chat_memory_conversation_id";
void add(String conversationId, List<Message> messages);
List<Message> get(String conversationId);
void clear(String conversationId);
}
单条 add(conversationId, message) 只是转成 List.of(message)。仓库侧是整表替换:
public interface ChatMemoryRepository {
List<String> findConversationIds();
List<Message> findByConversationId(String conversationId);
void saveAll(String conversationId, List<Message> messages);
void deleteByConversationId(String conversationId);
}
2.0 起会话键必填:缺键或 null,Advisor 取键会失败;空串在 ChatMemory 里也会被拒。常量字符串是 chat_memory_conversation_id,自定义 Advisor 要兼容官方记忆就用这个,别自创拼写。
chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user(userText)
.call()
.content();
同一次还要挂别的 Advisor,可以写在同一个块里:
.advisors(a -> a
.advisors(extraAdvisor)
.param(ChatMemory.CONVERSATION_ID, conversationId))
一次调用怎么读写

before
1. 按 conversationId 取历史
2. Prompt 里还没有这段历史时,拼进去
3. 尽量把 SystemMessage 挪到最前
4. 把本轮末条 User / ToolResponse 写入记忆
(getLastUserOrToolResponseMessage())
ChatModel.call / stream
after
5. 写回本轮 AssistantMessage
多轮能接上,靠的是同一把键上的这两次读写。第 2 步有去重:Prompt 里已经带了同一段 memory,就不会再拼一遍;你自己又塞半截历史,再挂记忆 Advisor,仍然可能乱,两边都要查。
流式也走这条路径:before 先执行,帧聚完再 after。中途断开时,常见情况是 user 写进去了、assistant 不完整。
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.maxMessages(20) // 默认 20
.build();
裁剪时 SystemMessage 会尽量留下;来了新的 system,旧的会被换掉。切窗口会尽量对齐到完整 USER 轮次,避免只剩半截 assistant / tool。工具循环、长 system、多图很容易把 20 条吃光——窗口只管近期原文,再往前的相关内容交给向量记忆。
默认 order 是 Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER(大约 HIGHEST_PRECEDENCE + 200),比 ToolCallingAdvisor(约 +300)靠外,所以记忆包住整次工具循环。
装配
ChatMemoryRepository
→ MessageWindowChatMemory
→ MessageChatMemoryAdvisor
→ ChatClient.defaultAdvisors(...)
→ 每次请求传 CONVERSATION_ID
加了 spring-ai-starter-model-chat-memory 之后,Boot 在缺少 Bean 时会提供 InMemoryChatMemoryRepository 和默认 MessageWindowChatMemory(maxMessages = 20)。Advisor 还是要自己挂:
@Configuration
class ChatMemoryConfig {
@Bean
MessageChatMemoryAdvisor messageChatMemoryAdvisor(ChatMemory chatMemory) {
return MessageChatMemoryAdvisor.builder(chatMemory).build();
}
@Bean
ChatClient chatClient(ChatClient.Builder builder,
MessageChatMemoryAdvisor memoryAdvisor) {
return builder
.defaultAdvisors(memoryAdvisor, new SimpleLoggerAdvisor())
.defaultSystem("你是简洁的技术助手。")
.build();
}
}
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory</artifactId>
</dependency>
要改窗口大小,声明自己的 ChatMemory Bean 即可(自动配置带 @ConditionalOnMissingBean):
@Bean
ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.maxMessages(20)
.build();
}
同一会话两轮:
String conversationId = "user-42-session-1";
String r1 = chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user("我叫 Tom,请记住。")
.call()
.content();
String r2 = chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user("我叫什么名字?")
.call()
.content();
第二轮换了 id 或漏传键,看起来就像模型「失忆」——先查键,别先怪模型。
仓库

| 仓库 | 适合 |
|---|---|
| InMemory | 单测、原型;starter 默认 |
| JDBC | 已有关系库,要审计 / 备份 |
| Redis | 低延迟、多实例共享 |
| Mongo / Cassandra / Neo4j | 已有对应集群 |
传 CONVERSATION_ID 的方式不变,换的是 Repository。
Redis(2.0.1 前缀):
spring:
ai:
chat:
memory:
repository:
redis:
host: localhost
port: 6379
JDBC 再加:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
配好 DataSource。既换仓库又改窗口:
@Bean
ChatMemory chatMemory(ChatMemoryRepository repository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(20)
.build();
}
接入时开一下 DEBUG,确认建表 / schema 是否正常。
产品上这把键怎么用

| 做法 | 效果 |
|---|---|
同一 conversationId | 接上历史 |
| 换新 id | 新会话 |
chatMemory.clear(id) | 清空该键;键还能继续用 |
| 多实例还用 InMemory | 请求打到不同节点就会偶发失忆 |
String newId = UUID.randomUUID().toString();
// 或
chatMemory.clear(oldId);
键可以前端生成,也可以后端按用户 + 会话拼。服务端要认同一把键;别拿未鉴权的客户端自称身份做隔离。
7.4 向量长期记忆
窗口裁掉的内容,如果还想按「和当前问题像不像」捞回来,用 VectorStoreChatMemoryAdvisor。
读写

它把对话写成向量库里的 Document,调用前用当前用户问题做相似检索,把命中结果写进 System 里的 LONG_TERM_MEMORY 段——不是往 Message 列表里塞一整段历史。
before
1. 取 CONVERSATION_ID 和当前 user 文本
2. similaritySearch(filter: metadata.conversationId == 该键)
3. 命中内容包成 <memory-entry>,XML 转义后填进 system 模板
4. 本轮 UserMessage 写入向量库
ChatModel.call / stream
after
5. 本轮 AssistantMessage 写入向量库
默认 defaultTopK 是 20;单次可以用 VectorStoreChatMemoryAdvisor.TOP_K(chat_memory_vector_store_top_k)改小。只走文本;图片等 media 不会按这条官方路径进向量记忆。模板里会写明:LONG_TERM_MEMORY 当历史数据看,不当指令。
装配
需要 Embedding、具体 VectorStore,以及:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vector-store-advisor</artifactId>
</dependency>
@Configuration
class LongTermMemoryConfig {
@Bean
VectorStoreChatMemoryAdvisor vectorStoreChatMemoryAdvisor(VectorStore vectorStore) {
return VectorStoreChatMemoryAdvisor.builder(vectorStore)
.defaultTopK(5)
.build();
}
@Bean
ChatClient chatClient(
ChatClient.Builder builder,
MessageChatMemoryAdvisor windowMemory,
VectorStoreChatMemoryAdvisor longTermMemory) {
return builder
.defaultAdvisors(windowMemory, longTermMemory)
.defaultSystem("你是简洁的技术助手。")
.build();
}
}
chatClient.prompt()
.advisors(a -> a
.param(ChatMemory.CONVERSATION_ID, conversationId)
.param(VectorStoreChatMemoryAdvisor.TOP_K, 3))
.user(userText)
.call()
.content();
键怎么过滤
Document metadata 里有 conversationId 和 messageType,检索带等值 filter。所以:
-
不会跨用户乱搜,先按键隔开,再在键内做相似检索。
-
和窗口共用
ChatMemory.CONVERSATION_ID,一次请求里两把 Advisor 吃同一键。 -
换 conversationId,向量侧旧片段也进不来,和窗口换键一样。
-
真要跨多个会话记用户偏好,要么用稳定的用户级 id 当这把键(窗口也会跟着变语义,或拆两个 Client / 自写 Advisor),要么会话结束把摘要写到带
userId的自建集合再 filter——这已经超出官方 Advisor 的默认能力。
删除用户时要能按键清向量数据。
和窗口一起用
最近几轮靠窗口连贯,更早的相关片段靠向量补。两边都占 token,maxMessages 和 topK 别一起开很大。排障时看最终 Prompt:窗口动的是 Message 列表,向量动的是 System。默认 order 同档,要固定先后就给其中一个设 .order(...)。同类记忆 Advisor 别挂两份。
官方路径每轮 user / assistant 都会 vectorStore.write,长对话成本不低。生产里常见改成会话结束再写摘要,或模型显式调「保存偏好」类工具才写入偏好表——这是产品策略,不是改个配置就有的开关。
7.5 用户偏好表
「说过什么相关的话」交给窗口或向量记忆;「花生过敏」「默认寄上海」这类字段,更适合写成你们业务里的一张偏好表(或用户画像字段),查出来再塞进 Prompt:
public record UserPreference(String key, String value, String source) {}
userPreferenceRepository.upsert(userId, new UserPreference("allergy", "花生", "user_said"));
String profile = userPreferenceRepository.renderForPrompt(userId);
chatClient.prompt()
.system("已知用户偏好:\n" + profile)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user(userText)
.call()
.content();
可以用结构化输出或 @Tool 从对话里抽出来再 upsert。这不是 Spring AI 自带的组件,就是普通业务表;别指望模型每次都能从杂乱聊天记录里把这类字段猜对。
7.6 别跟这几样搞混
三类都讲完了,剩下最容易拿错的是「看起来都像记住了点什么」。按场景对一下刀:
用户说过什么 vs 公司文档里写了什么 聊天原文、相关旧片段走窗口或向量对话记忆;制度、手册、产品说明走 RAG。别把用户闲聊当知识库文档灌进检索,也别指望知识库问答去「记住这个用户叫 Tom」。
向量长期记忆 vs 语义缓存 向量记忆是把相关历史喂进 Prompt,让模型接着聊。语义缓存是相似问题命中后直接短路,少调一次模型。一个补上下文,一个省钱加速,不是同一件事。
用户偏好表 vs 向量记忆 「花生过敏」「默认寄上海」这种必须准的字段,写偏好表再注入。向量适合「好像以前聊过类似的事」——召回相关,但不保证字段级正确。
对话记忆 vs 工作流断点 说过什么、偏好是什么,是 ChatClient 侧的账。图执行到哪一步、中断后怎么恢复,是另一套状态(checkpoint)。两本账不要假设会自动同步。
7.7 常见坑
| 现象 | 先看什么 |
|---|---|
| 第二轮不记得第一轮 | CONVERSATION_ID 是否相同、有没有漏传、有没有 clear |
| 空 id 直接失败 | 2.0 起不能传 null / 空串 |
| 挂了 Advisor 仍无历史 | 有没有进 defaultAdvisors;是不是同一个 ChatMemory;窗口是不是太小 |
| 多实例偶发失忆 | 是不是还在用 InMemory |
| Redis 不生效 | 前缀是不是 spring.ai.chat.memory.repository.redis.* |
| 历史重复 / 错乱 | 是否挂了两份记忆;窗口和向量是否叠太猛 |
| 工具轮次后上下文怪 | 窗口是否切在半截 tool;order 是否还包住 ToolCalling |
| 流式记忆不完整 | 有没有等到流结束再写回 |
| 换会话向量全空 | 官方按 conversationId 过滤,换键就是新分区 |
| 向量噪声大 | topK、Embedding 与 dimensions |
| 跨会话「长期记忆」没用 | 是不是每会话新 UUID;要不要用户级键或自建 filter |
| 多模态向量侧空白 | 官方只写文本 user / assistant |
7.8 小结
记忆不是 ChatClient 内置能力,而是 Advisor + 存储。先分清窗口、向量、用户偏好表各记什么,再按场景装配;会话键传对,比纠结模型「聪不聪明」更要紧。和 RAG、缓存、工作流状态分开记账,排障会省事很多。
207



被折叠的 条评论
为什么被折叠?



