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 一个 KeyOpenAI 一个 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里加这个 Starterdependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-chat-memory-repository-jdbc/artifactId /dependency它会自动引入JdbcChatMemoryRepository和对应的自动配置类。接下来是建表 SQLSpring 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是会话 IDVARCHAR(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?useSSLfalseserverTimezoneUTC 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 ListModelChatStrategy strategies; public ModelChatStrategy getStrategy(String provider) { return strategies.stream() .filter(s - s.supports(provider)) .findFirst() .orElseThrow(() - new IllegalArgumentException(暂不支持的模型提供商: provider)); } }动态构建ChatClientComponent 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 FluxString 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_001curl -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 doesnt 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/apiKey 填控制台生成的 API KeyModel 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 成本和延迟都会上升。滑动窗口截断的是最老的消息保证最近的上下文优先保留。