← 返回上一页

第3章: 对话记忆不玄学:ChatMemory 机制一次讲明白

通过测试用例掌握 ChatMemory 和 MessageChatMemoryAdvisor

📋 本章目标

本章通过 6 个完整的测试用例,带你掌握 Spring AI 的对话记忆机制。 你将学会如何使用 ChatMemory 接口和 MessageChatMemoryAdvisor 实现自动化的会话管理。

✨ 你将学到什么?

  • ✅ 理解 AI 默认"没有记忆"的原理
  • ✅ 学会配置和使用 InMemoryChatMemory
  • ✅ 掌握 MessageChatMemoryAdvisor 的自动记忆管理
  • ✅ 使用 conversationId 实现会话隔离
  • ✅ 手动查询和清空会话历史
  • ✅ 理解"会话记忆"与"会话历史"的区别

📚 测试用例索引

  • 测试 3.1:验证 AI 默认没有记忆
  • 测试 3.2:配置 ChatMemory Bean
  • 测试 3.3:使用 MessageChatMemoryAdvisor 实现自动记忆
  • 测试 3.4:使用 conversationId 实现会话隔离
  • 测试 3.5:手动查询会话历史
  • 测试 3.6:清空会话历史
  • 测试 3.7:验证 SimpleWindowChatMemory 可用性

🎯 前置准备

1. 确认已完成第1章

确保你已经:

2. 理解对话记忆的原理

💡 重要概念

  • AI 本身没有记忆能力
  • 每次调用都是独立的
  • "记忆"是通过发送完整对话历史实现的
  • Spring AI 提供了自动化的记忆管理机制

📝 创建测试类

1. 测试类基本结构

创建文件:src/test/java/com/example/chatbot/knowledge/Test03_ChatMemory.java

java
package com.example.chatbot.knowledge;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;

/**
 * 测试类:Spring AI 对话记忆机制
 * 
 * 本测试类演示如何使用 ChatMemory 和 MessageChatMemoryAdvisor
 */
@SpringBootTest(classes = {
    com.example.chatbot.AiChatbotApplication.class,
    Test03_ChatMemory.TestConfiguration.class
})
@DisplayName("知识点3:对话记忆机制")
public class Test03_ChatMemory {

    @Autowired
    private ChatModel chatModel;

    @Autowired
    private ChatMemory chatMemory;

    @Autowired
    private ChatClient chatClient;

    /**
     * 测试配置类
     * 配置 ChatMemory 和 ChatClient
     */
    @Configuration
    static class TestConfiguration {
        
        @Bean
        public ChatMemory chatMemory() {
            return new InMemoryChatMemory();
        }

        @Bean
        public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
            return ChatClient.builder(chatModel)
                    .defaultAdvisors(
                        new org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor(chatMemory)
                    )
                    .build();
        }
    }
    
    // 测试方法将在这里添加
}

⚠️ 注意

我们在测试类中定义了一个内部配置类 TestConfiguration, 用于配置 ChatMemory 和 ChatClient,这样测试更加独立和清晰。

🧪 测试 3.1:验证 AI 默认没有记忆

首先验证 AI 本身没有记忆,每次调用都是独立的。

测试代码

java
/**
 * 测试 3.1:验证 AI 默认没有记忆
 * 
 * 演示:不使用 ChatMemory 时,AI 无法记住之前的对话
 */
@Test
@DisplayName("3.1 验证 AI 默认没有记忆")
public void test01_NoMemoryByDefault() {
    System.out.println("\n========== 测试 3.1:AI 默认没有记忆 ==========");

    // 第一次对话
    String response1 = chatModel.call("我叫张三");
    System.out.println("👤 第一次:我叫张三");
    System.out.println("🤖 AI:" + response1);

    // 第二次对话(独立调用)
    String response2 = chatModel.call("我叫什么名字?");
    System.out.println("\n👤 第二次:我叫什么名字?");
    System.out.println("🤖 AI:" + response2);

    System.out.println("\n💡 观察:AI 不记得之前的对话!");
    System.out.println("✅ 测试通过");
}

运行测试

text
mvn test -Dtest=Test03_ChatMemory#test01_NoMemoryByDefault

预期输出

text
========== 测试 3.1:AI 默认没有记忆 ==========
👤 第一次:我叫张三
🤖 AI:你好,张三!很高兴认识你...
👤 第二次:我叫什么名字?
🤖 AI:抱歉,我不知道您的名字...
💡 观察:AI 不记得之前的对话!
✅ 测试通过

💡 理解要点

  • 每次 call() 都是独立的
  • AI 不会记住之前的对话
  • 这是 AI 的默认行为

🧪 测试 3.2:配置 ChatMemory Bean

