第7天 - 多轮对话与 Memory 管理深度解析

系列第7篇 | 2026-05-01 | 作者:老兰


目录

  1. 为什么需要多轮对话与 Memory
  2. Spring AI Alibaba 中的 ChatMemory 体系
  3. MessageWindowChatMemory 详解
  4. TokenWindowChatMemory 详解
  5. ChatMemory 的存储后端
  6. 完整实战:构建多轮对话应用
  7. Memory 管理的最佳实践
  8. 常见陷阱与解决方案
  9. 总结

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 数量窗口
特性MessageWindowChatMemoryTokenWindowChatMemory
窗口控制保留最近 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 回答像打字机一样逐字输出,大幅提升用户体验。