Spring AI 对话记忆深度实践:无状态本质、ChatMemory 抽象、上下文管理与会话隔离
前两篇文章我们解决了两个问题:
- 如何用三层架构接入大模型,并支持同步/流式调用;
- 如何用 System / User / Assistant 角色和 Prompt Template 管理提示词。
但到目前为止,每次请求都是独立的:用户问"Spring 是什么?"然后追问"那它和 Spring Boot 有什么区别?",第二次请求时模型完全不记得第一次说了什么。要构建真正的智能对话系统,就必须让模型具备记忆。
本文将从大模型的"无状态"本质出发,深入讲解 Spring AI 中的 ChatMemory 抽象、多轮对话上下文管理策略,以及如何用 conversationId 实现精准的会话隔离。
一、理解 Agent 的"无状态"问题
1.1 大模型本身是无状态的
无论是 OpenAI、通义千问还是 Ollama 中的本地模型,底层的大语言模型(LLM)本质上是一个 函数:输入一段 token 序列,输出一段 token 序列。它不保存任何跨请求的历史信息。
当你调用 chatModel.call("你好")时,模型只是根据你的输入生成了一个回复;当你再次调用 chatModel.call("我刚刚说了什么?")时,如果没有把之前的对话拼接到输入中,模型什么都不知道。
用公式表示:
ini
output = LLM(input)
没有记忆、没有状态、没有会话概念。
1.2 Agent 为什么需要状态
Agent(智能体)的任务是完成复杂用户目标。它需要:
- 记住用户已经提供的信息(如姓名、偏好、订单号);
- 记住自己已经回答过的内容,避免重复;
- 在多步推理中保持逻辑一致;
- 在对话中提供连续、自然的体验。
因此,我们必须把记忆"外置"到应用层,由开发者在每次请求时,把历史消息和当前新消息一起提交给模型。这正是对话记忆(Chat Memory)要解决的问题。
二、Spring AI 中的 ChatMemory 抽象
Spring AI 提供了 org.springframework.ai.chat.memory.ChatMemory接口,用来统一管理以 conversationId区分的消息历史。
2.1 ChatMemory 接口核心方法
arduino
public interface ChatMemory {
/**
* 向指定会话追加消息
*/
void add(String conversationId, List<Message> messages);
/**
* 获取指定会话的所有历史消息
*/
List<Message> get(String conversationId);
/**
* 清理指定会话
*/
void clear(String conversationId);
}
接口极其简洁,本质上就是一个以 conversationId 为键的消息列表存取器。
2.2 内置实现
Spring AI 默认提供了几个实现:
| 实现类 | 特点 |
|---|---|
InMemoryChatMemory |
基于 ConcurrentHashMap,无大小限制,适合简单场景 |
MessageWindowChatMemory |
滑动窗口,只保留最近 N 条消息,自动丢弃最旧消息 |
ReadOnlyChatMemory |
只读实现,多用于测试 |
下面是 InMemoryChatMemory的典型用法:
csharp
ChatMemory chatMemory = InMemoryChatMemory.builder().build();
chatMemory.add("conversation-001", List.of(
new UserMessage("你好"),
new AssistantMessage("你好!有什么可以帮你?")
));
List<Message> history = chatMemory.get("conversation-001");
System.out.println(history.size()); // 2
2.3 ChatClient 如何绑定记忆
ChatClient 提供了 .memory()方法,将 ChatMemory 和 conversationId 绑定到当前请求。
java
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
@Configuration
public class ChatConfig {
@Bean
public ChatClient chatClient(ChatModel chatModel) {
ChatMemory chatMemory = InMemoryChatMemory.builder().build();
return ChatClient.builder(chatModel)
.defaultMemory(chatMemory)
.build();
}
}
在 Controller 中调用时,只需传入会话 ID:
less
@RestController
public class MemoryController {
private final ChatClient chatClient;
public MemoryController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@GetMapping("/memory/chat")
public String chat(@RequestParam String conversationId, @RequestParam String message) {
return chatClient.prompt()
.user(message)
.memory(conversationId)
.call()
.content();
}
}
memory(conversationId)会自动完成下面几件事:
- 从默认 ChatMemory 中读取该会话历史;
- 将历史消息(System 除外)和当前用户消息拼接为完整的消息列表;
- 发送给模型得到回复;
- 将用户消息和助手回复写入 ChatMemory,更新历史。
整个过程对业务代码完全透明。
2.4 手动管理记忆
如果你希望更精细地控制,可以绕过 ChatClient 的自动拼接,自己从 ChatMemory 读取历史,再传给 Prompt:
arduino
@Service
public class ManualMemoryService {
private final ChatModel chatModel;
private final ChatMemory chatMemory;
public ManualMemoryService(ChatModel chatModel, ChatMemory chatMemory) {
this.chatModel = chatModel;
this.chatMemory = chatMemory;
}
public String chat(String conversationId, String userInput) {
// 1. 获取历史
List<Message> history = chatMemory.get(conversationId);
// 2. 构建消息列表:系统消息 + 历史 + 当前用户消息
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是智能助手"));
messages.addAll(history);
messages.add(new UserMessage(userInput));
// 3. 调用模型
ChatResponse response = chatModel.call(new Prompt(messages));
// 4. 提取回复
String assistantContent = response.getResult().getOutput().getText();
// 5. 写回记忆
chatMemory.add(conversationId, List.of(
new UserMessage(userInput),
new AssistantMessage(assistantContent)
));
return assistantContent;
}
}
这种方式让我们能够自定义历史长度、token 截断、异常回滚等策略,是学习 ChatMemory 底层机制的绝佳途径。
三、多轮对话上下文管理策略
默认的 InMemoryChatMemory会无限增长。如果用户聊了 100 轮,每轮平均 300 token,最终消息列表会非常庞大,导致 token 超限、费用膨胀、响应变慢。因此,生产环境必须采用适当的上下文管理策略。
3.1 策略一:固定窗口记忆(Message Window)
MessageWindowChatMemory是 Spring AI 提供的窗口式记忆实现。它只保留最近 N 条消息,丢弃更早的消息。
scss
ChatMemory windowedMemory = MessageWindowChatMemory.builder()
.maxMessages(20) // 最多保留20条消息
.build();
也可以按 token 数限制:
scss
ChatMemory tokenWindow = MessageWindowChatMemory.builder()
.maxTokens(2000) // 最多保留2000个token
.build();
优点:实现简单、内存占用稳定、行为可预测。
缺点:一旦超出窗口,早期关键信息会被永久丢弃;模型对超出窗口的部分"失忆"。
适用场景:客服机器人、闲聊对话。
3.2 策略二:摘要记忆(Summarization Memory)
当窗口无法容纳所有历史时,可以对旧消息生成摘要,将摘要作为一条"压缩记忆"保留。
Spring AI 没有直接提供内置的摘要组件(截至当前版本),但我们可以用 ChatModel 自己实现:
typescript
@Service
public class SummarizingChatMemory implements ChatMemory {
private final ChatModel chatModel;
private final Map<String, List<Message>> storage = new ConcurrentHashMap<>();
private static final int MAX_MESSAGES = 10;
public SummarizingChatMemory(ChatModel chatModel) {
this.chatModel = chatModel;
}
@Override
public void add(String conversationId, List<Message> messages) {
storage.computeIfAbsent(conversationId, k -> new ArrayList<>()).addAll(messages);
}
@Override
public List<Message> get(String conversationId) {
List<Message> all = storage.getOrDefault(conversationId, List.of());
if (all.size() <= MAX_MESSAGES) {
return all;
}
// 如果超出窗口,生成摘要并返回摘要 + 最近几条消息
List<Message> oldMessages = all.subList(0, all.size() - MAX_MESSAGES / 2);
String summary = summarize(oldMessages);
List<Message> recent = new ArrayList<>();
recent.add(new SystemMessage("以下是之前的对话摘要,请基于此继续聊天:\n" + summary));
recent.addAll(all.subList(all.size() - MAX_MESSAGES / 2, all.size()));
return recent;
}
private String summarize(List<Message> messages) {
StringBuilder sb = new StringBuilder();
for (Message m : messages) {
sb.append(m.getMessageType()).append(": ").append(m.getText()).append("\n");
}
String prompt = "请将以下对话压缩为不超过200字的摘要,保留关键信息:\n" + sb;
return chatModel.call(prompt);
}
@Override
public void clear(String conversationId) {
storage.remove(conversationId);
}
}
优点:上下文信息保留度高,token 成本低。
缺点:摘要本身可能丢失细节;每次生成摘要会额外消耗一次模型调用。
适用场景:长时间会话、角色扮演、深度咨询。
3.3 策略三:向量检索记忆(RAG Memory)
对于跨会话的长期记忆,可以配合向量数据库(如 PgVector、Milvus、Redis Vector),将历史消息向量化存储。每次请求时通过相似度搜索召回与当前问题最相关的历史片段。
这里给出一个简化版思路:
arduino
@Service
public class VectorMemoryService {
private final VectorStore vectorStore;
private final ChatClient chatClient;
public VectorMemoryService(VectorStore vectorStore, ChatModel chatModel) {
this.vectorStore = vectorStore;
this.chatClient = ChatClient.builder(chatModel).build();
}
public String chatWithLongMemory(String conversationId, String userMessage) {
// 1. 从向量库搜索相关历史
List<Document> relatedDocs = vectorStore.similaritySearch(
SearchRequest.query(userMessage)
.withFilter("conversationId", conversationId) // 过滤同一会话
.withTopK(5)
);
// 2. 将历史拼入 system prompt
String historyContext = relatedDocs.stream()
.map(Document::getText)
.reduce((a, b) -> a + "\n" + b)
.orElse("无历史记录");
// 3. 使用 ChatClient 传入上下文
return chatClient.prompt()
.system("以下是该用户的历史对话片段,供参考:\n" + historyContext)
.user(userMessage)
.call()
.content();
}
}
优点:支持跨会话长期记忆,检索精准,几乎不限制历史长度。
缺点:架构复杂,需要维护向量库、embedding 管道,检索质量受嵌入模型影响。
适用场景:个人助理、知识库问答、客服历史分析。
四、会话隔离(conversationId)
4.1 为什么需要会话隔离
在一个多用户系统中,所有用户共享同一个 ChatMemory。如果没有 conversationId 做隔离,用户 A 的对话历史可能会被用户 B 读取,导致严重的信息泄露和逻辑混乱。
ChatMemory 的设计天然支持隔离:它的所有方法都要求传入 conversationId。这个 ID 可以是:
- 用户 ID;
- 会话表主键;
- UUID;
- 前端 WebSocket 连接 ID。
4.2 全局会话 ID 的生成与传递
推荐在进入 Controller 前就为每个新会话生成唯一的 conversationId,通常使用 UUID:
less
@RestController
@RequestMapping("/api/chat")
public class ConversationController {
private final ChatClient chatClient;
private final ChatMemory chatMemory;
public ConversationController(ChatClient chatClient, ChatMemory chatMemory) {
this.chatClient = chatClient;
this.chatMemory = chatMemory;
}
// 创建新会话,返回 conversationId
@PostMapping("/conversations")
public Map<String, String> createConversation() {
String conversationId = UUID.randomUUID().toString();
return Map.of("conversationId", conversationId);
}
// 根据 conversationId 进行聊天
@GetMapping("/conversations/{conversationId}/messages")
public String chat(@PathVariable String conversationId,
@RequestParam String message) {
return chatClient.prompt()
.user(message)
.memory(conversationId)
.call()
.content();
}
// 查看某会话的历史记录
@GetMapping("/conversations/{conversationId}/history")
public List<Message> getHistory(@PathVariable String conversationId) {
return chatMemory.get(conversationId);
}
}
4.3 请求头传递 conversationId
对于 RESTful 接口,更标准的方式是将会话 ID 放在请求头中:
less
@GetMapping("/chat")
public String chat(@RequestHeader("X-Conversation-Id") String conversationId,
@RequestParam String message) {
return chatClient.prompt()
.user(message)
.memory(conversationId)
.call()
.content();
}
前端可以通过 axios 拦截器统一注入:
csharp
axios.get('/api/chat', {
headers: { 'X-Conversation-Id': localStorage.getItem('conversationId') }
})
4.4 内存清理与过期策略
如果使用 InMemoryChatMemory,随着用户增长,内存会不断膨胀。必须设置过期清理策略。
最简单的方式是启动一个定时任务,定期清除 N 天前的会话:
typescript
@Component
public class MemoryCleaner {
private final ChatMemory chatMemory;
public MemoryCleaner(ChatMemory chatMemory) {
this.chatMemory = chatMemory;
}
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
public void cleanExpiredMemories() {
// 假设我们有会话访问时间表
// 也可以用 redis 或数据库记录
// 这里演示:得到需要清理的会话ID列表
List<String> expiredIds = queryExpiredConversations();
for (String id : expiredIds) {
chatMemory.clear(id);
}
}
private List<String> queryExpiredConversations() {
// 模拟:实际应该查询数据库/Redis
return List.of("expired-1", "expired-2");
}
}
生产环境中更推荐使用 Redis + TTL 来实现分布式记忆存储,天然支持过期时间:
typescript
// 以 Spring Data Redis 为例,自己实现 ChatMemory
@Component
public class RedisChatMemory implements ChatMemory {
private final StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = "chat:memory:";
public RedisChatMemory(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void add(String conversationId, List<Message> messages) {
String key = KEY_PREFIX + conversationId;
messages.forEach(message ->
redisTemplate.opsForList().rightPush(key, toJson(message))
);
redisTemplate.expire(key, Duration.ofHours(24)); // 24小时有效
}
@Override
public List<Message> get(String conversationId) {
List<String> jsonList = redisTemplate.opsForList()
.range(KEY_PREFIX + conversationId, 0, -1);
return jsonList.stream().map(this::fromJson).toList();
}
@Override
public void clear(String conversationId) {
redisTemplate.delete(KEY_PREFIX + conversationId);
}
private String toJson(Message message) {
return """
{"role":"%s","content":"%s"}
""".formatted(message.getMessageType(), message.getText().replace(""", "\""));
}
private Message fromJson(String json) {
// 用 Jackson 解析,略
return null;
}
}
五、综合示例:隔离 + 记忆 + 流式响应的完整对话接口
下面整合前文成果,构建一个支持会话隔离、多轮记忆、流式响应的 REST 接口。
5.1 配置类
typescript
@Configuration
public class AiConfig {
@Bean
public ChatMemory chatMemory() {
// 使用窗口记忆,保留最近30条
return MessageWindowChatMemory.builder()
.maxMessages(30)
.build();
}
@Bean
public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
return ChatClient.builder(chatModel)
.defaultMemory(chatMemory)
.defaultSystem("你是一位专业的AI助手,请保持友好、准确的回答。")
.build();
}
}
5.2 控制器
less
@RestController
@RequestMapping("/api/v1/chat")
public class ChatController {
private final ChatClient chatClient;
private final ChatMemory chatMemory;
public ChatController(ChatClient chatClient, ChatMemory chatMemory) {
this.chatClient = chatClient;
this.chatMemory = chatMemory;
}
// 用户身份 + 会话隔离
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestHeader("X-User-Id") String userId,
@RequestParam String message) {
String conversationId = userId; // 以用户ID作为会话隔离标识
return chatClient.prompt()
.user(message)
.memory(conversationId)
.stream()
.content()
.doOnCancel(() -> System.out.println("用户 " + userId + " 取消连接"));
}
// 获取某用户的历史记录(仅限本人)
@GetMapping("/history")
public List<Message> getHistory(@RequestHeader("X-User-Id") String userId) {
return chatMemory.get(userId);
}
// 清空记忆
@DeleteMapping("/history")
public ResponseEntity<Void> clearHistory(@RequestHeader("X-User-Id") String userId) {
chatMemory.clear(userId);
return ResponseEntity.noContent().build();
}
}
调用示例:
arduino
curl -N -H "X-User-Id: alice" \
"http://localhost:8080/api/v1/chat/stream?message=我叫小明,记住我的名字"
curl -N -H "X-User-Id: alice" \
"http://localhost:8080/api/v1/chat/stream?message=我叫什么名字?"
第二次请求时,模型会正确回答"你叫小明",因为 Alice 的历史记录已被自动注入。
六、最佳实践与注意事项
6.1 始终以 conversationId 为边界
- 不要使用
static变量存储记忆; - 同一用户不同设备应使用不同 conversationId,除非你打算跨设备同步;
- 权限校验时,确保 conversationId 属于当前用户。
6.2 控制消息数量与 Token 预算
估算 token 公式:每 1 个汉字约 1.5~2 token。假设模型上下文上限 8K token,System + 历史 + 当前输入 + 输出需要总和在 8K 内。建议:
- 设置历史窗口
maxMessages为 10~20 条; - 单条消息截断至 500 token 以内;
- 长文档使用摘要或检索,而非直接拼接。
6.3 正确处理多轮中的 System 消息
ChatMemory 会自动剥离历史中的 System 消息(因为它只应出现在消息序列的开头)。手动构造时注意:
csharp
List<Message> allMessages = new ArrayList<>();
allMessages.add(systemMessage); // 只放一次
allMessages.addAll(chatMemory.get(conversationId)); // 只包含 User/Assistant
allMessages.add(new UserMessage(userInput));
6.4 流式调用时的记忆写入时机
使用 ChatClient.stream()时,Spring AI 会等到整个流结束再一次性写入 ChatMemory。如果客户端中途断开,可能不会写入。若需要更精细的控制,可以手动监听流完成事件:
scss
chatClient.prompt()
.user(message)
.memory(conversationId)
.stream()
.content()
.doOnComplete(() -> {
// 可以在此处手动触发一些后处理
})
.subscribe();
6.5 并发场景下的线程安全
InMemoryChatMemory基于 ConcurrentHashMap,add操作是线程安全的。但读-改-写序列仍可能产生竞争。例如两个请求同时读取同一会话历史,可能导致消息乱序。建议:
- 在一个会话内做并发限制,例如使用
@Lock或队列; - 或使用数据库事务保证顺序;
- 或让 ChatClient 执行串行化。
6.6 持久化方案选择
| 场景 | 推荐方案 |
|---|---|
| 单机测试 / Demo | InMemoryChatMemory或 MessageWindowChatMemory |
| 多实例部署 | Redis(RedisChatMemory自定义) |
| 企业级高可靠 | MySQL / PostgreSQL 消息表,配合 JPA 实现 ChatMemory |
| 长期记忆 | 向量库 + ChatMemory 双层架构 |
七、总结
回到最初的问题:Agent 是无状态的,但应用可以是有状态的。
Spring AI 用一套极其简洁的 ChatMemory接口解决了这个矛盾:
add()写入历史;get()读取历史;clear()清空历史;ChatClient.memory(conversationId)自动完成一切。
而 conversationId 则像一把钥匙,将记忆安全地分配给不同用户、不同会话。
在实际项目中,请记住:
- 理解无状态是大模型应用设计的前提;
- 选择合适记忆策略:窗口、摘要、向量检索,按需取舍;
- 始终做会话隔离,并设计过期清理机制;
- 对流式、并发、Token 预算做精心处理;
- 将记忆视为一等公民,像处理数据库事务一样处理它。