学习如何配置 ChatMemory Bean,这是实现记忆功能的第一步。

测试代码

java
/**
 * 测试 3.2:配置 ChatMemory Bean
 * 
 * 验证 ChatMemory 已正确注入
 */
@Test
@DisplayName("3.2 配置 ChatMemory Bean")
public void test02_ConfigureChatMemory() {
    System.out.println("\n========== 测试 3.2:配置 ChatMemory Bean ==========");

    // 验证 ChatMemory 已注入
    assertNotNull(chatMemory, "ChatMemory 应该被正确注入");
    System.out.println("✅ ChatMemory 已注入:" + chatMemory.getClass().getSimpleName());

    // 验证是 InMemoryChatMemory 实现
    assertTrue(chatMemory instanceof InMemoryChatMemory, 
        "应该是 InMemoryChatMemory 实现");
    System.out.println("✅ 使用的是 InMemoryChatMemory 实现");

    System.out.println("\n💡 ChatMemory 配置成功!");
    System.out.println("✅ 测试通过");
}

运行测试

text
mvn test -Dtest=Test03_ChatMemory#test02_ConfigureChatMemory

配置说明

java
@Configuration
static class TestConfiguration {
    
    @Bean
    public ChatMemory chatMemory() {
        return new InMemoryChatMemory();  // 使用内存存储
    }
}

✅ ChatMemory 接口

ChatMemory 提供了以下核心方法:

  • add(conversationId, message) - 添加消息
  • get(conversationId, lastN) - 获取历史
  • clear(conversationId) - 清空历史

🧪 测试 3.3:使用 MessageChatMemoryAdvisor 实现自动记忆

使用 MessageChatMemoryAdvisor 后,ChatClient 会自动管理对话历史, 无需手动添加 UserMessage 和 AssistantMessage。

测试代码

java
/**
 * 测试 3.3:使用 MessageChatMemoryAdvisor 实现自动记忆
 * 
 * 演示:配置 Advisor 后,AI 自动拥有记忆功能
 */
@Test
@DisplayName("3.3 使用 MessageChatMemoryAdvisor 实现自动记忆")
public void test03_UseMessageChatMemoryAdvisor() {
    System.out.println("\n========== 测试 3.3:MessageChatMemoryAdvisor 自动记忆 ==========");

    String sessionId = "test-session-001";

    // 第一轮对话
    String response1 = chatClient
            .prompt("我叫张三")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionId))
            .call()
            .content();
    
    System.out.println("👤 第一轮:我叫张三");
    System.out.println("🤖 AI:" + response1);

    // 第二轮对话(带上 sessionId)
    String response2 = chatClient
            .prompt("我叫什么名字?")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionId))
            .call()
            .content();
    
    System.out.println("\n👤 第二轮:我叫什么名字?");
    System.out.println("🤖 AI:" + response2);

    System.out.println("\n💡 观察:AI 记住了名字!");
    System.out.println("📝 原理:MessageChatMemoryAdvisor 自动管理了历史");
    System.out.println("✅ 测试通过");
}

运行测试

text
mvn test -Dtest=Test03_ChatMemory#test03_UseMessageChatMemoryAdvisor

预期输出

text
========== 测试 3.3:MessageChatMemoryAdvisor 自动记忆 ==========
👤 第一轮:我叫张三
🤖 AI:你好,张三!很高兴认识你...
👤 第二轮:我叫什么名字?
🤖 AI:你叫张三。
💡 观察:AI 记住了名字!
📝 原理:MessageChatMemoryAdvisor 自动管理了历史
✅ 测试通过

🔧 MessageChatMemoryAdvisor 工作原理

  1. 调用前:从 ChatMemory 中读取 conversationId 对应的历史消息
  2. 拼接消息:将历史消息 + 当前用户消息一起发送给 AI
  3. 调用 AI:ChatModel 处理完整的消息列表
  4. 调用后:将用户消息和 AI 回复都保存到 ChatMemory

🧪 测试 3.4:使用 conversationId 实现会话隔离

通过不同的 conversationId,可以为不同用户维护独立的对话历史。

测试代码

java
/**
 * 测试 3.4:使用 conversationId 实现会话隔离
 * 
 * 演示:不同 conversationId 的历史互不干扰
 */
