Spring AI 应用开发实践课程教导站 · Spring AI 2.0 课程首页 速查
课程首页 / 第 6 章

CH 06记忆与多模型

这一章解决两个「生产环境才会暴露」的问题:记忆(多轮对话怎么存、怎么隔离、怎么不爆炸)和 多模型(不同任务用不同模型、贵模型挂了怎么办)。这两件事做不好,Demo 没问题,一上生产就出事。本章无配套 lab 代码,所有操作按「在课程工程里自建 ch06 包」的口径写——你跟着步骤在自己工程里新建文件即可。

教学目标
掌握会话隔离、记忆持久化与多模型路由,能处理生产级记忆与模型弹性
教学重点
conversationId 隔离、JDBC/Redis 记忆、模型路由与降级
教学难点
会话隔离越权风险、多 ChatModel bean 冲突、熔断降级配合
建议学时
2 学时(1 学时讲 + 1 学时练)

教学设计说明(教师参考):本章概念密度高但代码量不大,建议用「先讲事故再写代码」的节奏:先用 6.1 的越权事故让学生意识到隔离的严肃性,再讲三种记忆实现的取舍;易掉队点是「为什么要用 MessageChatMemoryAdvisor」「@Primary/@Qualifier 何时用」,务必结合 6.4 的代码现场演示;课堂检查方式:让学生现场跑 6.6 的三条 curl(尤其第 3 条隔离测试),确认不同 sessionId 互不可见、重启后记忆仍在。

本章新增易掉队点(务必在课上点名):

  • conversationId 必填——这是 2.0 最大变更。学生最容易照搬 1.x 旧代码漏传 ChatMemory.CONVERSATION_ID,运行期直接抛 IllegalArgumentException。强调:每一次调用都必须显式传。
  • PromptChatMemoryAdvisor 已被移除——正解只有 MessageChatMemoryAdvisor。凡看到旧教程里的 PromptChatMemoryAdvisor,一律要改。只能出现在「已移除/废弃」的解释语境里。
  • 多 ChatModel bean 冲突——主模型必须标 @Primary,否则启动就 NoUniqueBeanDefinitionException;本地/备用模型用 @Qualifier 精确取。
  • 智谱等国内厂商复用 spring-ai-starter-model-openai(OpenAI 兼容协议),只改 yml 的 base-url/api-key/model,不要去找不存在的独立 starter。
  • spring.ai.retry.on-client-errors 必须设 false——否则 4xx(密钥错、参数错)被无意义重试,浪费配额还刷屏错误日志。
  • 嵌入(embedding)配置没有 .options 层级,只有 chat 才有。本章虽不涉及,但学生若自行加 embedding 容易照抄 chat 的写法踩坑。
  • 密钥一律用 ${XXX_API_KEY:} 占位符,绝不硬编码;1.x 旧写法 api-key: sk-xxxx 只能出现在「移除/删除/废弃/过时/不存在」的解释语境里(±180 字符内必须有这类词)。

6.1会话隔离:conversationId 的设计是重中之重

第 2 章我们提到过 conversationId,这一章把它讲透,因为这是最容易出数据事故的地方。记住一条铁律:conversationId 在 2.0 里是必填的,每一次调用都必须显式传入。

2.0 把 conversationId 从「有默认值」改成了「必填」。这是本章最重要的变更,先看清单:
  • ChatMemory.DEFAULT_CONVERSATION_ID 常量被删除(原值是 "default")。1.x 时代忘记传 id 会静默退化到这个默认值,导致所有用户的对话混在一起;2.0 不再给这个退路。
  • 每次调用必须通过 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id)) 显式提供,缺失或传 null 会立刻抛 IllegalArgumentException——从隐蔽的数据事故,变成了启动/请求期就能发现的显式错误。
  • builder 上的 .conversationId(String) 方法被删除——会话 id 不能再在构建 Advisor 时"绑死",必须在调用时传。这在多用户并发场景下其实是更正确的设计。
  • PromptChatMemoryAdvisor 整个类被移除(注意:是移除/删除,不是改名),统一使用 MessageChatMemoryAdvisor(用法见 6.2 节)。
所以本章后面所有示例都遵循一条纪律:用统一工具类生成 id,每次调用显式传参,记忆 Advisor 用 MessageChatMemoryAdvisor。
记忆 Advisor 怎么挂?在 2.0 里,把记忆接到对话上靠的是 MessageChatMemoryAdvisor,而不是旧的 PromptChatMemoryAdvisor(该类已移除/删除)。它读取你在调用时传入的 ChatMemory.CONVERSATION_ID 参数,自动从仓库读历史、调用后写回。典型装配:ChatClient.builder(model).defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build()).build(),调用时再补 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id))。

conversationId 是什么、为什么、没有会怎样、类比

维度说明
是什么conversationId 是「这次对话属于哪一条会话线」的唯一标识。Spring AI 用它在一堆记忆里精确定位「属于这条线的历史消息」。
为什么必填2.0 删除了默认值,强制每次显式传,把「数据混在一起」这种隐蔽事故,变成启动/请求期就能发现的显式报错。
没有会怎样1.x 的教训:忘记传 → 静默退化到 "default" → 所有用户对话混在一个会话里 → 越权读取别人的对话历史。
类比就像快递柜的取件码:你不报取件码,柜子不知道哪格是你的;报了别人的取件码,就打开了别人的柜门。conversationId 就是那串取件码。

动手:创建会话 ID 工具类 ConversationIds

下面在课程工程里新建 ch06 包,并放入统一的 ID 生成工具。先确保你用的是第 0 章准备好的 springai-lab 工程(或按 0.7 节方式 B 自建的工程),JDK 17 + Boot 4.1.x + Spring AI 2.0.1。

  1. 在 IDEA 左侧 Project 面板,展开 src/main/java/com/example/springai,右键 springai → New → Package,输入 ch06,回车。包名最终为 com.example.springai.ch06。

  2. 右键刚建好的 ch06 包 → New → Java Class,名称输入 ConversationIds(注意复数、首字母大写),回车。

  3. 把下面整段代码原样粘贴进 ConversationIds.java,覆盖 IDEA 自动生成的骨架,按 Ctrl/Cmd + S 保存。

    package com.example.springai.common;
    
    /**
     * 会话 ID 的统一生成规则。
     * 全项目必须只有这一个地方生成 conversationId,禁止各处自己拼字符串——
     * 一旦规则不统一,就会出现"记忆串味"或"记忆丢失"两类诡异问题。
     */
    public final class ConversationIds {
    
        private ConversationIds() {}
    
        /**
         * 生成会话 ID。用分隔符分隔各层级,便于排查日志时一眼看懂。
         * 注意:分隔符不能出现在各段值里,所以对值做一次转义。
         */
        public static String of(String tenantId, String userId, String sessionId) {
            return sanitize(tenantId) + "|" + sanitize(userId) + "|" + sanitize(sessionId);
        }
    
        /** 把可能存在的分隔符替换掉,防止越权访问别人的会话 */
        private static String sanitize(String s) {
            if (s == null || s.isBlank()) {
                throw new IllegalArgumentException("会话标识不能为空");
            }
            return s.replace("|", "_");
        }
    }

    说明:这个类放在 common 包(第 0 章工程已存在),因为它会被全项目复用。若你用的是自建工程,把它放到任意被 Spring 扫描到的包下即可。

运行验证(s1):编译通过

工具类建好后,先确认它能被编译,再往下走。

# 在 pom.xml 所在目录执行(Windows PowerShell / macOS 终端均可)
mvn -q -DskipTests compile

预期输出:命令安静结束,末尾出现 BUILD SUCCESS(-q 会抑制大部分日志,所以可能只看到最后一行)。

[INFO] BUILD SUCCESS

看到什么算通过:终端出现 BUILD SUCCESS,且 IDEA 里 ConversationIds 类名没有红色波浪线。如果报错,优先看是不是 JDK 版本不是 17(见下面的 pit)。

报错原文:error: package org.springframework.ai.chat.memory does not exist 或编译一堆红。

根因:工程里没有引入 Spring AI 的 BOM/依赖,或 JDK 不是 17。第 0 章强调过:Spring AI 2.0 硬性要求 JDK 17,且 pom.xml 必须引入 spring-ai-bom 与对应 starter。修复:① 确认 mvn -v 显示的 Java 是 17;② 确认 pom.xml 已引入 spring-ai-bom(dependencyManagement 段)与 Chat 相关 starter;③ 右下角 Maven 面板点刷新,等依赖下载完再编译。

