✨ 我是 Muzi 的「文章捕手」,擅长在文字的星海中打捞精华。每当新的篇章诞生,我就会像整理贝壳一样,将思想的闪光点串成珍珠项链~

本文深入解析了Spring AI聊天记忆持久化的设计与实现,重点解决了Message接口多态序列化的难题。文章介绍了三种存储方案:内存存储(简单但重启丢失)、基于Kryo的文件序列化(自动记录类信息,支持多态但数据不可读)、以及官方推荐的数据库持久化(通过拆解Message为简单字段存储,使用switch手动还原子类,避免序列化复杂性)。同时,详细剖析了MessageWindowChatMemory的滑动窗口机制及SystemMessage的特殊保护策略。作者基于官方设计实现了简化版数据库持久化方案,提升了数据可读性和分布式支持。文章强调“拆解+手动还原”策略在复杂对象持久化中的实用价值,为实际项目中聊天记忆管理提供了有效参考。

2026-06-09🌱上海: ☀️ 🌡️+82°F 🌬️SE6mph

# mu-ai-agent-4

# 前言

在前面的文章中,我们用 MessageChatMemoryAdvisor 让 AI 助手具备了"记住上下文"的能力 — 它能记住你之前问了什么,对话不再"断片"。但当时用的是 InMemoryChatMemoryRepository,所有对话都存在内存里,应用一重启就全丢了。

这对于一个真正要上线的应用来说显然不行。这篇文章就来深入聊聊 Spring AI 的聊天记忆持久化:它是怎么设计的、为什么序列化 Message 对象会比想象中复杂、以及怎么自己实现一个数据库持久化方案。


# Spring AI 聊天记忆的架构

Spring AI 把"记忆"拆成了两层,职责很清晰:

ChatMemory(接口)        ← 上层:记忆管理策略(滑动窗口、Token 限制等)
  └── MessageWindowChatMemory  ← 唯一内置实现:滑动窗口
        └── ChatMemoryRepository(接口)  ← 下层:具体存储(内存/文件/数据库/Redis)

ChatMemory 接口定义很简单:

public interface ChatMemory {
    void add(String conversationId, List<Message> messages);  // 存消息
    List<Message> get(String conversationId);                 // 取消息
    void clear(String conversationId);                        // 清空
}

ChatMemoryRepository 接口也很简洁:

public interface ChatMemoryRepository {
    List<String> findConversationIds();
    List<Message> findByConversationId(String conversationId);
    void saveAll(String conversationId, List<Message> messages);  // 全量替换
    void deleteByConversationId(String conversationId);
}

注意到一个关键设计:saveAll()全量替换而非追加。这和 MessageWindowChatMemory 的滑动窗口策略有关 — 它每次保存的可能是截断后的结果,所以需要覆盖旧数据。


# MessageWindowChatMemory 滑动窗口机制

MessageWindowChatMemory 是 Spring AI 内置的唯一 ChatMemory 实现。它的核心逻辑在 process() 方法中:

private List<Message> process(List<Message> memoryMessages, List<Message> newMessages) {
    // 1. 合并旧消息和新消息
    List<Message> processedMessages = new ArrayList<>();
    //    如果新消息中有 SystemMessage,先把旧的 SystemMessage 移除(避免重复)
    processedMessages.addAll(过滤后的旧消息);
    processedMessages.addAll(newMessages);

    // 2. 如果总数没超限,直接返回
    if (processedMessages.size() <= this.maxMessages) {
        return processedMessages;
    }

    // 3. 超限时,从前往后淘汰旧消息,但 SystemMessage 永远不会被淘汰
    int messagesToRemove = processedMessages.size() - this.maxMessages;
    //    遍历:遇到 SystemMessage 跳过不删,其他的依次删除直到数量达标
    //    ...
}

有几个值得注意的点:

maxMessages 控制的是总消息数(不是 Token 数)。我们设了 maxMessages=10,表示一个会话最多保留 10 条消息。当第 11 条进来时,最早的非系统消息会被淘汰。

