CH 06记忆与多模型
这一章解决两个「生产环境才会暴露」的问题:记忆(多轮对话怎么存、怎么隔离、怎么不爆炸)和 多模型(不同任务用不同模型、贵模型挂了怎么办)。这两件事做不好,Demo 没问题,一上生产就出事。本章无配套 lab 代码,所有操作按「在课程工程里自建 ch06 包」的口径写——你跟着步骤在自己工程里新建文件即可。
教学设计说明(教师参考):本章概念密度高但代码量不大,建议用「先讲事故再写代码」的节奏:先用 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 里是必填的,每一次调用都必须显式传入。
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 节)。
MessageChatMemoryAdvisor。
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。
-
在 IDEA 左侧 Project 面板,展开
src/main/java/com/example/springai,右键springai→ New → Package,输入ch06,回车。包名最终为com.example.springai.ch06。 -
右键刚建好的
ch06包 → New → Java Class,名称输入ConversationIds(注意复数、首字母大写),回车。 -
把下面整段代码原样粘贴进
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 的必填校验,把这张「报错脸」记住。
- 临时把
TicketConsultService(6.6 节)或任何调用处的.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))注释掉一行。 - 启动应用,用 6.6 的任一 curl 发一次请求。
- 预期:立刻抛
IllegalArgumentException,请求直接失败——而不是像 1.x 那样静默退化到 "default" 会话、把所有人对话混在一起。 - 把那行恢复,确认业务恢复正常。
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 的三层含义
| 层级 | 示例 | 作用 |
|---|---|---|
| 用户 userId | u1001 | 「这是谁的记忆」——决定数据归属,用于权限校验 |
| 会话 sessionId | web-20260915-a3f2 | 同一用户可能有多个并行会话(网页/小程序/客服工单),互不干扰 |
| 租户 tenantId | t-acme | B 端项目必需,用于物理隔离不同客户的数据 |
userId 由前端传入且未校验,攻击者传 userId = "u1001|s1"、sessionId = "x",拼出来的 id 恰好等于 "...|u1001|s1|x"?只要分隔符可被控制,就可能构造出别人的 conversationId,从而读到别人的对话历史。
排查步骤:① 出现「A 用户看到 B 用户对话」时,先查 spring_ai_chat_memory 表的 conversation_id 取值,看是否出现了非法分隔符拼接;② 确认 userId/sessionId 是否来自登录态而非前端裸传;③ 确认 ConversationIds.sanitize 是否对所有入参生效。
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();
② 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 即可。
-
打开
pom.xml,在<dependencies>里新增 JDBC 记忆仓库依赖(若已有则跳过)。把下面片段粘贴进去,保存:<!-- 依赖:JDBC 记忆仓库 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId> </dependency> -
打开
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 -
右键
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 自动装配;这里直接按类型注入即可。若你的工程里有多个ChatModelbean(见 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 仓库。下面是接法。
-
在
pom.xml新增 Redis 记忆仓库依赖:<!-- 依赖 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-chat-memory-repository-redis</artifactId> </dependency> -
在
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 | ✅ | ✅ | 已有对应基础设施的团队 |
spring-ai-session(社区项目)。内置的 ChatMemory 有一个设计上的局限:多数仓库实现不支持存储 tool-call 与 tool-response 消息,所以 2.0 默认把记忆 Advisor 放在工具循环之外(见第 3 章的顺序说明),循环中间的完整工具转录不会进记忆。spring-ai-session 用事件溯源(event-sourced)的方式重做了这件事:支持全部消息类型(含工具调用),可以安全地放在循环内部;并且提供可插拔的、按轮次感知的上下文压缩策略(包含 LLM 摘要),上下文窗口满了会自动压缩。官方计划在 2.1 用它替换内置 Memory。如果你的项目需要"完整对话审计"或长会话自动压缩,现在就可以评估它(以官方文档为准)。
maxMessages 都要设——存得多不等于每次都发得多。
6.3上下文窗口控制:别让记忆把成本和延迟拖垮
核心认知:存下来的历史 ≠ 每次都要发送的历史。存 1000 条是为了审计和回溯,但每次调用只应该发最相关的少量内容。把全部历史塞给模型,既烧钱又会让模型注意力被稀释。
策略一:滑动窗口(最简单)
// maxMessages(20) 就是滑动窗口:永远只保留最近 20 条(约 10 轮对话)
MessageWindowChatMemory.builder().maxMessages(20).build()
// 注意:maxMessages 数的是"消息条数",不是"token 数"。
// 20 条短消息可能只有 500 token,20 条长消息可能 20000 token。
// 所以更严谨的做法是再加一层 token 控制(见策略三)。
策略二:历史摘要压缩(长对话必备)
滑动窗口的问题:用户在第 3 轮说的关键信息(比如订单号),到第 25 轮就被滑出去了,模型就"忘了"。解决办法是把被滑出去的历史先摘要成一段话,作为系统提示的一部分保留下来。
-
右键
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);。代码语义不变。 -
摘要要落库才能跨轮复用。新建一个极简的
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); } } -
把摘要作为「长期记忆」注入到每次请求(下面的
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);
}
- 短期记忆:ChatMemory 存最近 20 条原文(
MessageWindowChatMemory) - 中期记忆:每积累 10 轮,把最早的历史压缩成摘要,覆盖式更新
- 长期记忆:把「用户偏好、历史工单、已购商品」等结构化事实存数据库,按需检索(本质上就是 RAG)
6.4多模型共存与路由:让合适的模型干合适的活
为什么需要多模型?三个非常现实的原因:
- 成本:一次简单的意图分类用 GPT-4 级别的模型,等于用出租车送快递。同样 1 万次调用,成本可能差 20 倍。
- 能力:复杂推理、长文规划必须用强模型;简单的摘要、抽取用小模型就够。
- 可用性:主模型服务商挂了(每年总会遇到几次),业务不能停。
第一步:让多个 ChatModel 共存
两个 starter 同时在 pom 里,Spring Boot 会自动装配出 OpenAiChatModel 和 OllamaChatModel,它们都实现了 ChatModel 接口。但容器里一旦有多个 ChatModel 类型的 bean,直接按类型注入就会冲突——这正是下一步要用 @Primary + @Qualifier 解决的关键。
-
右键
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 适合「有一个默认主力、其余是特例」的场景——主力上标 @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):
-
在
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" # 具体模型名以官方文档为准 -
若不想用自动装配,也可手工建一个 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(); }
第二步:写一个模型路由器
-
右键
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(); } }
第三步:用起来
-
右键
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> 注入(路由已用此技巧)。
根因: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 补充):两个模型确实在分工
光看启动日志还不够,要确认「分类真的走了便宜模型、生成走了标准模型」,否则成本没降下来你都不知道。
- 在
ModelRouter.route()选中客户端后,临时加一行日志:log.info("任务 {} 选中客户端 {}", type, 实际选中的客户端.getClass().getSimpleName()); - 启动后,用 6.6 的 curl 连续发「分类类」和「生成类」请求,观察控制台。
- 预期:分类类请求选中便宜模型(本例
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 冲突,但要在调用处写选择逻辑 |
@Primary(主)+ @Qualifier(其余)化解冲突;国内厂商复用 spring-ai-starter-model-openai 改 base-url 即可;路由用 Map<String, ChatClient> 按名取最稳。
6.5降级、重试与熔断:模型服务挂了业务不能挂
模型服务是外部依赖,它一定会挂(密钥过期、配额耗尽、厂商故障、网络抖动)。生产系统必须做到:模型挂了,业务还能体面地降级,而不是给用户一堆 500。
第一层:Spring AI 内置重试
-
在
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 做熔断与降级
-
在
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> -
在
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 # 硬超时,超了直接抛异常触发降级 -
右键
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 外)与被保护方法相同。
根因: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)到期,熔断会自动进入半开试调用并闭合。
on-client-errors:false),Resilience4j 做熔断+降级,降级方法签名要与被保护方法一致且多一个 Throwable。
6.6完整示例:多轮工单咨询
把本章能力全部用上:会话隔离 + JDBC 持久化记忆 + 分类路由 + 降级。这是本章的「总装」。下面给出完整可运行的文件清单——前面各节新建的类都已就绪,这里补上「业务总装」与「HTTP 入口」,让三条 curl 真能跑。
动手:总装业务类 TicketConsultService
-
右键
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参数驱动——每次调用都显式传,绝不漏。 -
右键
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) {} } -
再次编译确认全章文件都能通过:
mvn -q -DskipTests compile预期:
BUILD SUCCESS。
运行验证(s6):三条 curl 验收
先启动应用(保持运行),再依次执行下面三条命令。每条都给「预期输出」和「看到什么算通过」。
# 启动(若已在运行可跳过)
mvn spring-boot:run
-
第 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 轮:不带订单号,测试记忆是否生效(同一 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 轮:换会话,测试隔离(不同 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 的历史)。
-
验证持久化:重启应用后重放第 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 且格式统一。
根因:会话隔离失败——conversationId 用了固定值 / 未传 / 拼接规则不统一,导致 s1 与 s2 落到同一条记忆线。修复:立刻去查 spring_ai_chat_memory 表里 conversation_id 的取值(这是最快的定位方法),确认每次调用的 id 真的不同;并统一走 ConversationIds.of() 生成。
spring_ai_chat_memory 表里 conversation_id 的取值——这是最快的定位方法。
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。
- 能说出 conversationId 的三层结构与生成规则,并解释「为什么不能让前端直接传 userId」的越权风险。
- 能独立把记忆从内存实现替换成 JDBC 实现,并验证重启后记忆不丢。
- 会话隔离验证通过(不同 sessionId 互不可见),且每次调用显式传入
ChatMemory.CONVERSATION_ID。 - 能配置出「分类走小模型、生成走大模型」的路由,并能解释多 ChatModel bean 冲突用
@Primary+@Qualifier如何解决。