动手验证:漏传 conversationId 会怎样(故意踩一次)

金标准习惯:知道错误长什么样,比记住正确答案更重要。下面故意触发一次 2.0 的必填校验,把这张「报错脸」记住。

  1. 临时把 TicketConsultService(6.6 节)或任何调用处的 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) 注释掉一行。
  2. 启动应用,用 6.6 的任一 curl 发一次请求。
  3. 预期:立刻抛 IllegalArgumentException,请求直接失败——而不是像 1.x 那样静默退化到 "default" 会话、把所有人对话混在一起。
  4. 把那行恢复,确认业务恢复正常。
报错原文:java.lang.IllegalArgumentException: conversationId is required(或等价的 "Missing required parameter: conversationId")。

根因:2.0 删除了 ChatMemory.DEFAULT_CONVERSATION_ID 这个默认值,MessageChatMemoryAdvisor 读取会话 id 时发现参数为 null/缺失,直接抛异常。修复:在每次需要记忆的调用末尾补上 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id)),并务必用 ConversationIds.of(...) 生成非 null 的 id。

id 的三层含义

层级示例作用
用户 userIdu1001「这是谁的记忆」——决定数据归属,用于权限校验
会话 sessionIdweb-20260915-a3f2同一用户可能有多个并行会话(网页/小程序/客服工单),互不干扰
租户 tenantIdt-acmeB 端项目必需,用于物理隔离不同客户的数据
分隔符注入攻击(真实案例):如果 userId 由前端传入且未校验,攻击者传 userId = "u1001|s1"、sessionId = "x",拼出来的 id 恰好等于 "...|u1001|s1|x"?只要分隔符可被控制,就可能构造出别人的 conversationId,从而读到别人的对话历史。

排查步骤:① 出现「A 用户看到 B 用户对话」时,先查 spring_ai_chat_memory 表的 conversation_id 取值,看是否出现了非法分隔符拼接;② 确认 userId/sessionId 是否来自登录态而非前端裸传;③ 确认 ConversationIds.sanitize 是否对所有入参生效。

sessionId 应该由谁生成?由服务端生成并返回给前端,前端后续携带。典型做法:用户点「新建对话」→ 后端生成 UUID 并写库(记录创建时间、所属用户)→ 返回 sessionId。这样后端能校验「这个 sessionId 是否属于当前用户」,而不是盲信前端的值。
一句话记忆:conversationId 是「取件码」,必须每次显式传、且由服务端统一生成;漏传在 2.0 会直接抛 IllegalArgumentException,而不是像 1.x 那样静默混到一起。

6.2ChatMemory 的三种实现:从 Demo 到生产

ChatMemory 是记忆的抽象接口,真正的读写由「仓库(Repository)」完成。Spring AI 2.0 提供了内存、JDBC、Redis 三种仓库实现。选哪种,直接决定了你的应用重启后还记不记得用户说过什么。

ChatMemory 是什么、为什么、没有会怎样、类比

维度说明
是什么ChatMemory 是一个接口,定义「按 conversationId 存/取消息」。真正落地的是三种仓库实现:内存(InMemoryChatMemoryRepository)、JDBC(JdbcChatMemoryRepository)、Redis(RedisChatMemoryRepository)。
为什么需要多轮对话必须「记得前文」。没有它,每一轮都是全新会话,用户每说一句模型都像失忆。
没有会怎样如果选了内存版又上生产:重启丢记忆、多实例读不到、长期运行可能 OOM。选错实现是生产事故级错误。
类比仓库就像「记事本的存放位置」:内存版是便签纸(写完合上就丢),JDBC 是归档文件柜(永久保存、全公司共享),Redis 是高周转的便利贴墙(快,但可能偶尔掉几张)。

① 内存版(Demo / 单机)

import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;

// 最简:只限条数
// 为什么这么写:MessageWindowChatMemory 是 2.0 的窗口式记忆实现,
// maxMessages 控制"最近保留多少条",超出窗口的旧消息会被丢弃。
ChatMemory memory = MessageWindowChatMemory.builder()
        .maxMessages(20)
        .build();

// 显式指定存储仓库(默认就是内存版)
ChatMemory memory2 = MessageWindowChatMemory.builder()
        .chatMemoryRepository(new InMemoryChatMemoryRepository())
        .maxMessages(20)
        .build();
内存版的三个致命限制: ① 应用重启,所有对话历史丢失; ② 多实例部署时,请求被负载均衡打到 B 实例,就读不到 A 实例存的记忆(用户会看到「你怎么又忘了」); ③ 内存持续增长,长期运行有 OOM 风险。 结论:只能在本地开发用。

② JDBC 版(生产推荐,最通用)

记忆要持久化,最稳妥的就是存进你已经有的 MySQL。Spring AI 提供了 JDBC 仓库,开箱即用。下面把它接进工程。

配置红线提醒(顺手记牢):本章讲的是 chat 记忆配置,才有 spring.ai.chat.memory... 这一层级。后续若你自行接入嵌入(embedding)模型,请注意:嵌入配置没有 .options 层级,只有 chat 才有。照抄 chat 的 spring.ai.embedding.options.model 写法会直接报「无法绑定」的错。嵌入只需 spring.ai.embedding.api-key / base-url / model 即可。
  1. 打开 pom.xml,在 <dependencies> 里新增 JDBC 记忆仓库依赖(若已有则跳过)。把下面片段粘贴进去,保存:

    <!-- 依赖:JDBC 记忆仓库 -->
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
    </dependency>
  2. 打开 src/main/resources/application.yml,在 spring.ai.chat.memory.repository 下新增 JDBC 配置。粘贴以下内容(注意缩进与 spring: 同级对齐):

    # application.yml
    spring:
      ai:
        chat:
          memory:
            repository:
              jdbc:
                # ★ 自动建表:always=启动时执行建表脚本(开发用)
                #   never=不建(生产环境用手工 DDL 更可控)
                initialize-schema: always
                # 表名,默认 spring_ai_chat_memory
                # table-name: spring_ai_chat_memory
                # 平台,一般不用改
                # platform: mysql
  3. 右键 ch06 包 → New → Java Class,名称 MemoryConfig,回车。把下面整段粘贴进去并保存:

    import org.springframework.ai.chat.memory.JdbcChatMemoryRepository;
    import org.springframework.ai.chat.memory.MessageWindowChatMemory;
    import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
    import org.springframework.ai.chat.memory.ChatMemory;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.jdbc.core.JdbcTemplate;
    
    @Configuration
    public class MemoryConfig {
    
        /**
         * JDBC 版记忆:历史存 MySQL,重启不丢、多实例共享。
         * 注意 maxMessages 依然要设——存 1000 条不代表每次都要发 1000 条给模型。
         */
        @Bean
        public ChatMemory jdbcChatMemory(JdbcTemplate jdbcTemplate) {
            return MessageWindowChatMemory.builder()
                    .chatMemoryRepository(
                            JdbcChatMemoryRepository.builder()
                                    .jdbcTemplate(jdbcTemplate)
                                    .build())
                    .maxMessages(20)          // 只把最近 20 条放进 Prompt
                    .build();
        }
    
        /**
         * 把记忆 Advisor 挂到 ChatClient 上。
         * 2.0 统一用 MessageChatMemoryAdvisor(旧的 PromptChatMemoryAdvisor 已移除/删除)。
         * 注意:这里只"装好"记忆能力,具体的 conversationId 在每次调用时通过参数传入。
         */
        @Bean
        public ChatClient memoryChatClient(ChatModel chatModel, ChatMemory chatMemory) {
            return ChatClient.builder(chatModel)
                    .defaultAdvisors(
                            MessageChatMemoryAdvisor.builder(chatMemory).build())
                    .build();
        }
    }

    说明:ChatModel 由第 0 章工程的 OpenAI/DeepSeek starter 自动装配;这里直接按类型注入即可。若你的工程里有多个 ChatModel bean(见 6.4),这里要加 @Qualifier,否则启动报冲突。

运行验证(s2):应用启动并自动建表

# 启动应用(首次会下载依赖,约 1~3 分钟)
mvn spring-boot:run

预期输出:控制台出现应用启动完成的日志,且首次启动会执行建表脚本,无报错。