SystemMessage 享有"免死金牌" — 无论窗口怎么滑动,系统提示词永远不会被淘汰。这很合理,因为系统提示词定义了 AI 的角色和行为规则,丢了它对话就会"失忆"。

新消息进来时,如果包含一个之前没见过的 SystemMessage,旧的 SystemMessage 会被移除,确保只有一个生效。


# Message 接口的多态序列化问题

这是理解聊天记忆持久化最核心的部分。

Spring AI 的 Message 是一个接口,有 4 个具体实现类:

Message(接口)
  └── AbstractMessage(抽象基类,持有 messageType / textContent / metadata)
        ├── UserMessage        (用户消息,role=user,额外有 media 字段)
        ├── AssistantMessage   (AI 回复,role=assistant,额外有 toolCalls、media)
        ├── SystemMessage      (系统提示词,role=system)
        └── ToolResponseMessage(工具调用结果,role=tool)

ChatMemoryRepository 存的是 List<Message>,一个会话里的消息肯定是混合类型 — 用户说一句(UserMessage),AI 回一句(AssistantMessage),可能还有系统提示词(SystemMessage)。

问题来了:把这堆不同类型的对象存下来、再取回来时,怎么保证每个对象还原成正确的子类?

这就是经典的多态序列化问题。


# 三种存储方案

# 方案一:InMemoryChatMemoryRepository(内存)

最简单的实现,底层就是一个 ConcurrentHashMap

public final class InMemoryChatMemoryRepository implements ChatMemoryRepository {
    Map<String, List<Message>> chatMemoryStore = new ConcurrentHashMap<>();

    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        this.chatMemoryStore.put(conversationId, messages);
    }

    @Override
    public List<Message> findByConversationId(String conversationId) {
        return this.chatMemoryStore.get(conversationId);
    }
}

对象直接存内存,不涉及任何序列化,所以不存在多态问题。优点是最简单、零配置;缺点是重启就没了。适合开发调试。

# 方案二:FileBasedChatMemory(Kryo 序列化)

这是我们之前自己实现的方案,用 Kryo 把 List<Message> 序列化为二进制文件:

public class FileBasedChatMemory implements ChatMemoryRepository {

    private static final Kryo kryo = new Kryo();
    static {
        kryo.setRegistrationRequired(false);
        kryo.setInstantiatorStrategy(new StdInstantiatorStrategy());
    }

    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        try (Output output = new Output(new FileOutputStream(file))) {
            kryo.writeObject(output, new ArrayList<>(messages));
        }
    }

    @Override
    public List<Message> findByConversationId(String conversationId) {
        try (Input input = new Input(new FileInputStream(file))) {
            return kryo.readObject(input, ArrayList.class);
        }
    }
}

每个会话对应一个 .kryo 文件,保存在 chat-memory/ 目录下。Kryo 能自动处理多态序列化,下面详细分析原因。

# 方案三:数据库持久化

Spring AI 官方提供了 JdbcChatMemoryRepository,我们也自己实现了一个学习版 DbChatMemoryRepository。这种方案完全绕开序列化,后面重点分析。

三种方案的对比:

特性 内存(InMemory) 文件(Kryo) 数据库(JDBC)
多态处理 不涉及 Kryo 自动嵌入类信息 手动拆解 + switch 还原
持久化 ❌ 重启丢失 ✅ 文件 ✅ 数据库
数据可读性 内存对象 二进制不可读 明文 SQL 可直接查看
字段完整性 完整 完整 只保存 text + type
分布式支持 ❌ 本地文件 ✅ 共享数据库
查询能力 按会话 ID 按会话 ID 灵活 SQL 查询

# Kryo vs JSON:多态序列化详解

# 为什么 Kryo "自动"就能处理?

Kryo 序列化一个对象时,会在二进制流中自动记录该对象的实际 Class 信息。比如序列化一个 UserMessage,写入的数据大致是:

