Spring AI 指南:如何自主构造 ChatMemory

本文介绍了如何在Spring AI框架中自定义`ChatMemory`接口以管理对话上下文,重点解析了其核心方法(添加、获取、清空消息),并通过实战演示了基于文件存储的`FileBasedChatMemory`实现,使用Kryo序列化持久化对话历史。同时对比了`ChatMemory`与更底层的`MessageStore`接口的适用场景,建议根据需求选择直接实现`ChatMemory`或通过`MessageStore`简化存储方案。

作品集: Spring AI
作者头像
LumiBee
7 天前 · 22 1
分享

Spring AI 指南:如何自主构造 ChatMemory

在构建基于大语言模型(LLM)的对话式应用时,上下文管理是决定对话质量和连贯性的核心。用户不会希望模型在每一轮对话时都“忘记”之前说过的话。在 Spring AI 框架中,这个关键任务由 ChatMemory 接口来承担。

官方虽然提供了一些开箱即用的实现(如 InMemoryChatMemory),但在实际的生产环境中,我们往往有更复杂的需求,例如:

  • 持久化存储:将对话历史保存到数据库(如 Redis, MongoDB, JDBC 数据库)或文件中,以防应用重启后丢失。
  • 自定义记忆策略:实现更复杂的对话历史裁剪策略,比如只保留最近的 N 条对话、保留最近 M 分钟内的对话,或者基于 Token 数量进行控制。
  • 多租户/多会话隔离:为不同的用户或会话提供独立的记忆存储。

本文将深入解析 ChatMemory 接口,并从零开始,创建一个将对话历史持久化到本地文件的自定义 ChatMemory 实现。

1. ChatMemory 核心接口解析

ChatMemory 接口的定义非常简洁,它主要包含以下几个核心方法:

  • void add(String conversationId, Message message)
    • 作用:向记忆中添加一条新的消息。每当用户提问或模型回答后,对应的 Message 对象就应该被添加到记忆中。
    • 实现要点:这是最核心的写入操作。需要在这里实现将消息存入选择的后端存储(文件、数据库等)的逻辑。
    • 注意:现在这个方法已经可以不用重写了,只需要重写一次性添加多条消息的 add
  • void add(String conversationId, List<Message> messages)
    • 作用:一次性添加多条消息。
    • 实现要点:默认实现是遍历列表并逐一调用 add(Message)。通常情况下,不需要重写此方法,除非后端存储支持批量写入以优化性能。
  • List<Message> get(String conversationId)
    • 作用:获取当前记忆中存储的所有对话历史。在构建下一次对模型的请求时,Spring AI 会调用此方法来获取完整的上下文。
    • 实现要点:这是最核心的读取操作。需要在这里实现从后端存储读取并返回消息列表的逻辑。
  • void clear(String conversationId)
    • 作用:清空当前会话的所有记忆。
    • 实现要点:实现从后端存储删除所有相关消息的逻辑。

理解了这个接口,我们的目标就非常明确了:创建一个类,实现这几个方法,并使用我们选择的存储方式来完成消息的增、删、查操作,本质上就是做 CRUD。

2. 实战:构建一个基于文件的 FileBasedChatMemory

让我们来创建一个具体的实现:FileBasedChatMemory。它的功能是将特定对话的全部历史记录持久化保存到一个文件中。每个对话实例都将对应一个唯一的文件。

1. 项目准备

由于使用 JSON 来序列化会存在很多报错,我们可以选择高性能的 Kryo 序列化库

<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<dependency>
    <groupId>com.esotericsoftware</groupId>
    <artifactId>kryo</artifactId>
    <version>5.6.2</version>
</dependency>

2. 创建 FileBasedChatMemory 类

我们来定义这个类,它需要实现 ChatMemory 接口。

package com.lumibee.aiagent.chatmemory;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import org.objenesis.strategy.StdInstantiatorStrategy;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;

import com.esotericsoftware.kryo.Kryo;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;