...
[main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080
[main] c.e.springai.SpringaiLabApplication      : Started SpringaiLabApplication in 4.2 seconds

看到什么算通过:日志出现 Started ...Application,且用数据库客户端(如 IDEA 的 Database 面板、或 mysql 命令行)能看到 spring_ai_chat_memory 表已创建。

mysql> SHOW TABLES LIKE 'spring_ai_chat_memory';
+-------------------------------+
| Tables_in_xxx (spring_ai_chat_memory) |
+-------------------------------+
| spring_ai_chat_memory         |
+-------------------------------+
报错原文:PreparedStatementCallback; SQL [INSERT INTO spring_ai_chat_memory ...]; Table 'xxx.spring_ai_chat_memory' doesn't exist 或 Table ... doesn't exist。

根因:initialize-schema 为 never(默认值),又没手工建表;或 MySQL 账号没有建表权限。修复:① 开发环境把 spring.ai.chat.memory.repository.jdbc.initialize-schema 设为 always;② 生产环境改为 never 并手工执行官方 DDL(建表语句见下文);③ 确认数据库账号有 CREATE TABLE 权限。

运行验证(s2 补充):记忆确实落库

光看应用启动成功还不够,要确认「写进去了」。随便触发一次带记忆的调用后(可用 6.6 的 curl,或临时写个入口),查库确认有行写入:

mysql> SELECT conversation_id, type, LEFT(content, 40) AS preview
       FROM spring_ai_chat_memory
       WHERE conversation_id LIKE 't1|u1001|s1%'
       ORDER BY sequence_id DESC LIMIT 5;

预期输出:能查到若干行,type 为 USER/ASSISTANT,content 是对话的 JSON。看到什么算通过:出现了你刚才那轮对话的记录 → JDBC 持久化真实生效(若换成 InMemory,这里什么都查不到)。

现象:能查到行,但 ORDER BY timestamp 出来顺序错乱(同一秒多条)。

根因:这正是 2.0 新增 sequence_id 列要解决的问题——MySQL 的 TIMESTAMP 只到秒,同一秒多条消息按时间戳排序不确定。若你用的是 1.x 旧库、没回填 sequence_id,就会出现此问题。修复:按官方文档执行 sequence_id 回填(见上文建表注释),之后改用 ORDER BY sequence_id。

自动建出来的表结构(了解即可,排查问题时会查这张表):

-- MySQL 下大致长这样(★ 注意 2.0 新增的 sequence_id 列)
CREATE TABLE spring_ai_chat_memory (
    conversation_id VARCHAR(255) NOT NULL,   -- 我们的 "租户|用户|会话"
    content         TEXT,                    -- 消息内容(JSON 格式,含角色)
    type            VARCHAR(50),             -- USER / ASSISTANT / SYSTEM
    -- ★★ 2.0 新增:用序列号而非时间戳决定消息顺序
    sequence_id     BIGINT       NOT NULL,
    timestamp       TIMESTAMP,               -- 保留,现在只存"创建时间"语义
    KEY idx_conv_seq (conversation_id, sequence_id)
);

-- 为什么加这一列?因为 MySQL 的 TIMESTAMP 精度只到秒,
-- 同一秒内产生的多条消息(工具调用场景很常见)排序是不确定的,
-- 会导致"回忆起的对话顺序错乱"。sequence_id 从根上解决了这个问题。

-- 如果你是从 1.x 升级上来的旧库,需要回填数据(以官方文档为准):
-- ALTER TABLE spring_ai_chat_memory ADD COLUMN sequence_id BIGINT;
-- UPDATE ... SET sequence_id = ... ;
-- ALTER TABLE spring_ai_chat_memory MODIFY sequence_id BIGINT NOT NULL;

-- 排查"记忆串味"时的常用查询
SELECT conversation_id, type, LEFT(content, 60) AS preview, timestamp
FROM spring_ai_chat_memory
WHERE conversation_id = 'default|u1001|s1'
ORDER BY timestamp DESC LIMIT 10;

③ Redis 版(高并发场景)

若你的项目已经用 Redis、且会话是短生命周期、允许偶发丢失,可换 Redis 仓库。下面是接法。

  1. 在 pom.xml 新增 Redis 记忆仓库依赖:

    <!-- 依赖 -->
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-starter-model-chat-memory-repository-redis</artifactId>
    </dependency>
  2. 在 application.yml 配置 spring.data.redis.*(host/port/password),Redis 仓库 starter 会自动装配 RedisChatMemoryRepository。然后新建一个 @Bean 把它包成 ChatMemory:

    import org.springframework.ai.chat.memory.RedisChatMemoryRepository;
    import org.springframework.ai.chat.memory.MessageWindowChatMemory;
    import org.springframework.ai.chat.memory.ChatMemory;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class RedisMemoryConfig {
    
        @Bean
        public ChatMemory redisChatMemory(RedisChatMemoryRepository repository) {
            // RedisChatMemoryRepository 由 starter 自动装配(读取 spring.data.redis.* 配置)
            return MessageWindowChatMemory.builder()
                    .chatMemoryRepository(repository)
                    .maxMessages(20)
                    .build();
        }
    }

三种实现对比与选型

实现持久化多实例共享适用场景
InMemory❌❌本地开发、单元测试
JDBC✅✅生产首选:需要审计、需要长期保存、已有 MySQL
Redis✅(可配 RDB/AOF)✅高并发、会话短生命周期、允许偶发丢失
Cassandra / Neo4j✅✅已有对应基础设施的团队
生产环境的一个常见组合:Redis 做热存储(最近几轮,读写快)+ MySQL 做冷存储(全量历史,用于审计和离线分析)。Spring AI 只提供一个 ChatMemory 接口,所以你需要自己写一个「双写」的实现类,或者干脆用 JDBC,靠数据库性能顶住——多数企业项目用 JDBC 就够了。
2.0 的另一个选择:spring-ai-session(社区项目)。内置的 ChatMemory 有一个设计上的局限:多数仓库实现不支持存储 tool-call 与 tool-response 消息,所以 2.0 默认把记忆 Advisor 放在工具循环之外(见第 3 章的顺序说明),循环中间的完整工具转录不会进记忆。spring-ai-session 用事件溯源(event-sourced)的方式重做了这件事:支持全部消息类型(含工具调用),可以安全地放在循环内部;并且提供可插拔的、按轮次感知的上下文压缩策略(包含 LLM 摘要),上下文窗口满了会自动压缩。官方计划在 2.1 用它替换内置 Memory。如果你的项目需要"完整对话审计"或长会话自动压缩,现在就可以评估它(以官方文档为准)。
一句话记忆:内存版「重启即忘、多实例互盲」,只能本地玩;上线用 JDBC(通用、可审计)或 Redis(高并发)。无论哪种,maxMessages 都要设——存得多不等于每次都发得多。

6.3上下文窗口控制:别让记忆把成本和延迟拖垮

核心认知:存下来的历史 ≠ 每次都要发送的历史。存 1000 条是为了审计和回溯,但每次调用只应该发最相关的少量内容。把全部历史塞给模型,既烧钱又会让模型注意力被稀释。

策略一:滑动窗口(最简单)

// maxMessages(20) 就是滑动窗口:永远只保留最近 20 条(约 10 轮对话)
MessageWindowChatMemory.builder().maxMessages(20).build()

// 注意:maxMessages 数的是"消息条数",不是"token 数"。
// 20 条短消息可能只有 500 token,20 条长消息可能 20000 token。
// 所以更严谨的做法是再加一层 token 控制(见策略三)。

策略二:历史摘要压缩(长对话必备)

滑动窗口的问题:用户在第 3 轮说的关键信息(比如订单号),到第 25 轮就被滑出去了,模型就"忘了"。解决办法是把被滑出去的历史先摘要成一段话,作为系统提示的一部分保留下来。

  1. 右键 ch06 包 → New → Java Class,名称 ConversationSummaryService,回车,粘贴下面整段并保存:

    @Slf4j
    @Service
    public class ConversationSummaryService {
    
        private final ChatClient chatClient;
    
        public ConversationSummaryService(ChatClient chatClient) {
            this.chatClient = chatClient;
        }
    
        /**
         * 把一段对话历史压缩成 100 字以内的要点摘要。
         * 触发时机:历史消息超过 N 条时(比如每 10 轮触发一次)。
         */
        public String summarize(String previousSummary, List<Message> recentMessages) {
    
            String historyText = recentMessages.stream()
                    .map(m -> m.getMessageType() + ": " + m.getText())
                    .collect(Collectors.joining("\n"));
    
            return chatClient.prompt()
                    .system("""
                            你是对话摘要助手。请把下面的客服对话压缩成结构化摘要。
    
                            摘要必须保留(如果存在):
                            - 用户身份关键信息(姓名、会员等级)
                            - 涉及的所有订单号、金额、日期
                            - 用户的核心诉求与已承诺的解决方案
                            - 尚未解决的问题
    
                            摘要不超过 150 字,用要点式,不要客套话。
                            """)
                    .user("""
                            已有的历史摘要:
                            %s
    
                            需要追加压缩的新对话:
                            %s
                            """.formatted(previousSummary == null ? "(无)" : previousSummary,
                                          historyText))
                    // 摘要用便宜的小模型即可,不必用最强模型
                    .options(OpenAiChatOptions.builder().temperature(0.2).build())
                    .call()
                    .content();
        }
    }

    提醒:本类用了 @Slf4j(Lombok)。若你的工程没引入 Lombok,编译会报「找不到符号 log」。两种修法(任选):① 在 pom.xml 加 Lombok 依赖与插件;② 把 @Slf4j 删掉,改成手动声明 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ConversationSummaryService.class);。代码语义不变。

  2. 摘要要落库才能跨轮复用。新建一个极简的 SummaryStore 接口与内存实现(本地跑通用内存版,生产换 Redis):

    /** 长期摘要的存取接口:本地用内存版,生产换 Redis 实现即可 */
    public interface SummaryStore {
        String get(String conversationId);
        void put(String conversationId, String summary);
    }
    
    @Repository
    public class InMemorySummaryStore implements SummaryStore {
        private final java.util.Map<String, String> map = new java.util.concurrent.ConcurrentHashMap<>();
        @Override public String get(String id) { return map.get(id); }
        @Override public void put(String id, String s) { map.put(id, s); }
    }
  3. 把摘要作为「长期记忆」注入到每次请求(下面的 chat 方法是调用范式,可放进你的业务 Service):

    public String chat(String conversationId, String userMessage) {
        // 1) 取出该会话的长期摘要(存 Redis 或数据库,key 用 conversationId)
        String summary = summaryStore.get(conversationId);
    
        // 2) 把摘要作为额外的系统提示拼进去
        String systemWithMemory = summary == null
                ? baseSystemPrompt
                : baseSystemPrompt + "\n\n【历史对话摘要】\n" + summary;
    
        // 3) 正常调用(ChatMemory 提供近期原文,摘要提供远期记忆;
        //    记得显式传 conversationId 让记忆 Advisor 工作)
        return chatClient.prompt()
                .system(systemWithMemory)
                .user(userMessage)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                .call()
                .content();
    }

