☰
Spring AI 1.1.2 多模型切换 + 聊天记忆持久化:基于 MySQL 的 JDBC 实现深度解析
2026/10/2 12:11:25 网站建设 项目流程

1. 模型切换后对话失忆:Spring AI 多模型切换场景下的上下文丢失问题

大语言模型本身是无状态的,每次请求都是独立的,模型不会记得上一轮你说了什么。要实现多轮对话,必须由应用层维护上下文,并在每次请求时把历史消息一并发送给模型。这个结论听起来简单,但真正落到生产环境,尤其是多模型切换的场景里,坑就一个接一个冒出来了。

我遇到过的典型场景是这样的:用户先用 DeepSeek 聊了几轮,管理员在后台把默认模型切到 GPT-4o,用户继续追问“我叫什么”,结果 AI 一脸茫然。原因不复杂——Spring AI 默认使用InMemoryChatMemoryRepository,底层就是一个ConcurrentHashMap,服务一重启上下文全丢,多实例部署时各存各的,模型一换如果记忆层没解耦,历史对话直接断档。

Spring AI 1.1.2 给出的解法是把聊天记忆持久化到数据库,通过JdbcChatMemoryRepository把消息落到 MySQL 的SPRING_AI_CHAT_MEMORY表里。配合动态构建ChatClient的策略模式,就能做到“模型变,记忆不变”。这篇文章我会把建表 SQL、ChatMemoryRepository配置、多模型路由示例、重启后会话恢复验证、跨模型上下文延续验证全部拆开讲,并且把模型 endpoint 与鉴权统一改到 TaoToken,简化多模型接入。

适合谁看?正在用 Spring AI 做多轮对话、需要多模型动态切换、又不想自己造记忆轮子的后端同学。你需要有 Spring Boot 3.x 和 MySQL 的基础,剩下的跟着做就行。

先说清楚核心矛盾在哪。LLM 无状态,所以“记忆”这件事本质上是应用层在每次请求前把历史消息拼进 Prompt。多模型切换时,如果记忆存在内存里,切换模型相当于换了一个ChatModel实例,但记忆 Bean 如果是单例,理论上还能共享——问题出在重启和多实例。所以持久化不是可选项,是必选项。而 JDBC 方案的好处是:表结构简单、事务保证原子性、按conversationId查询天然支持多实例共享。

再补一个容易被忽略的点:SPRING_AI_CHAT_MEMORY表里没有“模型来源”字段。这不是设计缺陷,恰恰是多模型切换能无缝衔接的关键。表只关心conversation_id、content、type、timestamp,任何模型读到的历史都是同样的文本序列,唯一的连接点就是conversationId。前端不变,记忆就不断。

2. TaoToken 前置:统一多模型 endpoint 与鉴权,简化 Spring AI 接入

在讲配置之前,先把模型接入这一层理顺。多模型切换最烦的是什么?每个提供商一套 API Key、一套 endpoint、一套 SDK 初始化逻辑。DeepSeek 一个 Key,OpenAI 一个 Key,智谱又一个 Key,代码里到处是 if-else 判断 provider 然后 new 不同的客户端。维护成本高,切换模型时还要改配置重启。

我的做法是把所有模型的 endpoint 和鉴权统一收敛到 TaoToken。它提供 OpenAI 兼容的接口协议,也就是说你只需要一个 Base URL 和一个 API Key,就能访问多个模型。对于 Spring AI 来说,这意味着OpenAiChatModel这一套客户端就能覆盖大部分场景,ModelChatStrategy的实现可以大幅简化。

具体来说,TaoToken 的 API 地址是https://taotoken.net/api,注意这个地址不带任何查询参数。你在 Spring AI 里配置base-url时填这个,api-key填你在控制台生成的 Key。模型 ID 则根据你要用的模型填,比如gpt-4o、deepseek-chat之类,具体以控制台模型列表为准。

这里要强调一个工程原则:模型构建层(易变)和记忆存储层(稳定)必须解耦。TaoToken 解决的是构建层的统一接入问题,让DynamicChatClientFactory每次从数据库读配置时,不管 provider 是什么,底层都能用同一套客户端协议去构建ChatModel。而记忆层始终是那个单例ChatMemoryBean,通过conversationId从 MySQL 读写,跟模型是谁完全无关。

