还在为 Spring AI 应用重启后对话上下文丢失而烦恼吗?本文将带你深入 Spring AI 的对话记忆机制,并手把手教你实现一个基于文件的持久化方案,让你的 AI 应用拥有 “过目不忘” 的超能力!

哈喽,各位程序员朋友们!

在之前的文章里,我们一起探索了如何使用 Spring AI 构建能理解上下文的对话机器人。但一个棘手的问题很快就浮现了:我们的对话记忆都存在内存里,服务器一旦重启,珍贵的聊天记录就灰飞烟灭了。这可不行!

想象一下,用户正和你的 AI 聊得火热,结果服务器一更新,AI 就 “失忆” 了,之前的对话全忘了。这体验感,简直一言难尽。

那么,有没有办法让对话记忆像数据一样被持久化,存到文件、数据库或者 Redis 里呢?

答案是:当然有!Spring AI 早就为我们考虑到了这一点。

一、官方方案:理想与现实的差距

Spring AI 官方文档中提到,它提供了一些现成的持久化方案,可以将对话记忆保存到不同的数据源中。听起来很不错,对吧?

  • InMemoryChatMemory:默认的内存存储,我们一直在用。
  • CassandraChatMemory:用 Cassandra 持久化,还带过期时间。
  • Neo4jChatMemory:用 Neo4j 持久化,永不过期。
  • JdbcChatMemory:用 JDBC 持久化到关系型数据库。

看到 JdbcChatMemory,我们可能两眼放光:这不就是我们想要的吗?然而,现实却给我们泼了一盆冷水。spring-ai-starter-model-chat-memory-jdbc 这个依赖不仅版本稀少,相关文档也几乎没有,甚至在 Maven 中央仓库都搜不到。

虽然在 Spring 自己的仓库里能找到它的踪迹,但这用户量……基本上等于让我们去“开荒”,风险太高了。

Spring AI 对话记忆大揭秘:服务器重启,聊天记录不再丢失!_#ai

既然官方的路不好走,那我们就自己动手,丰衣足食!

二、另辟蹊径:自定义你的 ChatMemory

我更推荐的方案是:自定义实现 ChatMemory 接口

Spring AI 的设计非常巧妙,它将“存储介质”和“记忆算法”解耦了。这意味着我们可以只替换存储部分,而不用改动整个对话流程。

虽然官方没给示例,但没关系,我们可以“偷师”啊!直接去看默认实现类 InMemoryChatMemory 的源码,模仿它的实现。

ChatMemory 接口的核心方法很简单,就是对消息的增、删、查:

Spring AI 对话记忆大揭秘:服务器重启,聊天记录不再丢失!_#ai_02

InMemoryChatMemory 的源码显示,它内部其实就是用一个 ConcurrentHashMap 来存消息,Key 是对话 ID,Value 是这个对话的所有消息列表。

Spring AI 对话记忆大揭秘:服务器重启,聊天记录不再丢失!_#ai_03

思路有了,接下来就是实战!

三、实战演练:打造文件版 ChatMemory

为了避免引入数据库等额外依赖的复杂性,我们先来实现一个最简单的:基于文件的持久化 ChatMemory

这里的核心挑战在于 消息对象的序列化与反序列化。我们需要将内存中的 Message 对象转换成文本存入文件,也要能从文件中读出文本并还原成 Message 对象。

你可能会首先想到用 JSON,但很快就会发现困难重重:

  1. Message 是个接口,有 UserMessageSystemMessage 等多种实现。
  2. 不同子类的字段各不相同,结构不统一。
  3. 这些子类大多没有无参构造函数,也没有实现 Serializable 接口。

Spring AI 对话记忆大揭秘:服务器重启,聊天记录不再丢失!_#人工智能_04

直接用 JSON 序列化,大概率会踩坑。因此,我们请出一位“外援”——高性能序列化库  Kryo

第一步:引入 Kryo 依赖

pom.xml 中添加:

<dependency>
    <groupId>com.esotericsoftware</groupId>
    <artifactId>kryo</artifactId>
    <version>5.6.2</version>
</dependency>
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

第二步:编写 FileBasedChatMemory

新建 chatmemory 包,创建 FileBasedChatMemory.java。别被下面的代码吓到,核心逻辑就是文件的读写和对象的序列化/反序列化,完全可以让 AI 帮你生成。

// ... 省略 package 和 import ...

/**
 * @author BNTang
 * @version 1.0
 * @description 基于文件持久化的对话记忆,实现 ChatMemory 接口
 **/
public class FileBasedChatMemory implements ChatMemory {
    /**
     * 文件存储的基础目录
     */
    private final String BASE_DIR;
    // Kryo 实例,用于序列化和反序列化消息对象
    private static final Kryo KRYO = new Kryo();

    static {
        // 设置 Kryo 的注册要求为 false,允许未注册的类进行序列化
        KRYO.setRegistrationRequired(false);
        // 设置 Kryo 的实例化策略为标准实例化策略
        KRYO.setInstantiatorStrategy(new StdInstantiatorStrategy());
    }

    /**
     * 构造函数,初始化文件存储目录。
     *
     * @param dir 文件存储目录路径
     */
    public FileBasedChatMemory(String dir) {
        // 设置基础目录
        this.BASE_DIR = dir;
        // 确保目录存在
        File baseDir = new File(BASE_DIR);
        // 如果目录不存在,则创建目录
        if (!baseDir.exists()) {
            // 尝试创建目录,如果失败则抛出异常
            boolean created = baseDir.mkdirs();
            // 如果目录创建失败,抛出运行时异常
            if (!created) {
                // 目录创建失败,抛出异常
                throw new RuntimeException("Failed to create directory: " + BASE_DIR);
            }
        }
    }

