Spring AI会话记忆实战:ChatMemory选型与消息窗口设计

发布时间:2026/9/28 23:22:02
Spring AI会话记忆实战:ChatMemory选型与消息窗口设计 1. Day2为什么必须解决“记忆”问题昨天把社区志愿助手的第一个版本跑通之后我本来觉得今天是个轻松活儿——把对话历史接进Spring AI让AI记住用户之前说过什么。结果一上手才发现会话记忆远不是“把聊天记录攒起来”这么简单。先交代一下项目背景。这个社区志愿助手的目标很朴素居民问活动报名、志愿者招募、积分兑换这类问题时不用反复跟AI交代自己是谁、之前问过什么。比如一个用户昨天刚报名了周末的环保活动今天再问“我的活动几点集合”AI如果能直接回答“您报名的环保活动是周六上午9点集合”体验就完全不一样了。Day2的明确目标有三个一是让AI在单次会话内记住上下文二是让不同用户之间的对话彻底隔离三是为后续“跨会话长期记忆”留好扩展位。最后这一点很重要因为社区志愿场景下用户的身份和状态往往要跨天甚至跨月保留不能每次打开对话框都从零开始。会话记忆在Spring AI里的实现路径其实很清晰核心是一个叫ChatMemory的抽象再配上Advisor在每次请求前后自动加载和保存消息。但选哪条实现、怎么存、存哪些字段、什么时候翻窗口处处都是决策点。今天基本把这条链路走通了也踩了几个不算小的坑这篇复盘把从设计到落地的完整过程写下来包括表结构、代码接法和排错思路给同样在用Spring AI做项目的朋友一个参考。2. 会话记忆方案选型不是越强越好2.1 四种ChatMemory实现与选型逻辑Spring AI目前的ChatMemory接口有四个官方实现各有各的适用面。这里先把对比列出来再讲我的选型思路。实现类存储介质适合场景主要限制InMemoryChatMemoryJVM内存本地测试、单实例demo重启丢光、无法多实例共享JdbcChatMemory关系型数据库生产环境起步、已有MySQL/PostgreSQL需要自己建表或提供RepositoryRedisChatMemoryRedis高并发、多实例共享需要维护Redis、消息量大时内存占用高向量数据库实现向量库长期记忆、语义召回依赖嵌入模型、链路更重单看功能Redis和向量库都更“高级”但我最后选了Jdbc实现。原因很简单这个项目当前阶段不需要那么重的记忆能力。先算一笔账。社区志愿助手的并发量撑死同时几十个人在问远没到需要靠Redis扛会话读取压力的程度。数据库里多查几条消息的开销对目前这个量级完全可以忽略。而且项目的基础设施里本来就有MySQL加一张会话消息表几乎是零成本不需要为了会话记忆单独引入一套存储。向量库那个方向我留了观察位原因后面再细说。当前的核心矛盾是先让“会话记忆”这个能力稳定跑起来而不是一上来就把链路做得极其复杂。很多团队在起步阶段就同时上Redis加向量库结果会话记忆本身的正确性问题还没解决排查成本先翻了好几倍。2.2 全局记忆与会话内记忆的分层设计选型过程中我意识到一个问题会话记忆和全局记忆是两类东西不能混在一个方案里。会话内记忆解决的是“上下文连贯”用户在同一个conversationId下连续对话AI要能从历史消息里推断意图。全局记忆解决的是“用户画像保持”用户换了新会话甚至过了几天AI依然知道他的身份、偏好和状态。Spring AI的ChatMemory天然支持会话内记忆因为它所有的存取操作都围绕conversationId展开。但全局记忆不是ChatMemory能直接覆盖的需要更上层的设计。我今天的做法是先定好分层边界会话级记忆走JdbcChatMemory全局级记忆单独用向量库存用户语义画像两个存储互不干扰后续扩展时只动全局记忆那一层就行。这个分层思路也是为了避免一个常见的设计错误把所有东西都塞进ChatMemory。一旦把用户画像也塞进会话历史消息窗口翻转时画像就被冲掉了到时候AI连用户是谁都忘干净。会话消息归会话消息画像归画像各有各的存储和生命周期才是干净的做法。3. 会话记忆核心实现从表结构到Advisor3.1 数据表设计session与global两张表的结构差异Spring AI的Oracle JDBC实现里附带了建表SQL但我建议别直接抄因为默认表结构很多字段用不上而且它把多个附加消息放同一组主键下的设计思路需要理解透了才能排错。先说会话消息表。核心字段就这么几个session_id标识一次会话message_id标识单条消息message_type标记消息类型content存消息明文token_usage记录这轮对话消耗的token数create_time用时间戳排序。CREATE TABLE session_message ( session_id VARCHAR2(128) NOT NULL, message_id VARCHAR2(128) NOT NULL, message_type VARCHAR2(32) NOT NULL, content CLOB, token_usage NUMBER, create_time TIMESTAMP(6) NOT NULL, PRIMARY KEY (session_id, message_id) ); CREATE TABLE session_history_message ( session_id VARCHAR2(128) NOT NULL, message_id VARCHAR2(128) NOT NULL, message_type VARCHAR2(32) NOT NULL, content CLOB, token_usage NUMBER, create_time TIMESTAMP(6) NOT NULL, PRIMARY KEY (session_id, message_id) );为什么有两张结构几乎一样的表这是Spring AI源码里做了明确切分的SessionMessage表存的是当前有效的消息窗口内容session_history_message表存的是窗口翻转后滚动出去的旧消息。两者用相同的session_id关联但语义完全不同。全局记忆表则是另一个结构核心字段是global_history_id而不是session_id因为一条全局记忆可能横跨多次会话。我在设计时加了一个user_id字段后面对接用户体系时只需要按user_id查全局记忆不需要关心它来自哪次会话。CREATE TABLE history_message ( global_history_id VARCHAR2(128) PRIMARY KEY, user_id VARCHAR2(128), message_id VARCHAR2(128), message_type VARCHAR2(32), content CLOB, token_usage NUMBER, create_time TIMESTAMP(6) );这里有个细节值得注意Oracle版本里对CLOB字段的处理非常讲究直接往里塞超长字符串会有很多隐性问题后面在踩坑部分展开说。MySQL版本的表结构会有所不同但核心思路一致这里以我实际在用的Oracle为例来记录。3.2 ChatMemory与Advisor的接线逻辑用Spring AI做会话记忆最核心的代码动作是把ChatMemory实例注入到Advisor里再把Advisor挂到ChatClient上。这个链路不复杂但每一步的意图得拎清楚。先定义一个配置类把JdbcChatMemory作为Bean暴露出来。Configuration public class ChatMemoryConfig { Bean public ChatMemory chatMemory(DataSource dataSource) { return JdbcChatMemory.builder() .jdbcChatMemoryRepository(new JdbcChatMemoryRepository(dataSource)) .build(); } Bean public ChatMemoryAdvisor chatMemoryAdvisor(ChatMemory chatMemory) { return MessageWindowChatMemoryAdvisor.builder() .chatMemory(chatMemory) .chatMemoryRetrieveSize(20) .build(); } }MessageWindowChatMemoryAdvisor的作用是每次请求进来时自动从ChatMemory里捞指定条数的历史消息拼到当前请求里请求结束后再自动把这次的对话消息写回ChatMemory。这个“拉取-拼接-回写”的闭环就是会话记忆的底层机制。然后把这个Advisor挂到ChatClient的构建链路上。Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemoryAdvisor chatMemoryAdvisor) { return builder .defaultAdvisors(chatMemoryAdvisor) .build(); }调用的时候每个用户会话传一个固定的conversationId进去这样Spring AI就知道该去哪个会话的桶里捞消息。String response chatClient.prompt() .user(我的活动几点集合) .advisors(advisorSpec - advisorSpec .param(ChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID, user-1001-session-20240601) .param(ChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE, 20)) .call() .content();这段代码的巧妙处在于conversationId是每次调用时动态传入的而不是写死在配置里。这意味着同一个ChatClient实例可以被所有用户共用只要每个用户的conversationId不同消息就不会串。这个设计是Spring AI的会话隔离机制的灵魂一开始没想明白的时候我还以为要对每个用户创建独立的ChatClient其实完全不需要。3.3 消息序列化与token用量记录会话记忆存储时消息内容和token用量怎么记录是个容易被忽略、但影响不小的细节。Spring AI的JdbcChatMemory内部会把每条消息拆成一组字段来存储其中content字段存的是消息文本本身message_type用来区分user、assistant、system等角色。而token_usage字段记录的是这一轮对话的token消耗。这个字段不是摆设后面做费用统计和窗口大小优化时都要用到。这里要特别说一下JdbcChatMemory的存储策略它保存的是翻转后的消息集合而不是把完整对话历史全量复制一份。每次请求结束时会先往SessionMessage表插入本次的user消息和assistant消息然后触发一次窗口翻转判断。如果当前消息数超过窗口大小就把最旧的几条滚动到session_history_message表再把SessionMessage表里对应的行删掉。这个机制带来的问题是数据并不会真的“消失”只是从一个表挪到了另一个表。如果哪天你想彻底清理某次会话的数据两个表都得删只删一张表就会留下垃圾数据。我在项目里专门写了一个清理方法按session_id同时清两张表这个细节后面在问题排查部分还会提到。4. 实操过程与核心环节实现4.1 从配置到工程落地的完整步骤理论讲完下面是实际项目里走的完整流程按步骤记录可以直接照着操作。第一步确认Spring AI的版本。我这里用的是1.0.0-M6之后的结构因为在这个版本里JdbcChatMemoryRepository被正式引入之前的版本需要自己手写Repository会多出不少代码量。第二步建表。直接把上面的SQL脚本在数据库里执行一遍注意Oracle里CLOB字段不能走普通VARCHAR2作为主键所以主键只用session_id和message_id不包含content。如果用的是MySQL字段类型换成TEXT或LONGTEXT即可。第三步创建配置类。把ChatMemory、ChatMemoryAdvisor和ChatClient三个Bean按顺序创建出来。这里有个容易踩的坑ChatClient.Builder是Spring AI自动配置的你在自己的配置类里注入它没有任何问题但不能自己new一个Builder否则会破坏默认的自动配置链路。第四步写一个service层封装会话调用逻辑。Service public class VolunteerAssistantService { private final ChatClient chatClient; public VolunteerAssistantService(ChatClient chatClient) { this.chatClient chatClient; } public String chat(String userId, String sessionId, String userMessage) { String conversationId userId - sessionId; return chatClient.prompt() .user(userMessage) .advisors(advisorSpec - advisorSpec .param(ChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID, conversationId) .param(ChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE, 20)) .call() .content(); } }第五步验证会话记忆是否生效。连续调用两次接口第一次说“我叫张三报名了周六的环保活动”第二次问“我的活动几点集合”如果AI能答出周六环保活动的集合时间说明历史消息已经正确拼进了上下文。4.2 三种消息存储策略的取舍分析Spring AI里消息窗口有两种主流策略MessageWindow和TokenWindow。MessageWindow按条数控制历史消息量TokenWindow按token数控制。还有一个第三种方案是完全不做窗口管理所有历史都无条件带上但这在长对话场景下会撑爆上下文。我实测下来MessageWindow和TokenWindow的差异非常明显。TokenWindow更科学因为它以模型的上下文预算为准不会出现“带上50条消息但实际把上下文占满”的情况。但TokenWindow有个麻烦它需要实时计算每条消息的token数而这个数字在异步调用和并行调用时容易算不准导致窗口翻转的时机不太稳定。MessageWindow则简单粗暴直接按条数截断实现逻辑清晰也没有跨线程的计数问题。它的缺点是不同消息的长度差异可能很大有的人一句话十个字有的人一段需求几百字固定条数不一定能控制住token占用。策略控制维度优点缺点MessageWindow消息条数实现简单、行为稳定无法精确控制token占用TokenWindowtoken数量更符合模型上下文限制异步时计数可能不稳定全量历史不控制信息最完整长对话必然爆上下文社区志愿助手这个场景下我的选择是MessageWindow加retrieveSize20。20条历史消息足以覆盖大多数咨询类对话的上下文需求又不会让单次请求的token开销失控。如果未来发现20条不够或太多改一个参数就行不需要动其他代码。4.3 多Advisor与提示词的协同配置社区志愿助手不能只靠会话记忆还需要系统提示词来限定角色和回答风格。这就涉及到Advisor的执行顺序问题。Spring AI允许在ChatClient上挂多个Advisor通过ordered()方法控制执行顺序。比如先让系统提示词Advisor把“你是一个社区志愿者助手回答要简洁、专业、口语化”这个角色设定注入再让会话记忆Advisor把历史消息拼进来这样模型在生成回答时既知道自己是谁又知道用户之前聊过什么。Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemoryAdvisor chatMemoryAdvisor) { return builder .defaultAdvisors( new SystemPromptAdvisor( 你是社区志愿服务助手回答志愿活动、报名、积分等相关问题。 请保持回答简洁、准确如果不知道答案请直接说明。 ), chatMemoryAdvisor ) .build(); }Advisor顺序的执行逻辑是系统提示词先注入确保模型的角色设定在前面会话记忆后注入把历史对话追加到消息列表尾部。如果顺序反了模型可能把历史消息当角色设定来理解回答风格会跑偏。还有一点值得注意SystemPromptAdvisor和ChatMemoryAdvisor都修改消息列表但修改的侧重点不同。前者往消息列表头部插一条system消息后者往消息列表追加历史对话。这两个操作不冲突但Advisor的执行顺序仍然会影响最终效果建议固定下来不要随意调换。5. 常见问题与排查技巧实录5.1 同一个Message下多个附加消息的存储异常这是我在Day2遇到的头号问题排查过程比较曲折值得单独写一写。运行过程中我往会话里连续发了多条消息然后去查数据库发现SessionMessage表里出现了多行完全相同的session_id和message_id的记录只是message_type和content不同。更奇怪的是这些行居然在同一组主键下共存没有报主键冲突。后来翻Spring AI源码才弄明白JDBC实现中同一条Message可能包含多个附加消息子项比如带图片引用的文本消息在序列化时会被拆成一条纯文本行加一条图片引用行两者共享同一个message_id。这其实是设计的正常行为但一开始我看到数据时非常困惑以为是并发插入导致的主键冲突被吞了。排查这个问题的关键是查数据时不要默认session_id加message_id能唯一定位一行要加上message_type一起看。真正需要清理数据时也不能按message_id删否则会连带删掉同属一条消息的附加子项。5.2 窗口翻转逻辑导致的历史数据残留另一个高频问题出现在窗口翻转的执行时机上。MessageWindowChatMemoryAdvisor在每次请求结束后会触发一次窗口整理动作把超出窗口大小的历史消息滚动到session_history_message表。这个逻辑本身没有问题但如果你依赖“SessionMessage表只保存最新20条”这个假设来做统计就会踩坑。我实测过一种情况用户的消息特别长一轮对话就占了很多token虽然消息条数没有超过窗口上限但token窗口模式下会提前触发翻转。翻转后SessionMessage表里看起来只剩几条消息因为最旧的已经被挪走了。如果这时候做数据统计很容易得出“用户只聊了几句话”的错误结论。排查方法比较简单在每次请求后打一条日志输出当前会话的消息总数和历史表的消息总数对比就能看出翻转是否按预期执行。我建议在测试环境把窗口大小调成2到3条方便观察翻转行为。5.3 会话过期配置与生产环境注意事项Spring AI的MessageWindowChatMemoryAdvisor里有个IdleTimeout参数用来控制会话多久没活跃就自动清空。这个参数在单机测试时不明显但放到生产环境后非常关键。默认情况下如果不设置IdleTimeout会话数据会永久保留在数据库里时间长了表体积会膨胀。但对社区志愿助手来说用户两三个月后再回来问问题如果AI已经把他的会话清空了体验反而不好。我的做法是分场景设置短期咨询类会话IdleTimeout设为1小时结束后自动清理长期用户画像类会话不设IdleTimeout走全局记忆的定期归档机制。这个设计同样体现了前面说的分层记忆思路会话记忆要短命全局记忆要长存。5.4 一个由CLOB字段引发的生产事故最后分享一个跟数据库字段类型有关的坑。Oracle下如果content字段用的是VARCHAR2单条消息超过4000字符就写不进去了。Spring AI默认建表脚本用CLOB没问题但我在一个测试环境里手动建表时偷懒用了VARCHAR2结果用户发了一篇长文整条存储链路静默失败AI回答直接丢失上下文。排查时我一开始怀疑是消息序列化问题后来看数据库才发现content字段是空的才意识到是字段长度限制。这个坑让我养成了个习惯凡是涉及会话历史存储的表结构content字段一律用CLOB或者LONGTEXT不要为节省那点空间用短字段。另外还要注意CLOB字段的查询和对比操作和普通字符串不同分页查询时如果对CLOB做排序会有额外限制。Spring AI内置的查询逻辑已经处理了大部分问题但你在自己写统计SQL时不要顺手对content做ORDER BY或者GROUP BY很容易报ORA-00932。6. 会话记忆后续扩展向全局记忆演进6.1 从会话记忆升级到用户画像记忆的方案会话记忆上线后的下一步是把用户的关键信息沉淀到全局记忆层。这个扩展方向上Spring AI目前没有现成的开箱即用方案需要自己设计。我的思路是每轮对话结束后让模型从会话中抽取用户信息摘要比如用户报名了哪个活动、对哪个领域感兴趣、是否是志愿者、积分余额是多少然后把这些结构化摘要写入历史记忆表。下次用户开启新会话时先从全局记忆表里加载该用户的画像再拼接到系统提示词里。这个方案的本质是用模型自身做信息抽取而不是人去维护。虽然会额外消耗一些token但胜在自动化程度高。对社区志愿助手这种交互频次不高的场景完全划算。6.2 向量化记忆的引入时机与选型全局记忆要做得更好向量化是绕不开的一步。用户说的话千变万化不能总指望关键词匹配能命中语义检索才是长期记忆的归宿。但我对引入向量化的时机持保守态度。当前阶段先把会话记忆的准确性做扎实把用户画像的抽取链路跑稳再考虑上向量库。因为向量化意味着引入嵌入模型、向量存储和检索服务整个链路的复杂度和运维成本都会上一个台阶对这个项目来说属于锦上添花而不是雪中送炭。等真的需要做语义级长期记忆时Spring AI的向量存储抽象提供了统一接口到时候只需要切换实现不需要改动上层业务代码。这才是今天选型时留的扩展位的价值所在。6.3 社区志愿助手场景的数据规模预估关于数据规模我做了一个粗略估算。按一个社区5000个居民、20%的月活比例计算每月活跃用户1000人每人每天平均问3次一个月的会话消息总量大约在9万到10万条。单次会话消息按平均500字符计算一个月的存储增量大约50MB。这个量级对MySQL或者Oracle来说完全不是压力但也意味着即使不做任何清理一年也就600MB左右。真正的存储压力来自全局记忆的向量化索引因为向量数据的体积通常比原始文本大几十倍。所以我的判断是会话记忆的存储压力可控全局记忆的向量化才需要提前规划容量。这也反过来印证了今天的选型会话记忆用普通关系库完全够用不需要为了“看起来高性能”去上Redis增加运维负担但不解决实际问题。7. 写在最后会话记忆的定位与个人体会Day2做完之后我对会话记忆这件事的看法有了很大的变化。它不是一个简单的CRUD问题而是一个涉及存储选型、上下文窗口管理、用户隔离与会话生命周期的系统工程。Spring AI提供的ChatMemory和Advisor组合让整个接入过程比从零手写优雅很多但你要真正用好它还是得理解它背后的运行机制。我个人在实际操作中的体会是会话记忆的调试要比功能开发花更多时间因为问题往往不是报错而是“AI回答不对味”。比如历史消息拼接顺序错了、会话ID拼错了、消息窗口翻转导致旧信息被冲掉这些问题都不会抛异常但会让AI的回答质量直线下降。调试这类问题唯一可靠的办法就是去数据库里看真实的消息记录看到底存了什么、缺了什么、顺序是什么。最后再分享一个小技巧在开发阶段把ChatMemoryAdvisor的retrieveSize设成2到3条然后故意聊一个超过窗口长度的对话观察AI在窗口翻转前后的回答差异这是最快理解窗口机制的方式。等理解透了再调回20条后面基本不会有记忆相关的问题再来烦你。