Spring AI 1.1.2 多模型切换 + 聊天记忆持久化:基于 MySQL 的 JDBC 实现深度解析
ChatClient 每次请求动态构建(模型可变) → Advisor 拦截请求 → Memory 管理窗口 → Repository 全量替换 → MySQL 按 conversationId 存储排序(记忆不变)模型构建层(易变)和记忆存储层(稳定)通过解耦。ChatClient 随时可以换模型,但只要 conversationId 不变,ChatMemory 就能从 MySQL 中加载出完整
基于 Spring AI 1.1.2 + Spring Boot 3.5 + MySQL,剖析如何在多模型动态切换的场景下,通过 JDBC 持久化实现跨模型的会话记忆保持。
一、问题背景:模型切换后,对话上下文丢了?
大语言模型(LLM)本身是无状态的 —— 每次请求都是独立的,模型不会记得上一轮你说了什么。要实现多轮对话,必须由应用层维护上下文,并在每次请求时将历史消息一并发送给模型。
在实际生产中,我们面临一个更复杂的场景:多模型切换。用户可能先用 DeepSeek 聊了几轮,管理员将默认模型切换到 GPT-4o 后,用户继续对话 —— 如果上下文丢失,AI 就会"失忆",用户体验极差。
Spring AI 默认使用 InMemoryChatMemoryRepository(内存 ConcurrentHashMap),不仅服务重启后上下文全部丢失,而且天然无法跨模型实例共享。
JDBC 持久化 + 动态模型构建的组合方案,可以同时解决:
- 服务重启不丢失上下文
- 多实例部署共享会话
- 切换模型后,历史对话无缝延续
- 可按
conversationId查询/分析历史记录
二、整体架构:五层组件链
在岚迹项目中,一次携带上下文的聊天请求,会自上而下经过以下五层:
- ChatServiceImpl(业务层):接收前端请求,调用
DynamicChatClientFactory动态构建ChatClient,携带conversationId发起流式聊天。 - DynamicChatClientFactory(模型构建层):从数据库读取当前默认模型配置,通过策略模式(
ModelChatStrategyFactory)选择对应的提供商实现,动态构建ChatModel并组装ChatClient。每次请求都重新构建,模型配置变更即时生效。 - MessageChatMemoryAdvisor(Advisor 层):作为拦截器,在请求前(
before())加载历史消息、保存用户消息、将历史注入 Prompt;在响应后(after())保存 AI 回复。 - MessageWindowChatMemory(Memory 层):实现
ChatMemory接口,负责滑动窗口管理。get()从 Repository 加载消息并截断到maxMessages;add()合并新消息、截断窗口、调用saveAll()写回 Repository。 - JdbcChatMemoryRepository(存储层):实现
ChatMemoryRepository接口,通过JdbcTemplate直接操作 MySQL 的SPRING_AI_CHAT_MEMORY表。findByConversationId()按时间排序查询;saveAll()先删后插全量替换;deleteByConversationId()清空会话。
关键设计:模型变,记忆不变
请求 1(DeepSeek) 请求 2(切换到 GPT-4o)
│ │
▼ ▼
DynamicChatClientFactory DynamicChatClientFactory
→ 读取 DB 配置 → DeepSeek → 读取 DB 配置 → GPT-4o
→ 构建 ChatClient → 构建 ChatClient
│ │
▼ ▼
MessageChatMemoryAdvisor MessageChatMemoryAdvisor
│ │
▼ ▼
ChatMemory(单例 Bean) ◄───────────► ChatMemory(同一个 Bean)
│ │
▼ ▼
JdbcChatMemoryRepository JdbcChatMemoryRepository
conversationId = "conv_001" conversationId = "conv_001"
│ │
▼ ▼
MySQL(同一张表、同一个 conversationId)
核心原理:ChatMemory 是 Spring 容器中的单例 Bean,而 ChatClient(包含 ChatModel)是每次请求动态构建的。切换模型只影响 ChatClient 的构建过程,ChatMemory 始终通过同一个 conversationId 从同一张 MySQL 表读写历史。模型换了,记忆没换。
三、多模型动态切换:策略模式实现
3.1 策略接口
public interface ModelChatStrategy {
/** 是否支持当前 provider 标识 */
boolean supports(String provider);
/** 使用指定的模型配置构建底层 ChatModel */
ChatModel buildChatModel(ChatModelConfig config);
}
3.2 三种提供商实现
项目内置了三种策略实现,对应三大 AI 提供商:
DeepseekModelChatStrategy:构建DeepSeekChatModel,支持 DeepSeek 系列模型OpenAiModelChatStrategy:构建OpenAiChatModel,支持所有兼容 OpenAI 协议的提供商ZhipuModelChatStrategy:构建ZhiPuAiChatModel,支持智谱 AI 系列模型
每个策略内部负责:解析 API Key / Endpoint → 构建提供商 API 客户端 → 组装 ChatOptions(temperature、topP、maxTokens 等)→ 返回 ChatModel 实例。
3.3 策略工厂
@Component
@RequiredArgsConstructor
public class ModelChatStrategyFactory {
private final List<ModelChatStrategy> strategies;
public ModelChatStrategy getStrategy(String provider) {
return strategies.stream()
.filter(s -> s.supports(provider))
.findFirst()
.orElseThrow(() -> BizException.validationError(
ResultCode.BAD_REQUEST,
"暂不支持的模型提供商: " + provider
));
}
}
Spring 自动注入所有 ModelChatStrategy 实现,工厂根据 provider 字符串匹配。新增提供商只需实现接口并注册为 Bean,零侵入。
3.4 DynamicChatClientFactory:每次请求动态构建
@Component
@RequiredArgsConstructor
public class DynamicChatClientFactory {
private final AiModelConfigService aiModelConfigService;
private final AiRolePromptService aiRolePromptService;
private final ModelChatStrategyFactory modelChatStrategyFactory;
private final ChatMemory chatMemory;
private final List<ToolCallbackProvider> toolCallbackProviders;
public ChatClient buildDefaultClient() {
// 从数据库读取当前默认模型配置
AiModelConfig config = aiModelConfigService.getDefaultConfig();
// 策略模式:根据 apiProvider 选择构建策略
ModelChatStrategy strategy = modelChatStrategyFactory
.getStrategy(config.getApiProvider());
ChatModel chatModel = strategy.buildChatModel(toModelConfig(config));
// 组装 ChatClient,注入 ChatMemory Advisor
return ChatClient.builder(chatModel)
.defaultSystem(systemPrompt)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build()
)
.defaultToolCallbacks(toolCallbackProviders
.toArray(new ToolCallbackProvider[0]))
.build();
}
}
关键点: 每次调用 buildDefaultClient() 都会重新读取数据库配置。管理员在后台切换默认模型后,下一次请求就会使用新模型,无需重启服务。而 chatMemory 始终是同一个单例实例,历史消息不受影响。
四、自动建表:Spring AI 怎么创建表?
4.1 建表 SQL(schema-mysql.sql)
Spring AI 在 JAR 包中内置了 MySQL 建表脚本,位于:
classpath:org/springframework/ai/chat/memory/repository/jdbc/schema-mysql.sql
内容如下:
CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
`conversation_id` VARCHAR(36) NOT NULL,
`content` TEXT NOT NULL,
`type` ENUM('USER', 'ASSISTANT', 'SYSTEM', 'TOOL') NOT NULL,
`timestamp` TIMESTAMP NOT NULL,
INDEX `SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX` (`conversation_id`, `timestamp`)
);
4.2 字段逐个解析
| 字段 | 类型 | 说明 |
|---|---|---|
conversation_id |
VARCHAR(36) |
会话 ID,用于隔离不同对话。通常由前端生成(如 UUID),同一个 conversationId 下的消息属于同一轮对话。没有主键,同一会话的多条消息共享相同的 conversationId。 |
content |
TEXT |
消息的文本内容。存储的是 Message.getText() 的返回值,即纯文本。对于 TOOL 类型的消息,content 始终为空字符串。 |
type |
ENUM('USER','ASSISTANT','SYSTEM','TOOL') |
消息类型,对应 Spring AI 的 MessageType 枚举。USER= 用户发送的消息;ASSISTANT= AI 模型的回复;SYSTEM= 系统提示词(System Prompt);TOOL= 工具调用响应。MySQL 使用 ENUM 类型,在数据库层面做类型约束。 |
timestamp |
TIMESTAMP |
消息的时间戳,但它的真实作用是排序而非精确记录时间。源码中使用 Instant.now().getEpochSecond() 作为基准,每条消息递增 1 秒,确保同一批消息有严格的先后顺序。 |
索引: 联合索引 (conversation_id, timestamp) 保证按会话 ID 查询时能快速定位并按时间排序。
注意: 表中没有记录"由哪个模型生成"的字段 —— 这恰恰是多模型切换能无缝衔接的原因之一。SPRING_AI_CHAT_MEMORY 只关心消息内容和顺序,不关心消息由谁产生。
4.3 自动建表的触发机制
自动建表由 JdbcChatMemoryRepositoryAutoConfiguration 驱动,核心流程:
- 应用启动
- 检测到 classpath 上有
JdbcChatMemoryRepository.class+DataSource.class+JdbcTemplate.class - 读取配置:
spring.ai.chat.memory.repository.jdbc.initialize-schema - 判断
initialize-schema的值:- **YES(always)**→ 创建
JdbcChatMemoryRepositorySchemaInitializer→ 根据 DataSource 的 JDBC URL 检测数据库类型 → 加载对应的schema-mysql.sql→ 执行CREATE TABLE IF NOT EXISTS ... - **NO(never / embedded)**→ 跳过建表
- **YES(always)**→ 创建
- 创建
JdbcChatMemoryRepositoryBean → 通过JdbcChatMemoryRepositoryDialect.from(dataSource)自动检测方言 → MySQL →MysqlChatMemoryRepositoryDialect
配置项说明:
spring:
ai:
chat:
memory:
repository:
jdbc:
# embedded (默认): 仅嵌入式数据库(H2/HSQL)自动建表
# always: 始终自动建表(开发推荐)
# never: 不建表(生产环境配合 Flyway/Liquibase 使用)
initialize-schema: always
4.4 MysqlChatMemoryRepositoryDialect 源码
Dialect 定义了所有 CRUD 操作的 SQL,表名 SPRING_AI_CHAT_MEMORY 硬编码在此:
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 (?, ?, ?, ?)";
}
public String getSelectConversationIdsSql() {
return "SELECT DISTINCT conversation_id FROM SPRING_AI_CHAT_MEMORY";
}
public String getDeleteMessagesSql() {
return "DELETE FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ?";
}
}
注意: 表名不支持通过配置修改。如需自定义表名,需实现 JdbcChatMemoryRepositoryDialect 接口并手动注册 JdbcChatMemoryRepository Bean。
五、何时入库?—— saveAll 的"全量替换"策略
这是最关键的部分。很多人以为是"追加写入",实际上是先删后插的全量替换。
5.1 JdbcChatMemoryRepository.saveAll() 源码
@Override
public void saveAll(String conversationId, List<Message> messages) {
this.transactionTemplate.execute(status -> {
// 先删除该会话的所有旧消息
deleteByConversationId(conversationId);
// 再批量插入全部消息(包括旧的 + 新的,已经过窗口截断)
this.jdbcTemplate.batchUpdate(
this.dialect.getInsertMessageSql(),
new AddBatchPreparedStatement(conversationId, messages)
);
return null;
});
}
两步操作在同一事务中执行,保证原子性。
5.2 时间戳的排序技巧
AddBatchPreparedStatement 中的时间戳生成逻辑值得注意:
private record AddBatchPreparedStatement(
String conversationId,
List<Message> messages,
AtomicLong sequenceId
) implements BatchPreparedStatementSetter {
private AddBatchPreparedStatement(String conversationId, List<Message> messages) {
// 以当前"秒"为起点
this(conversationId, messages, new AtomicLong(Instant.now().getEpochSecond()));
}
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
var message = this.messages.get(i);
ps.setString(1, this.conversationId);
ps.setString(2, message.getText());
ps.setString(3, message.getMessageType().name());
// 每条消息递增 1 秒,确保严格有序
ps.setTimestamp(4, new Timestamp(this.sequenceId.getAndIncrement() * 1000L));
}
}
设计意图: timestamp 不是精确的消息发送时间,而是一个序列号。每条消息间隔 1 秒,保证 ORDER BY timestamp 能正确还原消息顺序。
5.3 MessageRowMapper:从数据库到 Message 对象
查询时,JdbcChatMemoryRepository 将数据库记录映射回 Spring AI 的 Message 对象:
private static class MessageRowMapper implements RowMapper<Message> {
@Override
public Message mapRow(ResultSet rs, int i) throws SQLException {
var content = rs.getString(1); // content 列
var type = MessageType.valueOf(rs.getString(2)); // type 列
return switch (type) {
case USER -> new UserMessage(content);
case ASSISTANT -> new AssistantMessage(content);
case SYSTEM -> new SystemMessage(content);
case TOOL -> ToolResponseMessage.builder().responses(List.of()).build();
};
}
}
注意 TOOL 类型消息的 content 始终为空 —— 这是 Spring AI 当前版本的已知限制。
六、跨模型上下文携带:一次请求的完整生命周期
以下是用户发送一条消息到收到 AI 回复的完整流程(以首次对话为例),精确到每一次数据库操作:
场景:当前默认模型为 DeepSeek,用户发送 “我叫张三”,conversationId = “conv_001”
阶段一:MessageChatMemoryAdvisor.before() —— 请求发送给 LLM 之前
ChatServiceImpl调用dynamicChatClientFactory.buildDefaultClient(),从数据库读取模型配置(DeepSeek),通过DeepseekModelChatStrategy构建ChatModel,组装ChatClient。- ChatClient 将请求交给 Advisor 链,
MessageChatMemoryAdvisor从参数中取出conversationId = "conv_001"。 - 调用
chatMemory.get("conv_001")加载历史消息:MessageWindowChatMemory.get()→repository.findByConversationId("conv_001")- 【DB 读】
SELECT content, type FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = 'conv_001' ORDER BY timestamp - 返回:
[](首次对话,无历史)
- 调用
chatMemory.add("conv_001", [UserMessage("我叫张三")])保存用户消息:MessageWindowChatMemory.add()→ 先调用repository.findByConversationId()【DB 读】 获取现有消息- 合并:
[] + [UserMessage]=[UserMessage],截断到maxMessages(20) - 调用
repository.saveAll("conv_001", [UserMessage]) - 【DB 事务】
DELETE WHERE conversation_id = 'conv_001'→INSERT ("conv_001", "我叫张三", "USER", timestamp)
- 将历史消息注入到发给 LLM 的 Prompt 中,最终消息列表为:
[SystemMessage, UserMessage("我叫张三")]
阶段二:请求发送到 DeepSeek,获取回复
- DeepSeek 返回回复:“你好张三!很高兴认识你。”
阶段三:MessageChatMemoryAdvisor.after() —— 收到 LLM 回复之后
- 调用
chatMemory.add("conv_001", [AssistantMessage("你好张三!...")])保存 AI 回复:MessageWindowChatMemory.add()→ 先调用repository.findByConversationId()【DB 读】 返回[UserMessage]- 合并:
[UserMessage] + [AssistantMessage],截断到maxMessages(20) - 调用
repository.saveAll("conv_001", [UserMessage, AssistantMessage]) - 【DB 事务】
DELETE WHERE conversation_id = 'conv_001'→batch INSERT两条记录:("conv_001", "我叫张三", "USER", t1)和("conv_001", "你好张三!...", "ASSISTANT", t2)
6.1 一次对话轮次的数据库操作统计
| 阶段 | 操作 | 次数 |
|---|---|---|
| before() - 加载历史 | SELECT | 1 |
| before() - 保存用户消息 | SELECT + DELETE + batch INSERT | 1 + 1 + 1 |
| after() - 保存 AI 回复 | SELECT + DELETE + batch INSERT | 1 + 1 + 1 |
| 合计 | 3 次读 + 2 次删 + 2 次批量插入 |
这就是"全量替换"策略的代价:每轮对话都会读 3 次数据库、做 2 次事务(各包含 1 次 DELETE + 1 次 batch INSERT)。对话越长,每次 INSERT 的行数越多(受 maxMessages 限制)。
6.2 模型切换后的第二轮对话:上下文怎么"带上"的?
场景:管理员将默认模型切换为 GPT-4o,用户接着问 “我叫什么?”,conversationId 仍为 “conv_001”
ChatServiceImpl调用dynamicChatClientFactory.buildDefaultClient(),从数据库读取模型配置 —— 此时已是 GPT-4o,通过OpenAiModelChatStrategy构建ChatModel,组装全新的ChatClient。MessageChatMemoryAdvisor.before()调用chatMemory.get("conv_001"),从同一张 MySQL 表加载出两条历史消息:[UserMessage("我叫张三"), AssistantMessage("你好张三!很高兴认识你。")]- 将历史消息 + 当前用户消息拼接后注入 Prompt,发给 GPT-4o 的实际消息列表为:
[0] SystemMessage— “你是岚迹的客服助手…”(系统提示词)[1] UserMessage— “我叫张三”(← 历史,原由 DeepSeek 处理)[2] AssistantMessage— “你好张三!很高兴认识你。”(← 历史,原由 DeepSeek 生成)[3] UserMessage— “我叫什么?”(← 当前)
- GPT-4o 收到完整上下文,回复:“你叫张三。”
关键洞察:
- LLM 并没有"记住"任何东西。是 Spring AI 在每次请求前从数据库加载历史消息,拼接成完整的消息列表发给模型,让模型"看起来"有记忆。
- 模型切换对记忆层完全透明。 因为
SPRING_AI_CHAT_MEMORY表只存储conversationId、content、type、timestamp,不记录模型来源。任何模型读取到的历史消息都是同样的文本序列。 - 唯一的"连接点"就是
conversationId—— 前端不变,记忆就不断。
七、实战集成:Spring Boot 项目配置
7.1 添加依赖
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
该 Starter 会自动引入 JdbcChatMemoryRepository 和 JdbcChatMemoryRepositoryAutoConfiguration。
7.2 配置 application.yml
spring:
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always # 自动建表
7.3 配置 ChatMemory Bean
@Configuration
public class LanjiiAiAutoConfiguration {
/**
* 聊天记忆(基于 JDBC 持久化,滑动窗口保留最近 20 条消息)
*
* @param chatMemoryRepository 由 Spring AI JDBC Starter 自动装配
*/
@Bean
public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(20)
.build();
}
}
Starter 的自动配置会创建 JdbcChatMemoryRepository Bean(@ConditionalOnMissingBean),自动检测 MySQL 数据源并选择 MysqlChatMemoryRepositoryDialect。你只需要声明 ChatMemory Bean 来控制窗口大小。
7.4 业务层调用
@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {
private final DynamicChatClientFactory dynamicChatClientFactory;
@Override
public Flux<String> chatStream(String message, String conversationId) {
// 每次请求动态构建 ChatClient(模型可能已切换)
ChatClient chatClient = dynamicChatClientFactory.buildDefaultClient();
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.stream()
.content();
}
}
前端为每个对话生成唯一的 conversationId(如 conv_ + 时间戳 + 随机串),后端通过 Advisor 参数传递,Spring AI 自动完成历史加载和持久化。模型切换在 Factory 层透明处理,业务层无需感知。
八、总结
Spring AI 的 JDBC 聊天记忆持久化 + 多模型动态切换,核心设计可以概括为:
ChatClient 每次请求动态构建(模型可变) → Advisor 拦截请求 → Memory 管理窗口 → Repository 全量替换 → MySQL 按 conversationId 存储排序(记忆不变)
关键在于一个架构分离:模型构建层(易变) 和 记忆存储层(稳定) 通过 conversationId 解耦。ChatClient 随时可以换模型,但只要 conversationId 不变,ChatMemory 就能从 MySQL 中加载出完整的历史上下文,让新模型无缝接续对话。
这就是"模型变,记忆不变"的全部秘密。
九、源码与在线体验
完整源码:https://gitee.com/leven2018/lanjii/tree/master
欢迎 Star ⭐ 和 Fork,项目包含本文涉及的所有代码(MCP 集成、多模型动态切换、RAG 知识库等)。
更多推荐



所有评论(0)