    @Override
    public void add(String conversationId, List<Message> messages) {
        // 获取或创建对话的消息列表
        List<Message> conversationMessages = getOrCreateConversation(conversationId);
        // 将新的消息添加到对话消息列表中
        conversationMessages.addAll(messages);
        // 保存更新后的对话消息列表到文件
        saveConversation(conversationId, conversationMessages);
    }

    @Override
    public List<Message> get(String conversationId, int lastN) {
        // 获取或创建对话的消息列表
        List<Message> allMessages = getOrCreateConversation(conversationId);
        // 如果消息总数小于等于 lastN,直接返回所有消息
        if (allMessages.size() <= lastN) {
            return allMessages;
        }
        // 否则,返回最后 N 条消息
        return allMessages.subList(allMessages.size() - lastN, allMessages.size());
    }

    @Override
    public void clear(String conversationId) {
        // 获取对话文件
        File file = getConversationFile(conversationId);
        // 如果文件存在,则删除该文件
        if (file.exists()) {
            // 尝试删除文件,如果删除失败则打印警告信息
            file.delete();
        }
    }

    /**
     * getOrCreateConversation 方法用于获取或创建一个对话的消息列表。
     *
     * @param conversationId 对话 ID,用于标识特定的对话
     * @return 一个包含对话消息的列表,如果文件不存在则返回一个空列表
     */
    private List<Message> getOrCreateConversation(String conversationId) {
        // 获取对话文件
        File file = getConversationFile(conversationId);
        // 如果文件不存在,则创建一个新的空列表
        if (!file.exists()) {
            return new ArrayList<>();
        }
        // 如果文件存在,则读取文件中的消息列表
        try (Input input = new Input(new FileInputStream(file))) {
            // 使用 Kryo 反序列化读取的对象
            return KRYO.readObject(input, ArrayList.class);
        } catch (Exception e) {
            // 如果读取文件失败,打印异常堆栈跟踪,并返回空列表以防程序崩溃
            e.printStackTrace();
            return new ArrayList<>();
        }
    }

    /**
     * saveConversation 方法用于将对话消息列表保存到文件中。
     *
     * @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 (IOException e) {
            // 如果写入文件失败,打印异常堆栈跟踪
            e.printStackTrace();
        }
    }

    /**
     * getConversationFile 方法用于获取特定对话 ID 的文件。
     *
     * @param conversationId 对话 ID,用于标识特定的对话
     * @return 一个 File 对象,表示存储该对话消息的文件
     */
    private File getConversationFile(String conversationId) {
        // 返回一个新的 File 对象,表示存储对话消息的文件
        return new File(BASE_DIR, conversationId + ".kryo");
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.
  • 86.
  • 87.
  • 88.
  • 89.
  • 90.
  • 91.
  • 92.
  • 93.
  • 94.
  • 95.
  • 96.
  • 97.
  • 98.
  • 99.
  • 100.
  • 101.
  • 102.
  • 103.
  • 104.
  • 105.
  • 106.
  • 107.
  • 108.
  • 109.
  • 110.
  • 111.
  • 112.
  • 113.
  • 114.
  • 115.
  • 116.
  • 117.
  • 118.
  • 119.
  • 120.
  • 121.
  • 122.
  • 123.
  • 124.
  • 125.
  • 126.
  • 127.
  • 128.
  • 129.
  • 130.
  • 131.

第三步:配置 ChatClient

修改 App 的构造函数,告诉 ChatClient 使用我们新的文件版对话记忆。

public App(ChatModel ollamaChatModel) {
    // 指定一个用于存放记忆文件的目录
    String fileDir = System.getProperty("user.dir") + "/temp/chat-memory";
    // 实例化我们自定义的 ChatMemory
    ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
    
    // 构建 ChatClient,并注入 ChatMemory
    chatClient = ChatClient.builder(ollamaChatModel)
            .defaultSystem(SYSTEM_PROMPT)
            .defaultAdvisors(
                    new MessageChatMemoryAdvisor(chatMemory),
                    new MyLoggerAdvisor()
            )
            .build();
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

第四步:见证奇迹

运行你的应用,进行几轮对话,然后查看项目根目录下的 temp/chat-memory 文件夹。你会发现,对话记录已经被成功保存为 .kryo 文件了!

Spring AI 对话记忆大揭秘:服务器重启,聊天记录不再丢失!_#ai_05

现在,即使你重启应用,AI 也能找回之前的对话,继续和用户愉快地交流。

Spring AI 开发中的常见痛点:对话记忆的持久化。通过自定义 ChatMemory 接口,我们成功地将对话历史从易失的内存转移到了稳定的文件中,让我们的 AI 应用拥有了“长期记忆”。

这个方法不仅限于文件存储,你可以举一反三,将其改造为基于 Redis、MongoDB 或任何你喜欢的存储方案。这正是 Spring AI 框架灵活性的体现。

希望这篇文章能对你有所启发!动手试试吧,给你的 AI 装上一个“超级大脑”!

如果你觉得本文对你有帮助,欢迎点赞、分享三连! 你的支持是我持续创作的最大动力!