[类标识: UserMessage] [messageType: USER] [textContent: "你好"] [metadata: {...}] [media: []]

反序列化时,Kryo 读取类标识,直接实例化对应的 UserMessage,不需要任何额外配置。

两个关键配置让它能"自动"工作:

kryo.setRegistrationRequired(false);  // 不要求预注册类,允许序列化任意类
kryo.setInstantiatorStrategy(new StdInstantiatorStrategy());  // Objenesis 实例化策略

StdInstantiatorStrategy 特别重要 — Spring AI 的 AssistantMessage 没有无参构造器(它的构造器至少需要 String content),普通反射会报 NoSuchMethodException,但 Objenesis 可以绕过构造器直接分配内存创建对象。

# JSON(Jackson)为什么不行?

如果用 Jackson 序列化 List<Message>,生成的 JSON 是这样的:

[
  {"messageType": "USER", "textContent": "你好", "metadata": {}},
  {"messageType": "ASSISTANT", "textContent": "你好!", "toolCalls": [], "metadata": {}}
]

反序列化时报错

Cannot construct instance of `Message`: abstract types either need to be mapped
to concrete types, have custom deserializer, or contain additional type information

Jackson 看到声明类型是 Message(接口),不知道该实例化哪个具体类。要解决这个问题,有三种办法:

方案 A:给接口加 @JsonTypeInfo 注解。但 Spring AI 的源码你改不了。

方案 B:开启 Jackson 的 DefaultTyping,让每个对象都带上 @class 字段。能工作但 JSON 变得不干净,而且有安全风险(反序列化漏洞)。

方案 C:手动按类型分发 — 这就是 Spring AI 官方 JDBC 实现的做法,下面重点分析。


# Spring AI 官方 JDBC 实现源码分析

JdbcChatMemoryRepository 的解决思路非常优雅:不做对象序列化,而是把 Message 拆成简单字段存入数据库

# 写入:拆解 Message

// JdbcChatMemoryRepository 内部的 AddBatchPreparedStatement
public void setValues(PreparedStatement ps, int i) throws SQLException {
    var message = this.messages.get(i);
    ps.setString(1, this.conversationId);              // 会话 ID
    ps.setString(2, message.getText());                // 消息文本
    ps.setString(3, message.getMessageType().name());  // "USER" / "ASSISTANT" / "SYSTEM" / "TOOL"
    ps.setTimestamp(4, new Timestamp(...));            // 时间戳
}

数据库表结构(以 MySQL 方言为例):

CREATE TABLE SPRING_AI_CHAT_MEMORY (
    conversation_id VARCHAR(255),
    content         TEXT,          -- 消息文本
    type            VARCHAR(20),   -- 消息类型枚举名
    timestamp       TIMESTAMP
);

# 读取:switch 手动还原

// JdbcChatMemoryRepository 内部的 MessageRowMapper
public Message mapRow(ResultSet rs, int i) throws SQLException {
    var content = rs.getString(1);
    var type = MessageType.valueOf(rs.getString(2));

    return switch (type) {
        case USER      -> new UserMessage(content);
        case ASSISTANT -> new AssistantMessage(content);
        case SYSTEM    -> new SystemMessage(content);
        case TOOL      -> new ToolResponseMessage(List.of());
    };
}

用一个 switch 语句根据 type 列的值手动构造正确的子类。完全绕开了序列化框架的类型推断问题。

代价是 AssistantMessagetoolCallsmedia 等复杂字段会丢失,只保留文本。但对于聊天记忆场景来说,历史对话中的工具调用记录通常不需要完整还原,文本内容就够了。

# 方言抽象

Spring AI 还做了一层方言抽象 JdbcChatMemoryRepositoryDialect,针对不同数据库提供不同的 SQL:

public interface JdbcChatMemoryRepositoryDialect {
    String getSelectMessagesSql();     // 查询消息
    String getInsertMessageSql();      // 插入消息
    String getSelectConversationIdsSql(); // 查询会话列表
    String getDeleteMessagesSql();     // 删除消息

