Spring AI 2.0 学习指南企业级智能应用开发实践 环境准备 速查 ⌂ 全部教程
首页 / Spring AI 指南 / 第 6 章

CH 06记忆与多模型

这一章解决两个「生产环境才会暴露」的问题:记忆(多轮对话怎么存、怎么隔离、怎么不爆炸) 和 多模型(不同任务用不同模型、贵模型挂了怎么办)。这两件事做不好,Demo 没问题,一上生产就出事。

学习目标
会话状态管理 + 模型弹性
前置要求
第 2、5 章
本章产出
多轮工单咨询(隔离 + 路由)
建议学时
4–5 小时

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

第 2 章我们提到过 conversationId,这一章把它讲透,因为这是最容易出数据事故的地方。

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。
所以本章后面所有示例都遵循一条纪律:用 ConversationIds.of() 生成 id,每次调用显式传参。

id 的三层含义

层级示例作用
用户 userIdu1001「这是谁的记忆」——决定数据归属,用于权限校验
会话 sessionIdweb-20260915-a3f2同一用户可能有多个并行会话(网页/小程序/客服工单),互不干扰
租户 tenantIdt-acmeB 端项目必需,用于物理隔离不同客户的数据
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。
sessionId 应该由谁生成?由服务端生成并返回给前端,前端后续携带。 典型做法:用户点「新建对话」→ 后端生成 UUID 并写库(记录创建时间、所属用户)→ 返回 sessionId。 这样后端能校验「这个 sessionId 是否属于当前用户」,而不是盲信前端的值。

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();
内存版的三个致命限制: ① 应用重启,所有对话历史丢失; ② 多实例部署时,请求被负载均衡打到 B 实例,就读不到 A 实例存的记忆(用户会看到「你怎么又忘了」); ③ 内存持续增长,长期运行有 OOM 风险。 结论:只能在本地开发用。

② 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✅✅已有对应基础设施的团队
生产环境的一个常见组合:Redis 做热存储(最近几轮,读写快)+ MySQL 做冷存储(全量历史,用于审计和离线分析)。 Spring AI 只提供一个 ChatMemory 接口,所以你需要自己写一个「双写」的实现类, 或者干脆用 JDBC,靠数据库性能顶住——多数企业项目用 JDBC 就够了。
2.0 的另一个选择: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);
}
三档记忆的完整设计(可以直接用在第 11 章):
  • 短期记忆:ChatMemory 存最近 20 条原文(MessageWindowChatMemory)
  • 中期记忆:每积累 10 轮,把最早的历史压缩成摘要,覆盖式更新
  • 长期记忆:把「用户偏好、历史工单、已购商品」等结构化事实存数据库,按需检索(本质上就是 RAG)

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

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

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

第一步:让多个 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();
    }
}
路由还可以做得更「智能」:用一个小模型先判断问题复杂度,再决定用哪个模型(叫「模型级联」)。 或者更简单的启发式规则:问题长度 < 30 字 → 便宜模型;包含「为什么/分析/对比/规划」→ 强模型; 其余 → 标准模型。这套规则看起来很土,但在真实业务里能省下大量成本。

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)。";
        }
    }
}
降级的四种层次(按优先级选): ① 换模型(最强的兜底,用户无感知); ② 用缓存(这个问题之前答过,缓存里有直接返回); ③ 用规则(FAQ 精确匹配,虽然不智能但至少能答); ④ 友好话术 + 转人工(最后退路,坦诚比假装聪明好)。

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

把本章能力全部用上:会话隔离 + JDBC 持久化记忆 + 分类路由 + 降级。

src/main/java/com/example/springai/ch06/ ├── MemoryConfig.java # JDBC ChatMemory 装配 ├── ModelRouter.java # 模型路由(6.4 节) ├── TicketConsultService.java # 核心业务:分类 → 路由 → 多轮回答 └── TicketConsultController.java # HTTP 接口
@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 持久化生效
这三条命令是本章的核心验收动作。第 3 条如果模型答出了订单号,说明会话隔离失败, 立刻去查 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 互不可见)。
  • 能配置出「分类走小模型、生成走大模型」的路由,并从日志确认路由生效。
  • 能解释重试、熔断、降级三者的区别与配合关系,并写出降级方法。