一、关键点分析
-
核心痛点:大模型 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: 0
4.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接口让用户能够主动清除对话历史。