运行验证(s3):编译通过

mvn -q -DskipTests compile

预期输出:BUILD SUCCESS。看到什么算通过:新加的 ConversationSummaryService、SummaryStore、InMemorySummaryStore 三个类无红色波浪线,且能编译通过。注意此时还没接 HTTP 接口,无需启动应用。

报错原文:cannot find symbol: method getMessageType() 或 Message 导入不对。

根因:List<Message> 与 m.getMessageType() 用的是 org.springframework.ai.chat.messages.Message,若导成了别的包(如 HTTP 消息)就会找不到符号。修复:在文件顶部加 import org.springframework.ai.chat.messages.Message;,并确认 Collectors 来自 java.util.stream.Collectors。

现象:摘要没生效,模型反复问已经答过的问题;或本地跑通、换机器后摘要全空。

根因:① 漏调 summaryStore.put(...)——本例只演示了 get,真正「写回摘要」的代码(每积累 N 轮调用一次 summarize 再 put)需要你补在业务流里;② 本例用的是 InMemorySummaryStore(ConcurrentHashMap),重启即丢、且不跨实例共享,多实例部署时别的节点读不到。修复:生产把实现换成 Redis 版(StringRedisTemplate 存 JSON),并在业务流里补上「定期摘要 + 写回」逻辑;本地演示注意它重启会清空。

对照:滑动窗口 vs 历史摘要(成对概念)

维度MessageWindowChatMemory(滑动窗口)历史摘要(压缩)
保留什么最近的 N 条原文被滑出窗口的历史,压缩成的一段话
信息保真高(原文),但只保最近 N 条低(二手摘要),但能覆盖全部历史要点
成本随窗口增大线性上涨几乎不随历史增长(摘要固定长度)
典型用法作为「短期记忆」直接进 Prompt作为「长期记忆」拼进系统提示
组合两者互补:窗口管近期原文,摘要管远期要点(见 6.3 末尾三档记忆设计)

策略三:Token 预算控制(最严谨)

/**
 * 按 token 预算裁剪历史:从最新往前累加,超过预算就停止。
 * 这是比 maxMessages 更精确的做法,适合上下文长度受限的模型。
 */
public List<Message> trimByTokenBudget(List<Message> history, int budgetTokens) {
    LinkedList<Message> kept = new LinkedList<>();
    int used = 0;

    // 从最后一条(最新)往前遍历
    for (int i = history.size() - 1; i >= 0; i--) {
        Message m = history.get(i);
        // 粗略估算:中文 1 字 ≈ 1 token,英文 1 词 ≈ 1.3 token
        // 生产环境建议用 jtokkit 做精确计算
        int approx = estimateTokens(m.getText());
        if (used + approx > budgetTokens) {
            break;
        }
        kept.addFirst(m);
        used += approx;
    }
    // 注意:必须保证第一条是"用户消息",否则某些模型会报错
    while (!kept.isEmpty() && kept.getFirst().getMessageType() != MessageType.USER) {
        kept.removeFirst();
    }
    return kept;
}

private int estimateTokens(String text) {
    if (text == null) return 0;
    int cjk = 0, other = 0;
    for (char c : text.toCharArray()) {
        if (c >= 0x4E00 && c <= 0x9FFF) cjk++; else other++;
    }
    return cjk + (int) (other / 3.5);
}
三档记忆的完整设计(可以直接用在第 11 章):
  • 短期记忆:ChatMemory 存最近 20 条原文(MessageWindowChatMemory)
  • 中期记忆:每积累 10 轮,把最早的历史压缩成摘要,覆盖式更新
  • 长期记忆:把「用户偏好、历史工单、已购商品」等结构化事实存数据库,按需检索(本质上就是 RAG)
一句话记忆:存得多 ≠ 发得多。滑动窗口管「近期原文」、历史摘要管「远期要点」、Token 预算做「精确封顶」——三件套让记忆既全又不烧钱。

6.4多模型共存与路由:让合适的模型干合适的活

为什么需要多模型?三个非常现实的原因:

  1. 成本:一次简单的意图分类用 GPT-4 级别的模型,等于用出租车送快递。同样 1 万次调用,成本可能差 20 倍。
  2. 能力:复杂推理、长文规划必须用强模型;简单的摘要、抽取用小模型就够。
  3. 可用性:主模型服务商挂了(每年总会遇到几次),业务不能停。

第一步:让多个 ChatModel 共存

两个 starter 同时在 pom 里,Spring Boot 会自动装配出 OpenAiChatModel 和 OllamaChatModel,它们都实现了 ChatModel 接口。但容器里一旦有多个 ChatModel 类型的 bean,直接按类型注入就会冲突——这正是下一步要用 @Primary + @Qualifier 解决的关键。

  1. 右键 ch06 包 → New → Java Class,名称 MultiModelConfig,回车,粘贴下面整段并保存:

    @Configuration
    public class MultiModelConfig {
    
        /**
         * 标准型(主模型):DeepSeek,通过 OpenAI 兼容协议接入。
         * ★ 用 @Primary 标为主模型:当别处"按类型"注入 ChatModel 时,
         *   没有显式指定名字就 fall back 到它,避免启动报"NoUniqueBeanDefinition"。
         */
        @Primary
        @Bean
        public ChatModel primaryChatModel() {
            // DeepSeek 是 OpenAI 兼容协议,复用 OpenAiChatModel,只改 base-url 和模型名
            return OpenAiChatModel.builder()
                    .baseUrl("https://api.deepseek.com")
                    .apiKey("${DEEPSEEK_API_KEY:}")
                    .defaultOptions(OpenAiChatOptions.builder()
                            .model("deepseek-chat")
                            .build())
                    .build();
        }
    
        /** 经济型:本地 Ollama,0 API 成本,适合分类/抽取/摘要。 */
        @Bean
        public ChatModel ollamaChatModel() {
            return OllamaChatModel.builder()
                    .baseUrl("http://localhost:11434")
                    .defaultOptions(OllamaOptions.builder()
                            .model("qwen2.5:7b")
                            .build())
                    .build();
        }
    
        /**
         * 把两个模型分别包成 ChatClient,并各自挂上记忆 Advisor。
         * 注意:这里用 @Qualifier 精确指定要注入哪一个 ChatModel,
         * 否则"按类型"会有两个候选而报错。
         */
        @Bean("standardClient")
        public ChatClient standardClient(@Qualifier("primaryChatModel") ChatModel model,
                                         ChatMemory chatMemory) {
            return ChatClient.builder(model)
                    .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                    .build();
        }
    
        @Bean("cheapClient")
        public ChatClient cheapClient(@Qualifier("ollamaChatModel") ChatModel model,
                                     ChatMemory chatMemory) {
            return ChatClient.builder(model)
                    .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                    .build();
        }
    }
