当前位置: 首页 > news >正文

多轮对话的记忆心脏:ChatMemory 滑动窗口原理

Spring AI 源码解读 - 第 4 篇:ChatMemory 记忆管理

多轮对话的上下文维护机制

📖 开篇引言

多轮对话的关键是 AI 能够记住之前的对话内容。但如何高效地存储和检索这些消息?如何避免消息堆积导致的 Token 浪费?

本篇将深入ChatMemory的设计与实现,理解记忆管理的核心机制。


一、ChatMemory 接口设计

1.1 ChatMemory 接口

// org.springframework.ai.chat.memory.ChatMemorypublicinterfaceChatMemory{// 添加消息到指定会话voidadd(ConversationIdconversationId,Messagemessage);// 获取指定会话的所有消息List<Message>get(ConversationIdconversationId);// 获取指定会话的消息数量intgetMessageCount(ConversationIdconversationId);// 清空指定会话的消息voidclear(ConversationIdconversationId);}// 会话 ID 包装类publicrecordConversationId(Stringid){}

1.2 ChatMemory 的职责

ChatMemory ├── 存储:将消息持久化 ├── 检索:根据会话 ID 获取消息 ├── 管理:清空、统计消息 └── 隔离:不同会话的消息相互独立

二、MessageWindowChatMemory 实现

2.1 滑动窗口的概念

消息窗口大小 = 3 第 1 轮 ┌─────────────────────┐ │ [msg1, msg2, msg3] │ ← 窗口满 └─────────────────────┘ 第 2 轮(添加 msg4) ┌─────────────────────┐ │ [msg2, msg3, msg4] │ ← msg1 被移出 └─────────────────────┘ 第 3 轮(添加 msg5) ┌─────────────────────┐ │ [msg3, msg4, msg5] │ ← msg2 被移出 └─────────────────────┘

2.2 MessageWindowChatMemory 源码

// org.springframework.ai.chat.memory.MessageWindowChatMemorypublicclassMessageWindowChatMemoryimplementsChatMemory{privatefinalintmaxMessages;// 最大消息数privatefinalMap<ConversationId,List<Message>>conversationHistory;// 构造方法publicMessageWindowChatMemory(intmaxMessages){this.maxMessages=maxMessages;this.conversationHistory=newConcurrentHashMap<>();}// 工厂方法:创建默认实例(最多 100 条消息)publicstaticMessageWindowChatMemorycreate(){returnnewMessageWindowChatMemory(100);}@Overridepublicvoidadd(ConversationIdconversationId,Messagemessage){// 1. 获取或创建该会话的消息列表List<Message>messages=conversationHistory.computeIfAbsent(conversationId,k->newArrayList<>());// 2. 添加新消息messages.add(message);// 3. 如果超过窗口大小,移除最旧的消息if(messages.size()>this.maxMessages){messages.remove(0);// 移除第一条(最旧)}}@OverridepublicList<Message>get(ConversationIdconversationId){// 返回该会话的所有消息(已在窗口内)returnconversationHistory.getOrDefault(conversationId,List.of());}@OverridepublicintgetMessageCount(ConversationIdconversationId){returnconversationHistory.getOrDefault(conversationId,List.of()).size();}@Overridepublicvoidclear(ConversationIdconversationId){conversationHistory.remove(conversationId);}}

2.3 滑动窗口的优缺点

优点缺点
实现简单可能丢失重要的早期消息
内存占用固定无法区分消息重要性
性能高对长对话支持不足

三、Token 计数与消息截断

3.1 为什么需要 Token 计数?

模型的上下文窗口大小是有限的 例如:Ollama qwen2.5:14b 的上下文窗口 = 32K tokens 如果消息总 Token 数超过上下文窗口,模型会报错

3.2 TokenTextSplitter 的 Token 计数