/**
 * 基于文件的聊天记忆实现类
 * 使用Kryo序列化库将聊天消息持久化到本地文件系统
 * 实现Spring AI的ChatMemory接口,提供对话历史的存储和检索功能
 */
public class FileBasedChatMemory implements ChatMemory {

    /** 存储聊天记录的基础目录路径 */
    private final String BASE_DIR;
    
    /** Kryo序列化器实例,用于对象的序列化和反序列化 */
    private static final Kryo kryo = new Kryo();

    /**
     * 静态初始化块,配置Kryo序列化器
     */
    static {
        // 禁用注册要求,允许序列化未注册的类
        kryo.setRegistrationRequired(false);
        // 设置实例化策略,使用Objenesis的StdInstantiatorStrategy
        // 这允许Kryo创建没有无参构造函数的对象
        kryo.setInstantiatorStrategy(new StdInstantiatorStrategy());
    }

    /**
     * 构造函数
     * @param dir 存储聊天记录的基础目录路径
     */
    public FileBasedChatMemory(String dir) {
        this.BASE_DIR = dir;
        File file = new File(dir);
        // 如果目录不存在,则创建目录结构
        if (!file.exists()) {
            file.mkdirs();
        }
    }

    /**
     * 添加消息到指定对话
     * @param conversationId 对话ID,用于标识不同的对话
     * @param messages 要添加的消息列表
     */
    @Override
    public void add(String conversationId, List<Message> messages) {
        // 获取或创建对话的消息列表
        List<Message> conversationMessages = getOrCreateConversation(conversationId);
        // 将新消息添加到现有消息列表中
        conversationMessages.addAll(messages);
        // 保存更新后的对话到文件
        saveConversation(conversationId, conversationMessages);
    }

    /**
     * 获取指定对话的所有消息
     * @param conversationId 对话ID
     * @return 该对话的所有消息列表的不可修改副本
     */
    @Override
    public List<Message> get(String conversationId) {
        List<Message> allMessages = getOrCreateConversation(conversationId);
        // 返回不可修改的消息列表副本,防止外部修改影响内部状态
        return allMessages.stream().toList();
    }

    /**
     * 清除指定对话的所有消息
     * @param conversationId 要清除的对话ID
     */
    @Override
    public void clear(String conversationId) {
        File file = getConversationFile(conversationId);
        // 如果文件存在,则删除该文件
        if (file.exists()) {
            file.delete();
        }
    }

    /**
     * 获取或创建指定对话的消息列表
     * 如果文件不存在,则创建新的空列表
     * @param conversationId 对话ID
     * @return 该对话的消息列表
     */
    private List<Message> getOrCreateConversation(String conversationId) {
        File file = getConversationFile(conversationId);
        List<Message> messages = new ArrayList<>();
        
        // 如果文件存在,则从文件中读取消息
        if (file.exists()) {
            try (Input input = new Input(new FileInputStream(file))) {
                // 使用Kryo反序列化消息列表
                messages = kryo.readObject(input, ArrayList.class);
            } catch (IOException e) {
                // 如果读取失败,打印错误信息并返回空列表
                e.printStackTrace();
            }
        }
        return messages;
    }

    /**
     * 保存对话消息到文件
     * @param conversationId 对话ID
     * @param messages 要保存的消息列表
     */
    private void saveConversation(String conversationId, List<Message> messages) {
        File file = getConversationFile(conversationId);
        try (Output output = new Output(new FileOutputStream(file))){
            // 使用Kryo序列化消息列表到文件
            kryo.writeObject(output, messages);
        } catch (FileNotFoundException e) {
            // 如果文件创建失败,抛出运行时异常
            throw new RuntimeException(e);
        }
    }

    /**
     * 获取指定对话对应的文件对象
     * @param conversationId 对话ID
     * @return 对应的文件对象,文件扩展名为.kryo
     */
    private File getConversationFile(String conversationId) {
        // 文件名格式:{conversationId}.kryo
        return new File(BASE_DIR, conversationId + ".kryo");
    }
}