    // 自动检测数据库类型
    static JdbcChatMemoryRepositoryDialect from(DataSource dataSource) {
        String url = dataSource.getConnection().getMetaData().getURL().toLowerCase();
        if (url.contains("postgresql")) return new PostgresChatMemoryRepositoryDialect();
        if (url.contains("mysql"))      return new MysqlChatMemoryRepositoryDialect();
        if (url.contains("sqlserver"))  return new SqlServerChatMemoryRepositoryDialect();
        // ...
    }
}

MySQL 方言的 SQL:

public class MysqlChatMemoryRepositoryDialect implements JdbcChatMemoryRepositoryDialect {
    public String getSelectMessagesSql() {
        return "SELECT content, type FROM SPRING_AI_CHAT_MEMORY " +
               "WHERE conversation_id = ? ORDER BY `timestamp`";
    }
    public String getInsertMessageSql() {
        return "INSERT INTO SPRING_AI_CHAT_MEMORY (conversation_id, content, type, `timestamp`) " +
               "VALUES (?, ?, ?, ?)";
    }
}

这种策略模式的设计让同一套代码可以无缝支持 MySQL、PostgreSQL、SQL Server、HSQLDB 等数据库。


# 自己实现数据库持久化

参考 Spring AI 官方的设计,我们自己实现了一个学习版的 DbChatMemoryRepository。核心逻辑和官方一致,但更简洁,方便理解原理。

# 建表

