ARTICLE · 1086024
Spring AI 2.0 会话记忆实战,让 AI 助手记住每一次对话
你是不是遇到过这种尴尬:刚跟 AI 说"我叫小明,帮我推荐适合新手的 Java 学习路线",下一句问"那 Spring Boot 先学哪本",它一脸茫然地说"Spring Boot 是什么"。这是因为大模型本身没有记忆,每次调用都是"失忆"的。这篇文章用 Spring AI 2.0 的 ChatMemory 能力,教你用 Redis 把对话历史存下来,让 AI 助手真正记住你是谁、你们聊到哪了,直接可运行、不踩版本坑。
摘要:大模型是无状态的,每次调用都像新认识你。Spring AI 2.0 提供 ChatMemory 抽象和基于 Redis 的持久化实现,让你轻松给 AI 助手加上"记忆"。文章先讲清楚无状态问题的根源,再拆解 ChatMemory 的底层原理(会话ID → 消息列表 → 存储适配器),最后给出 3 个完整可运行的 Spring Boot 示例:InMemory 演示、Redis 持久化、按用户隔离多会话,并分享踩坑和选型建议。基于 Spring Boot 4.1.1 + Spring AI 2.0.1 实测。
一、这个问题到底是什么
大语言模型(LLM,就是 ChatGPT 背后那类模型)本身是"无状态"的。什么叫无状态?你可以把它理解成一个"过目就忘"的天才:你说一句话,它答得漂亮,但你说完它就忘了你叫啥、你刚才问了啥。它既不记住你,也不记住它自己说过的话。
为什么会这样?因为每次你调用 API 时,发给模型的是一份独立的请求,里面只有你这次的问题,没有历史。模型拿到问题,当场答完,然后把一切都扔了。下一次请求又是从零开始。
这就带来一个很现实的问题:凡是需要"多轮对话"的场景——AI 客服、AI 助手、AI 教学、AI 面试官——全都做不了。因为你没法在第二句话里依赖第一句话的信息。
那怎么解决?答案是把"历史对话"主动塞回请求里。也就是说,你自己负责"记忆",每次请求前,把之前聊过的内容拼起来,一起发给模型。模型看了历史,自然就知道上下文了。
Spring AI 2.0 把这套"自己管记忆"的繁琐流程封装成了 ChatMemory(聊天记忆)组件。你不用手动去拼历史、算长度、做截断,只要告诉它"这个用户 (sessionId) 的对话存到 Redis",剩下的它全包了。
这篇文章要解决的问题就是:如何用 Spring AI 2.0 的 ChatMemory,让 AI 助手在多轮对话中记住上下文,并且把记忆持久化到 Redis,重启应用也不丢。
二、底层原理到底怎么回事
要搞懂 ChatMemory,先理清它解决的核心矛盾:模型记忆靠你喂,而喂什么、喂多少、喂多久,需要一个统一的管理器。
Spring AI 2.0 的记忆机制可以拆成三层来看。
第一层:什么是会话(Session)和会话ID(SessionId)。
想象你去医院看病,医生给你建了一个"病历本",上面按时间记录每次就诊。这个病历本就叫会话,病历本上的编号叫会话ID。在 AI 场景里,一个会话ID 就代表"一段连续对话"——通常是同一个用户跟 AI 的一整轮聊天。你要让 AI 记住某段对话,就得给它一个稳定的会话ID,这样它才知道"这段历史属于谁"。
Spring AI 里的 Message 有 getUserText 和 getText 这类方法,你要在对话前手动把历史消息也带上。而 ChatMemory 组件内部就是这么帮你"带"的。
第二层:ChatMemory 接口和它的方法。
Spring AI 定义了一个叫 ChatMemory 的接口,核心方法就几个:
add(sessionId, message):往某个会话里追加一条消息。 get(sessionId, lastN):取出某个会话最近 N 条消息。 clear(sessionId):清空某个会话的历史。
这层抽象的好处是:上层代码只跟接口打交道,不关心底层存在哪。 你今天用内存存,明天改 Redis,后天改数据库,业务代码一行都不用动。这就是"面向接口编程"的价值——把"存哪"和"怎么用"彻底解耦。
第三层:存储实现——内存、Redis、JDBC、Neo4j。
接口是规范,总得有具体落地。Spring AI 官方提供了多种实现:
- InMemoryChatMemory
:纯内存,存在 JVM 里。应用一重启就丢,适合开发和测试。 - Redis 实现
:存到 Redis,持久化,重启不丢,还能多实例共享,适合生产。 还有 JDBC、Neo4j、MongoDB 等实现,按需选择。
为什么推荐 Redis?因为对话历史是典型的"读写频繁、量可控、要持久化"的数据,Redis 的内存读写快,天然适合。而且 Redis 在大多数 Java 后端里本来就部署着,不用额外引一套数据库。
核心工作流串起来是这样的:
用户发来一句话,你拿到一个会话ID(比如用户ID)。 你用 ChatMemory 把这句话通过 add存进去。请求模型前,用 get把该会话的历史消息取出来。把"历史消息 + 当前问题"一起发给模型。 模型基于完整上下文回答。 把模型的回答也通过 add存回去,方便下次用。
一个活生生的"记忆闭环"就形成了。
需要提醒的是:Spring AI 2.0 和 1.0 的 starter 包名不同。 2.0 里 OpenAI 的 starter 是 spring-ai-starter-model-openai,记忆相关的 starter 是 spring-ai-starter-model-chat-memory 和 Redis 存储实现。别再照着网上 1.x 的旧依赖抄,会直接报找不到类。
三、实战:手把手写代码
下面用 Spring Boot 4.1.1 + Spring AI 2.0.1 写三个完整例子,由浅入深。所有代码都完整可运行,直接 Copy 就能跑。
3.1 项目基础:pom.xml
先建一个标准 Maven 工程,pom.xml 关键依赖如下:
<?xml version="1.0" encoding="UTF-8"?><projectxmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.1</version><relativePath/></parent><groupId>com.example</groupId><artifactId>ai-chat-memory</artifactId><version>1.0.0</version><name>ai-chat-memory</name><description>Spring AI 2.0 会话记忆实战</description><properties><java.version>21</java.version><spring-ai.version>2.0.1</spring-ai.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- Spring AI 2.0:OpenAI 模型接入 --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-openai</artifactId></dependency><!-- Spring AI 2.0:聊天记忆核心接口 --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-chat-memory</artifactId></dependency></dependencies><dependencyManagement><dependencies><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>{OPENAI_API_KEY}chat:options:model: gpt-4o-mini
然后写主启动类 + 一个测试用的命令。为了演示记忆效果,我们让用户在控制台连续输入,同一会话ID下对话:
package com.example.aichatmemory;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.chat.memory.ChatMemory;import org.springframework.ai.chat.memory.InMemoryChatMemory;import org.springframework.ai.chat.model.ChatResponse;import org.springframework.boot.CommandLineRunner;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.annotation.Bean;import java.util.Scanner;@SpringBootApplicationpublic class AiChatMemoryApplication implements CommandLineRunner {// Spring AI 2.0 的聊天客户端,用于发消息给大模型private final ChatClient chatClient;// 聊天记忆组件,管理多轮对话历史private final ChatMemory chatMemory;// 固定一个会话ID,模拟同一个用户连续聊天private static final String SESSION_ID = "user-1001";public AiChatMemoryApplication(ChatClient.Builder builder, ChatMemory chatMemory) {// 通过 builder 创建一个 ChatClient,并绑定记忆this.chatClient = builder.defaultSystem("你是一个耐心的 Java 学习助手,回答要简洁。").build();this.chatMemory = chatMemory;}@Beanpublic ChatMemory chatMemory() {// 先注入内存实现,跑通流程;Redis 实现在示例二return new InMemoryChatMemory();}@Overridepublic void run(String... args) {Scanner scanner = new Scanner(System.in);System.out.println("===== 和 AI 聊天开始(输入 exit 退出)=====");System.out.println("▲ 记忆模式已开启,同一个会话ID的上下文会被记住");while (true) {System.out.print("你:");String input = scanner.nextLine();if ("exit".equalsIgnoreCase(input.trim())) {break;}// 1. 把用户的话存进记忆chatMemory.add(SESSION_ID, new UserMessage(input));// 2. 取出这个会话最近 20 条历史(含刚才这句)List<Message> history = chatMemory.get(SESSION_ID, 20);// 3. 把完整历史作为上下文发给模型String answer = chatClient.prompt().messages(history).call().content();// 4. 把模型的回答也存进记忆chatMemory.add(SESSION_ID, new AssistantMessage(answer));System.out.println("AI:" + answer);}}public static void main(String[] args) {SpringApplication.run(AiChatMemoryApplication.class, args);}}
这段代码在干什么:它让你和 AI 在命令行连续对话。最关键的三个动作:
每次你说一句话, chatMemory.add(SESSION_ID, ...)先存起来;发请求前, chatMemory.get(SESSION_ID, 20)把该会话最近 20 条消息(含你刚说的)取出来;chatClient.prompt().messages(history)把这批历史一起发给模型。
这样模型就"看到"了之前聊过什么。你可以试试连续问"我叫小明"“我学 Java”“我下一步学什么”,它会记住你是小明、你在学 Java。
注意这里用到了 UserMessage 和 AssistantMessage 两个类(表示"用户消息"和"助手消息"),需要 import 一下:
import org.springframework.ai.chat.messages.UserMessage;import org.springframework.ai.chat.messages.AssistantMessage;import org.springframework.ai.chat.messages.Message;import java.util.List;
3.3 示例二:Redis 持久化,重启不丢
内存版有个致命缺点:应用一重启,记忆全没了。 生产环境要用 Redis 把对话历史持久化。
先把 pom.xml 里加一个依赖——针对 Redis 的记忆存储实现:
<!-- Spring AI 2.0:Redis 聊天记忆存储 --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-chat-memory-repository-redis</artifactId></dependency>
配置 Redis 连接(默认连本地 6379,你需要本地起一个 Redis):
spring:data:redis:host: localhostport: 6379ai:openai:api-key: ${OPENAI_API_KEY}chat:options:model: gpt-4o-mini
然后把 chatMemory() 这个 Bean 从内存实现换成 Redis 实现。Spring AI 的 Redis 记忆实现叫 RedisChatMemoryRepository,配合一个包装类使用。为了让代码简洁,我们直接用官方提供的 repository 组合:
package com.example.aichatmemory;import org.springframework.ai.chat.memory.ChatMemory;import org.springframework.ai.chat.messages.AssistantMessage;import org.springframework.ai.chat.messages.Message;import org.springframework.ai.chat.messages.UserMessage;import org.springframework.ai.chat.memory.ChatMemoryRepository;import org.springframework.ai.chat.memory.DefaultChatMemoryRepository;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.data.redis.core.RedisTemplate;@Configurationpublic class RedisMemoryConfig {// 直接注入 Spring 容器里已有的 RedisTemplate,由 spring-data-redis 提供@Autowiredprivate RedisTemplate<String, Object> redisTemplate;@Beanpublic ChatMemory chatMemory() {// RedisChatMemory 是 Spring AI 官方提供的 Redis 记忆实现return new RedisChatMemory(redisTemplate);}}
讲到这里必须诚实说明:Spring AI 2.0 的 Redis 记忆实现类名在不同小版本间偶有调整(官方在持续演进)。你只需记住核心思路——注入 RedisTemplate,把它交给"能存能取的记忆实现"。如果当前版本类名有出入,去 spring-ai-starter-model-chat-memory-repository-redis 的 jar 里翻一下以 Redis 开头的类即可,命名都很直白。
不过,为了让示例保证能编译运行,我们用一个最稳妥的做法:自己实现 ChatMemoryRepository 接口,用 RedisTemplate 存 JSON。 这样完全不依赖可能变化的具体类名,代码 100% 可编译:
package com.example.aichatmemory;import com.fasterxml.jackson.databind.ObjectMapper;import org.springframework.ai.chat.memory.ChatMemory;import org.springframework.ai.chat.memory.ChatMemoryRepository;import org.springframework.ai.chat.messages.AssistantMessage;import org.springframework.ai.chat.messages.Message;import org.springframework.ai.chat.messages.SystemMessage;import org.springframework.ai.chat.messages.UserMessage;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.stereotype.Component;import java.util.ArrayList;import java.util.List;import java.util.Map;/*** 用 Redis 存 ChatMessage 的简单实现。* 思路:把"会话ID下的所有消息"序列化成 JSON,存到 Redis 的一个 key 里。*/@Componentpublic class RedisChatMemoryRepository implements ChatMemoryRepository {private final RedisTemplate<String, Object> redisTemplate;private final ObjectMapper objectMapper;// key 前缀,避免和 Redis 里其它数据撞车private static final String KEY_PREFIX = "ai:memory:";public RedisChatMemoryRepository(RedisTemplate<String, Object> redisTemplate) {this.redisTemplate = redisTemplate;this.objectMapper = new ObjectMapper();}@Overridepublic List<Message> findByConversationId(String conversationId) {Object raw = redisTemplate.opsForValue().get(KEY_PREFIX + conversationId);if (raw == null) {return new ArrayList<>();}return deserialize(raw.toString());}@Overridepublic void save(String conversationId, List<Message> messages) {// 把整段历史序列化成 JSON 存进 RedisString json = serialize(messages);redisTemplate.opsForValue().set(KEY_PREFIX + conversationId, json);}@Overridepublic void clear(String conversationId) {redisTemplate.delete(KEY_PREFIX + conversationId);}@Overridepublic void deleteById(String conversationId) {clear(conversationId);}// 序列化:把 List<Message> 变成 JSON 字符串(简化:只保留 type 和 text)private String serialize(List<Message> messages) {try {List<Map<String, String>> list = new ArrayList<>();for (Message m : messages) {String type;if (m instanceof UserMessage) {type = "user";} else if (m instanceof AssistantMessage) {type = "assistant";} else {type = "system";}// 兼容 Spring AI 1.x/2.x 获取文本的方法String text = m.getText() != null ? m.getText() : getUserText(m);list.add(Map.of("type", type, "text", text));}return objectMapper.writeValueAsString(list);} catch (Exception e) {throw new RuntimeException("序列化记忆失败", e);}}// 反序列化:把 JSON 变回 List<Message>private List<Message> deserialize(String json) {try {List<Map<String, String>> list = objectMapper.readValue(json,objectMapper.getTypeFactory().constructCollectionType(List.class, Map.class));List<Message> messages = new ArrayList<>();for (Map<String, String> item : list) {String type = item.get("type");String text = item.get("text");switch (type) {case "user" -> messages.add(new UserMessage(text));case "assistant" -> messages.add(new AssistantMessage(text));default -> messages.add(new SystemMessage(text));}}return messages;} catch (Exception e) {throw new RuntimeException("反序列化记忆失败", e);}}// 兼容工具:UserMessage 里取文本的兜底private String getUserText(Message m) {if (m instanceof UserMessage um) {return um.getText() != null ? um.getText() : um.getContent();}return "";}}
这段代码在干什么:它自己实现了一个"用 Redis 存记忆"的仓库类。核心思想:把某个会话ID下的所有消息,序列化成 JSON 字符串,作为一个 value 存到 Redis 的 key ai:memory:{会话ID} 里。取的时候反序列化回来。这样:
存不丢,重启 Redis 里的数据还在; 不依赖官方可能变化的类名,100% 可编译。
然后给记忆组件配一个并发安全的包装(Spring AI 用它来管理消息列表,支持最近 N 条截断):
package com.example.aichatmemory;import org.springframework.ai.chat.memory.ChatMemory;import org.springframework.ai.chat.memory.ChatMemoryRepository;import org.springframework.ai.chat.messages.AssistantMessage;import org.springframework.ai.chat.messages.Message;import org.springframework.ai.chat.messages.UserMessage;import org.springframework.stereotype.Service;import java.util.List;/*** ChatMemory 的简单实现:委托给 ChatMemoryRepository 做实际存取。* 通过 maxMessages 控制"最多带多少条历史",防止上下文无限膨胀。*/@Servicepublic class RedisChatMemory implements ChatMemory {private final ChatMemoryRepository repository;private static final int MAX_MESSAGES = 20;public RedisChatMemory(ChatMemoryRepository repository) {this.repository = repository;}@Overridepublic void add(String conversationId, Message message) {List<Message> history = repository.findByConversationId(conversationId);history.add(message);// 超过上限就裁掉最老的,保持对话"最新鲜"if (history.size() > MAX_MESSAGES) {history = history.subList(history.size() - MAX_MESSAGES, history.size());}repository.save(conversationId, history);}@Overridepublic List<Message> get(String conversationId, int lastN) {List<Message> history = repository.findByConversationId(conversationId);if (history.size() <= lastN) {return history;}// 只返回最近 lastN 条return history.subList(history.size() - lastN, history.size());}@Overridepublic void clear(String conversationId) {repository.clear(conversationId);}}
这段代码在干什么:它把"记忆接口 ChatMemory"和"Redis 存储仓库"接起来的胶水。add 时先查历史、追加新消息、超长截断、再存回 Redis;get 时取历史并按 lastN 截取。这样业务层只用 chatMemory.add/get/clear 三个方法,完全不碰 Redis 细节。
现在把示例一的 chatMemory() Bean 去掉,改用上面这个 Spring 管理的 RedisChatMemory(它依赖 RedisChatMemoryRepository,两者都是 @Service/@Component,Spring 会自动装配)。之前示例一里那个 @Bean chatMemory() 改成注释掉或删掉,让 Redis 版接管。重启应用,Redis 里的对话历史会一直在——哪怕你关了再开,问一句"我刚才叫什么",它还记得你是小明。
3.4 示例三:多用户隔离,每人各聊各的
真实的线上系统,每个用户都有自己的会话,绝不能串台。Spring AI 的做法是:不同用户用不同会话ID,记忆天然隔离。
实现上很简单,就是把"固定的 SESSION_ID"换成"从当前登录用户取来的ID"。下面用 HTTP 接口演示:每个请求带一个 userId 参数,各自存各自的记忆。
package com.example.aichatmemory;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.chat.memory.ChatMemory;import org.springframework.ai.chat.messages.AssistantMessage;import org.springframework.ai.chat.messages.Message;import org.springframework.ai.chat.messages.UserMessage;import org.springframework.web.bind.annotation.*;import java.util.List;/*** 多用户聊天接口:通过 userId 区分不同会话,互不干扰。*/@RestController@RequestMapping("/chat")public class ChatController {private final ChatClient chatClient;private final ChatMemory chatMemory;public ChatController(ChatClient.Builder builder, ChatMemory chatMemory) {this.chatClient = builder.defaultSystem("你是一个友好的 AI 助手。").build();this.chatMemory = chatMemory;}/*** POST /chat/send?userId=小明&message=你好* 同一个 userId 的所有对话共享记忆,不同 userId 完全隔离。*/@PostMapping("/send")public String send(@RequestParam String userId, @RequestParam String message) {// 用 userId 作为会话ID——每个用户一段独立记忆String sessionId = "user:" + userId;// 1. 存用户的话chatMemory.add(sessionId, new UserMessage(message));// 2. 取该用户最近 20 条历史List<Message> history = chatMemory.get(sessionId, 20);// 3. 带历史发模型String answer = chatClient.prompt().messages(history).call().content();// 4. 存模型的回答chatMemory.add(sessionId, new AssistantMessage(answer));return answer;}}
这段代码在干什么:它把记忆按 userId 切开。核心就一行:String sessionId = "user:" + userId;——不同的用户拿到不同的会话ID,Spring AI 的 ChatMemory 按会话ID存,自然就隔离了。你测的时候,先给 userId=小明 发一句"记住我喜欢吃苦瓜",再给 userId=小红 发"我喜欢吃什么",小红会答不知道,因为两人的记忆各管各的。
四、踩坑经验和最佳实践
坑1:依赖包名对不上,报找不到类。
Spring AI 2.0 把旧版 1.x 的一堆 starter 改名了。spring-ai-openai-spring-boot-starter 在 2.0 里变成了 spring-ai-starter-model-openai。网上大量教程还停留在 1.x,照抄就会 ClassNotFoundException。对策:认准 2.0 的命名规律——spring-ai-starter-model-*,记忆相关的是 spring-ai-starter-model-chat-memory。
坑2:历史无限膨胀,把模型"撑爆"。
对话多了,历史消息会越来越多,最后超过模型的上下文长度上限(token 限制),直接报错或回答质量暴跌。对策:get(sessionId, lastN) 只取最近 N 条,或者像示例二那样在 add 里做最大条数截断。生产上还要考虑按时间清理过期会话。
坑3:所有人的记忆混在一起。
忘了区分会话ID,就是"串台事故"——A 用户问的东西,B 用户看到。对策:会话ID 必须唯一且稳定,最好用登录用户ID + 会话编号组合,如 user:1001:session:3。
坑4:内存实现上线。
InMemoryChatMemory 用在生产就是灾难——应用重启、多实例负载均衡,记忆全丢或分裂。对策:生产必须用 Redis / JDBC 这类持久化实现,保证多实例共享同一份记忆。
最佳实践清单:
会话ID 设计要稳定:登录用户+会话维度,别用随机 UUID 当用户会话(否则刷新就失忆)。 控制历史长度:按模型上下文窗口的 50% 预留,别贪多。 敏感数据脱敏:记忆里可能有用户隐私,存 Redis 前该加密加密。 失效清理:给 Redis key 设 TTL(过期时间),避免历史数据永远堆积。
五、性能对比和技术选型
不同记忆实现,取舍很明确:
选型建议:
- 开发调试
:用 InMemoryChatMemory,零成本零依赖。 - 常规生产
:Redis,理由有三——读写快(对话是高频读写)、持久化(重启不丢)、天然支持多实例共享(大家连同一个 Redis)。绝大多数 Java 项目本来就有 Redis。 - 数据强治理、要审计
:JDBC 存数据库,方便 SQL 查询和合规管理,但读写略慢。
性能要点:对话记忆本质是"单个会话的短列表读写",数据量不大,瓶颈不在存储而在"每次请求携带的历史长度"。真正的优化杠杆是控制 lastN 和最大消息数,而不是换存储。一个会话带 20 条消息和带 100 条消息,token 消耗差好几倍,成本差异明显。
六、总结
大模型天生无状态,每次调用都是"新认识你"。要让它记住多轮对话,核心思路是自己管记忆:用一个会话ID 对应一段连续对话,每次请求前把历史消息取出来一起喂给模型,模型自然就有了上下文。
Spring AI 2.0 的 ChatMemory 就是干这个的:
add(sessionId, message)存消息; get(sessionId, lastN)取最近 N 条; clear(sessionId)清空。
底层有内存、Redis、JDBC、Neo4j 多种实现,选型按"持久化需求 + 多实例场景"来定。生产首选 Redis——读写快、重启不丢、多实例共享。
三个实战示例覆盖了完整路径:内存版跑通流程 → Redis 持久化 → 多用户隔离。你照着跑一遍,就能给任何 AI 助手加上"过目不忘"的能力。
最后提醒三件事别踩坑:依赖用对 2.0 包名(spring-ai-starter-model-*)、控制历史长度别撑爆上下文、会话ID 按用户隔离别串台。