@Test
@DisplayName("3.4 使用 conversationId 实现会话隔离")
public void test04_ConversationIdIsolation() {
    System.out.println("\n========== 测试 3.4:会话隔离 ==========");

    // 会话1:用户A
    String sessionA = "user-A";
    String responseA1 = chatClient
            .prompt("我叫张三")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionA))
            .call()
            .content();
    System.out.println("👤 会话A:我叫张三");
    System.out.println("🤖 AI:" + responseA1);

    // 会话2:用户B
    String sessionB = "user-B";
    String responseB1 = chatClient
            .prompt("我叫李四")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionB))
            .call()
            .content();
    System.out.println("\n👤 会话B:我叫李四");
    System.out.println("🤖 AI:" + responseB1);

    // 在会话A中询问
    String responseA2 = chatClient
            .prompt("我叫什么?")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionA))
            .call()
            .content();
    System.out.println("\n👤 会话A询问:我叫什么?");
    System.out.println("🤖 AI:" + responseA2);

    // 在会话B中询问
    String responseB2 = chatClient
            .prompt("我叫什么?")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionB))
            .call()
            .content();
    System.out.println("\n👤 会话B询问:我叫什么?");
    System.out.println("🤖 AI:" + responseB2);

    System.out.println("\n💡 两个会话的记忆是独立的");
    System.out.println("✅ 测试通过");
}

运行测试

text
mvn test -Dtest=Test03_ChatMemory#test04_ConversationIdIsolation

预期输出

text
========== 测试 3.4:会话隔离 ==========
👤 会话A:我叫张三
🤖 AI:你好,张三!...
👤 会话B:我叫李四
🤖 AI:你好,李四!...
👤 会话A询问:我叫什么?
🤖 AI:你叫张三。
👤 会话B询问:我叫什么?
🤖 AI:你叫李四。
💡 两个会话的记忆是独立的
✅ 测试通过

💡 conversationId 的作用

  • 每个 conversationId 对应一个独立的对话历史
  • 不同 conversationId 的历史互不干扰
  • 这是实现多用户聊天的基础
  • 实际应用中可以用 userId 作为 conversationId

🧪 测试 3.5:手动查询会话历史

虽然 MessageChatMemoryAdvisor 会自动管理记忆, 但有时我们需要手动查询某个会话的历史消息。

测试代码

java
/**
 * 测试 3.5:手动查询会话历史
 * 
 * 演示:使用 ChatMemory.get() 查询历史消息
 */
@Test
@DisplayName("3.5 手动查询会话历史")
public void test05_QueryChatHistory() {
    System.out.println("\n========== 测试 3.5:查询会话历史 ==========");

    String sessionId = "test-session-002";

    // 进行几轮对话
    chatClient.prompt("我喜欢吃苹果")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionId))
            .call().content();
    
    chatClient.prompt("我还喜欢吃香蕉")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionId))
            .call().content();

    // 查询历史
    List<Message> messages = chatMemory.get(sessionId, 100);
    
    System.out.println("📝 会话历史(共 " + messages.size() + " 条消息):");
    for (int i = 0; i < messages.size(); i++) {
        Message msg = messages.get(i);
        String role = msg.getMessageType().name();
        String content = msg.getText();
        System.out.println((i + 1) + ". [" + role + "] " + 
            (content.length() > 50 ? content.substring(0, 50) + "..." : content));
    }

    // 验证历史长度
    assertEquals(4, messages.size(), "应该有4条消息(2轮对话)");
    
    System.out.println("\n💡 可以通过 chatMemory.get() 查询历史");
    System.out.println("✅ 测试通过");
}

运行测试

text
mvn test -Dtest=Test03_ChatMemory#test05_QueryChatHistory

预期输出

text
========== 测试 3.5:查询会话历史 ==========
📝 会话历史(共 4 条消息):
1. [USER] 我喜欢吃苹果
2. [ASSISTANT] 苹果是一种非常健康的水果...
3. [USER] 我还喜欢吃香蕉
4. [ASSISTANT] 香蕉也很不错...
💡 可以通过 chatMemory.get() 查询历史
✅ 测试通过

✅ 使用场景

  • 前端需要显示聊天记录
  • 导出对话历史
  • 分析用户对话内容
  • 调试和日志记录

🧪 测试 3.6:清空会话历史

提供"清空对话"功能,让用户可以重新开始。

测试代码

java
/**
 * 测试 3.6:清空会话历史
 * 
 * 演示:使用 ChatMemory.clear() 清空历史
 */
@Test
@DisplayName("3.6 清空会话历史")
public void test06_ClearChatHistory() {
    System.out.println("\n========== 测试 3.6:清空会话历史 ==========");

    String sessionId = "test-session-003";

    // 进行对话
    chatClient.prompt("我叫王五")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionId))
            .call().content();
    
    System.out.println("👤 对话:我叫王五");

    // 查询历史
    List beforeClear = chatMemory.get(sessionId, 100);
    System.out.println("📝 清空前历史长度: " + beforeClear.size());
    assertTrue(beforeClear.size() > 0, "清空前应该有历史");

    // 清空历史
    chatMemory.clear(sessionId);
    System.out.println("🗑️  执行清空操作");

    // 再次查询
    List afterClear = chatMemory.get(sessionId, 100);
    System.out.println("📝 清空后历史长度: " + afterClear.size());
    assertEquals(0, afterClear.size(), "清空后应该没有历史");

    // 验证 AI 不再记得
    String response = chatClient.prompt("我叫什么名字?")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionId))
            .call().content();
    
    System.out.println("\n👤 清空后询问:我叫什么名字?");
    System.out.println("🤖 AI:" + response);

    System.out.println("\n💡 清空后 AI 不再记得之前的对话");
    System.out.println("✅ 测试通过");
}

