保姆级教程:从内存版到 Redis 持久化)
一、关键点分析核心痛点大模型 API 本身是无状态的每次请求都是独立的。网页版 ChatGPT 能“记住”是因为在每次请求时把历史消息都塞进了上下文。手动实现的麻烦需要自己维护ListMessage并且要处理会话 ID、上下文窗口超限、服务重启丢历史等问题。Spring AI 的解决方案通过ChatMemoryAdvisor自动管理对话历史。MessageWindowChatMemory内存版按条数裁剪MessageChatMemoryAdvisor注入到ChatClient的调用链中控制消息数量maxMessages参数权衡记忆长度与 Token 消耗。生产级持久化架构分层ChatMemoryRepository纯存储 MessageWindowChatMemory包装、裁剪以 Redis 为例实现RedisChatMemoryRepository注入StringRedisTemplate存储为 List设置 TTL。注册为 Bean 后Controller 代码与内存版完全一致。会话管理提供clear(conversationId)接口。Token 预算问题简单策略限制保留条数。精确策略按字符数/Token 数截断示例TokenBudgetChatMemory。高级策略摘要压缩后续 Agent 涉及。模型本身是无状态的——每次 API 请求对它来说都是全新的上一次聊了什么它完全不知道。“但我在 ChatGPT 网页上聊天它明明能记住前面的内容啊”对那是因为网页前端每次都把历史记录一起发给了模型不是模型自己有记忆而是历史消息被塞进了这次请求的上下文里。Spring AI 的ChatMemory就是帮你做这件事的——自动管理对话历史每次发请求时自动带上前面的消息。二、不用 ChatMemory 时多轮对话要怎么做先看看手动实现多轮对话是什么样的RestController RequestMapping(/manual-chat) public class ManualChatController { private final ChatClient chatClient; // 手动维护每个会话的历史演示用生产不推荐 private final MapString, ListMessage sessions new ConcurrentHashMap(); public ManualChatController(ChatClient.Builder builder) { this.chatClient builder.build(); } PostMapping public String chat(RequestBody ChatRequest request) { // 获取或创建该会话的历史 ListMessage history sessions.computeIfAbsent(request.conversationId(), id - { ListMessage list new ArrayList(); list.add(new SystemMessage(你是一个 Java 技术助手)); return list; }); // 追加用户消息 history.add(new UserMessage(request.message())); // 带完整历史调用模型 String reply chatClient.prompt() .messages(history) .call() .content(); // 把模型回复也追加进历史 history.add(new AssistantMessage(reply)); return reply; } record ChatRequest(String conversationId, String message) {} }能实现但有明显痛点历史列表需要调用方自己维护接口无状态每次请求都要传完整历史。上下文窗口有限对话一长总 Token 数超出限制就会报错。没有持久化服务重启历史就丢了。ChatMemory解决的就是这些问题。三、ChatMemory 基础用法内存版Spring AI 内置了基于Advisor的ChatMemory支持。RestController RequestMapping(/memory-chat) public class MemoryChatController { private final ChatClient chatClient; private final MessageWindowChatMemory chatMemory; public MemoryChatController(ChatClient.Builder builder) { // 保留最近 10 条消息 this.chatMemory MessageWindowChatMemory.builder().maxMessages(10).build(); this.chatClient builder .defaultSystem(你是一个 Java 技术助手) .build(); } GetMapping public String chat( RequestParam String message, RequestParam(defaultValue default) String conversationId) { return chatClient.prompt() .user(message) .advisors(MessageChatMemoryAdvisor.builder(chatMemory) .conversationId(conversationId) .build()) .call() .content(); } }测试效果# 第一轮 curl http://localhost:8080/memory-chat?message我叫大王conversationIduser123 # 模型回复你好大王有什么可以帮你的 # 第二轮同一个 conversationId curl http://localhost:8080/memory-chat?message你还记得我叫什么吗conversationIduser123 # 模型回复记得你叫大王。 # 换一个 conversationId新会话不记得之前的内容 curl http://localhost:8080/memory-chat?message你还记得我叫什么吗conversationIduser456 # 模型回复抱歉我不知道你的名字你可以告诉我吗MessageWindowChatMemory默认基于内存存储重启应用后记忆会丢失。生产环境可替换为持久化实现如 Redis、数据库。四、控制保留的消息数量默认保留最近 20 条消息你可以自定义RestController RequestMapping(/long-chat) public class LongChatController { private final ChatClient chatClient; private final MessageWindowChatMemory chatMemory; public LongChatController(ChatClient.Builder builder) { // 保留最近 20 条消息 this.chatMemory MessageWindowChatMemory.builder().maxMessages(20).build(); this.chatClient builder .defaultSystem(你是一个 Java 技术助手) .build(); } GetMapping public String chat(...) { // 与上例完全相同只是 maxMessages 改了 return chatClient.prompt() .user(message) // 1.1.x 新 API按 conversationId 构建 Advisor .advisors(MessageChatMemoryAdvisor.builder(chatMemory) .conversationId(conversationId) .build()) .call() .content(); } }消息数量的权衡太少模型忘得快早期说的内容就不记得了。太多每次发送的 Token 增多费用上升超出上下文窗口也会报错。一般客服/聊天场景保留 10~20 条够用如果是长文档处理需要配合 RAG 或用长上下文模型。五、持久化存储生产环境必备MessageWindowChatMemory只在内存里服务一重启历史就没了生产环境不能用。Spring AI 1.1.x 把存储层和裁剪逻辑拆成了两层ChatMemoryRepository纯存储接口只管读写全量消息不做任何裁剪。MessageWindowChatMemory包装Repository对外暴露ChatMemory负责按条数裁剪窗口。4.1 添加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency4.2 配置 Redis 连接spring: data: redis: host: localhost port: 6379 database: 04.3 自定义 RedisChatMemoryRepositorypackage com.studying.chatMemory; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.collections4.CollectionUtils; 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.MessageType; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.data.redis.core.RedisTemplate; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; public class RedisChatMemoryRepository implements ChatMemoryRepository { private static final String KEY_PREFIX chat:memory:; private static final int TTL_DAYS 3; private static final ObjectMapper MAPPER new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); private final RedisTemplateString, Object redisTemplate; public RedisChatMemoryRepository(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } Override public ListString findConversationIds() { // 实现不推荐在生产环境使用KEYS * 命令可能导致性能问题 SetString keys redisTemplate.keys(KEY_PREFIX *); if (CollectionUtils.isEmpty(keys)) { return new ArrayList(); } return keys.stream() .map(key - key.substring(KEY_PREFIX.length())) .toList(); } Override public ListMessage findByConversationId(String conversationId) { String key KEY_PREFIX conversationId; ListObject rawMessages redisTemplate.opsForList().range(key, 0, -1); if (CollectionUtils.isEmpty(rawMessages)) { return new ArrayList(); } ListMessage messages new ArrayList(); rawMessages.stream() .forEach(raw - { MessageRecord record MAPPER.convertValue(raw, MessageRecord.class); if (MessageType.USER.getValue().equals(record.role())) { messages.add(new UserMessage(record.content())); } else if (MessageType.ASSISTANT.getValue().equals(record.role())) { messages.add(new AssistantMessage(record.content())); } }); return messages; } Override public void saveAll(String conversationId, ListMessage messages) { String key KEY_PREFIX conversationId; // 先清除旧数据再全量写入避免重复追加 redisTemplate.delete(key); ListMessageRecord recordList messages.stream() .map(msg - new MessageRecord(msg.getMessageType().getValue(), msg.getText())) .toList(); redisTemplate.opsForList().rightPushAll(key, recordList.toArray()); redisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS); } Override public void deleteByConversationId(String conversationId) { redisTemplate.delete(KEY_PREFIX conversationId); } record MessageRecord(String role, String content) { } }4.4 注册 ChatMemory BeanConfiguration public class ChatMemoryConfig { Bean public ChatMemory chatMemory(RedisTemplateString, Object redisTemplate) { RedisChatMemoryRepository repository new RedisChatMemoryRepository(redisTemplate); // 底层走 Redis 持久化上层限制最多保留 20 条消息 return MessageWindowChatMemory.builder() .chatMemoryRepository(repository) .maxMessages(20) .build(); } }4.5 Controller 使用与内存版完全一样RestController RequestMapping(/redis-chat) public class RedisChatController { private final ChatClient chatClient; private final ChatMemory chatMemory; public RedisChatController(ChatClient.Builder builder, ChatMemory chatMemory) { this.chatMemory chatMemory; this.chatClient builder .defaultSystem(你是一个 Java 技术助手) .build(); } GetMapping public String chat( RequestParam String message, RequestParam(defaultValue default) String conversationId) { return chatClient.prompt() .user(message) .advisors(MessageChatMemoryAdvisor.builder(chatMemory) .conversationId(conversationId) .build()) .call() .content(); } }测试效果服务重启后历史依然保留数据存在 Redis 里# 第一轮 curl http://localhost:8080/redis-chat?message我叫大王conversationIduser123 # 重启服务后再发第二轮 curl http://localhost:8080/redis-chat?message你还记得我叫什么吗conversationIduser123 # 模型回复记得你叫大王。六、会话管理清除历史用户退出登录、开启新对话时需要清除历史RestController RequestMapping(/session) public class SessionController { private final ChatMemory chatMemory; public SessionController(ChatMemory chatMemory) { this.chatMemory chatMemory; } DeleteMapping(/{conversationId}) public void clearHistory(PathVariable String conversationId) { chatMemory.clear(conversationId); } }七、完整的多轮对话 ControllerRestController RequestMapping(/api/conversation) public class ConversationController { private final ChatClient chatClient; private final ChatMemory chatMemory; public ConversationController(ChatClient.Builder builder, ChatMemory chatMemory) { this.chatMemory chatMemory; this.chatClient builder .defaultSystem( 你是一个智能助手。 记住用户告诉你的所有信息在后续对话中灵活运用。 回答简洁除非用户要求详细解释。 ) .build(); } PostMapping(/message) public MessageResponse sendMessage(RequestBody MessageRequest request) { String reply chatClient.prompt() .user(request.message()) .advisors(MessageChatMemoryAdvisor.builder(chatMemory) .conversationId(request.conversationId()) .build()) .call() .content(); return new MessageResponse(reply, request.conversationId()); } DeleteMapping(/{conversationId}) public void clearConversation(PathVariable String conversationId) { chatMemory.clear(conversationId); } record MessageRequest(String conversationId, String message) {} record MessageResponse(String reply, String conversationId) {} }八、上下文窗口和 Token 预算多轮对话最容易踩的坑是Token 超限。每个模型都有最大上下文长度限制比如 DeepSeek-V3 是 128K Token。历史消息越多每次请求的 Token 数就越多。策略一限制保留消息数最简单已介绍策略二按 Token 数限制更精确实现一个按 Token 预算裁剪的ChatMemorypublic class TokenBudgetChatMemory implements ChatMemory { private static final int CHARS_PER_TOKEN 4; // 粗估4个字符≈1 Token private final int maxTokenBudget; private final ConcurrentHashMapString, ListMessage store new ConcurrentHashMap(); public TokenBudgetChatMemory(int maxTokenBudget) { this.maxTokenBudget maxTokenBudget; } Override public void add(String conversationId, ListMessage messages) { store.computeIfAbsent(conversationId, k - new ArrayList()).addAll(messages); } Override public ListMessage get(String conversationId) { ListMessage all store.getOrDefault(conversationId, List.of()); if (all.isEmpty()) return List.of(); ListMessage result new ArrayList(); int tokenCount 0; for (int i all.size() - 1; i 0; i--) { int msgTokens all.get(i).getText().length() / CHARS_PER_TOKEN; if (tokenCount msgTokens maxTokenBudget) break; result.add(all.get(i)); tokenCount msgTokens; } Collections.reverse(result); return result; } Override public void clear(String conversationId) { store.remove(conversationId); } }使用ChatMemory tokenBudgetMemory new TokenBudgetChatMemory(2000); // 预算 2000 Token策略三摘要压缩高级定期把历史消息压缩成摘要用摘要替代原始历史大幅减少 Token 占用。这个方案比较复杂适合长期对话场景后续 Agent 模块会涉及。八、总结模型无状态多轮对话需要自行管理历史消息。Spring AI 的ChatMemory通过Advisor机制自动帮你做这件事。从内存版切换到Redis 持久化只需要实现ChatMemoryRepository并注入MessageWindowChatMemoryBeanController 代码零更改。注意控制消息数量或Token 预算防止超出模型上下文窗口。提供clear接口让用户能够主动清除对话历史。