// org.springframework.ai.document.TokenTextSplitterpublicclassTokenTextSplitterimplementsTextSplitter{privatefinalintchunkSize;// 每个块的 Token 数privatefinalintchunkOverlap;// 块之间的重叠 Token 数privatefinalTokenizertokenizer;// Token 计数器// 计算文本的 Token 数publicintcountTokens(Stringtext){returnthis.tokenizer.countTokens(text);}// 分割文本publicList<String>split(Stringtext){List<String>chunks=newArrayList<>();List<Integer>tokenCounts=newArrayList<>();// 1. 计算每个块的 Token 数for(Stringchunk:text.split("\n")){inttokens=countTokens(chunk);if(tokens>this.chunkSize){// 如果单个块超过大小,继续分割chunks.addAll(splitLargeChunk(chunk));}else{chunks.add(chunk);}}returnchunks;}}

3.3 消息截断策略

// 在 ChatMemory 中实现 Token 限制publicclassTokenLimitedChatMemoryimplementsChatMemory{privatefinalintmaxTokens;// 最大 Token 数privatefinalTokenizertokenizer;privatefinalMap<ConversationId,List<Message>>conversationHistory;@Overridepublicvoidadd(ConversationIdconversationId,Messagemessage){List<Message>messages=conversationHistory.computeIfAbsent(conversationId,k->newArrayList<>());messages.add(message);// 检查总 Token 数while(getTotalTokens(messages)>this.maxTokens){// 移除最旧的消息messages.remove(0);}}// 计算消息列表的总 Token 数privateintgetTotalTokens(List<Message>messages){returnmessages.stream().mapToInt(msg->tokenizer.countTokens(msg.getContent())).sum();}}

四、ConversationId 与会话隔离

4.1 ConversationId 的作用

// 不同用户的会话需要隔离ChatMemorymemory=newMessageWindowChatMemory(100);// 用户 A 的会话ConversationIdsessionA=newConversationId("user-a-session-1");memory.add(sessionA,newUserMessage("我想学 Java"));memory.add(sessionA,newAssistantMessage("Java 是..."));// 用户 B 的会话ConversationIdsessionB=newConversationId("user-b-session-1");memory.add(sessionB,newUserMessage("我想学 Python"));memory.add(sessionB,newAssistantMessage("Python 是..."));// 获取消息时完全隔离List<Message>messagesA=memory.get(sessionA);// 只包含 A 的消息List<Message>messagesB=memory.get(sessionB);// 只包含 B 的消息

4.2 会话 ID 的生成策略

// 策略 1:基于线程 ID(单线程场景)StringsessionId=String.valueOf(Thread.currentThread().getId());// 策略 2:基于用户 IDStringsessionId="user-"+userId;// 策略 3:基于 HTTP Session IDStringsessionId=httpSession.getId();// 策略 4:基于 UUID(每次对话新建)StringsessionId=UUID.randomUUID().toString();

五、ChatMemory 在 Advisor 中的使用

5.1 MessageChatMemoryAdvisor 的完整流程