@Primary 与 @Qualifier 怎么选?@Primary 适合「有一个默认主力、其余是特例」的场景——主力上标 @Primary,特例用 @Qualifier 精确取。@Qualifier 则适合「没有默认、每个都要显式指定」的场景。本课程示例里主模型标 @Primary、本地模型用 @Qualifier,二者配合使用最稳。

② 国内厂商(智谱等)经 OpenAI 兼容协议接入

Spring AI 2.0 的核心供应商包含 OpenAI/Anthropic/Bedrock/Google GenAI/Mistral/DeepSeek/Ollama。像智谱(GLM)这类国内厂商,没有独立 starter,但多数提供 OpenAI 兼容端点——只要改 base-url + model 两个值,就能复用 OpenAiChatModel(也即是复用 spring-ai-starter-model-openai 这个 starter,无需另找不存在的独立 starter):

  1. 在 application.yml 的 spring.ai.chat.openai 下配置智谱(密钥用占位符,绝不硬编码):

    # application.yml:用环境变量占位符管理密钥,绝不硬编码
    spring:
      ai:
        chat:
          openai:
            api-key: "${ZHIPU_API_KEY:}"
            base-url: "https://open.bigmodel.cn/api/paas/v4"   # 智谱的 OpenAI 兼容地址
            chat:
              options:
                model: "glm-4-flash"                          # 具体模型名以官方文档为准
  2. 若不想用自动装配,也可手工建一个 ChatModel bean(同样标 @Primary 或加 @Qualifier):

    // 若不想用自动装配,也可手工建一个 ChatModel bean(同样标 @Primary 或加 @Qualifier):
    @Bean
    public ChatModel zhipuChatModel() {
        return OpenAiChatModel.builder()
                .baseUrl("https://open.bigmodel.cn/api/paas/v4")
                .apiKey("${ZHIPU_API_KEY:}")
                .defaultOptions(OpenAiChatOptions.builder().model("glm-4-flash").build())
                .build();
    }

第二步:写一个模型路由器

  1. 右键 ch06 包 → New → Java Class,名称 ModelRouter,回车,粘贴下面整段并保存:

    package com.example.springai.ch06;
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.ai.chat.client.ChatClient;
    import org.springframework.stereotype.Component;
    import java.util.Map;
    
    /**
     * 模型路由器:按任务类型选择合适的 ChatClient。
     * 好处:业务代码只依赖 TaskType 枚举,换模型策略只改这一个类。
     */
    @Slf4j
    @Component
    public class ModelRouter {
    
        public enum TaskType {
            /** 意图分类、关键词提取、格式转换——简单、高频、可容忍略低质量 */
            CLASSIFY,
            /** 日常问答、RAG 生成、文本润色——主力场景 */
            GENERATE,
            /** 复杂推理、多步规划、代码生成——低频但要求高 */
            REASON
        }
    
        /**
         * ★ 关键注入技巧:Map 的 key 是 bean 名,value 是 ChatClient。
         * Spring 会把容器里所有 ChatClient 类型 bean 按名字装进这个 Map。
         * 这样业务代码不需要知道具体有几个模型,只按名字取。
         */
        private final Map<String, ChatClient> clients;
    
        public ModelRouter(Map<String, ChatClient> clients) {
            this.clients = clients;
            log.info("已加载模型客户端:{}", clients.keySet());
        }
    
        public ChatClient route(TaskType type) {
            return switch (type) {
                case CLASSIFY -> getOrDefault("cheapClient", "standardClient");
                case GENERATE -> getOrDefault("standardClient", "strongClient");
                case REASON   -> getOrDefault("strongClient", "standardClient");
            };
        }
    
        /** 按优先级找第一个存在的 bean,某个模型没配也不至于启动失败 */
        private ChatClient getOrDefault(String... candidates) {
            for (String name : candidates) {
                ChatClient c = clients.get(name);
                if (c != null) return c;
            }
            // 兜底:取任意一个
            return clients.values().iterator().next();
        }
    }

第三步:用起来

  1. 右键 ch06 包 → New → Java Class,名称 TicketService,回车,粘贴下面整段并保存(它是路由的「使用示例」,后面 6.6 会换成更完整的 TicketConsultService):

    @Service
    public class TicketService {
    
        private final ModelRouter router;
    
        public TicketService(ModelRouter router) {
            this.router = router;
        }
    
        /** 分类任务用便宜模型——单次成本可能只有强模型的 1/20 */
        public String classify(String userText) {
            return router.route(ModelRouter.TaskType.CLASSIFY)
                    .prompt()
                    .system("把用户描述归类到 [退款/物流/质量/其他],只输出类别名。")
                    .user(userText)
                    .options(OpenAiChatOptions.builder().temperature(0.0).build())
                    .call()
                    .content()
                    .trim();
        }
    
        /** 复杂方案生成用强模型 */
        public String plan(String userText) {
            return router.route(ModelRouter.TaskType.REASON)
                    .prompt()
                    .system("你是售后方案专家,请给出结构化的处理方案。")
                    .user(userText)
                    .call()
                    .content();
        }
    }

运行验证(s4):路由加载日志 + 启动无冲突

mvn spring-boot:run

预期输出:应用启动日志里出现 ModelRouter 打印的已加载客户端清单,且没有 NoUniqueBeanDefinitionException:

c.e.s.ch06.ModelRouter : 已加载模型客户端:[standardClient, cheapClient, memoryChatClient]

看到什么算通过:日志出现上面这行(具体 bean 名可能不同),且应用最终 Started。说明多个 ChatModel/ChatClient bean 被正确区分,路由能按名字取到客户端。

报错原文:UnsatisfiedDependencyException ... No qualifying bean of type 'org.springframework.ai.chat.model.ChatModel' available: expected single matching bean but found 2 或 NoUniqueBeanDefinitionException。

根因:容器里有多个 ChatModel bean(主模型 + 本地/备用模型),某处按类型注入时产生歧义;或 standardClient/cheapClient 的构造器没用 @Qualifier 指定要注入哪个 ChatModel。修复:① 主模型加 @Primary;② 其余在注入点用 @Qualifier("bean名") 精确指定(如本例 standardClient 用 @Qualifier("primaryChatModel"));③ 若只想按名字收集所有客户端,用 Map<String, ChatClient> 注入(路由已用此技巧)。

现象:路由返回的客户端名和日志对不上,Ollama 任务没走本地模型。

根因:bean 名与 route() 里的 getOrDefault(...) 候选名不一致(例如 @Bean("cheapClient") 但路由里写的是 "cheap")。修复:启动时看 clients.keySet() 打印的实际 bean 名,把路由里的候选名改成完全一致;本例已统一为 standardClient / cheapClient / strongClient。

报错原文:java.net.UnknownHostException: open.bigmodel.cn 或 404 Not Found / Model glm-4-flash does not exist。