运行测试

text
mvn test -Dtest=Test03_ChatMemory#test06_ClearChatHistory

预期输出

text
========== 测试 3.6:清空会话历史 ==========
👤 对话:我叫王五
📝 清空前历史长度: 2
🗑️  执行清空操作
📝 清空后历史长度: 0
👤 清空后询问:我叫什么名字?
🤖 AI:抱歉,我不知道您的名字...
💡 清空后 AI 不再记得之前的对话
✅ 测试通过

⚠️ 使用场景

  • 用户点击"清空对话"按钮
  • 会话超时自动清理
  • 切换话题重新开始
  • 隐私保护需求

🧠 ChatMemory 选型:除了 InMemoryChatMemory,还有哪些 Memory?

到这里,你已经通过测试 3.1 ~ 3.6 跑通了“Advisor 自动拼接历史 + conversationId 会话隔离 + 手动查询/清空”的完整闭环。 这一节我们把它上升到“工程化选型”:当你的业务开始关注 成本性能隐私合规多实例 时,ChatMemory 该怎么设计。

本项目以教学为主,页面代码以 InMemoryChatMemory 为主;但你仍然可以基于 ChatMemory 接口自己实现一套 Memory,满足你项目的策略需求。

1) Memory Type(决定“发给模型的历史上下文怎么控制”)

Memory Type 特点 / 适合场景 典型用途
InMemoryChatMemory 不裁剪:历史越聊越长(适合入门演示) 教学演示、本地小工具、极小并发
Window Memory 只保留最近 N 条消息,稳定控制 token、延迟和成本 客服问答、Copilot、短会话助手
Summary Memory 把较早历史压缩成摘要,再拼接最近若干轮 长对话助手、复杂业务流程
Persistent Memory 消息持久化到 Redis / MySQL / PostgreSQL,支持多实例共享 生产环境、水平扩容、重启不丢历史
Hybrid Memory 窗口 + 摘要 + 持久化组合,兼顾体验、成本与可恢复性 企业级 Agent、复杂多轮业务系统

✅ 常见的工程化 Memory 策略(不限定具体实现类)

  • 窗口型:只保留最近 N 条消息(控制 token 成本与延迟)
  • 摘要型:把早期历史压缩成 summary,只保留摘要 + 最近若干轮
  • 持久化型:把历史落到 DB/Redis(多实例共享、重启不丢)
  • 合规模型:支持 TTL、脱敏/加密、审计与删除(满足隐私与合规要求)

2) 如果我想自己实现一个 Memory 类,应该怎么做?

最关键的点是:ChatMemory 是按 conversationId 分区的。 你要保证同一个 conversationId 的消息按顺序追加、按需读取,并且能清空。 下面给出一个“最小可用”的自定义实现示例:用内存 Map 按会话存消息,并实现一个简单的“窗口上限”。

✅ 在“演示项目”里,这个类放在哪里最合适?

这份 chapter3.html教学页面,你可以把下面的代码拷到“演示项目”中执行。 如果你的演示项目是标准 Maven/Spring Boot 结构,我建议:

text
src/main/java/
  com/example/chatbot/
    memory/
      SimpleWindowChatMemory.java

src/test/java/
  com/example/chatbot/
    knowledge/
      Test03_ChatMemory.java

✅ 最小依赖(演示项目 pom.xml 需要)

下面的单测不需要大模型,只依赖 spring-ai-core 里的 ChatMemory/Message/UserMessage 等基础类型。 在演示项目的 pom.xml 增加:

html
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-core</artifactId>
  <version>1.0.0-M6</version>
</dependency>

如果你本地拉不到 1.0.0-M6(里程碑版本),再补一个 milestone 仓库(与 <dependencies> 同级):

html
<repositories>
  <repository>
    <id>spring-milestones</id>
    <name>Spring Milestones</name>
    <url>https://repo.spring.io/milestone</url>
    <snapshots>
      <enabled>false</enabled>
    </snapshots>
  </repository>
</repositories>
java
package com.example.chatbot.memory;

import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;

import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;

public class SimpleWindowChatMemory implements ChatMemory {

