第一次用 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 |
| 持久化记忆 | Redis | RedisChatMemory | 多实例部署、跨重启保存 |
| 持久化记忆 | 数据库 | JdbcChatMemory | 对一致性要求高的业务 |
| 语义记忆 | 向量库 | VectorStore 组合实现 | Agent 需要检索用户偏好 |
2. 记忆模块的核心构成:ChatMemory、MemoryAdvisor 和 conversationId
2.1 ChatMemory 接口:记忆仓库的最小抽象
在 Spring AI 的标准接口里,记忆仓库被抽象成了ChatMemory。它做的事情非常简单,核心方法就这几个:
public interface ChatMemory { void add(String conversationId, List<Message> messages); List<Message> 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> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <version>1.0.0-M6.2</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-redis</artifactId> <version>1.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(RedisTemplate<String, 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 RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, 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,还要清业务侧的摘要缓存和偏好缓存。这样才能让“长期记忆”既是能力,也是可控的资产,而不是越攒越乱的垃圾堆。