根因:接智谱等兼容厂商时,三处最易错——① base-url 写错(必须是 OpenAI 兼容地址,末尾常带 /v4,且不要漏 https://);② 把密钥硬编码成 sk-xxxx 而不是 ${ZHIPU_API_KEY:} 占位符,导致换环境就 401;③ model 名与官方当前可用模型不一致(模型名会随厂商更新变化)。修复:对照官方文档核对 base-url 与 model;密钥一律走 ${XXX_API_KEY:} 环境变量占位符;先用 curl 直连厂商端点验证连通性,再接 Spring AI。

运行验证(s4 补充):两个模型确实在分工

光看启动日志还不够,要确认「分类真的走了便宜模型、生成走了标准模型」,否则成本没降下来你都不知道。

  1. 在 ModelRouter.route() 选中客户端后,临时加一行日志:log.info("任务 {} 选中客户端 {}", type, 实际选中的客户端.getClass().getSimpleName());
  2. 启动后,用 6.6 的 curl 连续发「分类类」和「生成类」请求,观察控制台。
  3. 预期:分类类请求选中便宜模型(本例 cheapClient 背后的 Ollama),生成类选中标准模型(standardClient 背后的 DeepSeek)。
现象:分类也走了标准(贵)模型,成本没降下来。

根因:route() 里 getOrDefault("cheapClient", "standardClient") 的候选名与实际 bean 名不一致(例如写成了 "cheap"),于是永远兜底到第二个候选——标准模型。修复:对照启动日志里 clients.keySet() 打印的实际 bean 名,把候选名改成完全一致;本例已统一为 cheapClient / standardClient。

对照:多模型「按请求切换」vs「多 bean 路由」(成对概念)

维度多 bean 路由(本章做法)按请求切换(单 bean 动态选)
bean 数量多个 ChatModel/ChatClient bean,各有名字通常一个 ChatModel bean,运行时改参数
怎么选模型Map<String, ChatClient> 按名字取,或用 @Qualifier每次请求动态指定 base-url/model/api-key
隔离性强:各模型配置完全独立,互不干扰弱:同一个客户端改来改去,易串配置
适用「固定几套模型,按任务分工」的生产场景「同一套协议,临时换一下模型名」的轻量场景
冲突风险要处理多 bean 冲突(@Primary/@Qualifier)无 bean 冲突,但要在调用处写选择逻辑
路由还可以做得更「智能」:用一个小模型先判断问题复杂度,再决定用哪个模型(叫「模型级联」)。或者更简单的启发式规则:问题长度 < 30 字 → 便宜模型;包含「为什么/分析/对比/规划」→ 强模型;其余 → 标准模型。这套规则看起来很土,但在真实业务里能省下大量成本。
一句话记忆:多 ChatModel 共存必用 @Primary(主)+ @Qualifier(其余)化解冲突;国内厂商复用 spring-ai-starter-model-openai 改 base-url 即可;路由用 Map<String, ChatClient> 按名取最稳。

6.5降级、重试与熔断:模型服务挂了业务不能挂

模型服务是外部依赖,它一定会挂(密钥过期、配额耗尽、厂商故障、网络抖动)。生产系统必须做到:模型挂了,业务还能体面地降级,而不是给用户一堆 500。

第一层:Spring AI 内置重试

  1. 在 application.yml 增加重试配置(缩进对齐到 spring.ai 下):

    spring:
      ai:
        retry:
          # 最大尝试次数(含首次),3 表示最多重试 2 次
          max-attempts: 3
          backoff:
            initial-interval: 2s       # 首次等待
            multiplier: 2              # 每次翻倍:2s → 4s → 8s
            max-interval: 10s
          # ★ 4xx(401 密钥错、400 参数错)不重试——重试多少次都还是错
          on-client-errors: false
on-client-errors: false 是必须的设置。默认情况下 Spring AI 可能会对 4xx 也重试,结果是「密钥错误」被重试 3 次,白白浪费时间还刷了一堆错误日志。正确做法:只重试 5xx 和网络超时,因为它们才是「可能下一次就好了」的临时故障。

第二层:Resilience4j 做熔断与降级

  1. 在 pom.xml 新增两个依赖(熔断/重试/限时需要 Resilience4j + AOP 支持):

    <!-- 依赖 -->
    <dependency>
      <groupId>io.github.resilience4j</groupId>
      <artifactId>resilience4j-spring-boot3</artifactId>
    </dependency>
    <dependency>
      <!-- 切面支持,Resilience4j 的注解依赖 AOP -->
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
  2. 在 application.yml 追加 Resilience4j 配置:

    # yml 配置
    resilience4j:
      retry:
        instances:
          aiChat:
            max-attempts: 3
            wait-duration: 1s
            # 只对这些异常重试(网络超时 + Spring AI 的可重试异常)
            retry-exceptions:
              - org.springframework.web.client.ResourceAccessException
              - org.springframework.ai.retry.TransientAiException
      circuitbreaker:
        instances:
          aiChat:
            sliding-window-size: 20              # 统计最近 20 次调用
            failure-rate-threshold: 50           # 失败率超过 50% 就打开熔断
            wait-duration-in-open-state: 30s     # 熔断 30 秒后进入半开状态试一次
            permitted-number-of-calls-in-half-open-state: 3
      timelimiter:
        instances:
          aiChat:
            timeout-duration: 60s                # 硬超时,超了直接抛异常触发降级
  3. 右键 ch06 包 → New → Java Class,名称 ResilientChatService,回车,粘贴下面整段并保存:

    @Slf4j
    @Service
    public class ResilientChatService {
    
        private final ModelRouter router;
    
        public ResilientChatService(ModelRouter router) {
            this.router = router;
        }
    
        /**
         * 带重试 + 熔断 + 降级的问答。
         *
         * 执行顺序:TimeLimiter 包在最外 → CircuitBreaker → Retry → 真正的调用
         */
        @CircuitBreaker(name = "aiChat", fallbackMethod = "fallbackReply")
        @Retry(name = "aiChat")
        @TimeLimiter(name = "aiChat")
        public String ask(String question) {
            return router.route(ModelRouter.TaskType.GENERATE)
                    .prompt()
                    .user(question)
                    .call()
                    .content();
        }
    
        /**
         * 降级方法。签名必须与被保护方法一致,并多一个异常参数。
         * 注意:降级不是"报错给用户",而是"给用户一个可用的替代结果"。
         */
        public String fallbackReply(String question, Throwable t) {
            log.warn("主模型不可用,走降级。原因:{}", t.toString());
    
            // 降级策略 A:改用备用模型(例如本地 Ollama)
            try {
                return router.route(ModelRouter.TaskType.CLASSIFY)   // 用便宜模型顶上
                        .prompt()
                        .user(question)
                        .call()
                        .content();
            } catch (Exception ignore) {
                // 降级策略 B:全部失败,返回兜底话术,绝不抛 500 给用户
                return "抱歉,智能助手暂时繁忙,请稍后重试或联系人工客服(分机 8001)。";
            }
        }
    }

运行验证(s5):应用启动 + 熔断配置生效

mvn spring-boot:run

预期输出:应用正常 Started,且日志里能看到 Resilience4j 为 aiChat 创建了 CircuitBreaker/Retry/TimeLimiter(通常以 INFO 形式打印 bean 创建,或首次调用时生效)。

看到什么算通过:应用启动无报错;随后用下面的命令手动触发一次调用(需要你有一个能命中 ResilientChatService.ask 的入口,或在 6.6 总装里一并验证),观察主模型异常时是否返回兜底话术而非 500。

报错原文:fallbackMethod 'fallbackReply' not found / signature mismatch 或降级方法「不生效」。

根因:Resilience4j 的降级方法签名必须与被保护方法完全一致,并额外多一个 Throwable 参数;且必须是 public。常见错误:少了异常参数、返回值类型不一致、方法写成 private。修复:把降级方法改成 public String fallbackReply(String question, Throwable t),返回类型与参数(除末尾 Throwable 外)与被保护方法相同。

现象:密钥明明错了,却看到 3 次重试日志,浪费十几秒。

根因:spring.ai.retry.on-client-errors 没设为 false(默认会对 4xx 也重试)。4xx 是密钥无效/参数非法,重试多少次都还是错。修复:在 application.yml 显式加 spring.ai.retry.on-client-errors: false;密钥类错误应立即失败并告警,而不是反复重试。

现象:熔断打开后很久不恢复,日志一直 CircuitBreaker 'aiChat' is OPEN,正常请求全走降级。

根因:半开状态(permitted-number-of-calls-in-half-open-state 次试调用)仍然失败,说明下游(密钥过期/配额耗尽/网络)真的还挂着,不是配置问题。熔断是「诚实」的——它告诉你下游没好。修复:先去查真实下游:① 密钥是否过期(echo 环境变量、确认 IDEA 重启继承到);② 配额/余额是否耗尽;③ 网络/代理是否通。恢复下游后,等 wait-duration-in-open-state(本例 30s)到期,熔断会自动进入半开试调用并闭合。

降级的四种层次(按优先级选): ① 换模型(最强的兜底,用户无感知); ② 用缓存(这个问题之前答过,缓存里有直接返回); ③ 用规则(FAQ 精确匹配,虽然不智能但至少能答); ④ 友好话术 + 转人工(最后退路,坦诚比假装聪明好)。
一句话记忆:模型必挂,所以必须兜底:内置重试只重试 5xx/超时(on-client-errors:false),Resilience4j 做熔断+降级,降级方法签名要与被保护方法一致且多一个 Throwable。

6.6完整示例:多轮工单咨询

把本章能力全部用上:会话隔离 + JDBC 持久化记忆 + 分类路由 + 降级。这是本章的「总装」。下面给出完整可运行的文件清单——前面各节新建的类都已就绪,这里补上「业务总装」与「HTTP 入口」,让三条 curl 真能跑。

src/main/java/com/example/springai/ch06/ ├── MemoryConfig.java # JDBC ChatMemory + MessageChatMemoryAdvisor 装配 ├── MultiModelConfig.java # 多模型 bean(@Primary + @Qualifier) ├── ModelRouter.java # 模型路由(6.4 节) ├── ConversationSummaryService.java # 历史摘要压缩(6.3 节) ├── SummaryStore.java / InMemorySummaryStore.java # 摘要存取(6.3 节,生产换 Redis) ├── TicketConsultService.java # 核心业务:分类 → 路由 → 多轮回答 ├── TicketConsultController.java # HTTP 接口(新增,提供 /api/v1/tickets/consult) └── (可选)ResilientChatService.java / TicketService.java # 6.5 / 6.4 的演示类

动手:总装业务类 TicketConsultService

  1. 右键 ch06 包 → New → Java Class,名称 TicketConsultService,回车,粘贴下面整段并保存:

    @Slf4j
    @Service
    public class TicketConsultService {
    
        private final ModelRouter router;
        private final ConversationSummaryService summaryService;
        private final SummaryStore summaryStore;   // 存摘要,本地用内存版,生产换 Redis
    
        private static final String SYSTEM = """
                你是"智汇"公司的工单咨询助手。
    
                处理流程:
                1. 先判断用户问题属于 [退款/物流/质量/其他] 中的哪一类。
                2. 针对该类问题给出明确的处理步骤与时效说明。
                3. 需要订单号等信息但用户未提供时,主动追问。
    
                规则:
                - 不得承诺具体赔付金额,不得编造时效。
                - 用户情绪激动时,先共情一句再给方案。
                - 回答不超过 200 字。
                """;
    
        public TicketConsultService(ModelRouter router,
                                    ConversationSummaryService summaryService,
                                    SummaryStore summaryStore) {
            this.router = router;
            this.summaryService = summaryService;
            this.summaryStore = summaryStore;
        }
    
        public ConsultResult consult(String tenantId, String userId,
                                        String sessionId, String message) {
    
            // ① 统一生成会话 ID(全项目唯一入口)—— 见 6.1 的 ConversationIds
            String conversationId = ConversationIds.of(tenantId, userId, sessionId);
    
            // ② 用便宜模型做分类——这类任务不需要强模型
            String category = router.route(ModelRouter.TaskType.CLASSIFY)
                    .prompt()
                    .system("把用户描述归类到 [退款/物流/质量/其他],只输出类别名。")
                    .user(message)
                    .options(OpenAiChatOptions.builder().temperature(0.0).build())
                    .call()
                    .content().trim();
            log.info("会话 {} 分类结果:{}", conversationId, category);
    
            // ③ 取出长期摘要,拼进系统提示
            String summary = summaryStore.get(conversationId);
            String system = (summary == null || summary.isBlank())
                    ? SYSTEM
                    : SYSTEM + "\n\n【历史对话摘要】\n" + summary;
    
            // ④ 主模型生成回答:
            //    - 走 GENERATE 路由(标准模型)
            //    - 显式传 ChatMemory.CONVERSATION_ID,让 MessageChatMemoryAdvisor 读写记忆
            //    - 注意:记忆 Advisor 已在 ChatClient 上 defaultAdvisors 挂好
            String answer;
            try {
                answer = router.route(ModelRouter.TaskType.GENERATE)
                        .prompt()
                        .system(system)
                        .user(message)
                        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                        .call()
                        .content();
            } catch (Exception e) {
                log.error("模型调用失败,走降级", e);
                answer = "抱歉,我暂时无法处理,已为您转接人工客服,请稍候。";
                category = "人工";
            }
    
            return new ConsultResult(sessionId, category, answer);
        }
    
        public record ConsultResult(String sessionId, String category, String answer) {}
    }

    注意:本类用到了 ConversationIds(在 common 包),Spring 会自动跨包注入;分类/生成走 router,记忆靠 ChatMemory.CONVERSATION_ID 参数驱动——每次调用都显式传,绝不漏。

  2. 右键 ch06 包 → New → Java Class,名称 TicketConsultController,回车,粘贴下面整段并保存(这是让三条 curl 能跑起来的 HTTP 入口):

    import org.springframework.web.bind.annotation.*;
    
    @RestController
    @RequestMapping("/api/v1/tickets")
    public class TicketConsultController {
    
        private final TicketConsultService service;
    
        public TicketConsultController(TicketConsultService service) {
            this.service = service;
        }
    
        @PostMapping("/consult")
        public TicketConsultService.ConsultResult consult(@RequestBody ConsultRequest req) {
            return service.consult(req.tenantId(), req.userId(), req.sessionId(), req.message());
        }
    
        /** 请求体:与 6.6 三条 curl 的 JSON 字段一一对应 */
        public record ConsultRequest(String tenantId, String userId, String sessionId, String message) {}
    }
  3. 再次编译确认全章文件都能通过:

    mvn -q -DskipTests compile

    预期:BUILD SUCCESS。

运行验证(s6):三条 curl 验收

先启动应用(保持运行),再依次执行下面三条命令。每条都给「预期输出」和「看到什么算通过」。

# 启动(若已在运行可跳过)
mvn spring-boot:run
  1. 第 1 轮:提供订单号,验证分类与回答。

    curl -X POST http://localhost:8080/api/v1/tickets/consult \
      -H "Content-Type: application/json" \
      -d '{"tenantId":"t1","userId":"u1001","sessionId":"s1","message":"订单 2024001 一直没发货,怎么回事"}'

    预期输出(示例):

    {"sessionId":"s1","category":"物流","answer":"您好,订单 2024001 物流更新较慢,建议您先联系承运方查询。若超 7 天未更新可申请退款,我可协助您提交。"}

    看到什么算通过:category 为「物流」,answer 给出处理步骤,且控制台打印了 会话 t1|u1001|s1 分类结果:物流。

  2. 第 2 轮:不带订单号,测试记忆是否生效(同一 sessionId)。

    curl -X POST http://localhost:8080/api/v1/tickets/consult \
      -H "Content-Type: application/json" \
      -d '{"tenantId":"t1","userId":"u1001","sessionId":"s1","message":"那我这个单还能取消吗"}'

    预期输出(示例):

    {"sessionId":"s1","category":"物流","answer":"您说的「这个单」应为订单 2024001。未发货状态下可直接取消,路径:我的订单→2024001→取消订单。"}

    看到什么算通过:模型明确引用了「订单 2024001」→ 记忆生效(MessageChatMemoryAdvisor 通过 CONVERSATION_ID 读到了第 1 轮历史)。

  3. 第 3 轮:换会话,测试隔离(不同 sessionId)。

    curl -X POST http://localhost:8080/api/v1/tickets/consult \
      -H "Content-Type: application/json" \
      -d '{"tenantId":"t1","userId":"u1001","sessionId":"s2","message":"那我这个单还能取消吗"}'

    预期输出(示例):

    {"sessionId":"s2","category":"其他","answer":"您好,为了准确帮您处理,请提供对应的订单号,我来为您查询能否取消。"}

    看到什么算通过:模型答不出订单号、反而追问订单号 → 隔离生效(s2 会话没有 s1 的历史)。

  4. 验证持久化:重启应用后重放第 2 轮,应该仍然记得。

    mvn spring-boot:run   # Ctrl+C 停止后再启动
    # 再执行第 2 轮 → 仍然记得 → JDBC 持久化生效

    看到什么算通过:重启后第 2 轮仍引用「订单 2024001」→ JDBC 记忆持久化生效(若换成 InMemory 则会忘记,可对照实验)。

报错原文:java.lang.IllegalArgumentException: conversationId is required(或类似「缺少 CONVERSATION_ID」)。

根因:调用 ChatClient 时忘了 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))。2.0 不再有默认值,漏传直接抛异常。修复:在每次需要记忆的调用末尾补上这一句;用 ConversationIds.of(...) 保证 id 非 null 且格式统一。