    private final ConcurrentHashMap<String, Deque<Message>> store = new ConcurrentHashMap<>();
    private final int maxMessages;

    public SimpleWindowChatMemory(int maxMessages) {
        this.maxMessages = maxMessages;
    }

    @Override
    public void add(String conversationId, Message message) {
        Deque<Message> q = store.computeIfAbsent(conversationId, k -> new ConcurrentLinkedDeque<>());
        q.addLast(message);
        trimToWindow(q);
    }

    @Override
    public void add(String conversationId, List<Message> messages) {
        if (messages == null || messages.isEmpty()) {
            return;
        }
        Deque<Message> q = store.computeIfAbsent(conversationId, k -> new ConcurrentLinkedDeque<>());
        for (Message m : messages) {
            if (m != null) {
                q.addLast(m);
            }
        }
        trimToWindow(q);
    }

    private void trimToWindow(Deque<Message> q) {
        if (maxMessages <= 0) {
            q.clear();
            return;
        }
        while (q.size() > maxMessages) {
            q.pollFirst();
        }
    }

    @Override
    public List<Message> get(String conversationId, int lastN) {
        Deque<Message> q = store.get(conversationId);
        if (q == null || q.isEmpty()) {
            return List.of();
        }
        int n = Math.min(lastN, q.size());
        if (n <= 0) {
            return List.of();
        }
        ArrayList<Message> all = new ArrayList<>(q);
        return all.subList(all.size() - n, all.size());
    }

    @Override
    public void clear(String conversationId) {
        store.remove(conversationId);
    }
}

✅ 如何接入 MessageChatMemoryAdvisor(让 ChatClient 自动读写记忆)

一旦你有了 ChatMemory 实现,就可以像本章测试 3.3 那样交给 Advisor:

text
ChatMemory chatMemory = new SimpleWindowChatMemory(20);

ChatClient chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
        .build();

💡 自定义实现时,建议你优先想清楚的 5 件事

  1. 隔离:conversationId 如何生成(userId / userId:deviceId),避免串话
  2. 上限:是否要限制长度(窗口/摘要),避免成本与延迟失控
  3. 一致性:并发下同一 conversationId 的顺序与原子性如何保证
  4. 持久化:是否需要重启不丢(DB/Redis),以及序列化格式
  5. 合规:TTL、清空、脱敏/加密、审计(谁在何时清空/读取)

🧪 测试 3.7:验证 SimpleWindowChatMemory 可用性

这一节不再依赖大模型输出,而是直接验证自定义 ChatMemory 实现本身是否可靠。 重点检查 4 个能力:按会话隔离窗口裁剪按 lastN 读取清空历史

测试代码

java
import com.example.chatbot.memory.SimpleWindowChatMemory;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

private static void printMessages(String title, List<Message> messages) {
    System.out.println(title + "(共 " + messages.size() + " 条)");
    for (int i = 0; i < messages.size(); i++) {
        System.out.println((i + 1) + ". " + messages.get(i).getText());
    }
}

/**
 * 测试 3.7:验证自定义窗口记忆可用性
 *
 * 演示:窗口长度 = 3 时,只保留最近 3 条消息
 */
@Test
@DisplayName("3.7 验证 SimpleWindowChatMemory 可用性")
public void test07_SimpleWindowChatMemory() {
    System.out.println("\n========== 测试 3.7:SimpleWindowChatMemory ==========");

    SimpleWindowChatMemory memory = new SimpleWindowChatMemory(3);
    String sessionId = "window-session-001";

    System.out.println("🛠️ 初始化完成:windowSize = 3, sessionId = " + sessionId);

    memory.add(sessionId, new UserMessage("消息1:我喜欢苹果"));
    printMessages("添加消息1后窗口内容", memory.get(sessionId, 10));

    memory.add(sessionId, new UserMessage("消息2:我喜欢香蕉"));
    printMessages("添加消息2后窗口内容", memory.get(sessionId, 10));

    memory.add(sessionId, new UserMessage("消息3:我喜欢橙子"));
    printMessages("添加消息3后窗口内容", memory.get(sessionId, 10));

    memory.add(sessionId, new UserMessage("消息4:我喜欢西瓜"));
    System.out.println("⚠️ 添加第4条后,应触发窗口裁剪,最早的消息1应被淘汰");

    List<Message> messages = memory.get(sessionId, 10);
    printMessages("📝 当前窗口内消息", messages);

    assertEquals(3, messages.size(), "窗口大小为 3,只应保留最近 3 条消息");
    assertEquals("消息2:我喜欢香蕉", messages.get(0).getText());
    assertEquals("消息3:我喜欢橙子", messages.get(1).getText());
    assertEquals("消息4:我喜欢西瓜", messages.get(2).getText());

    System.out.println("✅ 验证通过:消息1 已被裁剪,只保留消息2/3/4");

    List<Message> lastTwo = memory.get(sessionId, 2);
    printMessages("🔎 查询最后 2 条消息", lastTwo);
    assertEquals(2, lastTwo.size(), "按 lastN 查询时应该返回最后 2 条");
    assertEquals("消息3:我喜欢橙子", lastTwo.get(0).getText());
    assertEquals("消息4:我喜欢西瓜", lastTwo.get(1).getText());

    System.out.println("✅ 验证通过:lastN = 2 时,返回消息3/4");

    memory.clear(sessionId);
    List<Message> afterClear = memory.get(sessionId, 10);
    printMessages("🗑️ 清空后窗口内容", afterClear);
    assertTrue(afterClear.isEmpty(), "清空后历史应为空");

    System.out.println("✅ 窗口裁剪、按需读取、清空历史全部验证通过");
}

