在这里插入图片描述

引言

单轮对话的 AI 就像一条金鱼——每次交互都是全新的开始,它不记得你上一句说了什么。在实际的知识库问答场景中,用户往往需要多轮追问来逐步深入一个话题。对话记忆(ChatMemory)正是解决这一问题的关键组件。

本篇将解析 Spring AI 的 ChatMemory 机制,包括它的存储模型、Advisor 集成方式,以及如何实现多用户之间的记忆隔离。

设计说明

多轮对话的核心挑战

大模型本身是无状态的——每次 API 调用都是独立的。要实现多轮对话,必须在每次请求时将历史对话作为上下文一并发送给模型。这带来了几个工程问题:

  1. 存储:历史消息存在哪里?内存、Redis、还是数据库?
  2. 注入:如何将历史消息无侵入地注入到每次请求中?
  3. 隔离:多用户场景下,如何确保 A 用户看不到 B 用户的对话?
  4. 窗口:历史消息不能无限增长,如何控制上下文窗口大小?

设计方案

Spring AI 通过 ChatMemory 接口 + MemoryAdvisor 的组合解决上述问题:

  • ChatMemory:负责消息的存储与检索,支持多种后端实现(InMemory、JDBC、Redis 等)
  • PromptChatMemoryAdvisor / MessageChatMemoryAdvisor:作为 Advisor 挂载到 ChatClient 的调用链中,在请求发出前自动注入历史消息

两种 MemoryAdvisor 的区别:

Advisor 注入方式 适用场景
MessageChatMemoryAdvisor 将历史消息作为独立的 Message 列表注入 模型原生支持多轮消息格式
PromptChatMemoryAdvisor 将历史消息序列化为文本,拼接到 System Prompt 中 兼容性更好,适合所有模型

原理方案

ChatMemory 的存储模型

ChatMemory 接口定义了三个核心方法:

public interface ChatMemory {
    String CONVERSATION_ID = "conversationId";
    
    void add(String conversationId, List<Message> messages);
    List<Message> get(String conversationId, int lastN);
    void clear(String conversationId);
}
  • add:追加消息到指定会话
  • get:获取指定会话的最近 N 条消息
  • clear:清空指定会话的所有消息

默认实现 InMemoryChatMemory 使用 ConcurrentHashMap 存储,适合开发和测试环境。生产环境建议使用 JDBC 或 Redis 实现持久化。

Advisor 链中的执行时机

用户请求 → PromptChatMemoryAdvisor.before() → [注入历史消息] → 其他 Advisor → ChatModel 调用
         ← PromptChatMemoryAdvisor.after()  ← [保存本轮消息] ← 其他 Advisor ← 模型响应

PromptChatMemoryAdvisorbefore 阶段从 ChatMemory 中读取历史消息并注入请求,在 after 阶段将本轮的用户消息和 AI 回复保存到 ChatMemory 中。

用户隔离机制

通过 ChatMemory.CONVERSATION_ID 参数实现隔离。每个用户使用自己的 userId 作为 conversationId,确保不同用户的对话历史完全独立:

.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId))

代码解析

依赖引入

<!--对话记忆-->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-autoconfigure-model-chat-memory</artifactId>
</dependency>

这个 starter 会自动装配 InMemoryChatMemory Bean(默认存储在 JVM 的 Map 中)。

ChatClient 集成记忆 Advisor

public AiRagController(ChatModel chatModel, ChatMemory chatMemory, VectorStore vectorStore) {
    this.chatClient = ChatClient.builder(chatModel)
            .defaultSystem("""
                你是知识库系统的对话助手,请以乐于助人的方式进行对话,
                今天的日期:{current_data}
                """)
            .defaultAdvisors(
                PromptChatMemoryAdvisor.builder(chatMemory).build(),
                SimpleLoggerAdvisor.builder().build()
            )
            .build();
    this.vectorStore = vectorStore;
}

