1. 本期目标
上一篇文章分析了 Advisor 机制。我们已经知道,ai_agent 项目不是直接把用户问题发给模型,而是通过:
ChatClient
↓
Advisor
↓
ChatMemory
↓
ChatModel
来组织完整的智能体调用链路。
这一期继续分析其中一个非常重要的能力:
多轮对话记忆
在项目 README 中,ai_agent 明确支持基于 conversationId 的上下文连续对话,并且当前 LoveApp 默认启用的是内存版 MessageWindowChatMemory。项目还提供了一个基于 Kryo 的文件式 ChatMemory 实现,用于支持服务重启后的历史会话恢复。(GitHub)
本期主要解决几个问题:
1. 为什么大模型本身没有真正的"记忆"?
2. ChatMemory 和 Chat History 有什么区别?
3. MessageWindowChatMemory 是什么?
4. InMemoryChatMemoryRepository 起什么作用?
5. MessageChatMemoryAdvisor 如何接入记忆?
6. conversationId 为什么是多轮对话的关键?
7. LoveApp 中的 doChat 如何实现上下文连续?
8. 当前内存版记忆有什么不足?
9. 后续为什么需要 FileBasedChatMemory 或数据库记忆?
2. 为什么需要 ChatMemory?
大模型本身是无状态的。
也就是说,每次请求对模型来说都是一次新的输入。如果系统没有把上一轮对话内容重新带给模型,模型并不会自动知道用户前面说过什么。Spring AI 官方文档也明确说明,LLM 不会保留前一次交互的信息,因此需要通过 Chat Memory 在多次交互之间存储和检索上下文。(Home)
例如,没有记忆时可能是这样:
用户:我和女朋友最近总吵架。
AI:可以先冷静下来,找一个合适的时间沟通。
用户:那我应该怎么开口?
AI:你指什么事情?
第二轮回答中,模型不知道"怎么开口"指的是和女朋友吵架后的沟通。
有记忆时,效果应该是:
用户:我和女朋友最近总吵架。
AI:可以先冷静下来,找一个合适的时间沟通。
用户:那我应该怎么开口?
AI:你可以先从表达感受开始,比如"我最近也在反思我们吵架的问题......"
这就是多轮对话记忆的作用:
让模型在当前轮回答中能够参考前几轮对话内容。
3. ChatMemory 不是模型真的记住了
这里需要先澄清一个常见误解。
所谓"模型有记忆",并不是模型参数发生了变化,也不是模型真的把用户信息永久记住了。
更准确的说法是:
系统把历史对话保存下来,
在下一次请求时重新取出相关历史,
再和当前问题一起交给模型。
也就是说,记忆主要是由应用层实现的,而不是由模型本身实现的。
可以理解为:
模型本身:
只处理当前输入。
应用系统:
负责保存历史消息。
ChatMemory:
负责管理哪些历史消息需要进入当前上下文。
Advisor:
负责把这些消息接入 ChatClient 调用流程。
所以,ChatMemory 的本质是:
上下文管理机制
而不是模型内部的长期记忆。
4. ChatMemory 和 Chat History 的区别
Spring AI 文档中特别区分了两个概念:
Chat Memory
Chat History
Chat Memory 指的是模型当前回答时需要使用的上下文信息;Chat History 指的是完整的聊天记录,包括用户和模型之间交换过的所有消息。Spring AI 文档也说明,ChatMemory 适合管理当前会话上下文,但完整聊天历史更适合用 Spring Data 等方式单独保存。(Home)
这两个概念不能混在一起。
可以这样理解:
Chat History:
完整聊天记录,适合审计、回看、导出、用户历史页面。
Chat Memory:
当前模型调用需要带入的上下文,适合帮助模型理解当前问题。
例如一个用户和系统聊了 100 轮。
完整历史是:
100 轮全部消息
但真正传给模型的记忆可能只需要:
最近 10 条消息
因为模型上下文长度有限,全部塞进去会浪费 token,也可能引入无关信息。
所以,ChatMemory 的重点不是"保存越多越好",而是:
在上下文成本和回答质量之间取得平衡。
5. 项目中的记忆模块在哪里?
项目的记忆相关代码位于:
src/main/java/com/ai/aiagent/chatmemory
从 GitHub 目录可以看到,目前该目录下主要有一个自定义实现:
FileBasedChatMemory.java
也就是说,项目当前自定义实现的记忆模块主要是文件式持久化记忆。(GitHub)
不过在 LoveApp 当前默认运行链路中,使用的并不是文件式记忆,而是 Spring AI 提供的内存版窗口记忆。
这一点从 LoveApp 构造函数可以看到:
String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory";
// ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
源码中先定义了 tmp/chat-memory 目录,并保留了一行被注释的 FileBasedChatMemory,随后实际创建的是 MessageWindowChatMemory,底层仓库是 InMemoryChatMemoryRepository。(GitHub)
所以当前项目的默认记忆方案是:
MessageWindowChatMemory + InMemoryChatMemoryRepository
6. MessageWindowChatMemory 是什么?
MessageWindowChatMemory 可以理解为"窗口式聊天记忆"。
它不会无限保存所有历史消息,而是保留一个消息窗口。
Spring AI 文档说明,MessageWindowChatMemory 会维护一个指定最大大小的消息窗口;当消息数量超过最大值时,较旧消息会被移除,同时保留系统消息;默认窗口大小是 20 条消息。(Home)
可以简单理解为:
只保留最近 N 条消息作为上下文。
例如设置最大消息数为 10 时:
第 1 条消息
第 2 条消息
...
第 10 条消息
当第 11 条消息加入时,最早的消息会被移出窗口。
这样做的目的有三个:
第一,控制 token 成本。
第二,避免上下文过长。
第三,让模型关注最近对话。
对恋爱咨询这种场景来说,最近几轮对话通常比很早之前的内容更重要,所以窗口式记忆是比较合理的入门方案。
7. InMemoryChatMemoryRepository 是什么?
MessageWindowChatMemory 负责决定"保留哪些消息"。
InMemoryChatMemoryRepository 负责决定"消息存在哪里"。
Spring AI 文档说明,InMemoryChatMemoryRepository 使用内存保存消息,并且内部使用 ConcurrentHashMap 存储。(Home)
可以理解为:
ChatMemory:
管理记忆策略。
ChatMemoryRepository:
管理记忆存储。
InMemoryChatMemoryRepository:
把记忆存在内存里。
当前 LoveApp 中的代码是:
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
这表示:
记忆策略:
使用窗口式记忆。
存储方式:
使用内存存储。
这种方式适合学习和测试,因为实现简单、启动方便、不需要额外数据库。
但它也有明显缺点:
服务重启后,对话记忆会丢失。
多实例部署时,不同实例之间不能共享记忆。
内存占用会随着会话数量增加而增加。
所以它适合项目早期验证,不适合直接作为正式生产方案。
8. MessageChatMemoryAdvisor 的作用
仅仅创建 ChatMemory 还不够。
因为 ChatMemory 只是一个记忆对象,它自己不会自动参与模型调用。
项目通过 MessageChatMemoryAdvisor 把 ChatMemory 接入 ChatClient 主链路。
在 LoveApp 构造函数中,项目这样配置默认 Advisor:
chatClient = ChatClient.builder(dashscopeChatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
new MyLoggerAdvisor()
)
.build();
源码显示,MessageChatMemoryAdvisor 和 MyLoggerAdvisor 被作为默认 Advisor 接入了 ChatClient,因此后续通过该 chatClient 发起的对话默认具备记忆和日志能力。(GitHub)
MessageChatMemoryAdvisor 的作用可以理解为:
模型调用前:
根据 conversationId 读取历史消息,并加入当前 Prompt。
模型调用后:
把本轮用户消息和模型回答写回 ChatMemory。
所以完整关系是:
InMemoryChatMemoryRepository
↓
MessageWindowChatMemory
↓
MessageChatMemoryAdvisor
↓
ChatClient
其中:
Repository 负责存储
ChatMemory 负责策略
Advisor 负责接入模型调用
9. conversationId 为什么关键?
多轮对话的核心不是简单保存消息,而是要知道:
哪些消息属于同一段会话?
这就需要 conversationId。
在 LoveApp 的 doChat() 方法中,项目通过这一行传入当前会话 ID:
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, chatId))
源码中 doChat() 接收 message 和 chatId,然后在 Advisor 参数中设置 ChatMemory.CONVERSATION_ID,最后调用模型并返回结果。(GitHub)
Spring AI 文档也说明,使用 MessageChatMemoryAdvisor 时,会根据指定的 conversation ID 从记忆中取出会话历史;ChatMemory.CONVERSATION_ID 是记忆 Advisor 所需的参数。(Home)
可以把 conversationId 理解成:
会话的唯一编号
例如:
conversationId = user_1001_chat_001
表示用户 1001 的第 1 个聊天会话。
同一个 conversationId 下的消息会被串起来。
不同 conversationId 下的消息会相互隔离。
10. doChat 的多轮对话流程
doChat() 的核心代码可以简化为:
public String doChat(String message, String chatId) {
ChatResponse response = chatClient
.prompt()
.user(message)
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, chatId))
.call()
.chatResponse();
return response.getResult().getOutput().getText();
}
从多轮记忆角度看,它的执行流程是:
用户输入 message
↓
传入 chatId
↓
ChatClient 开始构造请求
↓
MessageChatMemoryAdvisor 根据 chatId 读取历史消息
↓
历史消息 + 当前用户消息一起进入模型上下文
↓
模型生成回答
↓
Advisor 把本轮消息写回 ChatMemory
↓
返回结果
所以,doChat() 本身没有手动拼接历史上下文。
历史上下文的读取和写入由:
MessageChatMemoryAdvisor
完成。
这就是 Spring AI 中比较推荐的写法:业务方法只需要传入 conversationId,记忆管理交给 Advisor。
11. 用例理解:同一个 chatId
假设用户第一次发送:
message = "我和女朋友最近总是吵架"
chatId = "chat_001"
系统会把这轮对话存到 chat_001 对应的记忆中。
第二次用户继续发送:
message = "那我应该怎么开口沟通?"
chatId = "chat_001"
因为 chatId 仍然是 chat_001,所以系统会取出前一轮内容。
模型看到的上下文大致是:
用户:我和女朋友最近总是吵架
AI:可以先冷静分析矛盾来源......
用户:那我应该怎么开口沟通?
因此模型知道"怎么开口沟通"指的是前面提到的恋爱矛盾。
这就是多轮对话的连续性。
12. 用例理解:不同 chatId
如果用户换了一个会话:
message = "我想准备一次约会"
chatId = "chat_002"
那么这个问题不会读取 chat_001 的上下文。
系统会把它看作另一段独立对话:
chat_001:
关于吵架沟通
chat_002:
关于约会规划
这样做有两个好处:
第一,不同话题不会相互干扰。
第二,不同用户或不同会话之间可以隔离上下文。
所以 chatId 的设计非常重要。
它不仅是技术参数,也是会话隔离的边界。
13. 为什么不能所有用户共用一个 chatId?
如果所有用户共用一个 chatId,会出现严重问题。
例如:
用户 A:我和女朋友吵架了。
用户 B:我想准备一场表白。
如果两个人共用同一个会话 ID,用户 B 的回答可能受到用户 A 对话内容影响。
这会导致:
上下文污染
隐私泄露
回答混乱
用户体验变差
所以在正式系统中,chatId 应该和用户身份绑定。
更合理的设计是:
userId + sessionId
例如:
user_1001_session_20260519_001
这样可以保证:
同一用户的不同会话可以隔离。
不同用户的会话不会混在一起。
14. 当前项目为什么适合用内存记忆?
当前 ai_agent 项目更偏向"智能体能力验证 / 工程实践样例",README 中也说明项目当前已经具备智能体主链路、RAG 知识增强、工具调用执行和文件式记忆扩展,但 Web 层接口较少,主要是健康检查,后续可以继续补充会话管理接口等能力。(GitHub)
所以在当前阶段使用内存记忆是合理的。
因为项目重点不是先做完整用户系统,而是先验证:
ChatClient 能不能跑通
Advisor 能不能接入
ChatMemory 能不能工作
RAG 能不能增强
Tool Calling 能不能调用
内存记忆的优势是:
配置简单
无需数据库
方便调试
适合单机测试
对学习项目来说,这种方案足够清晰。
15. 内存记忆的问题
但是,内存记忆也有明显局限。
15.1 服务重启后记忆丢失
因为消息存在内存中,所以应用一旦重启,历史对话就没有了。
这意味着:
用户上午聊过的问题,
服务重启后下午再问,
模型无法继续参考上午的内容。
15.2 多实例部署时无法共享
如果项目部署了多个实例:
实例 A
实例 B
实例 C
用户第一次请求打到实例 A,第二次请求打到实例 B,那么实例 B 的内存里可能没有第一次对话。
这会导致上下文不连续。
15.3 不适合做历史记录查询
内存版 ChatMemory 主要服务于模型上下文,不适合做完整聊天记录管理。
如果后续需要做:
历史会话列表
聊天记录回看
消息搜索
审计日志
用户数据导出
就应该单独设计 Chat History 存储。
15.4 隐私和生命周期难管理
用户对话内容可能涉及隐私。
如果所有内容都存在内存里,后续需要明确:
什么时候清除?
谁有权限清除?
是否支持用户删除会话?
是否需要敏感信息脱敏?
这些都是正式系统中必须考虑的问题。
16. FileBasedChatMemory 的位置
项目已经考虑到了内存记忆的不足,所以提供了:
FileBasedChatMemory
README 中说明,该实现使用 Kryo 对消息对象进行序列化,按 conversationId 写入本地文件,并支持服务重启后恢复历史会话。(GitHub)
在 LoveApp 中也能看到切换入口:
String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory";
// ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
也就是说,只要把当前的内存版 ChatMemory 替换成 FileBasedChatMemory,就可以把对话记忆保存到文件中。(GitHub)
不过这一期先不展开它的内部实现。
下一期会专门分析:
FileBasedChatMemory 文件式持久化记忆
包括:
Kryo 如何序列化消息
conversationId 如何映射成本地文件
add / get / clear 方法如何实现
文件式记忆有什么优点和风险
17. ChatMemory 与 RAG 的区别
学习这里时,还容易把 ChatMemory 和 RAG 混淆。
它们都能给模型补充上下文,但来源不一样。
ChatMemory:
补充的是当前会话的历史对话。
RAG:
补充的是外部知识库中的文档内容。
例如:
ChatMemory 提供:
用户前面说过"我和女朋友异地恋"。
RAG 提供:
知识库中关于异地恋沟通方法的文档片段。
在 LoveApp 的 doChatWithRag() 中,项目同时使用了:
ChatMemory.CONVERSATION_ID
QuestionAnswerAdvisor
源码中可以看到,RAG 对话方法先设置 ChatMemory.CONVERSATION_ID,然后添加 QuestionAnswerAdvisor(loveAppVectorStore)。这说明 RAG 对话也可以同时具备多轮记忆和知识库检索能力。(GitHub)
所以二者关系是:
ChatMemory 解决"我前面说过什么"
RAG 解决"知识库里有什么"
18. ChatMemory 与 Tool Calling 的关系
doChatWithTools() 中也传入了:
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, chatId))
然后再通过:
.toolCallbacks(allTools)
注入工具。源码中 doChatWithTools() 正是这样组合记忆和工具调用能力的。(GitHub)
这说明工具调用对话也可以使用会话记忆。
例如:
第一轮:
用户:我想在杭州安排一次约会,预算 500 元。
第二轮:
用户:帮我找几个适合晚上去的地方。
第二轮中,模型可以通过记忆知道:
地点:杭州
预算:500 元
任务:约会规划
然后再调用搜索工具。
不过需要注意,Spring AI 当前文档中提到,在执行工具调用时,LLM 与工具之间的中间消息目前不会自动存储到 memory 中。(Home)
这意味着正式系统中如果想完整记录工具调用过程,还需要额外做工具调用日志或审计记录。
19. 当前记忆链路的完整图
综合起来,ai_agent 当前默认的多轮对话链路可以画成:
用户输入 message + chatId
↓
LoveApp.doChat()
↓
ChatClient.prompt()
↓
.user(message)
↓
.advisors(ChatMemory.CONVERSATION_ID = chatId)
↓
MessageChatMemoryAdvisor
↓
MessageWindowChatMemory
↓
InMemoryChatMemoryRepository
↓
读取当前 chatId 的历史消息
↓
历史消息 + 当前消息进入模型
↓
模型生成回答
↓
保存本轮用户消息和 AI 回答
↓
返回最终内容
一句话概括:
chatId 决定是哪段会话,
MessageChatMemoryAdvisor 负责接入记忆,
MessageWindowChatMemory 负责控制窗口,
InMemoryChatMemoryRepository 负责存储消息。
20. 当前设计的优点
20.1 代码简洁
业务方法里没有手动写历史消息拼接逻辑。
只需要:
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, chatId))
记忆读取和写入由 MessageChatMemoryAdvisor 完成。
20.2 易于扩展
当前是:
MessageWindowChatMemory + InMemoryChatMemoryRepository
后续可以替换成:
MessageWindowChatMemory + JDBC Repository
MessageWindowChatMemory + Mongo Repository
FileBasedChatMemory
自定义 RedisChatMemory
主对话链路不用大改。
20.3 适合学习 Spring AI
这个项目很好地展示了 Spring AI 的推荐思路:
ChatClient 负责模型调用
Advisor 负责对话增强
ChatMemory 负责上下文管理
ChatMemoryRepository 负责消息存储
对于学习 Agent 工程结构来说,这比手写 prompt 拼接更规范。
20.4 支持多种对话模式复用
同一个记忆机制可以用于:
普通对话
结构化报告
RAG 对话
工具调用对话
源码中 doChat()、doChatWithReport()、doChatWithRag()、doChatWithTools() 都传入了 ChatMemory.CONVERSATION_ID,说明这些链路都可以共享会话记忆机制。(GitHub)
21. 当前设计可以改进的地方
21.1 明确设置 maxMessages
当前 MessageWindowChatMemory.builder() 没有显式设置最大消息数。
可以改成:
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.maxMessages(10)
.build();
这样更容易让读者或维护者理解:
最多保留多少条上下文消息。
21.2 chatId 应该由系统生成
当前方法直接接收 chatId。
正式系统中最好不要让前端随便传任意 chatId,而应该由系统生成并绑定用户。
例如:
userId + sessionId
这样可以减少会话串用和越权访问风险。
21.3 ChatMemory 和 Chat History 分开设计
ChatMemory 只负责模型当前上下文。
完整聊天记录建议单独保存。
例如:
chat_memory:
只保存模型需要的最近上下文。
chat_message_history:
保存完整消息历史,用于用户查看和系统审计。
这样结构更清晰。
21.4 增加清除会话接口
当前 FileBasedChatMemory 中实现了 clear(conversationId) 方法,可以按会话删除文件。(GitHub)
如果后续做 Web 接口,可以增加:
删除某个会话
清空当前用户所有会话
导出聊天记录
这样会话管理能力会更完整。
21.5 生产环境需要持久化记忆
内存记忆适合学习和单机测试。
正式系统更适合使用:
MySQL
PostgreSQL
Redis
MongoDB
或者项目中已经实现的:
FileBasedChatMemory
其中,文件式记忆适合轻量持久化;数据库记忆更适合多用户、多实例、可查询的正式系统。
22. 本期重点理解
这一期最重要的是理解 ChatMemory 的工程定位。
可以总结为五点:
第一,大模型本身是无状态的,多轮对话需要应用层保存上下文。
第二,ChatMemory 管理的是当前模型调用需要使用的上下文,不等同于完整聊天历史。
第三,MessageWindowChatMemory 通过窗口机制控制进入上下文的消息数量。
第四,InMemoryChatMemoryRepository 把消息存在内存中,适合学习和测试。
第五,MessageChatMemoryAdvisor 负责把 ChatMemory 接入 ChatClient 主链路。
一句话概括:
ChatMemory 的作用,是根据 conversationId 管理当前会话上下文,让模型在多轮对话中能够理解前后文。
23. 我的理解
我认为 ai_agent 项目中的多轮对话设计,最值得学习的是它没有把"历史消息拼接"写死在业务方法里。
它采用的是:
ChatMemory + MessageChatMemoryAdvisor + conversationId
这种结构。
这样做的好处是:
业务代码保持简单
记忆机制可以替换
不同对话链路可以复用
后续扩展持久化更自然
从工程角度看,这比手动把历史消息拼到 prompt 里更清晰。
可以把它理解成:
conversationId 负责区分会话
ChatMemory 负责保存上下文
Advisor 负责把上下文接入模型
ChatClient 负责完成模型调用
这个结构是后续理解 RAG、工具调用、文件式记忆的基础。
24. 本期小结
本期主要分析了 ai_agent 项目中的多轮对话与 ChatMemory 机制。
大模型本身是无状态的,因此项目需要在应用层保存历史对话,并在下一轮调用时重新带入上下文。LoveApp 当前默认使用 MessageWindowChatMemory 作为窗口式记忆策略,使用 InMemoryChatMemoryRepository 作为内存存储,并通过 MessageChatMemoryAdvisor 将记忆接入 ChatClient 主链路。在具体调用中,doChat() 等方法通过 ChatMemory.CONVERSATION_ID 传入 chatId,从而让系统知道当前请求属于哪一段会话。相同 chatId 下的消息会形成连续上下文,不同 chatId 下的消息相互隔离。
这一期可以用一句话总结:
ai_agent 的多轮对话机制,本质上是通过 conversationId 找到对应会话记忆,再由 MessageChatMemoryAdvisor 将历史消息注入 ChatClient 调用链路。
下一期可以继续分析:
AI Agent 项目学习笔记(五):FileBasedChatMemory 文件式持久化记忆
下一期重点分析 FileBasedChatMemory 的源码实现,包括 Kryo 序列化、add()、get()、clear()、getOrCreateConversation()、saveConversation() 和 conversationId.kryo 文件存储方式。