运行测试

text
mvn test -Dtest=Test03_ChatMemory#test07_SimpleWindowChatMemory

预期输出

text
========== 测试 3.7:SimpleWindowChatMemory ==========
🛠️ 初始化完成:windowSize = 3, sessionId = window-session-001
添加消息1后窗口内容(共 1 条)
1. 消息1:我喜欢苹果
添加消息2后窗口内容(共 2 条)
1. 消息1:我喜欢苹果
2. 消息2:我喜欢香蕉
添加消息3后窗口内容(共 3 条)
1. 消息1:我喜欢苹果
2. 消息2:我喜欢香蕉
3. 消息3:我喜欢橙子
⚠️ 添加第4条后,应触发窗口裁剪,最早的消息1应被淘汰
📝 当前窗口内消息(共 3 条)
1. 消息2:我喜欢香蕉
2. 消息3:我喜欢橙子
3. 消息4:我喜欢西瓜
✅ 验证通过:消息1 已被裁剪,只保留消息2/3/4
🔎 查询最后 2 条消息(共 2 条)
1. 消息3:我喜欢橙子
2. 消息4:我喜欢西瓜
✅ 验证通过:lastN = 2 时,返回消息3/4
🗑️ 清空后窗口内容(共 0 条)
✅ 窗口裁剪、按需读取、清空历史全部验证通过

✅ 这段单测验证了什么?

  • 窗口裁剪:超过上限时,旧消息会被淘汰
  • 读取正确get(sessionId, lastN) 会返回最后 N 条
  • 会话隔离:不同 sessionId 可以扩展做更多断言,避免串话
  • 清空能力clear() 后历史为空

🚀 运行所有测试

运行完整测试类,验证所有对话记忆功能:

text
mvn test -Dtest=Test03_ChatMemory

预期输出

text
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0

✅ 所有测试通过!

测试用例总结

测试编号 测试名称 核心知识点
3.1 验证 AI 默认没有记忆 AI 本身没有记忆
3.2 配置 ChatMemory Bean InMemoryChatMemory 配置
3.3 使用 MessageChatMemoryAdvisor 自动记忆管理
3.4 会话隔离 conversationId 使用
3.5 查询会话历史 chatMemory.get() 方法
3.6 清空会话历史 chatMemory.clear() 方法
3.7 验证 SimpleWindowChatMemory 可用性 窗口裁剪、按需读取、清空历史

📚 课后习题与常见面试题

请完成下面的练习,巩固本章的“对话记忆”核心机制与工程化落地能力

⚠️ 提示:本章面试高频点集中在 conversationId、记忆窗口、持久化、以及多实例部署下的会话一致性。

🎯 课后习题(动手为主)