关键点:

  1. ChatMemory chatMemory 通过构造函数注入,由 Spring 自动装配提供
  2. PromptChatMemoryAdvisor.builder(chatMemory).build() 创建记忆 Advisor,将其注册为默认 Advisor
  3. SimpleLoggerAdvisor 用于调试,会打印完整的请求/响应日志

请求时传入会话 ID

@PostMapping(value = "/rag")
@Loggable
public Flux<String> generatePost(@RequestParam String message) {
    Long userId = BaseContext.getCurrentId();
    
    return chatClient.prompt()
            .user(message)
            .system(a -> a.param("current_data", LocalDate.now().toString()))
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId))
            .stream()
            .content();
}

BaseContext.getCurrentId() 从 ThreadLocal 中获取当前登录用户的 ID(由 JWT 拦截器在请求进入时设置)。这个 ID 作为 CONVERSATION_ID 传递给 MemoryAdvisor,实现了:

  • 同一用户的多次请求共享同一段对话历史
  • 不同用户之间的对话完全隔离

BaseContext —— ThreadLocal 用户上下文

public class BaseContext {
    public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

    public static void setCurrentId(Long id) {
        threadLocal.set(id);
    }

    public static Long getCurrentId() {
        return threadLocal.get();
    }

    public static void removeCurrentId() {
        threadLocal.remove();
    }
}

JWT 拦截器在验证 token 成功后调用 BaseContext.setCurrentId(userId),后续业务代码通过 BaseContext.getCurrentId() 获取当前用户身份。

ChatController 中的另一种记忆方式

public ChatController(ChatClient.Builder builder, ChatMemory chatMemory) {
    this.chatClient = builder
            .defaultSystem("你是公司的客服代理")
            .defaultAdvisors(
                MessageChatMemoryAdvisor.builder(chatMemory).build()
            )
            .build();
}

这里使用了 MessageChatMemoryAdvisor,它会将历史消息作为独立的 Message 对象注入到请求的消息列表中,而非拼接到 System Prompt。两种方式的效果类似,但 MessageChatMemoryAdvisor 更符合 OpenAI 等模型的多轮对话格式。

验证结果

多轮对话测试

第一轮:

POST /api/v1/ai/rag?message=我叫张三

响应:你好张三,很高兴认识你!有什么可以帮助你的吗?

第二轮:

POST /api/v1/ai/rag?message=我叫什么名字?

响应:你叫张三,你刚才告诉我的。

AI 成功记住了上一轮对话中的信息。

用户隔离测试

用户 A(userId=1):

POST /api/v1/ai/rag?message=我的项目是电商系统

用户 B(userId=2):

POST /api/v1/ai/rag?message=我的项目是什么?

响应:抱歉,你还没有告诉我你的项目是什么。

用户 B 无法获取用户 A 的对话上下文,隔离生效。

调试日志(SimpleLoggerAdvisor)

开启 debug 日志后,可以看到完整的请求内容:

logging:
  level:
    org.springframework.ai.chat.client.advisor: debug

日志输出会包含注入的历史消息,方便排查记忆相关问题。

生产环境建议

  1. 持久化存储:默认的 InMemoryChatMemory 在应用重启后会丢失所有记忆。生产环境应使用 JDBC 或 Redis 实现
  2. 窗口控制:通过 PromptChatMemoryAdvisor.builder(chatMemory).maxMessages(20).build() 限制历史消息数量,避免超出模型上下文窗口
  3. 会话过期:配合 Redis TTL 或定时任务清理长时间不活跃的会话
  4. 异步保存:高并发场景下,消息保存可以异步化,避免阻塞主流程

小结

本篇介绍了 Spring AI 对话记忆的完整实现:

  • ChatMemory 接口提供消息的存储与检索能力
  • PromptChatMemoryAdvisor / MessageChatMemoryAdvisor 以 Advisor 模式无侵入地注入历史消息
  • 通过 CONVERSATION_ID 参数实现多用户记忆隔离
  • 默认使用 InMemory 存储,生产环境建议持久化

下一篇将进入 RAG 的核心——文档上传与向量化入库,看看知识是如何被"喂"给系统的。

在这里插入图片描述

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