ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring AI Alibaba Agent长期记忆机制:从ChatMemory到MemoryAdvisor实战

2026/9/9 19:45:49 拓冰建站 浏览量
Spring AI Alibaba Agent长期记忆机制:从ChatMemory到MemoryAdvisor实战 第一次用 Spring AI Alibaba 给 Agent 接“长期记忆”的时候我犯了个特别低级的错误给 ChatClient 挂上 MemoryAdvisor 后我以为它就会自动记住用户了结果换了个 sessionId 再问照样什么都不记得。后来翻了半天源码才明白Spring AI Alibaba 里的 Memory 不是模型自带的超能力而是一套需要你理解并配合的历史消息管理机制。这篇就是这个机制从原理到落地的完整拆解适合已经跑通 Spring AI Alibaba 基础对话、正准备把 Agent 做得像“真人”一点的开发者。1. 先解决一个认知问题Agent 的“记忆”到底是谁的记忆1.1 无状态模型与有状态应用之间的那道缝你问过大模型“我叫陈晨”下一轮它确实能记住你叫陈晨但这中间发生了非常多额外的操作。大模型本身没有记忆它的底层推理机制是每次请求都把你之前说过的所有消息重新塞进上下文中再根据这些内容生成回答。换句话说它记得你不是因为它真的记住了而是因为有人每次都在“翻聊天记录”给它看。放到 Agent 场景里这个逻辑会变得很麻烦。Agent 通常有多个步骤接收用户指令、调用工具、返回中间结果、再决定下一步。如果每一步都需要自己手动维护“上次聊了什么”代码会立刻失控。而且真实业务里用户还分 session、分 userId不同的人不能共享同一份对话上下文这里就天然需要一层独立的“记忆管理”。这部分就是 Spring AI Alibaba 中 Memory 要解决的问题。它不会改变模型本身的能力而是帮你在请求之前读取历史在请求之后保存新消息让你的应用层真正变成有状态。1.2 Spring AI Alibaba 怎么补上这道缝我最早接触 Spring AI Alibaba 时容易把它理解成“一个调用阿里云大模型的封装”。实际上它的结构比这复杂一点模型调用只是最底层往上还有 ChatClient 这种面向业务开发者的一站式入口再往上就是各种 Advisor切面。Memory 恰恰是 Advisor 机制里最典型、最常用的一种。你完全可以不依赖框架自己做一套历史消息表每次调模型前查数据库调完后插一条。这样做也没问题但你会重复踩很多坑并发下消息顺序怎么保证窗口长度怎么控制多轮会话怎么隔离如果换一个用户、换一个 session代码怎么不糊成一团Spring AI Alibaba 跟 Spring AI 标准保持了一致的 Memory 抽象所以这些通用问题基本都有现成答案。你要做的不是“从零写记忆”而是选一个合适的存储实现把 conversationId 这个关键参数传对剩下的交给框架。记忆类型存储位置常见实现适合场景会话窗口记忆JVM 内存MessageWindowChatMemory本地调试、单机 Demo持久化记忆RedisRedisChatMemory多实例部署、跨重启保存持久化记忆数据库JdbcChatMemory对一致性要求高的业务语义记忆向量库VectorStore 组合实现Agent 需要检索用户偏好2. 记忆模块的核心构成ChatMemory、MemoryAdvisor 和 conversationId2.1 ChatMemory 接口记忆仓库的最小抽象在 Spring AI 的标准接口里记忆仓库被抽象成了ChatMemory。它做的事情非常简单核心方法就这几个public interface ChatMemory { void add(String conversationId, ListMessage messages); ListMessage get(String conversationId, int lastN); void clear(String conversationId); void delete(String conversationId); }add是把新消息追加到某个会话后面get是取出最近 N 条历史clear和delete是清理。这套接口非常克制没有搞一堆花哨的“记忆等级”“记忆权重”因为底层存储的逻辑可以很复杂但对外暴露的语义必须足够简单。Spring AI Alibaba 并没有绕开这套接口重新定义一套自己的存储 API而是选择兼容 Spring AI 的标准。所以在实际项目里你看到MessageWindowChatMemory、RedisChatMemory这些实现类时不需要额外学习一套新用法。这一点对我这种比较习惯 Spring 生态的开发者来说非常友好。2.2 MemoryAdvisor把存取历史变成了“切面”光有ChatMemory还不够因为代码里不能每次调模型都手动写“先 get、再 add”。Spring AI 用 Advisor 机制把这件事做成了切面最核心的实现是MessageChatMemoryAdvisor。它的工作流程可以拆成三步请求进入时根据 conversationId 从 ChatMemory 中读取最近 N 条历史消息。把历史消息和当前用户消息一起组装成完整的 Prompt发送给模型。模型返回后把“用户消息 模型回复”成对写入 ChatMemory。这个设计最大的好处是你的业务代码基本不用关心记忆逻辑。只要你把 Advisor 挂在 ChatClient 上调用方只需要传一个 sessionId剩下的增删改查都由框架完成。对于团队协作来说这也意味着记忆策略可以收敛在一个统一配置里不至于每个接口各自写一套。2.3 conversationId长期记忆的“分区键”如果你只记住一个概念我建议你记 conversationId。它是整个 Memory 机制里最重要的钥匙。无论是内存窗口还是 Redis它们本质上都是一个大仓库里面存了无数段对话。你要在不互相干扰的前提下取出某个用户的历史就必须有一个维度来区分不同会话。这个维度就是 conversationId。我见过非常多在线上出问题的项目最后定位下来都是 conversationId 处理不当。有人图省事全局写死一个DEFAULT_CONVERSATION_ID有人用 userId 当作 conversationId结果用户开多个窗口时互相串还有人把 UUID 每次请求都重新生成导致记忆永远为空。正确的做法是如果是一次性单聊可以直接用 sessionId如果是在你的业务系统里建议用userId : sessionId这种组合。这样既能保证用户之间隔离也能支持同一用户的多会话并发。3. 动手接入从内存窗口到 Redis 持久化3.1 第一步引入依赖和基础配置接入前先把依赖和配置准备好。以最常见的 Redis 持久化方案为例你需要同时引入 Spring AI Alibaba 的 starter 和 Spring AI 的 Redis 扩展包。版本号我这里只写示例真正落地时请以你当前 Spring Boot 对应的 BOM 为准。dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter/artifactId version1.0.0-M6.2/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-redis/artifactId version1.0.0/version /dependency然后是模型和 Redis 的配置spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus data: redis: host: localhost port: 6379如果你只是想在本地快速验证效果不想依赖 Redis可以直接用MessageWindowChatMemory它能跑通流程但重启后记忆就没了。真正要做“长期记忆”Redis 或数据库终归是绕不过去的。3.2 第二步注册 ChatMemory 和 MemoryAdvisor配置类里要做两件事注册一个 ChatMemory 的 Bean然后把 MemoryAdvisor 挂到 ChatClient.Builder 上。import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.RedisChatMemory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; import java.time.Duration; Configuration public class ChatMemoryConfig { Bean public ChatMemory chatMemory(RedisTemplateString, Object redisTemplate) { return new RedisChatMemory(redisTemplate, Duration.ofDays(30)); } Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); } }要注意一个版本细节不同版本的RedisChatMemory构造方法可能略有差异有的需要传入RedisTemplate有的需要额外传入 key 前缀。编译报错时先看一眼你引入的 jar 包里的实际签名别死磕我这段示例。3.3 第三步在接口里显式传 conversationId配置好之后业务接口就非常简单了。每次请求只要在 Advisor 参数里带上 conversationId 和检索窗口大小即可。import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.ChatMemoryAdvisor; import org.springframework.web.bind.annotation.*; RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient chatClient; } PostMapping(/chat) public String chat(RequestParam String sessionId, RequestParam String message) { return chatClient.prompt() .user(message) .advisors(a - a .param(ChatMemoryAdvisor.CONVERSATION_ID, sessionId) .param(ChatMemoryAdvisor.RETRIEVE_SIZE, 20)) .call() .content(); } }这行param(ChatMemoryAdvisor.CONVERSATION_ID, sessionId)是记忆是否“认得你”的关键。如果不传很多版本会落到默认 conversationId所有人的消息全混在一起这在线上是事故级别的 bug。3.4 验证效果启动服务后用同一个 sessionId 连续发两条消息第一条我姓陈以后叫我老陈就行。 第二条记住我住在杭州。接着再发第三条你还记得我姓什么、住在哪里吗正常情况下模型能从历史上下文中提炼出“你姓陈住在杭州”。如果换成另一个 sessionId 再问同样的问题模型就不应该记得这些信息。这一步验证看起来简单却是判断 Memory 是否生效最靠谱的方法。4. 窗口大小、TTL、序列化长期记忆不能只做“能跑”4.1 检索窗口并不是越大越好很多开发者觉得“记忆越长越智能”于是把RETRIEVE_SIZE设成 100、200。这种做法在 Demo 里没事一上生产就出问题。大模型的上下文窗口是有限度的。窗口越大能放进去的内容越多但同时意味着 Token 消耗越来越大、响应越来越慢。更麻烦的是当历史消息真正多到一定程度模型反而会“迷失在上下文里”对早期信息不敏感也更容易产生幻觉。我在实际项目里的经验是如果模型是 qwen-plus 这类中等上下文模型单轮普通问答按 1000 Token 估算检索窗口设置在 8 到 15 条左右比较安全。不要想着把用户过去半年的对话全部塞进去那不是长期记忆那是给自己制造超限报错。4.2 TTL 和“长期”的定义“长期记忆”不等于“永久记忆”。我见过很多团队把 Redis TTL 直接设成-1觉得这样最省事。但用户数据是有时效性的用户搬家了、换工作了、退出登录了这些历史信息继续留存在库里既占空间又有隐私风险。建议给记忆设置一个合理的生命周期临时会话TTL 设为 1 天以内。普通用户记忆TTL 设为 7 到 30 天。核心偏好单独建表显式管理不依赖 TTL。另外记忆的清理不能只依赖 Redis 过期。过期机制只负责“数据没了”但你在业务层如果缓存了某些摘要内容可能不会跟着 Redis 自动失效。所以凡是做了摘要记忆、偏好记忆的都要在业务代码里考虑主动删除。4.3 序列化与多租户隔离用 RedisChatMemory 时最容易翻车的是序列化。Spring AI 的 Message 对象不是一个纯字符串它包含 messageType、属性、内容等多个字段。如果 RedisTemplate 的默认序列化器不支持写入时可能不报错读出来的时候会很奇怪或者干脆直接抛类型转换异常。稳妥的做法是显式配置一个支持复杂对象的序列化器Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; }至于多租户隔离我建议把租户 ID 也拼进 conversationId比如tenantA:user-123:session-456。这样即使多个业务方共用同一套 Redis也不会互相读到对方的消息。5. 踩坑实录为什么我加了 Memory它还是不记得5.1 所有用户共用了同一个 conversationId这是最常见的问题也是我开头说过的那个坑。现象是用户 A 说“我叫老陈”用户 B 发一句“我叫什么”模型居然回答“你叫老陈”。看起来很智能其实是记忆串线了。排查思路很简单在 Redis 里扫描一下 key。redis-cli --scan --pattern *conversation*如果你发现不同用户的数据都落在同一个 key 下面那就说明调用接口时没有传 conversationId或者所有请求都用了同一个默认值。修复方式就是上一节讲的在.advisors()里显式传 sessionId。5.2 Advisor 被自己创建的 ChatClient 绕开了另一个隐蔽问题你在配置类里给ChatClient.Builder挂上了 defaultAdvisors但业务代码里又手动new了一个 ChatClient或者用ChatClient.builder()重新构建了一个新实例。这种情况下你挂的 Advisor 根本不会生效。这类问题很难通过看代码一眼发现因为它不报错表现就是“记忆时好时坏”。排查时先确认服务里所有注入 ChatClient 的地方是否都来自同一个 Bean。你可以在配置类里给 ChatClient 加一个自定义名字所有地方统一注入这个 Bean比反复new要稳定得多。5.3 Redis 序列化报错消息写不进去还有一次我遇到的问题是控制台疯狂刷反序列化异常但对话功能看起来正常。原因就是 RedisTemplate 没有配置 JSON 序列化器Spring AI 的消息对象写入时走了 JDK 序列化导致内容很乱而且升级依赖后旧数据可能完全读不出来。修复方式就是我上面给出的 RedisTemplate 配置。另外提醒一句如果 Redis 里已经写入了脏数据改完序列化器之后最好把旧 key 清掉否则运行时仍可能读取到无法解析的历史记录。5.4 记忆串线短期和长期混在一起有些项目做了两套记忆一套是会话窗口内的短期记忆一套是从数据库读取的长期偏好。但代码里没有把两者隔离导致短期历史里总是掺杂着长期偏好模型分不清哪些是本次对话的事实哪些是用户早先留下的档案。我的建议是短期记忆走MessageChatMemoryAdvisor长期偏好单独维护在系统 Prompt 中以“用户档案”的身份注入。这样模型能看到“档案”和“本次会话”两个清晰的信息来源不会混为一谈。String systemPrompt 你是客服助手。 【用户长期档案】 %s 【注意事项】 1. 用户长期档案只作为背景信息不要主动复述全部内容。 2. 如果档案与本次对话冲突以本次对话为准。 .formatted(userProfile);6. 从“记住对话”走向“记住用户”长期记忆的进阶思路6.1 用摘要记忆提炼用户偏好单纯把聊天记录存下来其实还停留在“日志”阶段。真正的长期记忆应该让 Agent 记住用户真正重要的信息。我常用的一个玩法是每经过 5 轮对话让模型额外生成一段摘要只保留值得长期记住的内容比如用户称呼、职业、居住城市、产品偏好。然后把摘要单独存起来。下次对话开始时先读摘要再决定要不要回溯完整历史。String summaryPrompt 请从下面的对话中提取需要长期记住的用户信息。 只需要提取明确表达的事实例如用户的名字、职位、地址、偏好。 没有值得记住的内容就回答无。 对话内容 %s .formatted(history); String summary chatClient.prompt() .user(summaryPrompt) .call() .content(); userMemoryStore.save(conversationId, summary);这样做对 Token 消耗非常友好而且模型回答时更容易遵循用户偏好因为它看到的不是一大堆历史噪音而是一条条被提炼过的事实。6.2 结合向量存储做语义检索如果你的 Agent 复杂度已经很高比如用户会在这个系统里聊产品需求、项目方案、团队成员那么纯摘要可能不够用了。这时候可以考虑语义记忆把每一条用户陈述转成一个向量存到向量数据库里每次请求时根据当前用户消息检索最相关的几条历史记忆再注入 Prompt。Spring AI Alibaba 本身也接入了阿里云的向量检索和 DashScope 的 Embedding 能力做这件事的路径是通的。不过我不建议一上来就上向量库只有在摘要记忆确实满足不了需求时再引入否则维护成本会明显上升。6.3 给记忆加一条“遗忘/删除”的规则最后分享一个很容易被忽略的点记忆系统一定要允许用户删除。很多 Demo 项目只做了 add 和 get忘了做 delete。但真实产品里用户可能想清除历史记录或者重新开始一段对话。哪怕只是简单的“清除会话”按钮也建议做成显式接口DeleteMapping(/chat/memory) public void clearMemory(RequestParam String sessionId) { chatMemory.clear(sessionId); }我在实际项目里的习惯是所有记忆写入时都带上时间戳定期清理超过 TTL 的数据用户主动清除时不仅清 Redis还要清业务侧的摘要缓存和偏好缓存。这样才能让“长期记忆”既是能力也是可控的资产而不是越攒越乱的垃圾堆。