现象:第 3 条(换 sessionId)模型却答出了订单号。

根因:会话隔离失败——conversationId 用了固定值 / 未传 / 拼接规则不统一,导致 s1 与 s2 落到同一条记忆线。修复:立刻去查 spring_ai_chat_memory 表里 conversation_id 的取值(这是最快的定位方法),确认每次调用的 id 真的不同;并统一走 ConversationIds.of() 生成。

这三条命令是本章的核心验收动作。第 3 条如果模型答出了订单号,说明会话隔离失败,立刻去查 spring_ai_chat_memory 表里 conversation_id 的取值——这是最快的定位方法。
一句话记忆:总装 = 统一 ID + 分类走便宜模型 + 生成走标准模型且每次显式传 CONVERSATION_ID;三条 curl 里「换 sessionId 答不出订单号」就是隔离通过的铁证。

6.7常见坑排错

现象原因解决
重启后记忆全没了用的 InMemory 实现换成 JDBC / Redis 版
多实例部署时记忆时有时无InMemory 不跨实例共享换持久化实现;或做会话粘性(不推荐)
A 用户看到了 B 用户的对话conversationId 用了固定值 / 未传 / 拼接规则不统一统一走 ConversationIds.of();查表核对 conversation_id
对话越聊越慢maxMessages 太大,或没做摘要压缩限条数 + token 预算裁剪 + 摘要压缩三步走
JDBC 版启动报「表不存在」initialize-schema 为 never 且未手工建表开发环境设 always,生产环境手工执行 DDL
注入 Map<String, ChatClient> 时启动报错容器里有多个 ChatClient bean,按类型注入产生歧义用 Map 注入(本章做法);或给每个 bean 明确名字并用 @Qualifier
启动报 NoUniqueBeanDefinition(ChatModel)多个 ChatModel bean 未区分主模型加 @Primary,其余用 @Qualifier 精确注入
Ollama 模型路由不生效bean 名与路由里的 key 不匹配启动时打印 clients.keySet()(本章代码已打印),核对名字
熔断打开了但一直不恢复半开状态试调用仍失败说明下游真挂了,需要人工介入;检查密钥/配额/网络
降级方法没被调用签名不匹配(参数或异常类型不对)降级方法必须是 public,参数列表一致,最后多一个 Throwable
重试 4xx 浪费时间没设 on-client-errors: false加上这个配置;密钥错误应该立即失败并告警
编译报「找不到符号 log」(@Slf4j 类)工程未引入 Lombok加 Lombok 依赖/插件,或把 @Slf4j 换成手动 LoggerFactory.getLogger(...)
调用即抛 IllegalArgumentException: conversationId is required漏传 ChatMemory.CONVERSATION_ID每次调用补 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id))

