Spring AI 聊天记忆存储
多轮对话不是把上一轮回答拼回提示词那么简单。Spring AI 把“哪些消息应再交给模型”与“消息保存在哪里”拆成
ChatMemory和ChatMemoryRepository两层;前者控制上下文窗口,后者负责存取。
官方参考:Chat Memory。本文示例按 Spring AI 2.0 文档说明;项目使用其他版本时,应以对应版本的 API 和 starter 为准。
1. 先区分记忆与历史
| 概念 | 目标 | 是否应保存全部消息 |
|---|---|---|
| Chat Memory | 为当前模型调用提供相关上下文 | 否,通常只保留一个受控窗口 |
| Chat History | 留存完整会话,供审计、检索或分析 | 是,另建完整的业务存储模型 |
ChatMemory 的设计目标是前者。将无限增长的全量聊天记录直接塞回 prompt,会增加 token 成本、延迟和噪声,也会超出模型上下文上限。需要完整留档时,应由业务表或独立历史存储承担,不能把 ChatMemory 当作审计系统。
2. 三个组件如何协作
下面是一个完整的对话端点。chatId 由 URL 提供;实际系统还应在进入方法前完成当前用户或租户的会话归属校验。
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();
}
}一次请求的过程如下:
chatId作为ChatMemory.CONVERSATION_ID标识本次会话。MessageChatMemoryAdvisor用该 ID 从ChatMemory读取已有上下文,并把它作为消息列表加入模型请求。- 模型完成响应后,Advisor 将本轮用户消息和助手消息写回
ChatMemory。 ChatMemory根据自身策略裁剪消息;其底层ChatMemoryRepository决定数据保存在内存、关系数据库或 Redis 等位置。
CONVERSATION_ID 是所有记忆 Advisor 的必填参数,没有默认值;遗漏它会在运行时抛出 IllegalArgumentException。因此不要以登录用户 ID 直接替代会话 ID:一个用户可能同时拥有多个独立对话。推荐使用服务端创建并持久化的 chatId,再在每次请求中传回。
3. MessageWindowChatMemory:受控的短期记忆
MessageWindowChatMemory 按消息数维护滑动窗口,是 Spring AI 自动配置 ChatMemory 时默认采用的记忆策略。示例控制器的内存实现可以写成:
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:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>Spring Boot 自动配置 JdbcChatMemoryRepository 后,可用下面的完整配置将它与窗口策略组合:
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 使用仓库专属配置,让应用启动时执行对应数据库的建表脚本:
spring:
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: alwaysinitialize-schema 的选择如下:
| 值 | 行为 | 推荐环境 |
|---|---|---|
embedded | 仅对嵌入式数据库初始化,也是默认值 | H2 等本地快速验证 |
always | 每次启动都尝试初始化 schema,非嵌入式数据库也适用 | 本地开发、演示环境或首次自助搭建 |
never | 不执行建表脚本 | 使用 Flyway、Liquibase 或 DBA 管理生产 schema |
若需要适配自定义脚本或数据库方言,可指定脚本位置:
spring.ai.chat.memory.repository.jdbc.schema=classpath:/custom/path/schema-mysql.sql开发环境可用 always 省去手工建表步骤;生产环境应将官方脚本纳入 Flyway 或 Liquibase 的版本化迁移,并配置 never。这样建表、变更和回滚都有明确记录,不会由应用启动过程隐式修改生产数据库。
JDBC 仓库按 sequence_id 升序恢复同一会话内的消息,避免仅依赖时间戳精度造成同一时刻消息乱序。
6. 两个容易忽略的限制
6.1 工具调用消息
当前 JdbcChatMemoryRepository 会在保存时过滤带工具调用的 AssistantMessage 和 ToolResponseMessage;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 纳入迁移工具,明确初始化策略和保留期限。
- 涉及工具调用时,按实际仓库能力验证消息类型是否会被过滤。
- 完整历史、审计和检索与模型上下文分开设计。
