Skip to content

Spring AI 聊天记忆存储

多轮对话不是把上一轮回答拼回提示词那么简单。Spring AI 把“哪些消息应再交给模型”与“消息保存在哪里”拆成 ChatMemoryChatMemoryRepository 两层;前者控制上下文窗口,后者负责存取。

官方参考:Chat Memory。本文示例按 Spring AI 2.0 文档说明;项目使用其他版本时,应以对应版本的 API 和 starter 为准。

1. 先区分记忆与历史

概念目标是否应保存全部消息
Chat Memory为当前模型调用提供相关上下文否,通常只保留一个受控窗口
Chat History留存完整会话,供审计、检索或分析是,另建完整的业务存储模型

ChatMemory 的设计目标是前者。将无限增长的全量聊天记录直接塞回 prompt,会增加 token 成本、延迟和噪声,也会超出模型上下文上限。需要完整留档时,应由业务表或独立历史存储承担,不能把 ChatMemory 当作审计系统。

2. 三个组件如何协作

下面是一个完整的对话端点。chatId 由 URL 提供;实际系统还应在进入方法前完成当前用户或租户的会话归属校验。

java
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/chats")
class ChatEndpoint {
    private final ChatClient chatClient;
    private final MessageChatMemoryAdvisor memoryAdvisor;

    ChatEndpoint(ChatClient.Builder builder, ChatMemory chatMemory) {
        this.chatClient = builder.build();
        this.memoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory).build();
    }

    @PostMapping("/{chatId}/messages")
    String sendMessage(
            @PathVariable String chatId,
            @RequestBody String userInput) {
        return chatClient.prompt()
            .user(userInput)
            .advisors(advisor -> advisor.param(
                ChatMemory.CONVERSATION_ID, chatId))
            .advisors(memoryAdvisor)
            .call()
            .content();
    }
}

一次请求的过程如下:

  1. chatId 作为 ChatMemory.CONVERSATION_ID 标识本次会话。
  2. MessageChatMemoryAdvisor 用该 ID 从 ChatMemory 读取已有上下文,并把它作为消息列表加入模型请求。
  3. 模型完成响应后,Advisor 将本轮用户消息和助手消息写回 ChatMemory
  4. ChatMemory 根据自身策略裁剪消息;其底层 ChatMemoryRepository 决定数据保存在内存、关系数据库或 Redis 等位置。

CONVERSATION_ID 是所有记忆 Advisor 的必填参数,没有默认值;遗漏它会在运行时抛出 IllegalArgumentException。因此不要以登录用户 ID 直接替代会话 ID:一个用户可能同时拥有多个独立对话。推荐使用服务端创建并持久化的 chatId,再在每次请求中传回。

3. MessageWindowChatMemory:受控的短期记忆

MessageWindowChatMemory 按消息数维护滑动窗口,是 Spring AI 自动配置 ChatMemory 时默认采用的记忆策略。示例控制器的内存实现可以写成:

java
ChatMemory memory = MessageWindowChatMemory.builder()
    .maxMessages(6)
    .build();

当消息数超过 maxMessages,旧消息会被驱逐;SystemMessage 会被保留。官方实现还会按完整轮次裁剪:一轮从 UserMessage 开始,到下一条用户消息之前的助手回复、工具调用和工具结果为止。窗口不会从一轮的中间截断,因此实际保留消息数可能小于上限。

这里的 6消息条数,不是 6 轮对话。例如“用户提问 + 助手回答”通常已占两条;工具调用还会增加消息。生产环境应根据模型上下文、平均消息长度和成本压测后设定,而不是只为“记得更久”无限增大。

4. 内存版与持久化版的边界

默认的 InMemoryChatMemoryRepository 只适合本地开发或单实例短生命周期场景:应用重启即丢失,多实例之间也不共享数据。