代码解析:

  1. 构造函数: 接收存储目录路径,并确保该目录存在。如果目录不存在,会自动创建目录结构。
  2. add(String conversationId, List messages): 这是核心的写入逻辑。它首先调用 getOrCreateConversation() 获取当前完整的消息列表,然后将新消息添加到该列表中,最后将整个更新后的列表使用 Kryo 序列化并写入到文件中。
  3. get(String conversationId): 首先检查文件是否存在。如果存在,则读取文件内容,并使用 Kryo 的 Input 流将二进制数据反序列化为 List 对象。如果文件不存在,返回一个空的 ArrayList。
  4. clear(String conversationId): 逻辑最简单,直接删除对应的 .kryo 文件即可。
  5. 线程安全: 请注意,这个简单的实现不是线程安全的。如果多个线程同时调用 add 方法操作同一个对话,可能会导致数据丢失或文件损坏。在生产环境中,需要使用 synchronized 关键字或 java.util.concurrent.locks.Lock 来保护对文件的读写操作。

3. 使用自定义 ChatMemory

使用起来非常简单。当需要与 ChatClient 交互时,不再使用默认的记忆体,而是实例化自己的 FileBasedChatMemory

public App(ChatClient.Builder builder,
                   MysqlChatMemoryRepository mysqlChatMemoryRepository,
                   @Value("classpath:/prompts/love-prompt.st") Resource lovePromptResource) {

        String fileDir = System.getProperty("user.dir") + "/chat-memory";
        ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
        
        this.lovePromptTemplate = new PromptTemplate(lovePromptResource);

        this.chatClient = builder
                .defaultAdvisors(
                        MessageChatMemoryAdvisor.builder(chatMemory)
                                .build())
                .build();
}

现在,每次调用 chatClient.call(),框架都会自动:

  1. 调用 fileBasedChatMemory.get() 获取历史记录。
  2. 将历史记录和当前问题一起发送给 LLM。
  3. 在收到回答后,调用 fileBasedChatMemory.add() 将用户的新问题和模型的回答保存到文件中。

3. 思考:从 ChatMemory 到 MessageStore

虽然我们成功实现了一个自定义的 ChatMemory,但 Spring AI 还提供了一个更底层的抽象:MessageStore

  • MessageStore: 它的职责更纯粹,只关心如何根据一个 key(通常是会话ID)来存储和检索 List<Message>。它不关心 ChatMemory 的那些运行时逻辑。
  • ChatMemory 的实现可以委托给 MessageStore: Spring AI 提供了一个 InMemoryChatMemory 的构造函数,它接收一个 MessageStore。这意味着你可以只实现一个 JdbcMessageStoreRedisMessageStore,然后把它包装在 InMemoryChatMemory 中,即可获得一个功能完备且持久化的 ChatMemory,而无需自己处理记忆裁剪等逻辑。

选择哪种方式?

  • 如果需要完全控制记忆的所有行为(包括如何添加、如何检索以及复杂的裁剪逻辑),直接实现 ChatMemory 接口,如我们的文件示例所示。
  • 如果主要目标只是更换后端存储(例如从内存换到 Redis),并且满足于 Spring AI 提供的默认记忆管理逻辑,那么实现 MessageStore 接口并将其注入到 InMemoryChatMemory 中是更推荐、更省力的方式。
阅读量: 22

评论区

登录后发表评论

正在加载评论...
相关阅读

Gemini for Google Workspace 提示工程指南101 报告

# **Gemini for Google Workspace 提示工程指南101 报告** ## **引言** Google Workspace 最初的设计理念便是促进人与人之间的实时协作...

266
2

Spring Boot 多数据源配置指南:从自动配置到手动掌控

# Spring Boot 多数据源配置指南:从自动配置到手动掌控 在企业级应用开发中,单一数据源往往无法满足复杂的业务需求。无论是为了实现读写分离、业务隔离,还是对接不同类型的数据库,配置和...

8
0