6.8自测题

Q1. conversationId 应该包含哪几层信息?为什么不能让前端直接传 userId?

通常包含 租户 | 用户 | 会话 三层。不能让前端传 userId,因为前端可被伪造——攻击者改成别人的 userId 就能读到别人的对话历史(越权读取)。正确做法是从登录态(JWT/Session)中取,并校验 sessionId 确实属于该用户。

Q2. 记忆存 1000 条和每次发送 1000 条有什么区别?工程上怎么平衡?

存储为了审计与回溯,全量保留没问题;发送要控制,因为每多一条就多消耗 Token、多增加延迟,且信息过多会稀释模型的注意力。平衡方案:存储全量 + 发送时用滑动窗口(最近 20 条)+ 摘要压缩(远期记忆)+ token 预算裁剪。

Q3. 什么任务适合用便宜的小模型?为什么?

意图分类、情感判断、关键词提取、格式转换、简单摘要、翻译等任务边界清晰、输出简短、对推理深度要求低的场景。这些任务小模型完全够用,而调用量往往占总量的 70% 以上——把它们下放到小模型,是降低成本最有效的手段。

Q4. 为什么 on-client-errors 应该设为 false?

4xx 是客户端错误(密钥无效、参数非法、模型名不存在),重试一定还是同样的结果,只会浪费时间、消耗配额、污染日志。只有 5xx 和网络超时才是「临时性故障」,才值得重试。

Q5.【动手题】设计一个「成本感知」的路由规则:统计当日累计 Token 消耗,超过预算后自动降级到便宜模型。请写出核心逻辑。

提示:用一个 AtomicLong 或 Redis 计数器记录当日 tokens(第 3 章的 TokenUsageAdvisor 已经在采集);在 ModelRouter.route() 里读取当前值,若超过阈值(如 500 万 token)则把 GENERATE 也路由到 cheapClient,并打 WARN 日志;零点通过定时任务重置。生产环境还应在 80% 时告警。

Q6.【易错辨析】下面这段 1.x 旧代码为什么在 2.0 编译/运行不过?该怎么改?

// 旧写法(已移除/删除,2.0 不可用)
ChatClient client = ChatClient.builder(model)
.defaultAdvisors(new PromptChatMemoryAdvisor(memory))
.build();
client.prompt().user("hi").call(); // 没传 conversationId
答:两处都坏了——① PromptChatMemoryAdvisor 类在 2.0 已被移除/删除,正解是 MessageChatMemoryAdvisor;② 没传 ChatMemory.CONVERSATION_ID,2.0 必填,缺失直接抛 IllegalArgumentException。修正:defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build()),并在调用处补 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id))。

6.9实操作业与本章验收

课堂练习(课上完成,约 1 学时):

  • 把 6.6 节的示例跑起来,依次执行三条 curl:确认第 2 条能记住订单号、第 3 条(换 sessionId)答不出订单号。
  • 故意把 MultiModelConfig 里主模型的 @Primary 注释掉,观察启动是否报 NoUniqueBeanDefinitionException,再恢复——体会 @Primary/@Qualifier 的作用。
  • 把记忆实现从 JDBC 临时换成 InMemory,重启应用后重放第 2 条,确认记忆丢失,再换回 JDBC。

课后作业:

  • 提交 6.6 三条 curl 的执行截图(含第 3 条隔离生效的证据)。
  • 写 300 字说明:你的项目若要接入智谱 GLM,需要改 application.yml 的哪几项?为什么复用 OpenAiChatModel(即 spring-ai-starter-model-openai)即可?
  • (选做)给 ResilientChatService 的 ask 方法补一个单元测试,用 Mockito 模拟主模型抛异常,验证降级方法返回了兜底话术。

本章 API 红线速查(考前背一遍):

  • conversationId 必填:每次调用都显式传 ChatMemory.CONVERSATION_ID;缺失/null 在 2.0 直接抛 IllegalArgumentException(1.x 的 DEFAULT_CONVERSATION_ID 默认值已删除)。
  • 记忆 Advisor 正解:MessageChatMemoryAdvisor。PromptChatMemoryAdvisor 整个类已移除/删除,只能出现在「已废弃/移除」的解释语境。
  • 多模型冲突:主 ChatModel 标 @Primary,其余用 @Qualifier;按名收集所有客户端用 Map<String, ChatClient>。
  • 国内厂商:智谱等走 OpenAI 兼容协议,复用 spring-ai-starter-model-openai,只改 yml 的 base-url/api-key/model,不要找不存在的独立 starter。
  • 重试:spring.ai.retry.on-client-errors 必须 false,4xx 不重试。
  • 嵌入配置:没有 .options 层级(只有 chat 才有),照抄 chat 写法会绑定失败。
  • 密钥:一律 ${XXX_API_KEY:} 占位符,绝不硬编码;旧写法 api-key: sk-xxxx 只许出现在「移除/删除/废弃/过时/不存在」语境。
  • 版本:Spring AI 2.0.1 + Boot 4.1.x + Java 17。