CREATE TABLE IF NOT EXISTS `chat_memory` (
    `id`              BIGINT AUTO_INCREMENT PRIMARY KEY,
    `conversation_id` VARCHAR(100) NOT NULL,
    `content`         TEXT,
    `type`            VARCHAR(20)  NOT NULL,
    `created_at`      TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX `idx_conversation_id` (`conversation_id`),
    INDEX `idx_conversation_time` (`conversation_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

和官方表结构基本一致,多了自增主键 id,索引也做了优化。

# 实现代码

public class DbChatMemoryRepository implements ChatMemoryRepository {

    private final JdbcTemplate jdbcTemplate;

    private static final String SQL_FIND_CONVERSATION_IDS =
            "SELECT DISTINCT conversation_id FROM chat_memory";
    private static final String SQL_FIND_BY_CONVERSATION =
            "SELECT content, type FROM chat_memory WHERE conversation_id = ? ORDER BY created_at";
    private static final String SQL_INSERT_MESSAGE =
            "INSERT INTO chat_memory (conversation_id, content, type, created_at) VALUES (?, ?, ?, ?)";
    private static final String SQL_DELETE_BY_CONVERSATION =
            "DELETE FROM chat_memory WHERE conversation_id = ?";

    public DbChatMemoryRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}

查询 — switch 还原子类(和官方 MessageRowMapper 同样的策略):

@Override
public List<Message> findByConversationId(String conversationId) {
    return jdbcTemplate.query(SQL_FIND_BY_CONVERSATION, (rs, rowNum) -> {
        String content = rs.getString("content");
        MessageType type = MessageType.valueOf(rs.getString("type"));

        // 根据类型手动构造正确的 Message 子类
        return switch (type) {
            case USER      -> new UserMessage(content);
            case ASSISTANT -> new AssistantMessage(content);
            case SYSTEM    -> new SystemMessage(content);
            case TOOL      -> new ToolResponseMessage(List.of());
        };
    }, conversationId);
}

保存 — 先删后插 + 递增时间戳

@Override
public void saveAll(String conversationId, List<Message> messages) {
    deleteByConversationId(conversationId);  // 全量替换策略

    AtomicLong timestampSeq = new AtomicLong(Instant.now().toEpochMilli());
    jdbcTemplate.batchUpdate(SQL_INSERT_MESSAGE, messages, messages.size(), (ps, message) -> {
        ps.setString(1, conversationId);
        ps.setString(2, message.getText());
        ps.setString(3, message.getMessageType().name());
        ps.setTimestamp(4, new Timestamp(timestampSeq.getAndIncrement()));
    });
}

AtomicLong 递增时间戳的设计来自官方实现 — 确保同一批次插入的消息有严格的时间顺序(起始值为当前时间,每条 +1ms),查询时按 created_at 排序就能还原消息的先后顺序。

# 在 InterViewApp 中切换

三种存储方式只需改一行代码:

public InterViewApp(ChatModel dashscopeChatModel, SensitiveWordFilter sensitiveWordFilter) {
    // 方式一:文件持久化(Kryo)
    ChatMemoryRepository repository = new FileBasedChatMemory(fileDir);

    // 方式二:内存(开发调试)
    // ChatMemoryRepository repository = new InMemoryChatMemoryRepository();

    // 方式三:数据库持久化(需要先建表 + 配置数据源)
    // ChatMemoryRepository repository = new DbChatMemoryRepository(jdbcTemplate);

    ChatMemory chatMemory = MessageWindowChatMemory.builder()
            .chatMemoryRepository(repository)
            .maxMessages(10)
            .build();

    // ...构建 ChatClient
}

如果要启用数据库方式,需要三步:执行建表 SQL、配置 application.yml 中的数据源、去掉 MuAiAgentApplication 上的 exclude = {DataSourceAutoConfiguration.class}


# 新增/修改文件

src/main/java/com/muzi/muaiagent/chatmemory/
├── FileBasedChatMemory.java       # 文件持久化(Kryo 序列化)
└── DbChatMemoryRepository.java    # 数据库持久化(学习版,手动拆解 Message)

src/main/resources/db/
└── chat_memory_schema.sql         # 建表 SQL

src/main/java/com/muzi/muaiagent/app/
└── InterViewApp.java              # 更新注释,增加数据库方式说明

# 遇到的问题

# 1. FileBasedChatMemory 编译报错:两个构造器

之前 InterViewApp 有两个构造器,都试图给 final chatClient 赋值,Java 编译器不允许。

解决:合并为一个构造器,用注释区分不同的存储方式切换。

# 2. DataSource 自动配置报错

spring-ai-alibaba-starter-memory 引入了 JDBC 依赖,Spring Boot 自动配置 DataSource 但没配数据库连接。

解决@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})。如果后续要使用数据库方式,去掉这个 exclude 并配置好数据源即可。

# 3. Kryo 反序列化报 ClassCastException

早期版本 Kryo 反序列化出来的 List 被当成了 List<Object> 使用,转型失败。

解决:确保 readObject 时指定了正确的容器类型 ArrayList.class,Kryo 会根据二进制流中记录的类信息自动还原每个元素的正确子类。


# 心得体会

这次深入研究了 Spring AI 的聊天记忆持久化机制,最大的收获是理解了"多态序列化"这个核心问题。

之前理所当然地觉得"对象存下来再取出来"是很简单的事,直到认真看了 Message 的继承体系才发现:接口类型 + 多个实现类 = 序列化框架不知道怎么还原。Kryo 通过在二进制中嵌入类信息自动解决了这个问题,代价是数据不可读、不可移植;而 Spring AI 官方的 JDBC 实现用了一种更朴素也更优雅的方式 — 不做序列化,拆解为简单字段存储,读取时 switch 手动还原。

这种"拆解 + 手动还原"的思路其实很值得借鉴。在遇到复杂对象的持久化问题时,不要一上来就找序列化框架,先想想:我真的需要完整保存整个对象吗?很多时候只存关键字段就够了,反而更可控、更易维护。

MessageWindowChatMemory 的滑动窗口设计也很精巧 — SystemMessage 永远不会被淘汰、全量替换的保存策略配合窗口截断。理解了这些细节,在实际项目中调整 maxMessages 参数时会更有把握。

下一步打算把数据库方式真正跑起来,配合 MySQL 做一次完整的端到端测试,验证重启后对话记忆的恢复效果。


项目地址:mu-ai-agent