1) 写一个“多会话并发”测试:同一时间创建 3 个 conversationId
要求:用 ChatClient 发送 3 组对话(A/B/C),每组都包含“我叫谁 + 我叫什么”; 断言三组对话互不串话。建议把 conversationId 设为 userIduserId:deviceId
参考答案(展开/收起)
核心目标:同一套 ChatClient 下,不同 conversationId 的历史必须隔离;并发下也不能串话。
java
@Test
public void multiConversationConcurrent() throws Exception {
    String a = "userA";
    String b = "userB";
    String c = "userC";

    ExecutorService pool = Executors.newFixedThreadPool(3);
    Callable<String> taskA = () -> {
        chatClient.prompt("我叫张三")
                .advisors(x -> x.param(CHAT_MEMORY_CONVERSATION_ID_KEY, a))
                .call().content();
        return chatClient.prompt("我叫什么?")
                .advisors(x -> x.param(CHAT_MEMORY_CONVERSATION_ID_KEY, a))
                .call().content();
    };
    Callable<String> taskB = () -> {
        chatClient.prompt("我叫李四")
                .advisors(x -> x.param(CHAT_MEMORY_CONVERSATION_ID_KEY, b))
                .call().content();
        return chatClient.prompt("我叫什么?")
                .advisors(x -> x.param(CHAT_MEMORY_CONVERSATION_ID_KEY, b))
                .call().content();
    };
    Callable<String> taskC = () -> {
        chatClient.prompt("我叫王五")
                .advisors(x -> x.param(CHAT_MEMORY_CONVERSATION_ID_KEY, c))
                .call().content();
        return chatClient.prompt("我叫什么?")
                .advisors(x -> x.param(CHAT_MEMORY_CONVERSATION_ID_KEY, c))
                .call().content();
    };

    List<Future<String>> results = pool.invokeAll(List.of(taskA, taskB, taskC));
    String rA = results.get(0).get();
    String rB = results.get(1).get();
    String rC = results.get(2).get();

    assertTrue(rA.contains("张三"));
    assertTrue(rB.contains("李四"));
    assertTrue(rC.contains("王五"));
}
注意:并发测试要避免“模型随机性”造成断言波动;这里断言只检查关键字即可。
3) 为“清空会话”写一个 API 级用例:模拟用户点击“清空对话”按钮
要求:先对话 2 轮,确认能记住;然后调用 chatMemory.clear(conversationId);再问一次“我叫什么”, 验证模型不再记得。附加:说明“清空”应该由后端做还是前端做。
参考答案(展开/收起)
测试要点:先证明“能记住”,再清空,最后验证“记忆消失”。
java
@Test
public void clearConversationShouldWork() {
    String cid = "clear-001";

    chatClient.prompt("我叫张三")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, cid))
            .call().content();
    String remembered = chatClient.prompt("我叫什么名字?")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, cid))
            .call().content();
    assertTrue(remembered.contains("张三"));

    chatMemory.clear(cid);

    String afterClear = chatClient.prompt("我叫什么名字?")
            .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, cid))
            .call().content();
    // 清空后,模型不应再能从历史得出“张三”(断言避免过强)
    assertFalse(afterClear.contains("张三"));
}
清空由谁做:必须由后端做(持久化/多实例);前端只清 UI 不清后端会造成“看似清空但仍记得”。
4) 给每条消息打上“来源标签”:从 Advisor 参数传入 userId 并记录到日志
要求:把 userIdconversationId 作为一对进行传递;每次请求打印日志, 形成“谁在和 AI 说话”的可观测链路(为后续治理/审计做准备)。
参考答案(展开/收起)
原则:把 userId 与 conversationId 一起进入日志与链路追踪(traceId),做到可审计、可定位。
java
String userId = "u001";
String cid = userId + ":web";

String text = chatClient.prompt("我叫张三")
        .advisors(a -> {
            a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, cid);
            a.param("userId", userId); // 自定义参数(用于日志/审计)
        })
        .call().content();

log.info("chat userId={} conversationId={} replyPreview={}", userId, cid,
        text == null ? "null" : (text.length() <= 50 ? text : text.substring(0, 50) + "..."));
注意:日志只打印参数摘要/截断,避免把敏感信息(用户隐私/长文本)直接落盘。
5) 设计“会话过期策略”:超过 30 分钟未对话就清空
要求:给出你的实现方案(例如:在 Repository 层做 TTL/定时清理/数据库字段 + 索引),并写出伪代码。 重点说明:过期策略为什么重要(隐私、成本、性能)。
参考答案(展开/收起)
推荐落点:在持久化层做 TTL(例如 Redis TTL)或在数据库表中记录 lastActiveTime 并定时清理。
text
表字段:conversation_id, message, created_at, last_active_at
索引:idx_last_active_at

定时任务(每 5 分钟):
  find conversation where now - last_active_at > 30min
  delete messages by conversation_id
为什么重要:隐私合规(最小化保留)、成本控制(历史无限增长会推高 token/存储)、性能与检索可控。
6) 选型题:你的项目要多实例部署,如何保证“记忆不丢、不串会话”?
要求:回答应包含: JDBC 持久化(或其他持久化方案) + conversationId 规范。 并说明为什么单纯的 InMemory 会出问题(重启丢失、负载均衡导致读不到历史)。
参考答案(展开/收起)
结论:多实例必须把“记忆”放到共享介质(DB/Redis),并用稳定的 conversationId 作为主键。
推荐组合:
1) 持久化:JDBC(或 Redis 等共享介质)。 2) 规范:conversationId = userId 或 userId:deviceId(确保隔离且可复用)。
InMemory 的问题:进程重启丢失;负载均衡导致“写在 A、读在 B”直接读不到历史。

💼 常见面试题(概念 + 场景)