如果你还没拿到 Key,可以去 TaoToken 控制台创建一个。整个流程是:注册登录 → 进入控制台 → 创建 API Key → 复制保存。Key 只在创建时显示一次,记得存好。模型对话功能可以在线测试,确认 Key 能用之后再写进 Spring AI 配置。

对于长期做编码和 Agent 的场景,可以考虑 Coding Plan,它在调用额度和模型覆盖上更适合高频使用。但如果你只是先跑通这篇的 Demo,一个普通 API Key 就够了。

把接入层统一之后,后面ModelChatStrategy的实现就清爽很多。原本要为每个 provider 写一套客户端初始化,现在大部分可以复用 OpenAI 兼容协议。这也是我推荐先做这一步的原因——不然后面多模型路由的代码会越写越乱。

3. 可复制配置:JDBC 建表 SQL、ChatMemoryRepository 与多模型路由

这一节是重头戏,所有片段都可以直接复制。先看依赖,pom.xml里加这个 Starter:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId> </dependency>

它会自动引入JdbcChatMemoryRepository和对应的自动配置类。接下来是建表 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`) );

字段逐个说清楚。conversation_id是会话 ID,VARCHAR(36)刚好放 UUID,同一会话的多条消息共享这个值,表里没有主键,靠联合索引(conversation_id, timestamp)加速查询和排序。content是消息文本,存的是Message.getText()的返回值,注意 TOOL 类型消息的 content 始终是空字符串。type是 MySQL 的 ENUM,对应MessageType枚举,在数据库层面做类型约束。timestamp的真实作用是排序而非精确记录时间,源码里用Instant.now().getEpochSecond()作基准,每条消息递增 1 秒,保证同一批消息有严格先后顺序。

自动建表由JdbcChatMemoryRepositoryAutoConfiguration驱动。应用启动时检测到 classpath 上有JdbcChatMemoryRepository、DataSource、JdbcTemplate,然后读配置spring.ai.chat.memory.repository.jdbc.initialize-schema。这个配置有三个值:embedded是默认值,仅嵌入式数据库自动建表;always始终自动建表,开发环境推荐;never不建表,生产环境配合 Flyway 或 Liquibase 用。

application.yml配置如下:

spring: ai: chat: memory: repository: jdbc: initialize-schema: always datasource: url: jdbc:mysql://localhost:3306/your_db?useSSL=false&serverTimezone=UTC username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver

然后是ChatMemoryBean 的配置,控制滑动窗口大小:

@Configuration public class ChatMemoryConfig { @Bean public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) { return MessageWindowChatMemory.builder() .chatMemoryRepository(chatMemoryRepository) .maxMessages(20) .build(); } }

JdbcChatMemoryRepository由 Starter 自动装配,你只需要声明ChatMemoryBean 来控制窗口。maxMessages(20)表示保留最近 20 条消息,超出部分会被截断。

接下来是多模型路由。策略接口:

public interface ModelChatStrategy { boolean supports(String provider); ChatModel buildChatModel(ChatModelConfig config); }

基于 TaoToken 统一接入后,OpenAI 兼容策略可以覆盖大部分模型:

@Component public class OpenAiCompatibleChatStrategy implements ModelChatStrategy { @Override public boolean supports(String provider) { return "openai".equalsIgnoreCase(provider) || "deepseek".equalsIgnoreCase(provider) || "taotoken".equalsIgnoreCase(provider); } @Override public ChatModel buildChatModel(ChatModelConfig config) { OpenAiApi api = OpenAiApi.builder() .baseUrl("https://taotoken.net/api") .apiKey(config.getApiKey()) .build(); return OpenAiChatModel.builder() .openAiApi(api) .defaultOptions(OpenAiChatOptions.builder() .model(config.getModelId()) .temperature(config.getTemperature()) .build()) .build(); } }

策略工厂:

@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 IllegalArgumentException("暂不支持的模型提供商: " + provider)); } }

动态构建ChatClient:

@Component @RequiredArgsConstructor public class DynamicChatClientFactory { private final AiModelConfigService aiModelConfigService; private final ModelChatStrategyFactory modelChatStrategyFactory; private final ChatMemory chatMemory; public ChatClient buildDefaultClient() { AiModelConfig config = aiModelConfigService.getDefaultConfig(); ModelChatStrategy strategy = modelChatStrategyFactory.getStrategy(config.getApiProvider()); ChatModel chatModel = strategy.buildChatModel(toModelConfig(config)); return ChatClient.builder(chatModel) .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .build(); } }

关键点:每次调用buildDefaultClient()都重新读数据库配置,管理员切换默认模型后下一次请求就生效,无需重启。而chatMemory始终是同一个单例,历史消息不受影响。

业务层调用:

@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,后端通过 Advisor 参数传递,Spring AI 自动完成历史加载和持久化。

4. 验证请求:重启后会话恢复与跨模型上下文延续实测

配置写完,必须验证两件事:重启后会话能不能恢复,跨模型切换后上下文能不能延续。这一节给出可跟做的验证步骤。

先启动应用,确认表已自动创建。连上 MySQL 执行:

SHOW TABLES LIKE 'SPRING_AI_CHAT_MEMORY'; DESC SPRING_AI_CHAT_MEMORY;

能看到表结构和联合索引就说明自动建表生效了。

第一步,发一条消息。用 curl 或前端调用你的接口,conversationId固定为conv_001:

curl -N -X POST http://localhost:8080/chat/stream \ -H "Content-Type: application/json" \ -d '{"message":"我叫张三","conversationId":"conv_001"}'

收到流式回复后,查数据库:

SELECT conversation_id, content, type, timestamp FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = 'conv_001' ORDER BY timestamp;

你应该看到两条记录:一条 USER 类型的“我叫张三”,一条 ASSISTANT 类型的回复。注意timestamp是递增的,两条相差 1 秒。

第二步,验证重启恢复。停掉应用,重新启动,再发一条:

curl -N -X POST http://localhost:8080/chat/stream \ -H "Content-Type: application/json" \ -d '{"message":"我叫什么?","conversationId":"conv_001"}'

如果回复里能说出“你叫张三”,说明重启后会话恢复成功。原理是MessageChatMemoryAdvisor.before()调用了chatMemory.get("conv_001"),从 MySQL 加载出历史消息注入 Prompt。

第三步,验证跨模型切换。在数据库里把默认模型配置改成另一个模型(比如从 DeepSeek 改成 GPT-4o),或者通过管理后台切换。然后继续用conv_001发消息:

curl -N -X POST http://localhost:8080/chat/stream \ -H "Content-Type: application/json" \ -d '{"message":"我刚才说我叫什么?","conversationId":"conv_001"}'

这次DynamicChatClientFactory会读到新模型配置,构建全新的ChatClient,但chatMemory还是同一个单例,从同一张表加载出之前 DeepSeek 处理的历史消息。发给新模型的消息列表是:SystemMessage、UserMessage("我叫张三")、AssistantMessage("你好张三...")、UserMessage("我刚才说我叫什么?")。新模型收到完整上下文,应该能正确回答。

这里有个细节值得注意:saveAll()是全量替换策略,不是追加。源码里先deleteByConversationId(conversationId)再batchUpdate插入全部消息,两步在同一事务里保证原子性。所以每轮对话的数据库操作是:before 阶段 1 次 SELECT 加载历史、1 次 SELECT + 1 次 DELETE + 1 次 batch INSERT 保存用户消息;after 阶段 1 次 SELECT + 1 次 DELETE + 1 次 batch INSERT 保存 AI 回复。合计 3 次读、2 次删、2 次批量插入。对话越长,每次 INSERT 的行数越多,但受maxMessages限制不会无限增长。

时间戳的生成逻辑也值得看一眼。AddBatchPreparedStatement里用AtomicLong以当前秒为起点,每条消息getAndIncrement() * 1000L,也就是每条递增 1 秒。这样ORDER BY timestamp能严格还原消息顺序,不会因为同一秒内多条消息导致排序错乱。

5. 常见报错排查:401、local proxy failed、reading choices 与 OAuth 问题

这一节对照真实报错来排查。多模型接入和 JDBC 记忆持久化组合起来,容易踩的坑集中在鉴权、网络、响应解析和认证配置上。

401 Unauthorized。最常见的原因是 API Key 配错或没带上。如果你用 TaoToken 统一接入,检查base-url是不是https://taotoken.net/api,注意不要多加路径或参数。Key 是否复制完整,有没有多余空格。Spring AI 的OpenAiApi在构建时会校验 Key 非空,但格式错误要到请求时才报 401。排查方法:先用 curl 直接打 TaoToken 的接口确认 Key 有效:

curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}'

如果 curl 通而 Spring AI 报 401,检查配置里 Key 有没有被环境变量覆盖成空值。

local proxy failed。这个报错通常出现在网络层,说明请求根本没发出去。检查你的base-url是否可达,本地防火墙或容器网络是否限制了出站。如果你在 Docker 里跑应用,确认容器能解析外部域名。注意不要配置任何非法的网络代理,企业环境里应该走合规的出口。排查方法:在应用所在机器上curl -v https://taotoken.net/api看能否建立连接。

Error reading choices / reading choices。这个报错说明请求发出去了,响应也回来了,但解析响应体时失败。常见原因有两个:一是模型 ID 填错,服务端返回了错误结构而不是标准的 choices 数组;二是响应被中间层截断或改写了。排查方法:打开 Spring AI 的 debug 日志,看原始响应体:

logging: level: org.springframework.ai: DEBUG

对比返回的 JSON 结构是否符合 OpenAI 兼容格式。如果模型 ID 不存在,服务端一般会返回明确的错误信息,先确认模型 ID 在 TaoToken 控制台的模型列表里。

OAuth 相关报错。如果你用的是需要 OAuth 流程的模型接入方式,报错可能出现在 token 获取阶段。Spring AI 的 OpenAI 兼容客户端默认走 API Key 鉴权,不涉及 OAuth。如果你确实需要 OAuth,检查 token 端点、client_id、client_secret 配置,以及 token 是否过期。对于 TaoToken 接入,用 API Key 即可,不需要 OAuth 流程。

表不存在或字段不匹配。如果报Table 'SPRING_AI_CHAT_MEMORY' doesn't exist,检查initialize-schema是否设为always,或者生产环境是否手动执行了建表 SQL。如果报字段类型错误,确认 MySQL 版本支持 ENUM 类型,以及timestamp字段没有被其他逻辑改写。

跨模型切换后上下文丢失。如果切换模型后 AI 不记得之前的内容,先查数据库确认历史消息还在:

SELECT COUNT(*) FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = 'conv_001';

如果记录在但模型没用到,检查conversationId是否前后一致。前端每次请求必须传同一个conversationId,如果切换模型时前端重新生成了 ID,记忆自然断档。另外确认MessageChatMemoryAdvisor是否正确注入了ChatMemoryBean,以及maxMessages是否设得太小导致历史被截断。

CC Switch / Cline MCP / Codex auth.json 场景。如果你在编码工具里接入,需要写全三件套:Base URL 填https://taotoken.net/api,Key 填控制台生成的 API Key,Model ID 填具体模型标识。以 Codex 的auth.json为例,配置结构里这三个字段缺一不可,少任何一个都会导致鉴权失败或模型找不到。Cline 的 MCP 配置同理,Base URL、Key、Model ID 要对应上。

6. 从记忆持久化到多模型工程化:接入文档与 Coding Plan 分流

把 JDBC 记忆持久化和多模型动态切换跑通之后,你会发现这套架构的扩展性比想象中好。新增一个模型提供商,只需要实现ModelChatStrategy接口并注册为 Bean,工厂自动注入,零侵入。记忆层完全不用动,因为SPRING_AI_CHAT_MEMORY表只认conversationId,不认模型。

我在实际项目里踩过的一个坑是:早期把ChatMemory和ChatModel绑在一起构建,结果每次切换模型都要重建记忆 Bean,历史全丢。后来改成ChatMemory单例、ChatClient每次动态构建,才彻底解决。这个分离是整套方案的核心,记住一句话:模型构建层易变,记忆存储层稳定,两者通过conversationId解耦。

如果你在排障或接入过程中遇到问题,建议先看接入文档,里面有完整的参数说明和示例。需要创建或管理 Key 的话,去 API Keys 页面操作。想先验证模型能不能正常对话,可以用模型对话功能在线测试,确认 Key 和模型 ID 没问题再写进代码。

对于长期做编码和 Agent 开发的场景,Coding Plan 在调用额度和模型覆盖上更适合高频使用,可以考虑。但无论用哪种方式,核心的工程实践是一样的:统一 endpoint 和鉴权、持久化记忆、动态构建客户端、用conversationId串联上下文。

最后留一个实用技巧:生产环境建议把initialize-schema设为never,用 Flyway 或 Liquibase 管理建表脚本,避免应用启动时意外改表。开发环境用always方便快速迭代。另外maxMessages不要设太大,20 到 50 之间比较合理,太大每次请求的 Prompt 会很长,token 成本和延迟都会上升。滑动窗口截断的是最老的消息,保证最近的上下文优先保留。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询