ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring AI会话记忆实战:原理、ChatMemory接入与避坑指南

Spring AI会话记忆实战:原理、ChatMemory接入与避坑指南 我做的第一个Spring AI对话接口上线第二天产品就丢过来一条反馈你们这个AI怎么聊着聊着就不记得前面说了什么这个问题我太熟了。我一开始以为大模型像人一样天然有记忆后来才发现完全不是这么回事。你调一次接口模型就针对你这次发过来的内容输出一次结果上一轮说过的用户消息、我回复的话它全都不记得。要让对话有连续感唯一办法是每次请求时把之前的历史消息一起发给模型——也就是开发者自己维护上下文。这是Spring AI自学成才系列的第二篇。上一篇我们把Spring AI的基础对话接口跑通了这篇就聚焦对话场景里最核心的一个能力会话记忆。我会从Spring AI 1.0.0 GA版本的ChatMemory机制讲起把接口、窗口裁剪、Advisor接入、流式回写、多用户隔离这些点全部过一遍最后给出一套可以直接抄的落地配置和自测方案。1. 先从原理说清Spring AI的记忆到底在干什么1.1 无状态请求下上下文只能靠拼大模型的API在设计上就是无状态的。你发一个UserMessage过去它返回一个AssistantMessage仅此而已。它不保存你的会话编号不关心你上一轮问过什么不同请求之间完全是孤立的。想让对话看起来有记忆力就要把之前说过的话作为历史消息跟随当前问题一并发给模型。模型看到一个完整的对话记录才能基于上下文给出连贯的回复。这个过程叫上下文拼装是会话记忆最底层的原理。这里就引出一个重要概念消息角色。在Spring AI里对话消息被抽象成几个角色SystemMessage系统提示词设定模型的行为边界和人格UserMessage用户输入AssistantMessage模型回复ToolResponseMessage工具调用返回的结果模型看到的对话上下文本质上是这几种消息按时间顺序排列的列表。会话记忆要做的就是把这个列表存下来在每次新请求时把它和当前消息拼接好、塞进去。1.2 Spring AI把记忆抽象成了ChatMemory接口早期我写记忆功能时是自己用ArrayList存消息每次请求前手动拼Prompt。这样能做但代码很难看而且一旦要考虑上下文太长怎么截断多用户会话怎么隔离自己写很容易写出bug。Spring AI从设计上就把这个场景收拢了。它提供了一个ChatMemory接口专门负责会话消息的存储、取回和清理。接口本身不关心底层用内存还是Redis还是数据库只定义行为public interface ChatMemory { void add(String conversationId, Message message); void add(String conversationId, ListMessage messages); ListMessage get(String conversationId, int lastN); void clear(String conversationId); }conversationId就是会话的唯一标识。你可以传用户ID、传订单号、传一个UUID都行。只要同一个会话用同一个IDChatMemory就能把消息归拢到同一个桶里。注意一个关键点ChatMemory本身只负责存储和取回消息它不负责把消息注入到模型请求里。真正把历史消息拼进Prompt的是后面要讲的Advisor组件。存储和注入解耦这是Spring AI记忆设计里比较聪明的地方你可以在不同请求里用不同的注入策略但底层存储始终是同一套。1.3 记忆最终会出现在请求的哪个位置顺着依赖关系往上看一次带记忆的对话请求最终发送给模型的消息列表大致是这个结构SystemMessage系统提示词 历史UserMessage / 历史AssistantMessage来自ChatMemory 当前UserMessage本次用户输入顺序很重要。模型对消息顺序非常敏感历史的User消息和Assistant消息必须交替排列一旦乱序模型会理解混乱甚至出现角色错乱。这也是为什么我强烈建议不要自己去拼接字符串来模拟对话历史而是用Spring AI的Message对象来维护。2. ChatMemory三件套接口、内存实现、Token窗口2.1 先认识开箱即用的InMemoryChatMemorySpring AI默认提供了一个最简单的实现InMemoryChatMemory。它内部就是一个MapconversationId作为key消息列表作为value所有操作都在内存里完成。ChatMemory chatMemory new InMemoryChatMemory();这个类适合什么场景原型验证、单机部署、并发量不大的内部系统。它的优点是零配置、速度极快、代码量最少缺点也明显进程重启后记忆全部丢失多实例部署时每个实例各存各的用户第一次请求打到A机器、第二次打到B机器记忆就对不上了。所以我现在的习惯是拿来写Demo和本地测试没问题一旦考虑上线就要评估是否需要Redis或数据库做共享存储。这个放到后面多用户隔离那节展开。2.2 TokenWindowChatMemory让上下文待在安全线以内模型上下文窗口是有限度的。比如GPT-4o的上下文有128K token但你真的把所有历史消息都塞进去成本先不说模型对早期内容的注意力也会衰减。更现实的问题是如果历史消息无限累积总token数超过模型上限请求直接报错。TokenWindowChatMemory就是为了解决这个问题的。它在内部包了一层基础ChatMemory比如InMemoryChatMemory在取消息时不是全量返回而是只返回最近不超过指定Token阈值的那部分消息。构建方式ChatMemory chatMemory TokenWindowChatMemory.builder() .withChatMemory(new InMemoryChatMemory()) .withMaxTokenThreshold(1000) .withOverrage(50) .build();这两个参数值得好好说一下因为它们直接决定你的记忆能覆盖多少轮对话。2.3 withMaxTokenThreshold和withOverrage的含义withMaxTokenThreshold是窗口的硬上限意思是取回的这段消息总token数尽量不超过这个值。超过上限的部分最老的会被丢弃。它衡量的不只是历史消息通常还会把当前请求的User消息考虑进去保证整套Prompt不会撑爆上下文。withOverrage是允许超出的余量。为什么需要余量因为Token数量是估算出来的不是模型精确计费后的数字。如果阈值卡得太死真实token数稍微多一点就触发裁剪容易把用户最近几句关键的话误删掉。设一个余量相当于在边缘地带给了缓冲让系统只在实际快溢出时才动手裁剪。我自己的经验值是overrage设为maxTokenThreshold的10%到20%。比如阈值1000余量就设100到200。太小容易频繁裁剪太大又失去了保护意义。2.4 窗口裁剪背后的决策规则窗口裁剪不是简单地从最老消息开始砍Spring AI内部的裁剪器逻辑要讲究得多。我在项目里实测下来它的基本决策顺序是这样的优先保留SystemMessage。系统提示词是模型的底稿删了它模型行为和人格会漂移。按时间从旧到新移除非System消息直到总Token数低于阈值。如果单条消息本身就超长会尝试对消息内容截断如果截断后依然顶格可能直接丢弃这条消息。用户刚发出的当前消息不会被裁剪。裁剪器只管历史消息不管本次输入。这背后其实是一种产品取舍模型对话越靠前的信息对当前回复的影响越小。真到窗口爆掉的时候与其把一段早期冗长的讨论留下来不如让模型只看到最近的几轮问答。3. 把记忆接进对话流程Advisor自动注入与手动方案3.1 零侵入方案MessageChatMemoryAdvisorChatMemory存好了怎么让它自动参与每次请求Spring AI给出的标准答案是用Advisor。Advisor是ChatClient流程里的拦截器在请求发给模型之前先对Prompt做增强返回结果后再做后处理。最常用的就是这个MessageChatMemoryAdvisor它的工作流程很清晰从ChatMemory里取出指定会话的历史消息把历史消息插到当前User消息之前把完整消息列表发给模型模型返回后把Assistant回复作为一条AssistantMessage写回ChatMemory接入代码很短ChatClient chatClient ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build();这里要提醒一个细节MessageChatMemoryAdvisor默认会取最近100条消息。如果你们的对话轮数很多、单条消息又长100条很容易超出上下文窗口。你需要显式设置一个更小的取回数量比如20.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory, 20))这样限制后每次请求只取最近20条消息加上TokenWindowChatMemory的裁剪双重保险上下文就不会轻易爆掉。3.2 请求级别的会话标识注入上面的配置里还有一个隐藏参数conversationId。MessageChatMemoryAdvisor默认会查一个叫chat_memory_conversation_id的参数这个参数必须在每次请求时通过Advisor参数传进去否则所有用户共用一个会话记录串号会让你怀疑人生。传法是这样的String content chatClient.prompt() .user(你好我叫张三) .advisors(advisor - advisor .param(MessageChatMemoryAdvisor.CONVERSATION_ID, user-10086) ) .call() .content();如果你用的是defaultAdvisors但没有在请求里指定conversationIdSpring AI会给一个默认的会话标识。多用户系统里一旦忘记传所有请求就混到一个会话里了。我早期就吃过这个亏测试环境只有我一个人用看不出来联调时两个测试账号互相看到对方的对话记录排查了半天才发现是conversationId没有按用户区分。3.3 PromptChatMemoryAdvisor用一段文本承载历史除了MessageChatMemoryAdvisorSpring AI还提供了PromptChatMemoryAdvisor。它的区别是不把历史消息作为独立的Message对象注入而是先把历史消息格式化拼接成一段文本再放进一个UserMessage里。.defaultAdvisors(new PromptChatMemoryAdvisor(chatMemory))这种方式的优势是token消耗更可控历史先被压缩成一段叙述性文字上下文更紧凑。代价是消息的角色交错信息丢失了——模型看到的是一大段你之前问过xxx我回答过xxx的概括而不是严格的User/Assistant交替记录对细节敏感的模型回复质量可能会轻微下降。我一般这样取舍预算紧张、轮次很多的长对话用PromptChatMemoryAdvisor对上下文精准度要求高的场景用MessageChatMemoryAdvisor。3.4 手动拼接什么时候需要自己来如果你对模型请求的组装有强定制需求比如要在历史消息中间插入一段背景说明、要动态调整系统提示词那可以考虑手动管理消息流。做法是直接从ChatMemory取历史自己组装成一个PromptListMessage history chatMemory.get(conversationId, 20); ListMessage promptMessages new ArrayList(); promptMessages.add(new SystemMessage(你是订单客服)); promptMessages.addAll(history); promptMessages.add(new UserMessage(我的订单什么时候发货)); String response chatClient.prompt() .messages(promptMessages) .call() .content();手动方案灵活但别忘了一件事取完历史消息后当前这轮的用户输入和模型回复都要手动add回ChatMemory不然下一轮就断了。三种接入方式我做了个对比方便直接选型对比项MessageChatMemoryAdvisorPromptChatMemoryAdvisor手动拼接代码量最少少多消息角色保真完整保留丢失完全可控Token消耗较高较低取决于写法适用场景通用对话长对话、预算敏感定制化流程4. 流式对话是记忆的重灾区Assistant消息回写的大坑4.1 为什么流式返回时记忆会断掉如果你用的是同步的call()方法MessageChatMemoryAdvisor会在模型返回后自动把Assistant回复写进ChatMemory整个链路是闭环的。但换成流式返回后就变了FluxString stream chatClient.prompt() .user(给我讲个笑话) .stream() .content();流式情况下模型回复是分片返回的Flux Advisor无法等到完整回复生成后再统一回写。默认行为是这轮请求结束后ChatMemory里并没有写入这条Assistant消息。下一轮用户再问问题时历史里就缺了上一次的回复记录模型相当于隔着一条消息在对话上下文明显不连贯。这个坑我印象太深了。第一次上线流式对话功能时测试同学反馈聊着聊着AI就开始重复问同样的问题我查了半天模型参数最后才发现问题根本不在模型而是流式回复压根没进记忆。4.2 正确的回写姿势解决办法是自己在流式响应结束时手动把Assistant消息写回ChatMemory。大致思路StringBuilder assistantContent new StringBuilder(); chatClient.prompt() .user(userInput) .advisors(advisor - advisor.param(MessageChatMemoryAdvisor.CONVERSATION_ID, conversationId)) .stream() .content() .doOnNext(assistantContent::append) .doOnComplete(() - { chatMemory.add(conversationId, new AssistantMessage(assistantContent.toString())); }) .subscribe();注意两个细节第一要用同一个conversationId。如果流式请求时用了A会话标识回写时用了B这条回复还是记不到该记的会话里。第二用户消息也要确保已经进了ChatMemory。MessageChatMemoryAdvisor在请求发起时会把用户消息存入记忆但如果你用的是手动拼接方式用户消息同样需要手动add。4.3 空消息与乱序两个必须防的边角情况流式情况下还要防两件小事。一件是空回复。有些偏门参数组合下模型可能先返回一个空内容片段或者因为内容过滤返回一段空字符串。如果你把这样的空AssistantMessage写进记忆后面模型会看到一堆空白回复不仅浪费token还可能干扰行为。稳妥做法是回写前判断一下内容是否为空if (assistantContent.length() 0) { chatMemory.add(conversationId, new AssistantMessage(assistantContent.toString())); }另一件是并发乱序。用户在界面上快速连发两条消息两条流式请求并行处理先发的那条反而后返回回复写入记忆的顺序就和真实对话顺序不一致了。我们可以对同一会话的请求做串行化简单做法是给会话加一把锁或者把回写动作放到一个有顺序保证的队列里去执行。这个问题在单机小并发下不明显但在面向C端用户的高并发场景是必须提前设计好的。5. 多用户多会话隔离一个ChatMemory怎么服务所有人5.1 conversationId的命名设计直接决定隔离效果隔离的核心就是conversationId。最简单的做法是用UUID当conversationId每个会话一个独立ID天然隔离。但纯UUID有一个实际问题一个用户连续发起新对话你就不知道这个ID属于哪个用户后续要做按用户查历史或者定时清理过期会话都无从下手。我更推荐的方案是组合式命名用户标识会话序号。比如10086:20250918:1前段是用户ID中段是日期后段是当天第几个会话。这样既保证唯一性又保留了查询维度。实际项目中用户标识建议直接用你们系统的user_id字段不要用手机号邮箱之类的敏感信息避免日志里泄露数据。请求时这样传参String conversationId userId : LocalDate.now() : sessionNo; chatClient.prompt() .user(text) .advisors(advisor - advisor .param(MessageChatMemoryAdvisor.CONVERSATION_ID, conversationId) .param(MessageChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE, 20) ) .call() .content();CHAT_MEMORY_RETRIEVE_SIZE这个参数就是动态调整取回历史消息条数的入口可以在请求级别覆盖默认值。5.2 消息进存储前的两级过滤在多用户共享同一套ChatMemory时有个隐患需要强调内存存储不会自动区分哪些消息属于哪个会话它完全靠conversationId来归类。这就要求你写入和读取时conversationId必须严格一致。我见过一个事故团队在controller里取用户ID时用了不同的request字段名一套服务从session里取另一套从header里取结果两个ID对不上同一个用户的对话历史被分成两个孤岛。解决思路是收敛入口无论请求从哪里来统一在一个Filter或拦截器里解析出标准化的userId再拼装conversationId全链路都用这个值。这样即使后续改造也不会出现多个口径。5.3 从内存到外部存储Redis与数据库方案当服务实例超过一台InMemoryChatMemory就会出问题。最直接的替代方案是Redis。Spring AI社区有一些Redis版本的ChatMemory实现思路都是把ChatMemory接口的add和get映射到Redis的List结构上conversationId作为key的一部分。用Redis的好处是多实例共享同一份记忆用户请求打到哪台机器都不怕可以设置过期时间比如30分钟无交互自动清理旧会话性能高读写都是毫秒级数据库方案适合需要长期留存对话记录的场景比如客服工单复盘。实现上同样是把ChatMemory接口落成SQL取消息时按时间排序取最近N条。注意消息内容要存成可序列化的格式Message对象序列化成JSON即可。5.4 更进一步用向量存储做长期记忆会话记忆的尽头还有一个进阶方向长期记忆。上面的方案都是把完整历史一股脑保留但人类记忆不是这样的记得太久远的事只能记住关键要点。AI的长期记忆也可以这样把历史对话切块后做嵌入向量存入向量数据库新问题来时先做相似度检索只把最相关的历史片段当作可参考上下文注入。Spring AI的VectorStore接口就是为这类场景设计的。我之前在客服机器人上试过长期存储沉淀下来的常见问题、用户偏好检索召回后再拼进Prompt效果比全量塞历史消息好很多成本也低。这一步属于进阶玩法当前ChatMemory解决的是短期会话内的连续性问题长期记忆解决的是跨会话的个性化问题两者可以并行。6. 上下文窗口快满时的取舍压缩策略和实测调参6.1 裁剪器默认会怎么做实际线上环境聊天轮次一多再怎么控制也会触碰Token上限。TokenWindowChatMemory内部的裁剪器执行动作是有优先级的我通过日志和插桩实测总结了这套行为保留SystemMessage不裁剪保证系统行为稳定从最老的非System消息开始丢弃按时间一条条移除如果某条消息长度超过当前阈值的一半会先尝试对消息内容截断截断后仍然过大直接丢弃这段内容并在日志里打warning这套逻辑整体符合直觉旧的让位于新的短消息优先保留超长消息是首要剪除对象。6.2 怎么定maxTokenThreshold最合理阈值设置的依据要同时考虑三个数模型上下文上限、系统提示词长度、模型回复预留空间。举个例子我用的是上下文8K token的模型。系统提示词加角色设定大约占400 token模型的完整回复通常需要1500到2000 token的余量那么历史上下文最多只能占到8K减去400再减去1500左右也就是6000 token上下。再考虑到模型输出偶尔超长留一点buffermaxTokenThreshold我会设到5500左右。公式可以归纳为maxTokenThreshold 模型上下文上限 - 系统提示词token - 模型回复预留token - 余量buffer这里有个很容易踩的坑很多人直接把maxTokenThreshold设成模型上下文上限比如8K的模型就设8000。结果就是历史消息把所有空间占满模型输出到一半触发截断客户端收到一段没说完的话体验极差。记住上下文窗口不是只能装历史消息模型输出也要从同一个窗口里分空间。6.3 Token估算的误差从哪里来Spring AI内部对消息token数用的是估算实现不同的估算器精度差别很大。严谨的会走tokenizer计算粗略的就按字符数除以若干系数估算。同一个句子不同估算方式可能差出20%到30%。带来的实际问题就是阈值明明设了5500实际可能已经到6000多才触发裁剪。所以overrage要留够我的经验是至少留出两个模型平均回复长度的余量。如果你观察到裁剪日志频繁出现但实际请求并没有接近上限那就说明阈值设得偏小适当上调如果出现请求报错context length exceeded但裁剪器却没有提前触发先把阈值调低15%再观察。这类问题没法一次调到位我建议上线前用你们真实业务的历史对话数据离线回放一遍统计真实token分布再定阈值。盲猜参数是后端开发最容易翻车的地方。7. 验证记忆是否生效的最小自测方案7.1 三轮对话三个问题写完了功能怎么快速验证记忆真的生效我有一套固定的自测问法三十秒就能跑完第一轮发一条自我介绍我叫张三我喜欢喝冰美式第二轮发一条看短期记忆的问题我叫什么名字第三轮换个conversationId发同样的问题我叫什么名字如果第二轮能答上来短期记忆生效如果第三轮答不上来说明会话隔离生效。这两个结果都得满足才算是正确接入。7.2 测试时要盯的三件事除了看回复内容我还会抓一次请求的日志确认下面这三件事第一请求中确实携带了历史消息。日志里能看到消息列表里除了当前UserMessage还有前面几轮的User和Assistant消息。第二消息顺序正确。SystemMessage在最前然后历史消息按时间正序排列最后才是当前用户的输入。顺序一旦乱了模型很容易把你和用户的角色搞混。第三裁剪动作符合预期。观察TokenWindowChatMemory的裁剪日志确认裁剪器是在总tokens超过阈值后才动手而不是一开始就误杀新消息。7.3 一份可以直接参考的完整配置把上面所有点串起来我平时项目初始化记忆功能时一个最简可用的装配是这样的Configuration public class ChatMemoryConfig { Bean public ChatMemory chatMemory() { return TokenWindowChatMemory.builder() .withChatMemory(new InMemoryChatMemory()) .withMaxTokenThreshold(5500) .withOverrage(800) .build(); } Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultSystem(你是一个乐于助人的助手回答要简洁直接。) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory, 20)) .build(); } }如果你的对话是流式返回记得在响应流结束时手动回写AssistantMessage如果多实例部署把InMemoryChatMemory换成Redis或数据库实现如果对话轮次非常长再评估是否引入PromptChatMemoryAdvisor来压缩历史。按这个顺序排查大部分记忆功能问题都能定位到一个明确的原因。回到最开头那个产品反馈。后来我把记忆功能修好之后那个AI至少不会再失忆了但产品同学又开始提新的需求能不能让AI记住用户上次聊到哪了、下次继续聊。这其实就是从会话内记忆走向长期记忆的路。我的建议是先把会话记忆这层扎扎实实做好再去碰长期记忆。会话记忆是地基地基不稳上面盖什么都会歪。
返回列表