基于 Spring AI 1.1.2 + Spring Boot 3.5 + MySQL,剖析如何在多模型动态切换的场景下,通过 JDBC 持久化实现跨模型的会话记忆保持。

一、问题背景:模型切换后,对话上下文丢了?

大语言模型(LLM)本身是无状态的 —— 每次请求都是独立的,模型不会记得上一轮你说了什么。要实现多轮对话,必须由应用层维护上下文,并在每次请求时将历史消息一并发送给模型。

在实际生产中,我们面临一个更复杂的场景:多模型切换。用户可能先用 DeepSeek 聊了几轮,管理员将默认模型切换到 GPT-4o 后,用户继续对话 —— 如果上下文丢失,AI 就会"失忆",用户体验极差。

Spring AI 默认使用 InMemoryChatMemoryRepository(内存 ConcurrentHashMap),不仅服务重启后上下文全部丢失,而且天然无法跨模型实例共享。

JDBC 持久化 + 动态模型构建的组合方案,可以同时解决:

  • 服务重启不丢失上下文
  • 多实例部署共享会话
  • 切换模型后,历史对话无缝延续
  • 可按 conversationId 查询/分析历史记录

二、整体架构:五层组件链

在岚迹项目中,一次携带上下文的聊天请求,会自上而下经过以下五层:

  1. ChatServiceImpl(业务层):接收前端请求,调用 DynamicChatClientFactory 动态构建 ChatClient,携带 conversationId 发起流式聊天。
  2. DynamicChatClientFactory(模型构建层):从数据库读取当前默认模型配置,通过策略模式(ModelChatStrategyFactory)选择对应的提供商实现,动态构建 ChatModel 并组装 ChatClient每次请求都重新构建,模型配置变更即时生效。
  3. MessageChatMemoryAdvisor(Advisor 层):作为拦截器,在请求前(before())加载历史消息、保存用户消息、将历史注入 Prompt;在响应后(after())保存 AI 回复。
  4. MessageWindowChatMemory(Memory 层):实现 ChatMemory 接口,负责滑动窗口管理。get() 从 Repository 加载消息并截断到 maxMessagesadd() 合并新消息、截断窗口、调用 saveAll() 写回 Repository。
  5. 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 驱动,核心流程:

  1. 应用启动
  2. 检测到 classpath 上有 JdbcChatMemoryRepository.class + DataSource.class + JdbcTemplate.class
  3. 读取配置:spring.ai.chat.memory.repository.jdbc.initialize-schema
  4. 判断 initialize-schema 的值:
    • **YES(always)**→ 创建 JdbcChatMemoryRepositorySchemaInitializer → 根据 DataSource 的 JDBC URL 检测数据库类型 → 加载对应的 schema-mysql.sql → 执行 CREATE TABLE IF NOT EXISTS ...
    • **NO(never / embedded)**→ 跳过建表
  5. 创建 JdbcChatMemoryRepository Bean → 通过 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 之前

  1. ChatServiceImpl 调用 dynamicChatClientFactory.buildDefaultClient(),从数据库读取模型配置(DeepSeek),通过 DeepseekModelChatStrategy 构建 ChatModel,组装 ChatClient
  2. ChatClient 将请求交给 Advisor 链,MessageChatMemoryAdvisor 从参数中取出 conversationId = "conv_001"
  3. 调用 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
    • 返回:[](首次对话,无历史)
  4. 调用 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)
  5. 将历史消息注入到发给 LLM 的 Prompt 中,最终消息列表为:[SystemMessage, UserMessage("我叫张三")]

阶段二:请求发送到 DeepSeek,获取回复

  1. DeepSeek 返回回复:“你好张三!很高兴认识你。”

阶段三:MessageChatMemoryAdvisor.after() —— 收到 LLM 回复之后

  1. 调用 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”

  1. ChatServiceImpl 调用 dynamicChatClientFactory.buildDefaultClient(),从数据库读取模型配置 —— 此时已是 GPT-4o,通过 OpenAiModelChatStrategy 构建 ChatModel,组装全新的 ChatClient
  2. MessageChatMemoryAdvisor.before() 调用 chatMemory.get("conv_001"),从同一张 MySQL 表加载出两条历史消息:[UserMessage("我叫张三"), AssistantMessage("你好张三!很高兴认识你。")]
  3. 将历史消息 + 当前用户消息拼接后注入 Prompt,发给 GPT-4o 的实际消息列表为:
    • [0] SystemMessage — “你是岚迹的客服助手…”(系统提示词)
    • [1] UserMessage — “我叫张三”(← 历史,原由 DeepSeek 处理)
    • [2] AssistantMessage — “你好张三!很高兴认识你。”(← 历史,原由 DeepSeek 生成)
    • [3] UserMessage — “我叫什么?”(← 当前)
  4. GPT-4o 收到完整上下文,回复:“你叫张三。”

关键洞察:

  • LLM 并没有"记住"任何东西。是 Spring AI 在每次请求前从数据库加载历史消息,拼接成完整的消息列表发给模型,让模型"看起来"有记忆。
  • 模型切换对记忆层完全透明。 因为 SPRING_AI_CHAT_MEMORY 表只存储 conversationIdcontenttypetimestamp,不记录模型来源。任何模型读取到的历史消息都是同样的文本序列。
  • 唯一的"连接点"就是 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 会自动引入 JdbcChatMemoryRepositoryJdbcChatMemoryRepositoryAutoConfiguration

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 知识库等)。

在线体验http://106.54.167.194/admin/index

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