方案适合场景需要注意
In-memory本地调试、演示重启丢失,不能跨实例
JDBC已有关系数据库、需可靠持久化需要建表与迁移治理
Redis低延迟、临时会话、需要 TTL需使用 Redis Stack,并设计过期策略
Cassandra / MongoDB / Neo4j已使用相应基础设施按已有运维能力选择,不为记忆单独引入重型组件

窗口策略与存储方案可以组合。例如 JDBC 只负责持久化消息,MessageWindowChatMemory 仍负责限制送给模型的窗口大小。

5. 用 JDBC 持久化窗口记忆

先引入官方 starter:

xml
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>

Spring Boot 自动配置 JdbcChatMemoryRepository 后,可用下面的完整配置将它与窗口策略组合:

java
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class ChatMemoryConfiguration {
    @Bean
    ChatMemory chatMemory(JdbcChatMemoryRepository repository) {
        return MessageWindowChatMemory.builder()
            .chatMemoryRepository(repository)
            .maxMessages(20)
            .build();
    }
}

将这个 ChatMemory Bean 传给 MessageChatMemoryAdvisor 后,chatId 相同的请求在应用重启后仍可取回窗口内容。

5.1 JDBC 表的自动初始化

JdbcChatMemoryRepository 需要聊天记忆表。Spring AI 2.x 对非嵌入式数据库不会默认建表;MySQL、PostgreSQL 等部署必须显式选择初始化策略。下列 YAML 使用仓库专属配置,让应用启动时执行对应数据库的建表脚本:

yaml
spring:
  ai:
    chat:
      memory:
        repository:
          jdbc:
            initialize-schema: always

initialize-schema 的选择如下:

行为推荐环境
embedded仅对嵌入式数据库初始化,也是默认值H2 等本地快速验证
always每次启动都尝试初始化 schema,非嵌入式数据库也适用本地开发、演示环境或首次自助搭建
never不执行建表脚本使用 Flyway、Liquibase 或 DBA 管理生产 schema

若需要适配自定义脚本或数据库方言,可指定脚本位置:

properties
spring.ai.chat.memory.repository.jdbc.schema=classpath:/custom/path/schema-mysql.sql

开发环境可用 always 省去手工建表步骤;生产环境应将官方脚本纳入 Flyway 或 Liquibase 的版本化迁移,并配置 never。这样建表、变更和回滚都有明确记录,不会由应用启动过程隐式修改生产数据库。

JDBC 仓库按 sequence_id 升序恢复同一会话内的消息,避免仅依赖时间戳精度造成同一时刻消息乱序。

6. 两个容易忽略的限制

6.1 工具调用消息

当前 JdbcChatMemoryRepository 会在保存时过滤带工具调用的 AssistantMessageToolResponseMessage;Cassandra、MongoDB 也有该限制。工具调用是核心能力时,不能只验证普通问答,要验证重启后的工具会话能否完整续接。官方建议此类需求评估 Spring AI Session 的 JDBC session store。

同时,ChatClient 的记忆 Advisor 目前也不会自动保存工具调用中与模型交换的中间消息。若业务需要完整还原工具链路,应采用用户控制的工具执行方案或独立审计记录。

6.2 会话 ID 的生命周期

conversationId 在接口实例中初始化为 UUID.randomUUID().toString() 只适合作为单个演示会话。Spring MVC 的 Controller 通常是单例,这会让所有未显式传入 chatId 的请求可能共用该 ID;应用重启又会生成新值。对外接口应由客户端或服务端会话表持有稳定的 chatId,并校验它归属当前用户或租户,防止串读他人上下文。

7. 选型与落地检查清单

  • chatId 隔离会话,不使用全局固定 ID。
  • MessageWindowChatMemory 或其他明确策略限制上下文,不把历史全量喂给模型。
  • 本地演示可用内存仓库;生产多实例至少使用共享持久化仓库。
  • 数据库 schema 纳入迁移工具,明确初始化策略和保留期限。
  • 涉及工具调用时,按实际仓库能力验证消息类型是否会被过滤。
  • 完整历史、审计和检索与模型上下文分开设计。

参考资料