第7天 - 多轮对话与 Memory 管理深度解析
系列第7篇 | 2026-05-01 | 作者:老兰
目录
- 为什么需要多轮对话与 Memory
- Spring AI Alibaba 中的 ChatMemory 体系
- MessageWindowChatMemory 详解
- TokenWindowChatMemory 详解
- ChatMemory 的存储后端
- 完整实战:构建多轮对话应用
- Memory 管理的最佳实践
- 常见陷阱与解决方案
- 总结
1. 为什么需要多轮对话与 Memory
1.1 大模型的”失忆”本质
大语言模型(LLM)本质上是一个无状态的函数。每次调用时,模型只看到当次请求传入的完整 Prompt 上下文,它不会记住上一次对话说了什么。这就意味着:
用户:我喜欢吃川菜
AI: 川菜很棒!你喜欢哪种?
用户:推荐一家吧
AI: 推荐什么?(❌ 不知道上下文)
如果不在每次请求中把历史对话一并发送,模型就会”失忆”。
1.2 手动管理历史消息的问题
最朴素的做法是开发者自己维护一个消息列表,每次请求时拼接历史:
// 手动管理 - 不推荐
List<Message> history = new ArrayList<>();
public String chat(String userMessage) {
history.add(new UserMessage(userMessage));
ChatResponse response = chatModel.call(
Prompt.builder()
.messages(history)
.build()
);
history.add(response.getResult().getOutput());
return response.getResult().getOutput().getText();
}
这种方式存在以下问题:
| 问题 | 说明 |
|---|---|
| 上下文窗口溢出 | 模型有最大 Token 限制(如 8K/32K/128K),历史消息持续累积必然超出 |
| 成本浪费 | 每轮对话都要把全部历史重新发送给 API,Token 消耗随对话轮数线性增长 |
| 缺少摘要能力 | 无法对久远对话进行压缩摘要 |
| 多会话隔离 | 需要自己管理不同用户的对话隔离 |
| 缺少生命周期管理 | 对话超时清理、会话归档等都需要自行实现 |
Spring AI Alibaba 通过 ChatMemory 抽象层解决了上述所有问题。
2. Spring AI Alibaba 中的 ChatMemory 体系
2.1 核心接口
Spring AI(包括 Spring AI Alibaba)定义了统一的 ChatMemory 接口:
public interface ChatMemory {
/**
* 添加消息到指定会话
*/
void add(String conversationId, List<Message> messages);
/**
* 获取指定会话的历史消息(可带限制条件)
*/
List<Message> get(String conversationId, int maxMessages);
/**
* 清除指定会话的所有消息
*/
void clear(String conversationId);
/**
* 清空所有会话
*/
void clearAll();
}
关键概念:
- conversationId:会话标识,用于区分不同用户或不同对话线程
- maxMessages:获取的最大消息数量(由具体实现决定如何筛选)
2.2 两种内置实现
Spring AI 提供了两种内置的 ChatMemory 实现策略:
ChatMemory (接口)
├── MessageWindowChatMemory ← 基于消息数量窗口
└── TokenWindowChatMemory ← 基于 Token 数量窗口
| 特性 | MessageWindowChatMemory | TokenWindowChatMemory |
|---|---|---|
| 窗口控制 | 保留最近 N 条消息 | 保留最近 N 个 Token |
| 适用场景 | 消息长度较均匀 | 消息长度差异大 |
| 成本可控性 | 较低(长消息可能溢出) | 较高(精确控制 Token) |
| 性能开销 | 低(仅计数) | 中(需要 Token 计算) |
2.3 自动集成机制
Spring AI Alibaba 通过 ChatMemoryAdvisor 将 ChatMemory 与 ChatClient 自动串联:
用户请求
→ ChatClient
→ ChatMemoryAdvisor (拦截)
├── 从 ChatMemory 获取历史消息
├── 将历史消息注入到 Prompt 中
└── 将 AI 响应写回 ChatMemory
→ ChatModel (调用 LLM)
→ 返回响应
3. MessageWindowChatMemory 详解
3.1 基本配置
MessageWindowChatMemory 是最简单的实现,它按照消息数量来维护窗口:
@Configuration
public class MemoryConfig {
@Bean
public ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.maxMessages(10) // 最多保留最近 10 条消息
.build();
}
}
3.2 内部工作原理
MessageWindowChatMemory 内部使用一个 ConcurrentHashMap 来存储所有会话:
// 伪代码示意内部结构
public class MessageWindowChatMemory implements ChatMemory {
private final int maxMessages;
private final Map<String, LinkedList<Message>> store =
new ConcurrentHashMap<>();
@Override
public void add(String conversationId, List<Message> messages) {
store.computeIfAbsent(conversationId, k -> new LinkedList<>())
.addAll(messages);
// 裁剪到窗口大小
trimToMaxMessages(conversationId);
}
@Override
public List<Message> get(String conversationId, int maxMessages) {
LinkedList<Message> messages = store.get(conversationId);
if (messages == null) return List.of();
// 返回最近 N 条
return messages.subList(
Math.max(0, messages.size() - Math.min(maxMessages, this.maxMessages)),
messages.size()
);
}
private void trimToMaxMessages(String conversationId) {
LinkedList<Message> messages = store.get(conversationId);
while (messages.size() > this.maxMessages) {
messages.removeFirst(); // 移除最老的消息
}
}
}
3.3 消息裁剪策略
当消息数量超过 maxMessages 时,采用 FIFO(先进先出) 策略:
假设 maxMessages = 5
当前消息列表: [M1, M2, M3, M4, M5]
新消息到达: M6
裁剪后: [M2, M3, M4, M5, M6]
↑ M1 被移除(最老的消息)
3.4 使用示例
@Service
public class ChatService {
private final ChatClient chatClient;
private final ChatMemory chatMemory;
public ChatService(ChatClient.Builder clientBuilder, ChatMemory chatMemory) {
this.chatMemory = chatMemory;
this.chatClient = clientBuilder
.defaultAdvisors(
MessageWindowChatMemory.builder()
.chatMemory(chatMemory)
.build()
)
.build();
}
public String chat(String conversationId, String userMessage) {
return chatClient.prompt()
.system("你是一个友好的助手")
.user(userMessage)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
}
}
4. TokenWindowChatMemory 详解
4.1 为什么需要 Token 级别控制
MessageWindowChatMemory 的问题在于:它只看消息条数,不看消息长度。一条消息可能只有 10 个字,也可能有 500 个字。在 API 计费以 Token 为单位的现实中,Token 级别的控制更精确。
场景对比:
MessageWindow (maxMessages=5):
[10字, 10字, 500字, 10字, 10字] → 总计约 540 字
Token 消耗: 不可预测
TokenWindow (maxTokens=1000):
自动确保总 Token 数不超过 1000
Token 消耗: 精确可控
4.2 配置方式
@Bean
public ChatMemory tokenWindowChatMemory() {
return TokenWindowChatMemory.builder()
.maxTokens(4000) // 最多保留 4000 Token 的上下文
.build();
}
4.3 内部工作原理
TokenWindowChatMemory 在裁剪消息时,需要从后向前累积计算 Token 数量:
// 伪代码示意
public class TokenWindowChatMemory implements ChatMemory {
private final int maxTokens;
private final TokenCountEstimator tokenCountEstimator;
private final Map<String, LinkedList<Message>> store =
new ConcurrentHashMap<>();
@Override
public List<Message> get(String conversationId, int maxMessages) {
LinkedList<Message> messages = store.get(conversationId);
if (messages == null) return List.of();
// 从最新消息向前累积,直到达到 maxTokens
List<Message> result = new ArrayList<>();
int totalTokens = 0;
ListIterator<Message> it = messages.listIterator(messages.size());
while (it.hasPrevious()) {
Message msg = it.previous();
int msgTokens = tokenCountEstimator.estimate(msg);
if (totalTokens + msgTokens > maxTokens) {
break; // 超过限制,停止
}
result.add(0, msg); // 插入到头部(保持顺序)
totalTokens += msgTokens;
}
return result;
}
}
4.4 Token 估算器
Token 的估算依赖于 TokenCountEstimator:
// Spring AI 内置的简单估算器
public class SimpleTokenCountEstimator implements TokenCountEstimator {
// 粗略估算:平均每 4 个字符 ≈ 1 个 Token(英文)
// 中文通常 1 个汉字 ≈ 1-2 个 Token
private static final double CHARS_PER_TOKEN = 3.5;
@Override
public int estimate(Message message) {
String text = message.getText();
return (int) Math.ceil(text.length() / CHARS_PER_TOKEN);
}
}
注意:简单估算器不够精确。生产环境中建议使用与目标模型匹配的 Tokenizer(如 TikToken for GPT 系列)。
4.5 系统提示词的特殊处理
TokenWindowChatMemory 有一个重要特性:系统提示词(System Message)通常会被优先保留,因为它包含了 AI 的行为设定,丢失会导致 AI “忘记自己的角色”。
TokenWindow 的消息保留策略:
1. 永远保留 SystemMessage(如果存在)
2. 从最新的消息向前累积
3. 直到总 Token 数达到 maxTokens
4. 移除超出部分(最老的消息优先)
5. ChatMemory 的存储后端
5.1 内存存储(默认)
上述示例中使用的都是内存存储。特点是:
| 优点 | 缺点 |
|---|---|
| 零配置 | 应用重启后数据丢失 |
| 极高性能 | 不适合分布式部署 |
| 适合开发测试 | 占用 JVM 堆内存 |
5.2 持久化存储方案
在生产环境中,ChatMemory 需要持久化。Spring AI 支持多种存储后端:
方案一:JDBC(关系型数据库)
@Configuration
public class PersistentMemoryConfig {
@Bean
public ChatMemory jdbcChatMemory(JdbcTemplate jdbcTemplate) {
// Spring AI 提供了基于 JDBC 的实现
JdbcChatMemoryRepository repository = new JdbcChatMemoryRepository(jdbcTemplate);
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(20)
.build();
}
}
对应的建表语句:
CREATE TABLE spring_ai_chat_memory (
id VARCHAR(255) PRIMARY KEY,
conversation_id VARCHAR(255) NOT NULL,
message_type VARCHAR(50) NOT NULL, -- USER / ASSISTANT / SYSTEM
content TEXT NOT NULL,
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_conversation (conversation_id)
);
方案二:Redis(推荐用于生产)
@Configuration
public class RedisMemoryConfig {
@Bean
public ChatMemory redisChatMemory(RedisTemplate<String, String> redisTemplate) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(new RedisChatMemoryRepository(redisTemplate))
.maxMessages(20)
.build();
}
@Bean
public RedisTemplate<String, String> redisTemplate(
RedisConnectionFactory factory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setStringSerializer(new StringRedisSerializer());
return template;
}
}
Redis 数据结构设计:
Key 模式:
chat:memory:{conversationId} → List<Message>
chat:memory:{conversationId}:meta → Hash (metadata)
操作:
LPUSH chat:memory:user-123 '{"type":"user","content":"你好"}'
LTRIM chat:memory:user-123 0 19 // 保持最近 20 条
LRANGE chat:memory:user-123 0 -1 // 获取全部
方案三:自定义存储(MongoDB 示例)
public class MongoChatMemoryRepository implements ChatMemoryRepository {
private final MongoTemplate mongoTemplate;
public MongoChatMemoryRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public void saveMessages(String conversationId, List<Message> messages) {
ChatMessageDoc doc = new ChatMessageDoc();
doc.setConversationId(conversationId);
doc.setMessages(messages);
doc.setUpdatedAt(Instant.now());
mongoTemplate.save(doc);
}
@Override
public List<Message> findMessages(String conversationId) {
Query query = new Query(
Criteria.where("conversationId").is(conversationId)
);
ChatMessageDoc doc = mongoTemplate.findOne(query, ChatMessageDoc.class);
return doc != null ? doc.getMessages() : List.of();
}
@Override
public void clearMessages(String conversationId) {
mongoTemplate.remove(
new Query(Criteria.where("conversationId").is(conversationId)),
ChatMessageDoc.class
);
}
}
@Document(collection = "chat_memory")
@Data
class ChatMessageDoc {
@Id
private String id;
private String conversationId;
private List<Message> messages;
private Instant updatedAt;
}
5.3 存储方案对比
| 存储方案 | 性能 | 持久化 | 分布式 | 运维成本 | 推荐场景 |
|---|---|---|---|---|---|
| 内存 (ConcurrentHashMap) | ⭐⭐⭐⭐⭐ | ❌ | ❌ | 低 | 开发/测试 |
| JDBC (MySQL/PostgreSQL) | ⭐⭐⭐ | ✅ | ✅ | 中 | 已有数据库基础设施 |
| Redis | ⭐⭐⭐⭐⭐ | ✅ | ✅ | 中 | 高并发生产环境 |
| MongoDB | ⭐⭐⭐⭐ | ✅ | ✅ | 中 | 文档存储偏好 |
| 文件系统 | ⭐⭐ | ✅ | ❌ | 低 | 单机小型应用 |
6. 完整实战:构建多轮对话应用
6.1 项目结构
src/main/java/com/example/chat/
├── ChatApplication.java
├── config/
│ ├── MemoryConfig.java # Memory 配置
│ └── ChatModelConfig.java # ChatModel 配置
├── service/
│ └── MultiTurnChatService.java # 多轮对话服务
├── controller/
│ └── ChatController.java # REST 接口
└── model/
├── ChatRequest.java
└── ChatResponse.java
6.2 依赖配置
<dependencies>
<!-- Spring AI Alibaba -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0.3</version>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Redis 持久化 Memory(可选) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
6.3 配置文件
# application.yml
spring:
ai:
alibaba:
dashscope:
api-key: ${DASHSCOPE_API_KEY}
chat:
options:
model: qwen-plus
temperature: 0.7
data:
redis:
host: localhost
port: 6379
# 自定义配置
chat:
memory:
max-messages: 20 # 最多保留 20 条消息
ttl-minutes: 30 # 会话超时 30 分钟
storage: redis # 存储类型: memory / jdbc / redis
6.4 Memory 配置类
@Configuration
@ConfigurationProperties(prefix = "chat.memory")
@Data
public class MemoryConfig {
private int maxMessages = 20;
private int ttlMinutes = 30;
private String storage = "memory";
@Bean
public ChatMemory chatMemory(
@Autowired(required = false) RedisTemplate<String, String> redisTemplate) {
ChatMemoryRepository repository;
switch (storage) {
case "redis" -> {
if (redisTemplate == null) {
throw new IllegalStateException(
"Redis storage selected but RedisTemplate not available");
}
repository = new RedisChatMemoryRepository(redisTemplate);
}
case "jdbc" -> {
// 需要 JdbcTemplate
repository = new JdbcChatMemoryRepository(
applicationContext.getBean(JdbcTemplate.class));
}
default -> {
// 内存存储(默认)
repository = new InMemoryChatMemoryRepository();
}
}
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(maxMessages)
.build();
}
}
6.5 多轮对话服务
@Service
public class MultiTurnChatService {
private final ChatClient chatClient;
private final ChatMemory chatMemory;
private final ScheduledExecutorScheduler scheduler;
public MultiTurnChatService(
ChatClient.Builder clientBuilder,
ChatMemory chatMemory) {
this.chatMemory = chatMemory;
this.chatClient = clientBuilder
.defaultSystem("""
你是一个专业的技术助手,擅长回答编程相关问题。
请用简洁、准确的语言回答问题。
如果不确定,请明确告知用户。
""")
.defaultAdvisors(
MessageWindowChatMemory.builder()
.chatMemory(chatMemory)
.build()
)
.build();
}
/**
* 发送消息(多轮对话)
*/
public String sendMessage(String userId, String message) {
// 使用 userId 作为 conversationId
String conversationId = "user:" + userId;
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(
ChatMemory.CONVERSATION_ID,
conversationId
))
.call()
.content();
}
/**
* 获取对话历史
*/
public List<Message> getHistory(String userId) {
String conversationId = "user:" + userId;
return chatMemory.get(conversationId, 20);
}
/**
* 清除对话历史
*/
public void clearHistory(String userId) {
String conversationId = "user:" + userId;
chatMemory.clear(conversationId);
}
/**
* 带上下文的增强对话
*/
public String chatWithContext(
String userId,
String message,
Map<String, Object> context) {
String conversationId = "user:" + userId;
// 构建带有上下文的 Prompt
PromptTemplate template = new PromptTemplate(
"""
你是一个专业的技术助手。
以下是当前对话的上下文信息:
{context}
用户问题:{question}
"""
);
Map<String, Object> model = new HashMap<>();
model.put("context", context.entrySet().stream()
.map(e -> "- " + e.getKey() + ": " + e.getValue())
.collect(Collectors.joining("\n")));
model.put("question", message);
String enrichedMessage = template.render(model);
return chatClient.prompt()
.user(enrichedMessage)
.advisors(a -> a.param(
ChatMemory.CONVERSATION_ID,
conversationId
))
.call()
.content();
}
}
6.6 REST 控制器
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final MultiTurnChatService chatService;
/**
* 发送消息(多轮对话)
* POST /api/chat/send
*/
@PostMapping("/send")
public ResponseEntity<ChatResponse> sendMessage(
@RequestParam String userId,
@RequestBody ChatRequest request) {
String response = chatService.sendMessage(
userId,
request.getMessage()
);
return ResponseEntity.ok(new ChatResponse(response));
}
/**
* 获取对话历史
* GET /api/chat/history/{userId}
*/
@GetMapping("/history/{userId}")
public ResponseEntity<List<MessageDto>> getHistory(
@PathVariable String userId) {
List<Message> history = chatService.getHistory(userId);
List<MessageDto> dtos = history.stream()
.map(msg -> new MessageDto(
msg.getMessageType().toString(),
msg.getText()
))
.toList();
return ResponseEntity.ok(dtos);
}
/**
* 清除对话历史
* DELETE /api/chat/history/{userId}
*/
@DeleteMapping("/history/{userId}")
public ResponseEntity<Void> clearHistory(
@PathVariable String userId) {
chatService.clearHistory(userId);
return ResponseEntity.ok().build();
}
// DTO
public record ChatRequest(String message) {}
public record ChatResponse(String message) {}
public record MessageDto(String type, String content) {}
}
6.7 前端调用示例
// 多轮对话前端示例
const conversationId = 'user-123';
async function sendMessage(message) {
const response = await fetch(`/api/chat/send?userId=${conversationId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const data = await response.json();
return data.message;
}
// 对话示例
async function demo() {
// 第一轮
console.log(await sendMessage('我喜欢吃川菜'));
// AI: 川菜很棒!你喜欢哪种川菜?
// 第二轮 - AI 知道上下文
console.log(await sendMessage('推荐一家正宗的川菜馆吧'));
// AI: 如果你在成都,我推荐...
// 第三轮 - 继续上下文
console.log(await sendMessage('那家店的招牌菜是什么?'));
// AI: 那家的招牌菜是水煮鱼和麻婆豆腐...
}
7. Memory 管理的最佳实践
7.1 合理设置窗口大小
# 根据模型上下文窗口调整
chat:
memory:
# qwen-plus 支持 32K Token 上下文
# 建议预留 20% 给系统提示词和新消息
max-tokens: 25000 # 约 25K Token 用于历史对话
经验公式:
maxTokens = (模型上下文窗口 × 0.8) - 系统提示词Token数
7.2 会话超时与自动清理
@Component
public class MemoryCleanupScheduler {
private final ChatMemory chatMemory;
private final ChatMemoryRepository chatMemoryRepository;
// 每 10 分钟检查一次
@Scheduled(fixedRate = 600_000)
public void cleanupExpiredSessions() {
// 获取所有会话 ID
Set<String> conversationIds = chatMemoryRepository.getAllConversationIds();
Instant now = Instant.now();
for (String convId : conversationIds) {
Instant lastActivity = chatMemoryRepository.getLastActivityTime(convId);
// 超过 30 分钟未活动则清理
if (Duration.between(lastActivity, now).toMinutes() > 30) {
chatMemory.clear(convId);
log.info("清理过期会话: {}", convId);
}
}
}
}
7.3 对话摘要(长期 Memory)
当对话很长时,可以引入对话摘要机制,将旧对话压缩为摘要保存:
@Service
public class SummarizingMemoryService {
private final ChatClient chatClient;
private final ChatMemory chatMemory;
private final ChatMemoryRepository summaryRepository;
private static final int SUMMARY_THRESHOLD = 15; // 15 条消息时触发摘要
/**
* 带摘要的多轮对话
*/
public String chatWithSummary(String conversationId, String userMessage) {
// 1. 获取已有的对话摘要
String previousSummary = summaryRepository.getSummary(conversationId);
// 2. 构建包含摘要的系统提示词
String systemPrompt = buildSystemPrompt(previousSummary);
// 3. 发送请求
String response = chatClient.prompt()
.system(systemPrompt)
.user(userMessage)
.advisors(a -> a.param(
ChatMemory.CONVERSATION_ID,
conversationId
))
.call()
.content();
// 4. 检查是否需要生成摘要
List<Message> history = chatMemory.get(conversationId, 100);
if (history.size() >= SUMMARY_THRESHOLD) {
generateAndSaveSummary(conversationId, history);
}
return response;
}
/**
* 生成对话摘要
*/
private void generateAndSaveSummary(
String conversationId,
List<Message> history) {
// 取前 N 条消息(即将被窗口淘汰的部分)
int summaryCount = history.size() - SUMMARY_THRESHOLD + 5;
List<Message> toSummarize = history.subList(0, summaryCount);
String conversationText = toSummarize.stream()
.map(m -> m.getMessageType() + ": " + m.getText())
.collect(Collectors.joining("\n"));
// 调用 LLM 生成摘要
String summary = chatClient.prompt()
.system("请将以下对话总结为一段简洁的摘要,保留关键信息:")
.user(conversationText)
.call()
.content();
// 保存摘要
summaryRepository.saveSummary(conversationId, summary);
// 清除已被摘要的旧消息
chatMemoryRepository.removeMessages(conversationId, toSummarize.size());
}
private String buildSystemPrompt(String summary) {
if (summary == null || summary.isEmpty()) {
return "你是一个专业的技术助手。";
}
return """
你是一个专业的技术助手。
以下是之前对话的摘要,请基于这些信息继续对话:
%s
""".formatted(summary);
}
}
7.4 多会话隔离
@Service
public class MultiSessionService {
private final ChatClient chatClient;
/**
* 为用户的每个话题创建独立会话
*/
public String chat(String userId, String topic, String message) {
// conversationId 包含 userId + topic
String conversationId = String.format("user:%s:topic:%s", userId, topic);
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(
ChatMemory.CONVERSATION_ID,
conversationId
))
.call()
.content();
}
}
7.5 消息过滤与敏感信息处理
public class SanitizingChatMemory implements ChatMemory {
private final ChatMemory delegate;
private final Pattern sensitivePattern = Pattern.compile(
"(密码|token|密钥|key)[=:]\\s*\\S+",
Pattern.CASE_INSENSITIVE
);
public SanitizingChatMemory(ChatMemory delegate) {
this.delegate = delegate;
}
@Override
public void add(String conversationId, List<Message> messages) {
// 过滤敏感信息后再存储
List<Message> sanitized = messages.stream()
.map(this::sanitizeMessage)
.toList();
delegate.add(conversationId, sanitized);
}
private Message sanitizeMessage(Message message) {
String text = message.getText();
String sanitized = sensitivePattern.matcher(text)
.replaceAll("$1=[已隐藏]");
if (!sanitized.equals(text)) {
log.warn("已过滤敏感信息: {}", sanitized);
}
return switch (message) {
case UserMessage um -> new UserMessage(um.getMetadata(), sanitized);
case AssistantMessage am -> new AssistantMessage(am.getMetadata(), sanitized);
default -> message;
};
}
@Override
public List<Message> get(String conversationId, int maxMessages) {
return delegate.get(conversationId, maxMessages);
}
@Override
public void clear(String conversationId) {
delegate.clear(conversationId);
}
}
8. 常见陷阱与解决方案
8.1 陷阱一:上下文窗口溢出
问题:即使设置了 maxMessages,如果消息很长,仍可能超出模型的上下文窗口。
解决方案:
// 使用 TokenWindowChatMemory 替代 MessageWindowChatMemory
@Bean
public ChatMemory chatMemory() {
return TokenWindowChatMemory.builder()
.maxTokens(30000) // 精确控制 Token 上限
.build();
}
8.2 陷阱二:Memory 未正确传递 conversationId
问题:忘记在 Advisor 中传递 CONVERSATION_ID,导致每次都是新会话。
// ❌ 错误 - 没有传递 conversationId
chatClient.prompt()
.user(message)
.call()
.content();
// ✅ 正确 - 传递 conversationId
chatClient.prompt()
.user(message)
.advisors(a -> a.param(
ChatMemory.CONVERSATION_ID,
conversationId
))
.call()
.content();
8.3 陷阱三:并发访问导致消息丢失
问题:多线程同时写入同一会话,可能导致消息覆盖。
解决方案:使用线程安全的存储实现(ConcurrentHashMap 或 Redis LIST 操作):
// Redis 使用原子操作
public class RedisChatMemoryRepository implements ChatMemoryRepository {
@Override
public synchronized void addMessages(String conversationId, List<Message> messages) {
String key = "chat:memory:" + conversationId;
for (Message msg : messages) {
redisTemplate.opsForList().rightPush(key, serialize(msg));
}
// 原子裁剪
redisTemplate.opsForList().trim(key, -maxMessages, -1);
}
}
8.4 陷阱四:Memory 与 Function Calling 冲突
问题:Function Calling 产生的工具调用消息也需要存入 Memory,否则模型会忘记它调用过什么工具。
解决方案:确保 Advisor 链中 Memory 在正确的位置:
// ✅ 正确的 Advisor 顺序
chatClient = clientBuilder
.defaultAdvisors(
new FunctionCallingAdvisor(...), // 先处理 Function Calling
MessageWindowChatMemory.builder()
.chatMemory(chatMemory)
.build() // 再记录到 Memory
)
.build();
8.5 陷阱五:系统提示词被窗口淘汰
问题:当消息过多时,SystemMessage 可能被裁剪掉,导致 AI “忘记自己的角色”。
解决方案:
// 方案1: 使用 ChatClient 的 defaultSystem,它不会被存入 Memory
chatClient = clientBuilder
.defaultSystem("你是一个专业的技术助手") // 每次请求自动注入
.defaultAdvisors(
MessageWindowChatMemory.builder()
.chatMemory(chatMemory)
.build()
)
.build();
// 方案2: TokenWindowChatMemory 会优先保留 SystemMessage
chatMemory = TokenWindowChatMemory.builder()
.maxTokens(30000)
.build(); // 内部实现会优先保留 SystemMessage
9. 总结
核心要点回顾
| 概念 | 要点 |
|---|---|
| ChatMemory 接口 | 统一的对话历史管理抽象,支持 add/get/clear 操作 |
| MessageWindowChatMemory | 按消息条数控制窗口,简单高效 |
| TokenWindowChatMemory | 按 Token 数量控制窗口,成本可控 |
| 存储后端 | 支持内存、JDBC、Redis、MongoDB 等 |
| conversationId | 会话标识,必须通过 Advisor 参数传递 |
| 对话摘要 | 长期对话的压缩机制,保留关键信息 |
| 会话超时 | 定期清理过期会话,释放资源 |
选型建议
选择指南:
开发/测试阶段
└── MessageWindowChatMemory + InMemory 存储
小规模生产 (日活 < 1000)
└── MessageWindowChatMemory + JDBC 存储
大规模生产 (日活 > 1000)
└── TokenWindowChatMemory + Redis 存储
+ 会话摘要机制
+ 定时清理
对成本敏感
└── TokenWindowChatMemory(精确控制 Token 消耗)
下一步
下一篇我们将探讨 Streaming 流式响应处理,学习如何让 AI 回答像打字机一样逐字输出,大幅提升用户体验。