CH 06记忆与多模型
这一章解决两个「生产环境才会暴露」的问题:记忆(多轮对话怎么存、怎么隔离、怎么不爆炸) 和 多模型(不同任务用不同模型、贵模型挂了怎么办)。这两件事做不好,Demo 没问题,一上生产就出事。
6.1会话隔离:conversationId 的设计是重中之重
第 2 章我们提到过 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。
ConversationIds.of() 生成 id,每次调用显式传参。
id 的三层含义
| 层级 | 示例 | 作用 |
|---|---|---|
| 用户 userId | u1001 | 「这是谁的记忆」——决定数据归属,用于权限校验 |
| 会话 sessionId | web-20260915-a3f2 | 同一用户可能有多个并行会话(网页/小程序/客服工单),互不干扰 |
| 租户 tenantId | t-acme | B 端项目必需,用于物理隔离不同客户的数据 |
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("|", "_");
}
}
userId 由前端传入且未校验,
攻击者传 userId = "u1001|s1"、sessionId = "x",
拼出来的 id 恰好等于 "...|u1001|s1|x"?只要分隔符可被控制,就可能构造出别人的 conversationId,从而读到别人的对话历史。
两条防线:① 服务端对 userId 做黑白名单校验(必须是已知用户);
② 拼接时对分隔符转义(如上)。永远不要相信前端传来的 userId。
6.2ChatMemory 的三种实现:从 Demo 到生产
① 内存版(Demo / 单机)
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
// 最简:只限条数
ChatMemory memory = MessageWindowChatMemory.builder()
.maxMessages(20)
.build();
// 显式指定存储仓库(默认就是内存版)
ChatMemory memory2 = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.maxMessages(20)
.build();
② JDBC 版(生产推荐,最通用)
<!-- 依赖(pom 里已有) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
# 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
import org.springframework.ai.chat.memory.JdbcChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
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();
}
}
自动建出来的表结构(了解即可,排查问题时会查这张表):
-- 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 spring_ai_chat_memory t JOIN (
-- SELECT conversation_id, timestamp,
-- ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY timestamp) - 1 AS seq
-- FROM spring_ai_chat_memory
-- ) o ON t.conversation_id = o.conversation_id AND t.timestamp = o.timestamp
-- SET t.sequence_id = o.seq;
-- 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 版(高并发场景)
<!-- 依赖 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-redis</artifactId>
</dependency>
import org.springframework.ai.chat.memory.RedisChatMemoryRepository;
@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.5 节的顺序说明),
循环中间的完整工具转录不会进记忆。spring-ai-session 用事件溯源(event-sourced)的方式重做了这件事:
支持全部消息类型(含工具调用),可以安全地放在循环内部;
并且提供可插拔的、按轮次感知的上下文压缩策略(包含 LLM 摘要),
上下文窗口满了会自动压缩。官方计划在 2.1 用它替换内置 Memory。如果你的项目需要"完整对话审计" 或长会话自动压缩,现在就可以评估它。
6.3上下文窗口控制:别让记忆把成本和延迟拖垮
核心认知:存下来的历史 ≠ 每次都要发送的历史。 存 1000 条是为了审计和回溯,但每次调用只应该发最相关的少量内容。
策略一:滑动窗口(最简单)
// maxMessages(20) 就是滑动窗口:永远只保留最近 20 条(约 10 轮对话)
MessageWindowChatMemory.builder().maxMessages(20).build()
// 注意:maxMessages 数的是"消息条数",不是"token 数"。
// 20 条短消息可能只有 500 token,20 条长消息可能 20000 token。
// 所以更严谨的做法是再加一层 token 控制(见策略三)。
策略二:历史摘要压缩(长对话必备)
滑动窗口的问题:用户在第 3 轮说的关键信息(比如订单号),到第 25 轮就被滑出去了,模型就"忘了"。 解决办法是把被滑出去的历史先摘要成一段话,作为系统提示的一部分保留下来。
@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();
}
}
把摘要作为「长期记忆」注入到每次请求:
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 提供近期原文,摘要提供远期记忆)
return chatClient.prompt()
.system(systemWithMemory)
.user(userMessage)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
}
策略三: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 接口。
@Configuration
public class MultiModelConfig {
/**
* 经济型:本地 Ollama,0 API 成本,适合分类/抽取/摘要。
* 注意 bean 名会成为路由时的 key。
*/
@Bean("cheapClient")
public ChatClient cheapClient(OllamaChatModel ollamaModel) {
return ChatClient.builder(ollamaModel).build();
}
/** 标准型:DeepSeek,性价比高,适合日常问答、RAG 生成 */
@Bean("standardClient")
public ChatClient standardClient(OpenAiChatModel openAiModel) {
return ChatClient.builder(openAiModel).build();
}
/**
* 强力型:另一个厂商的强模型(也可以用同一个 OpenAiChatModel + 不同 model 名)。
* 这里演示用同一个 model 类但覆盖默认 options。
*/
@Bean("strongClient")
public ChatClient strongClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultOptions(OpenAiChatOptions.builder()
.model("deepseek-reasoner") // 推理型模型
.temperature(0.3)
.build())
.build();
}
}
第二步:写一个模型路由器
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();
}
}
第三步:用起来
@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();
}
}
6.5降级、重试与熔断:模型服务挂了业务不能挂
第一层: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 做熔断与降级
<!-- 依赖 -->
<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>
# 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 # 硬超时,超了直接抛异常触发降级
@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)。";
}
}
}
6.6完整示例:多轮工单咨询
把本章能力全部用上:会话隔离 + JDBC 持久化记忆 + 分类路由 + 降级。
@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 ConsultResult consult(String tenantId, String userId,
String sessionId, String message) {
// ① 统一生成会话 ID(全项目唯一入口)
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;
// ④ 主模型生成回答,同时带上 ChatMemory(近期原文)
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) {}
}
验证(重点看第 2、3 条)
# 第 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 一直没发货,怎么回事"}'
# 期望:category=物流,回答里包含处理步骤
# 第 2 轮:不带订单号,测试记忆
curl -X POST http://localhost:8080/api/v1/tickets/consult \
-H "Content-Type: application/json" \
-d '{"tenantId":"t1","userId":"u1001","sessionId":"s1","message":"那我这个单还能取消吗"}'
# 期望:模型知道"这个单"指 2024001 → 记忆生效
# 第 3 轮:换个会话,测试隔离
curl -X POST http://localhost:8080/api/v1/tickets/consult \
-H "Content-Type: application/json" \
-d '{"tenantId":"t1","userId":"u1001","sessionId":"s2","message":"那我这个单还能取消吗"}'
# 期望:模型回答"请提供订单号" → 隔离生效
# 验证持久化:重启应用后重放第 2 轮,应该仍然记得
mvn spring-boot:run # Ctrl+C 停止后再启动
# 再执行第 2 轮 → 仍然记得 → JDBC 持久化生效
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 |
| Ollama 模型路由不生效 | bean 名与路由里的 key 不匹配 | 启动时打印 clients.keySet()(本章代码已打印),核对名字 |
| 熔断打开了但一直不恢复 | 半开状态试调用仍失败 | 说明下游真挂了,需要人工介入;检查密钥/配额/网络 |
| 降级方法没被调用 | 签名不匹配(参数或异常类型不对) | 降级方法必须是 public,参数列表一致,最后多一个 Throwable |
| 重试 4xx 浪费时间 | 没设 on-client-errors: false | 加上这个配置;密钥错误应该立即失败并告警 |
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% 时告警。
- 能说出 conversationId 的三层结构与生成规则,并解释越权风险。
- 能独立把记忆从内存实现替换成 JDBC 实现,并验证重启不丢。
- 会话隔离验证通过(不同 sessionId 互不可见)。
- 能配置出「分类走小模型、生成走大模型」的路由,并从日志确认路由生效。
- 能解释重试、熔断、降级三者的区别与配合关系,并写出降级方法。