基于 Spring AI + Spring Boot + MySQL,剖析如何在多模型动态切换的场景下,通过 JDBC 持久化实现跨模型的会话记忆保持。
一、问题背景:模型切换后,对话上下文丢了?
大语言模型(LLM)本身是无状态的 —— 每次请求都是独立的,模型不会记得上一轮你说了什么。要实现多轮对话,必须由应用层维护上下文,并在每次请求时将历史消息一并发送给模型。
基于 Spring AI 1.1.2 与 Spring Boot,深入解析如何在多模型动态切换场景下通过 JDBC 持久化实现跨模型会话记忆。针对 LLM 无状态特性导致上下文丢失的问题,采用 JdbcChatMemoryRepository 结合 MySQL 存储历史消息。核心架构包括动态 ChatClient 构建工厂与单例 ChatMemory Bean,利用 conversationId 解耦模型与记忆。实现策略模式支持多提供商切换,数据库采用全量替换策略保证原子性。最终达成服务重启不丢数据、多实例共享会话及模型无缝切换后对话连续性的目标。
基于 Spring AI + Spring Boot + MySQL,剖析如何在多模型动态切换的场景下,通过 JDBC 持久化实现跨模型的会话记忆保持。
大语言模型(LLM)本身是无状态的 —— 每次请求都是独立的,模型不会记得上一轮你说了什么。要实现多轮对话,必须由应用层维护上下文,并在每次请求时将历史消息一并发送给模型。
在实际生产中,我们面临一个更复杂的场景:多模型切换。用户可能先用 DeepSeek 聊了几轮,管理员将默认模型切换到 GPT-4o 后,用户继续对话 —— 如果上下文丢失,AI 就会"失忆",用户体验极差。
Spring AI 默认使用 InMemoryChatMemoryRepository(内存 ConcurrentHashMap),不仅服务重启后上下文全部丢失,而且天然无法跨模型实例共享。
JDBC 持久化 + 动态模型构建的组合方案,可以同时解决:
conversationId 查询/分析历史记录在一次携带上下文的聊天请求中,会自上而下经过以下五层:
ChatClient,携带 conversationId 发起流式聊天。ChatModel 并组装 ChatClient。每次请求都重新构建,模型配置变更即时生效。before())加载历史消息、保存用户消息、将历史注入 Prompt;在响应后(after())保存 AI 回复。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,而 ChatClient(包含 ChatModel)是每次请求动态构建的。切换模型只影响 ChatClient 的构建过程,ChatMemory 始终通过同一个 conversationId 从同一张 MySQL 表读写历史。模型换了,记忆没换。
public interface ModelChatStrategy {
/** 是否支持当前 provider 标识 */
boolean supports(String provider);
/** 使用指定的模型配置构建底层 ChatModel */
ChatModel buildChatModel(ChatModelConfig config);
}
项目内置了三种策略实现,对应三大 AI 提供商:
DeepseekModelChatStrategy:构建 DeepSeekChatModel,支持 DeepSeek 系列模型OpenAiModelChatStrategy:构建 OpenAiChatModel,支持所有兼容 OpenAI 协议的提供商ZhipuModelChatStrategy:构建 ZhiPuAiChatModel,支持智谱 AI 系列模型每个策略内部负责:解析 API Key / Endpoint → 构建提供商 API 客户端 → 组装 ChatOptions(temperature、topP、maxTokens 等)→ 返回 ChatModel 实例。
@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,零侵入。
@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 在 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`)
);
| 字段 | 类型 | 说明 |
|---|---|---|
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 只关心消息内容和顺序,不关心消息由谁产生。
自动建表由 JdbcChatMemoryRepositoryAutoConfiguration 驱动,核心流程:
JdbcChatMemoryRepository.class + DataSource.class + JdbcTemplate.classspring.ai.chat.memory.repository.jdbc.initialize-schemainitialize-schema 的值:
JdbcChatMemoryRepositorySchemaInitializer → 根据 DataSource 的 JDBC URL 检测数据库类型 → 加载对应的 schema-mysql.sql → 执行 CREATE TABLE IF NOT EXISTS ...JdbcChatMemoryRepository Bean → 通过 JdbcChatMemoryRepositoryDialect.from(dataSource) 自动检测方言 → MySQL → MysqlChatMemoryRepositoryDialect配置项说明:
spring:
ai:
chat:
memory:
repository:
jdbc:
# embedded (默认): 仅嵌入式数据库 (H2/HSQL) 自动建表
# always: 始终自动建表(开发推荐)
# never: 不建表(生产环境配合 Flyway/Liquibase 使用)
initialize-schema: always
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。
这是最关键的部分。很多人以为是"追加写入",实际上是先删后插的全量替换。
@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;
});
}
两步操作在同一事务中执行,保证原子性。
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 能正确还原消息顺序。
查询时,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 之前
ChatClient,从数据库读取模型配置(DeepSeek),通过 DeepseekModelChatStrategy 构建 ChatModel,组装 ChatClient。MessageChatMemoryAdvisor 从参数中取出 conversationId = "conv_001"。chatMemory.get("conv_001") 加载历史消息:
MessageWindowChatMemory.get() → repository.findByConversationId("conv_001")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])DELETE WHERE conversation_id = 'conv_001' → INSERT ("conv_001", "我叫张三", "USER", timestamp)[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])DELETE WHERE conversation_id = 'conv_001' → batch INSERT 两条记录:("conv_001", "我叫张三", "USER", t1) 和 ("conv_001", "你好张三!...", "ASSISTANT", t2)| 阶段 | 操作 | 次数 |
|---|---|---|
| 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 限制)。
场景:管理员将默认模型切换为 GPT-4o,用户接着问'我叫什么?',conversationId 仍为'conv_001'
ChatClient,从数据库读取模型配置 —— 此时已是 GPT-4o,通过 OpenAiModelChatStrategy 构建 ChatModel,组装全新的 ChatClient。MessageChatMemoryAdvisor.before() 调用 chatMemory.get("conv_001"),从同一张 MySQL 表加载出两条历史消息:[UserMessage("我叫张三"), AssistantMessage("你好张三!很高兴认识你。")][0] SystemMessage — '你是岚迹的客服助手…'(系统提示词)[1] UserMessage — '我叫张三'(← 历史,原由 DeepSeek 处理)[2] AssistantMessage — '你好张三!很高兴认识你。'(← 历史,原由 DeepSeek 生成)[3] UserMessage — '我叫什么?'(← 当前)关键洞察:
SPRING_AI_CHAT_MEMORY 表只存储 conversationId、content、type、timestamp,不记录模型来源。任何模型读取到的历史消息都是同样的文本序列。conversationId —— 前端不变,记忆就不断。<!-- pom.xml -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
该 Starter 会自动引入 JdbcChatMemoryRepository 和 JdbcChatMemoryRepositoryAutoConfiguration。
spring:
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always # 自动建表
@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 来控制窗口大小。
@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 中加载出完整的历史上下文,让新模型无缝接续对话。
这就是"模型变,记忆不变"的全部秘密。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online