1) ChatMemory、MessageChatMemoryAdvisor 两者分别负责什么?
建议回答:ChatMemory 负责“面向会话的记忆读写抽象”(写入/读取/清空); Advisor 负责“把历史自动拼进请求 + 把本轮结果自动写回记忆”,业务代码只管 conversationId
参考答案(展开/收起)
ChatMemory:面向会话的记忆抽象,提供 add/get/clear 等能力。
MessageChatMemoryAdvisor:一次调用的编排器:调用前把历史拼进 Prompt,调用后把本轮 user/assistant 消息写回记忆。
工程建议:业务层只负责提供 conversationId(以及必要的 userId),不要手写“历史拼接”。
2) 为什么必须要 conversationId?不用会怎样?
建议回答:conversationId 是“会话隔离”的主键;不用会导致所有人共享一份历史(串话/隐私泄露), 或者每次都当新会话(无法记忆)。在多端登录时可用 userIduserId:deviceId
参考答案(展开/收起)
定义:conversationId 是对话历史的分区键/主键,用来实现会话隔离。
不用的后果(两种极端):
1) 共享历史:所有请求落到同一份历史 => 串话、隐私泄露。 2) 无历史:每次都当新会话 => 无法记忆。
规范建议:单端:conversationId=userId;多端:conversationId=userId:deviceId;并做好用户登出/清空能力。
4) InMemory vs JDBC:多实例部署下会出现什么问题?怎么解决?
建议回答:InMemory 只在当前 JVM 有数据;负载均衡后下一次请求可能打到另一台机器读不到历史;重启也会丢。 解决:使用 JDBC/其他持久化 Repository + 统一的 conversationId 规范;必要时做会话过期与清理。
参考答案(展开/收起)
问题本质:InMemory 的数据只存在“某一台机器的某一个 JVM 内存里”。
多实例下会发生:
1) 负载均衡漂移:请求 1 落在 A 写了历史,请求 2 落在 B => B 读不到历史(看起来“失忆”)。 2) 重启丢失:A 重启后内存清空 => 历史直接没了。 3) 无法治理:TTL/审计/检索/合规删除难做。
解决方案:把存储换成共享介质(JDBC/Redis 等),并统一 conversationId 规范 + 过期清理。
5) 你会把“对话历史”存多久?如何做删除/脱敏/审计?
建议回答:按业务与合规定义保留期(如 7/30/180 天);支持用户“清空/删除”; 对敏感信息做脱敏或加密;用 TTL/定时任务/分区表控制增长;保留审计日志。
参考答案(展开/收起)
存多久:先按“最小必要”原则定默认值(例如 7/30 天),再根据业务(客服留痕/合规要求)调整,并允许用户手动清空。
删除:提供 conversationId 级清空;必要时提供按时间范围/按用户维度删除(满足合规)。
脱敏/加密:敏感字段(手机号/邮箱/身份证等)入库前脱敏或加密;日志只记录摘要。
审计:记录 userId、conversationId、操作类型(写入/读取/清空)、时间与 traceId;审计日志与业务数据分库/分表更安全。
增长控制:TTL/定时清理/分区表(按月)+ 索引(conversationId, createdAt)。
6) 如果要把“对话记忆”从内存迁到 Redis/DB,需要关注哪些能力?
建议回答:至少要支持按 conversationId 追加/读取/清空; 需要考虑顺序性、并发一致性、序列化格式、TTL/清理策略,以及多实例可用性。
参考答案(展开/收起)
必须能力(最小闭环):
1) append:按 conversationId 追加消息(保持顺序)。 2) get:按 conversationId 读取历史(支持 lastN/分页)。 3) clear:按 conversationId 清空历史。
工程关注点:
1) 顺序性:同一会话内消息顺序不能乱(时间戳/自增序号/stream id)。 2) 一致性与并发:并发写入时避免覆盖/乱序。 3) 序列化:Message 的 role/content/metadata 如何存(JSON schema 版本化)。 4) TTL/清理:对话过期策略与合规删除。 5) 可观测:统计、失败重试、熔断、慢查询。

📝 本章小结

🎓 你已经掌握:

  • ChatMemory 接口:Spring AI 提供的会话记忆管理标准
  • InMemoryChatMemory:内存级的 ChatMemory 实现
  • MessageChatMemoryAdvisor:基于 AOP 的自动记忆管理
  • conversationId:实现多用户会话隔离的关键
  • 手动查询历史:使用 chatMemory.get() 方法
  • 清空会话:使用 chatMemory.clear() 方法

🚀 下一步学习

继续学习 第4章:项目二 · 带记忆的聊天API, 将本章学到的 ChatMemory 知识应用到实际项目中, 实现完整的会话管理、会话历史列表等功能!

← 上一章 下一章 →