publicclassMessageChatMemoryAdvisorimplementsCallAroundAdvisor,StreamAroundAdvisor{privatefinalChatMemorychatMemory;// 前置处理:注入历史消息@OverridepublicAdvisedRequestbefore(AdvisedRequestadvisedRequest){// 1. 获取会话 IDStringsessionId=getSessionId(advisedRequest);ConversationIdconversationId=newConversationId(sessionId);// 2. 从 ChatMemory 获取历史消息List<Message>historyMessages=this.chatMemory.get(conversationId);// 3. 将历史消息注入到请求中// 注入顺序:SystemMessage → HistoryMessages → CurrentUserMessageList<Message>allMessages=newArrayList<>();// 添加系统消息(如果有)if(advisedRequest.getSystemText()!=null){allMessages.add(newSystemMessage(advisedRequest.getSystemText()));}// 添加历史消息allMessages.addAll(historyMessages);// 添加当前用户消息allMessages.addAll(advisedRequest.getUserMessage());// 4. 更新请求returnAdvisedRequest.from(advisedRequest).userMessage(allMessages).build();}// 后置处理:保存消息到 ChatMemory@OverridepublicChatResponseafter(AdvisedRequestadvisedRequest,AdvisedResponse<ChatResponse>advisedResponse){StringsessionId=getSessionId(advisedRequest);ConversationIdconversationId=newConversationId(sessionId);// 1. 保存用户消息for(Messagemsg:advisedRequest.getUserMessage()){if(msginstanceofUserMessage){this.chatMemory.add(conversationId,msg);}}// 2. 保存 AI 回复ChatResponseresponse=advisedResponse.getChatResponse();AssistantMessageassistantMessage=newAssistantMessage(response.getResult().getOutput().getContent());this.chatMemory.add(conversationId,assistantMessage);returnresponse;}}

5.2 记忆注入的完整示例

第 1 轮调用 ┌─────────────────────────────────────────┐ │ before() │ │ ChatMemory.get(sessionId) → [] │ │ 注入消息:[SystemMessage, UserMessage] │ └─────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────┐ │ doChat() │ │ chatModel.call(prompt) │ │ 返回 AssistantMessage │ └─────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────┐ │ after() │ │ ChatMemory.add(sessionId, UserMessage) │ │ ChatMemory.add(sessionId, AssistantMsg) │ └─────────────────────────────────────────┘ 第 2 轮调用 ┌─────────────────────────────────────────┐ │ before() │ │ ChatMemory.get(sessionId) → │ │ [UserMessage, AssistantMessage] │ │ 注入消息:[SystemMessage, │ │ UserMessage(历史), │ │ AssistantMessage(历史), │ │ UserMessage(当前)] │ └─────────────────────────────────────────┘

六、分布式记忆存储

6.1 为什么需要分布式记忆?

单机 ChatMemory 的问题: - 应用重启后消息丢失 - 多实例部署时消息不共享 - 无法跨应用访问 解决方案:使用 Redis 等分布式存储

6.2 Redis 实现的 ChatMemory

// 基于 Redis 的 ChatMemory 实现publicclassRedisChatMemoryimplementsChatMemory{privatefinalRedisTemplate<String,Message>redisTemplate;privatefinalStringkeyPrefix="chat:memory:";@Overridepublicvoidadd(ConversationIdconversationId,Messagemessage){// 1. 构建 Redis keyStringkey=keyPrefix+conversationId.id();// 2. 将消息序列化后存储redisTemplate.opsForList().rightPush(key,message);// 3. 设置过期时间(24 小时)redisTemplate.expire(key,Duration.ofHours(24));}@OverridepublicList<Message>get(ConversationIdconversationId){Stringkey=keyPrefix+conversationId.id();// 从 Redis 获取所有消息returnredisTemplate.opsForList().range(key,0,-1);}@Overridepublicvoidclear(ConversationIdconversationId){Stringkey=keyPrefix+conversationId.id();redisTemplate.delete(key);}}

6.3 Redis 中的数据结构

Redis 中的存储结构: chat:memory:user-a-session-1 ├── [0] UserMessage("我想学 Java") ├── [1] AssistantMessage("Java 是...") ├── [2] UserMessage("它有哪些特点?") └── [3] AssistantMessage("Java 的特点是...") chat:memory:user-b-session-1 ├── [0] UserMessage("我想学 Python") └── [1] AssistantMessage("Python 是...")

七、ChatMemory 的生命周期

7.1 创建

// 方式 1:默认实现ChatMemorymemory=MessageWindowChatMemory.create();// 方式 2:自定义大小ChatMemorymemory=newMessageWindowChatMemory(50);// 方式 3:Redis 实现ChatMemorymemory=newRedisChatMemory(redisTemplate);

7.2 使用

// 在 ChatClient 中使用ChatClientchatClient=ChatClient.builder(chatModel).defaultAdvisors(newMessageChatMemoryAdvisor(memory)).build();// 自动注入记忆chatClient.prompt().user("你好").call();

7.3 清理

// 清空特定会话的记忆memory.clear(newConversationId("user-a-session-1"));// 或者依赖过期时间自动清理(Redis)

八、小结

8.1 本篇要点

主题核心要点
ChatMemory 接口add / get / clear 三个核心方法
MessageWindowChatMemory滑动窗口实现,固定消息数量
Token 计数防止消息堆积导致的 Token 溢出
ConversationId会话隔离,不同用户消息独立
Advisor 集成before() 注入记忆,after() 保存消息
分布式存储Redis 实现跨应用、跨实例的记忆共享

8.2 关键类清单

类 / 接口职责
ChatMemory记忆接口
MessageWindowChatMemory滑动窗口实现
RedisChatMemoryRedis 分布式实现
ConversationId会话 ID 包装类
MessageChatMemoryAdvisor记忆注入拦截器
TokenTextSplitterToken 计数与分割

系列目录

  • 第 1 篇:整体架构与核心抽象
  • 第 2 篇:ChatClient 调用链路
  • 第 3 篇:Prompt 与 Message 体系
  • 第 4 篇:ChatMemory 记忆管理(本篇)

需要Spring AI系列学习代码的同学
欢迎关注公众号「AI日撰」,点击菜单「获取源码」获取完整代码(Gitee 仓库)。


http://www.jsqmd.com/news/607395/

相关文章:

  • 如何3步免费激活Cursor Pro:AI编程助手破解工具终极指南
  • 自动化机器学习:H2O、TPOT、AutoGluon 核心框架解析与测试实践
  • 西交大:多组学生存分析
  • 智能垃圾桶的物联网升级实战:用ESP8266+STM32实现远程监控(MQTT协议详解)
  • Arduino Modbus主站库SensorModbusMaster实战指南
  • 怎样快速提升Windows性能:开源工具Win11Debloat的完整优化指南
  • ArcGIS新手避坑指南:处理三调数据DLTB时,关于‘请查询:DLBM’的那些事儿
  • 边缘AI部署:TensorFlow Lite与ONNX Runtime的技术架构与应用挑战——面向软件测试从业者的深度解析
  • 第17章 增长推广:让更多人知道你
  • 如何免费解锁SonarQube社区版的分支分析:完整安装指南
  • DeepSeek V4全面转向华为昇腾,国产算力生态迎来里程碑
  • OmenSuperHub:释放硬件潜能的游戏本性能管理革新
  • 嘉立创EDA专业版与Photoshop联袂:不规则面板设计全流程解析
  • 实战指南:将CrowdHuman数据集ODGT标注高效适配YOLO训练流程
  • 千万级数据表优化:分库分表、分区、索引最佳实践生产实战
  • 多模态开发工具:LangChain与LlamaIndex——赋能软件测试的新引擎
  • STPopup底部表单设计:如何创建类似iOS原生控件的用户体验
  • 网易云音乐推荐算法如何精准调校?这款免费工具帮你快速重塑音乐品味
  • 抖音直播回放智能下载工具:从技术实现到价值创造的完整指南
  • Cuvil编译器安全边界实测报告(CVE-2024-38291绕过防护+Tensor级IR验证缺失预警)
  • 别再只抄代码了!ESP32蓝牙网关项目实战,这些配置细节和调试技巧才是关键
  • 抖音视频批量下载实战:3分钟搞定无水印收藏,高效管理你的数字内容
  • 第19章 AI创业趋势:看见未来
  • 硬件器件考核题库【器件选型+应力考核+答案解析】-3(熔断器)
  • Envato Elements学生优惠全攻略:如何用教育折扣每天4元无限下载百万素材
  • Blue-Topaz主题高效配置指南:5分钟打造个性化Obsidian笔记环境
  • 紫光Pango开发环境避坑指南:从License申请到Synplify版本回退的完整踩坑记录
  • 第18章 规模化与团队建设:从个人到组织
  • BetterGenshinImpact:智能自动化游戏辅助工具的技术实践与应用指南
  • R3nzSkin开源工具:重新定义英雄联盟视野控制的技术实践