一、关键点分析
核心痛点:大模型 API 本身是无状态的,每次请求都是独立的。网页版 ChatGPT 能“记住”是因为在每次请求时把历史消息都塞进了上下文。
手动实现的麻烦:需要自己维护
List<Message>,并且要处理会话 ID、上下文窗口超限、服务重启丢历史等问题。Spring AI 的解决方案:通过
ChatMemory+Advisor自动管理对话历史。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 Map<String, List<Message>> sessions = new ConcurrentHashMap<>(); public ManualChatController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @PostMapping public String chat(@RequestBody ChatRequest request) { // 获取或创建该会话的历史 List<Message> history = sessions.computeIfAbsent(request.conversationId(), id -> { List<Message> 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=我叫大王&conversationId=user123" # 模型回复:你好,大王!有什么可以帮你的? # 第二轮(同一个 conversationId) curl "http://localhost:8080/memory-chat?message=你还记得我叫什么吗&conversationId=user123" # 模型回复:记得,你叫大王。 # 换一个 conversationId(新会话,不记得之前的内容) curl "http://localhost:8080/memory-chat?message=你还记得我叫什么吗&conversationId=user456" # 模型回复:抱歉,我不知道你的名字,你可以告诉我吗?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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>4.2 配置 Redis 连接
spring: data: redis: host: localhost port: 6379 database: 04.3 自定义 RedisChatMemoryRepository
package 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 RedisTemplate<String, Object> redisTemplate; public RedisChatMemoryRepository(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } @Override public List<String> findConversationIds() { // 实现不推荐在生产环境使用,"KEYS *" 命令可能导致性能问题 Set<String> 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 List<Message> findByConversationId(String conversationId) { String key = KEY_PREFIX + conversationId; List<Object> rawMessages = redisTemplate.opsForList().range(key, 0, -1); if (CollectionUtils.isEmpty(rawMessages)) { return new ArrayList<>(); } List<Message> 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, List<Message> messages) { String key = KEY_PREFIX + conversationId; // 先清除旧数据,再全量写入,避免重复追加 redisTemplate.delete(key); List<MessageRecord> 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 Bean
@Configuration public class ChatMemoryConfig { @Bean public ChatMemory chatMemory(RedisTemplate<String, 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=我叫大王&conversationId=user123" # 重启服务后再发第二轮 curl "http://localhost:8080/redis-chat?message=你还记得我叫什么吗&conversationId=user123" # 模型回复:记得,你叫大王。六、会话管理:清除历史
用户退出登录、开启新对话时,需要清除历史:
@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); } }七、完整的多轮对话 Controller
@RestController @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 预算裁剪的ChatMemory:
public class TokenBudgetChatMemory implements ChatMemory { private static final int CHARS_PER_TOKEN = 4; // 粗估:4个字符≈1 Token private final int maxTokenBudget; private final ConcurrentHashMap<String, List<Message>> store = new ConcurrentHashMap<>(); public TokenBudgetChatMemory(int maxTokenBudget) { this.maxTokenBudget = maxTokenBudget; } @Override public void add(String conversationId, List<Message> messages) { store.computeIfAbsent(conversationId, k -> new ArrayList<>()).addAll(messages); } @Override public List<Message> get(String conversationId) { List<Message> all = store.getOrDefault(conversationId, List.of()); if (all.isEmpty()) return List.of(); List<Message> 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并注入MessageWindowChatMemoryBean,Controller 代码零更改。注意控制消息数量或Token 预算,防止超出模型上下文窗口。
提供
clear接口让用户能够主动清除对话历史。