跳到主要内容
Spring AI 多模型切换与聊天记忆持久化:MySQL JDBC 实现解析 | 极客日志
Java AI java
Spring AI 多模型切换与聊天记忆持久化:MySQL JDBC 实现解析 基于 Spring AI 1.1.2 与 Spring Boot,深入解析如何在多模型动态切换场景下通过 JDBC 持久化实现跨模型会话记忆。针对 LLM 无状态特性导致上下文丢失的问题,采用 JdbcChatMemoryRepository 结合 MySQL 存储历史消息。核心架构包括动态 ChatClient 构建工厂与单例 ChatMemory Bean,利用 conversationId 解耦模型与记忆。实现策略模式支持多提供商切换,数据库采用全量替换策略保证原子性。最终达成服务重启不丢数据、多实例共享会话及模型无缝切换后对话连续性的目标。
人间失格 发布于 2026/3/30 更新于 2026/5/23 27 浏览
基于 Spring AI + Spring Boot + MySQL,剖析如何在多模型动态切换的场景下,通过 JDBC 持久化实现跨模型的会话记忆保持。
一、问题背景:模型切换后,对话上下文丢了?
大语言模型(LLM)本身是无状态的 —— 每次请求都是独立的,模型不会记得上一轮你说了什么。要实现多轮对话,必须由应用层维护上下文,并在每次请求时将历史消息一并发送给模型。
在实际生产中,我们面临一个更复杂的场景:多模型切换 。用户可能先用 DeepSeek 聊了几轮,管理员将默认模型切换到 GPT-4o 后,用户继续对话 —— 如果上下文丢失,AI 就会"失忆",用户体验极差。
Spring AI 默认使用 InMemoryChatMemoryRepository(内存 ConcurrentHashMap),不仅服务重启后上下文全部丢失,而且天然无法跨模型实例共享。
JDBC 持久化 + 动态模型构建 的组合方案,可以同时解决:
服务重启不丢失上下文
多实例部署共享会话
切换模型后,历史对话无缝延续
可按 conversationId 查询/分析历史记录
二、整体架构:五层组件链
在一次携带上下文的聊天请求中,会自上而下经过以下五层:
业务层 :接收前端请求,调用工厂类动态构建 ChatClient,携带 conversationId 发起流式聊天。
模型构建层 :从数据库读取当前默认模型配置,通过策略模式选择对应的提供商实现,动态构建 ChatModel 并组装 ChatClient。每次请求都重新构建,模型配置变更即时生效。
Advisor 层 :作为拦截器,在请求前(before())加载历史消息、保存用户消息、将历史注入 Prompt;在响应后(after())保存 AI 回复。
Memory 层 :实现 ChatMemory 接口,负责滑动窗口管理。get() 从 Repository 加载消息并截断到 maxMessages;add() 合并新消息、截断窗口、调用 saveAll() 写回 Repository。
存储层 :实现 ChatMemoryRepository 接口,通过 JdbcTemplate 直接操作 MySQL 的 SPRING_AI_CHAT_MEMORY 表。findByConversationId() 按时间排序查询;saveAll() 先删后插全量替换;deleteByConversationId() 清空会话。
关键设计:模型变,记忆不变
请求 1(DeepSeek) 请求 2(切换到 GPT-4o) │ │ ▼ ▼ 模型构建层 模型构建层 → 读取 DB 配置 → DeepSeek → 读取 DB 配置 → GPT-4o → 构建 ChatClient → 构建 ChatClient │ │ ▼ ▼ Advisor 层 Advisor 层 │ │ ▼ ▼ ChatMemory(单例 Bean) ◄───────────► ChatMemory(同一个 Bean) │ │ ▼ ▼ 存储层 存储层 conversationId = "conv_001" conversationId = "conv_001" │ │ ▼ ▼ MySQL(同一张表、同一个 conversationId)
核心原理 :ChatMemory 是 Spring 容器中的单例 Bean ,而 (包含 )是 。切换模型只影响 的构建过程, 始终通过同一个 从同一张 MySQL 表读写历史。模型换了,记忆没换。
ChatClient
ChatModel
每次请求动态构建的
ChatClient
ChatMemory
conversationId
三、多模型动态切换:策略模式实现
3.1 策略接口 public interface ModelChatStrategy {
boolean supports (String provider) ;
ChatModel buildChatModel (ChatModelConfig config) ;
}
3.2 三种提供商实现
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(() -> new BizException ("暂不支持的模型提供商:" + 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();
ModelChatStrategy strategy = modelChatStrategyFactory.getStrategy(config.getApiProvider());
ChatModel chatModel = strategy.buildChatModel(toModelConfig(config));
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_idVARCHAR(36)会话 ID,用于隔离不同对话。通常由前端生成(如 UUID),同一个 conversationId 下的消息属于同一轮对话。没有主键,同一会话的多条消息共享相同的 conversationId。 contentTEXT消息的文本内容。存储的是 Message.getText() 的返回值,即纯文本。对于 TOOL 类型的消息,content 始终为空字符串。 typeENUM('USER','ASSISTANT','SYSTEM','TOOL')消息类型,对应 Spring AI 的 MessageType 枚举。USER = 用户发送的消息;ASSISTANT = AI 模型的回复;SYSTEM = 系统提示词(System Prompt);TOOL = 工具调用响应。MySQL 使用 ENUM 类型,在数据库层面做类型约束。 timestampTIMESTAMP消息的时间戳,但它的真实作用是排序而非精确记录时间 。源码中使用 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) → 跳过建表
创建 JdbcChatMemoryRepository Bean → 通过 JdbcChatMemoryRepositoryDialect.from(dataSource) 自动检测方言 → MySQL → MysqlChatMemoryRepositoryDialect
spring:
ai:
chat:
memory:
repository:
jdbc:
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());
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 );
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 -> ToolResponseMessage.builder().responses(List.of()).build();
};
}
}
注意 TOOL 类型消息的 content 始终为空 —— 这是 Spring AI 当前版本的已知限制。
六、跨模型上下文携带:一次请求的完整生命周期 以下是用户发送一条消息到收到 AI 回复的完整流程(以首次对话为例),精确到每一次数据库操作:
场景:当前默认模型为 DeepSeek,用户发送'我叫张三',conversationId = 'conv_001'
阶段一:MessageChatMemoryAdvisor.before() —— 请求发送给 LLM 之前
业务层调用工厂类构建 ChatClient,从数据库读取模型配置(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 返回回复:'你好张三!很高兴认识你。'
阶段三: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'
业务层调用工厂类构建 ChatClient,从数据库读取模型配置 —— 此时已是 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 添加依赖
<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 {
@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 = 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 中加载出完整的历史上下文,让新模型无缝接续对话。
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
RSA密钥对生成器 生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
Mermaid 预览与可视化编辑 基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online