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

CH 11综合项目:企业知识问答 + 工单辅助系统

前九章你学了一堆「零件」,本章把它们装成一台机器。这个项目会用到前面全部章节的能力:RAG(第 4、5 章)、记忆(第 2、6 章)、结构化输出(第 3 章)、工具调用与 MCP(第 7、8 章)、Agent 与人工确认(第 9 章)、可观测性与评估(第 10 章)。更重要的是,本章会讲清「怎么把 Demo 变成产品」——这是企业开发真正的门槛。

教学目标
端到端交付一个可部署、可演示、可验收的系统
教学重点
分层架构、编排层、数据模型、HITL 与部署
教学难点
意图路由设计、成本/性能优化、生产化落地
建议学时
4 学时(含大作业 15–25 小时)

教学设计说明(教师参考):本章是整门课的「收口」,建议用两节课讲架构与关键代码(重点放在 11.6 编排层和 11.4 数据模型),其余作为大作业让学生在课外完成。评分按「功能 20 分 + 代码规范 10 分 + 答辩 10 分」三部分(详见 11.11)。务必强调:验收看点不是「用了多少高大上的技术」,而是「检索准不准、拒答靠不靠谱、引用清不清楚、成本看不看得见」这四件基础事——任何答辩里夸多智能体的,先问他把「不编造」做扎实了没有。

本章新增「易掉队点」提醒(课前务必口头强调,避免学生卡在 API 换代坑里):

  • 版本基准牢牢锁死:Spring AI 2.0.1 + Boot 4.1.x + Java 17。网上 1.x 教程(甚至 0.8.x)复制过来编译不过,判断标准看 spring-ai-bom 版本号——2.x 开头才是本课程兼容写法。
  • 记忆 API 已换代:PromptChatMemoryAdvisor 在 2.0 已移除,正解是 MessageChatMemoryAdvisor;调用记忆时必须传 ChatMemory.CONVERSATION_ID 这个 key,漏传记忆就不生效。
  • 工具循环归 Advisor 管:函数式工具用 FunctionToolCallback,多轮工具循环由 ToolCallingAdvisor 接管;internalToolExecutionEnabled 这个属性在 2.0 不存在,toolNames() 也已移除——旧写法只出现在「已被移除」的语境里。
  • 结构化输出与 RAG:结构化输出用 .entity(YourClass.class);RAG 召回由 QuestionAnswerAdvisor 承担;若要接 MCP 必须显式声明 SyncMcpToolCallbackProvider,不是自动生效。
  • 配置层级差异:只有 chat 下才有 options 层级,嵌入(embedding)配置没有 .options;密钥一律写成 ${DEEPSEEK_API_KEY:} 占位符,绝不硬编码。
  • 分阶段交付:要求学生严格按「先跑通检索单点 → 再串问答链路 → 最后接工单工具」的顺序推进,每阶段都有可运行验证,不要一上来就堆整套。

11.1需求与业务场景

先讲一个真实的业务痛点

讲综合项目,先讲清楚「为谁解决什么问题」——学生最容易一上来就堆技术,却说不清业务价值。下面用「智汇科技」客服团队的真实困境与可量化目标,作为整个项目的需求锚点。

😫 现状(成本高、体验差)

  • 60% 的问题是重复的:「退款几天到账」「发票怎么开」——答案都写在制度文档里,但客服要手动翻找。
  • 新人上手慢:制度文档 200 多页,新人要 2 周才能独立应对常见问题。
  • 答复口径不一致:同一个问题,不同客服给的答案不一样,引发二次投诉。
  • 工单靠手工建:查完订单、翻完政策,再手工录工单,平均 8 分钟一单。
  • 知识库更新滞后:制度改了,客服还在按旧版回答。

🎯 目标(可量化的收益)

  • 常见问题自动回答,客服只处理疑难——目标自动化解率 50%
  • 回答必带出处,客服可一键核对,杜绝口径不一致
  • 工单处理从 8 分钟降到 2 分钟(自动查订单 + 自动生成方案)
  • 知识库一键重建索引,制度更新当天生效
  • 全链路可观测:成本、效果、异常都有数据支撑

用户角色与核心诉求

为什么先列角色?因为不同角色关心的能力完全不同——知识库管理员关心「能不能一键重建索引」,运维关心「健康检查和成本」,客服主管关心「效果看板」。架构设计必须回应每个角色的核心诉求,否则做出来的系统没人用。

角色核心诉求系统要提供什么
客服专员快速拿到"可以直接发给客户"的答案带出处的问答 + 一键复制 + 转人工
客服主管知道 AI 答得准不准、哪些问题答不了效果看板 + 未命中问题清单(反哺知识库)
知识库管理员制度更新后能快速生效文档上传 + 一键重建索引 + 版本管理
终端用户自助解决问题,不用排队多轮对话 + 自助查订单 + 工单进度查询
运维系统稳定、成本可控健康检查 + 指标看板 + 降级策略

动手:用一句话画出项目边界(课前热身)

在动手写第一行代码之前,先用纸笔把「这个项目不做什么」写下来——边界比功能更重要,它决定了你后面不会被「再加个小功能」拖垮。

  1. 打开一张纸,中间写「企业知识问答 + 工单辅助系统」。
  2. 左侧列「做」:制度问答、多轮记忆、订单查询、工单辅助、知识库管理、可观测。
  3. 右侧列「不做」:不替代人工最终决策(高风险必须确认)、不做多轮复杂谈判、不接第三方支付(只查不付)。
  4. 把右侧三条念一遍——答辩时如果有人让你「顺手加个自动退款」,你就用这三条挡回去。
一句话记忆:先定边界,再写功能;企业项目死在「顺手加一个」上的比死在「做不出来」上的多得多。

11.2功能清单与验收标准

功能模块(P0 必须做,P1 加分)

为什么用 P0/P1/P2 分级?因为期末项目时间有限,必须让学生先保证「核心闭环能跑通」,再谈加分项。P0 是经过裁剪的最小可用产品(MVP),覆盖「问答—记忆—查单—建单—可观测」主链路;P1/P2 是体现区分度的加分项。

优先级模块功能点用到章节
P0知识问答基于公司制度文档的问答,回答必须带引用出处;知识库外的问题必须拒答3,4,5
P0多轮对话能记住上下文;不同用户/会话隔离;重启不丢2,6
P0订单查询通过工具查询订单状态;越权查询必须被拦截7
P0工单辅助查订单 → 匹配政策 → 生成方案 → 创建工单(需人工确认)9
P0知识库管理文档上传、一键重建索引、按来源删除4,5
P0可观测性Trace 链路、Token 成本指标、评测报告10
P1意图路由统一入口自动识别:知识问答 / 查订单 / 建工单 / 闲聊3,6
P1模型路由与降级分类用便宜模型;主模型挂了自动切换6
P1MCP 工具服务把订单查询能力独立成 MCP Server,供其他系统复用8
P2效果看板Grafana 展示 QPS、延迟、Token 成本、检索命中率10
P2流式输出前端打字机效果1

验收标准(对应大纲「产物验收标准」)

技术验收(必须全部满足)
  • 知识问答准确率 ≥ 80%(在 30 条自建评测集上)
  • 知识库外问题拒答率 100%(不能编造)
  • 问答响应 P95 ≤ 8 秒(流式首字 ≤ 2 秒)
  • 越权订单查询拦截率 100%
  • 高风险操作 100% 需人工确认,挂起期间零副作用
  • 服务重启后会话记忆不丢失
  • 全链路可在 Zipkin 中查看
  • 单次问答平均成本可计算、可展示
业务验收(演示时能说清楚)
  • 能现场演示:一个真实客服场景从提问到建单的完整流程
  • 能展示:三种失败场景下的兜底表现(知识库外、越权、模型不可用)
  • 能给出:成本测算(每天 300 次咨询,月成本约多少)
  • 能解释:每个技术选型的理由与替代方案

成对概念:开发验收 vs 上线验收

为什么要把「验收」分成两道?很多学生认为「我本地能跑、答辩过了」就完了,结果一上线就崩。开发验收看的是「功能对不对」,上线验收看的是「扛不扛得住、出事会不会炸」——两者标准完全不同。

维度开发验收(本地 / 答辩)上线验收(生产环境)
关注点功能是否正确、引用是否带出、拒答是否触发稳定性、成本、安全、降级、可观测
数据规模几条样例、30 条评测集真实流量、十万级向量、并发
失败处理报错直接看控制台必须有降级与应急开关,不能 500 暴露给用户
密钥与日志本地能跑即可密钥绝不进仓库、Prompt 不落日志、日志脱敏
判定人自己 / 老师运维、安全、业务方
一句话记忆:开发验收问「对不对」,上线验收问「稳不稳」;两道关都过,项目才算真的完成。

11.3总体架构设计

分层架构

为什么分层?分层把「请求如何进来」「如何决策路由」「如何执行各业务链路」「依赖哪些能力」「跑在什么基础设施上」解耦开。这样每一层都能独立测试、独立扩展——比如把 pgvector 换成 Milvus,只动基础设施层,编排层完全不用改。

┌──────────────────────────────────────────────────────────────┐ │ 接入层 前端控制台 / 客服工作台 / 第三方系统(OpenAPI) │ └───────────────────────────┬──────────────────────────────────┘ │ HTTP / SSE ┌───────────────────────────▼──────────────────────────────────┐ │ 网关层 Controller + 统一响应 + 鉴权(JWT) + 限流 + 全局异常 │ │ · ChatController · KnowledgeController │ │ · TicketController · AgentConfirmController │ └───────────────────────────┬──────────────────────────────────┘ │ ┌───────────────────────────▼──────────────────────────────────┐ │ 编排层 Orchestrator(★ 项目的"大脑") │ │ ① 意图识别(小模型,结构化输出) │ │ ② 路由分发:KNOWLEDGE / ORDER / TICKET / CHITCHAT │ │ ③ 组装结果:answer + citations + traceId + cost │ └──────┬────────────────┬────────────────┬─────────────────────┘ │ │ │ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────────────────┐ │ 知识链路 │ │ 业务链路 │ │ Agent 链路 │ │ RAG │ │ Tool 调用 │ │ ReAct 循环 + HITL │ │ ·检索 │ │ ·订单查询 │ │ ·查订单→查政策→建工单 │ │ ·重排 │ │ ·越权拦截 │ │ ·高风险操作挂起 │ │ ·生成 │ │ ·结果格式化 │ │ ·轨迹记录 │ │ ·引用溯源 │ │ │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────────────────┘ │ │ │ ┌──────▼────────────────▼────────────────▼─────────────────────┐ │ 能力层 │ │ ModelRouter(多模型路由/降级) ChatMemory(会话记忆) │ │ VectorStore(向量检索) ToolCallbacks(本地工具) │ │ McpClient(外部工具) Advisor(日志/限流/脱敏) │ └──────────────────────────┬───────────────────────────────────┘ │ ┌──────────────────────────▼───────────────────────────────────┐ │ 基础设施层 │ │ MySQL(业务/工单/记忆) Redis(缓存/限流) PostgreSQL+pgvector(向量) │ │ Milvus(可选大规模) Zipkin(链路) Prometheus(指标) │ └──────────────────────────────────────────────────────────────┘

模块划分(工程结构)

课堂作业用单模块 + 分包就够,不要一上来拆多模块。下面给出两种工程结构,先按「单模块分包」落地,等真的需要独立部署再拆。

springai-lab/                         # 推荐:单模块 + 分包
├── pom.xml
├── docker-compose.yml
├── init/pgvector-init.sql
└── src/main/java/com/example/springai/
    ├── SpringAiLabApplication.java
    ├── common/                       # 全局配置、异常、Advisor
    ├── orchestrator/                 # 编排层:意图识别 + 路由
    ├── rag/                          # 知识检索与问答
    ├── tools/                        # 本地工具:订单 / 工单
    ├── agent/                        # 智能体 + HITL
    ├── controller/                   # 网关层
    └── repo/                         # 数据访问

# 进阶(多人协作 / 独立部署时)才拆多模块:
springai-lab/
├── springai-common/   # 公共配置
├── springai-rag/      # 知识检索模块
├── springai-agent/    # 智能体模块
├── springai-tools/    # 工具模块
├── springai-eval/     # 评测模块
└── springai-app/      # 启动模块
关于多模块:课堂作业用单模块 + 分包就够(简单,不容易出配置问题)。 只有在「模块要独立部署」「多人协作需明确边界」时才拆多模块。 拆模块的代价是 Maven 配置复杂度上升、启动变慢、调试链路变长——不要为了架构而架构。

动手:从零搭出工程骨架(先让应用跑起来)

本章无配套 lab 代码,按「自建」口径操作:所有包、类、配置文件都自己建,代码块照抄本节后面给出的源码。下面这一步的目标是先把空壳应用跑起来,后面每加一块能力都基于它验证。

  1. 打开 start.spring.io,按下表选择并 Generate 下载 zip,解压后用 IDEA 打开:

    选项选择
    Project / LanguageMaven / Java
    Spring Boot4.1.x(不要 3.x,不要 SNAPSHOT)
    Group / Artifactcom.example / springai-lab
    Java17
    DependenciesSpring Web + Spring AI Model Chat OpenAI + Spring Data JPA + Spring Data Redis + Actuator
  2. 打开 pom.xml,在 <properties> 旁加入 spring-ai-bom 的 dependencyManagement 段(统一管理版本,避免各 starter 版本打架),并补上本项目的依赖。完整片段如下,直接覆盖 / 合并进你的 pom.xml:

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             https://maven.apache.org/xsd/maven-4.0.0.xsd">
      <modelVersion>4.0.0</modelVersion>
    
      <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
      </parent>
    
      <groupId>com.example</groupId>
      <artifactId>springai-lab</artifactId>
      <version>0.0.1-SNAPSHOT</version>
    
      <properties>
        <java.version>17</java.version>
        <spring-ai.version>2.0.1</spring-ai.version>
      </properties>
    
      <!-- ★ 统一锁死 Spring AI 版本,所有 ai starter 都从这里取 -->
      <dependencyManagement>
        <dependencies>
          <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
          </dependency>
        </dependencies>
      </dependencyManagement>
    
      <dependencies>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- ① 模型:DeepSeek 走 OpenAI 兼容协议 -->
        <dependency>
          <groupId>org.springframework.ai</groupId>
          <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>
        <!-- ② RAG 召回顾问 QuestionAnswerAdvisor 所在包 -->
        <dependency>
          <groupId>org.springframework.ai</groupId>
          <artifactId>spring-ai-advisors-vector-store</artifactId>
        </dependency>
        <!-- ③ 向量库:PostgreSQL + pgvector -->
        <dependency>
          <groupId>org.springframework.ai</groupId>
          <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
        </dependency>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
      </dependencies>
    </project>

    粘贴位置:整个文件以 <?xml ...> 开头,直接替换 Initializr 生成的 pom.xml 即可。

  3. 在 src/main/resources/ 下新建 application.yml,粘贴以下配置(密钥一律占位符,绝不硬编码):

    server:
      port: 8080
    
    spring:
      application:
        name: springai-lab
      ai:
        openai:
          # DeepSeek 兼容 OpenAI 协议;密钥从环境变量读,冒号后留空表示无默认值
          api-key: ${DEEPSEEK_API_KEY:}
          base-url: https://api.deepseek.com
          chat:
            # ★ 只有 chat 下才有 options 层级,embedding 没有
            options:
              model: deepseek-chat
              temperature: 0.7
      datasource:
        url: jdbc:mysql://localhost:3306/springai_lab?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false
        username: root
        password: root123456
      data:
        redis:
          host: localhost
          port: 6379
    
    management:
      endpoints:
        web:
          exposure:
            include: health, prometheus, info
      zipkin:
        tracing:
          endpoint: http://localhost:9411/api/v2/spans

    粘贴位置:src/main/resources/application.yml。若文件已存在同名项,以这一段为准。

  4. 在 com.example.springai 包下确认启动类存在(Initializr 已生成 SpringAiLabApplication)。右键 com.example.springai → New → Package,依次新建 common、orchestrator、rag、tools、agent、controller、repo 七个空包,先不用填内容。

  5. 确保环境变量 DEEPSEEK_API_KEY 已设置(见第 0 章 0.6 节),重开 IDEA 使之生效。然后启动应用:

    # 在终端进入项目根目录(pom.xml 所在目录)
    mvn spring-boot:run

运行验证 ①:空壳应用能起来

# 另开一个终端,确认健康检查返回 UP
curl -s http://localhost:8080/actuator/health
# 期望输出(节选):
# {"status":"UP","components":{"db":{"status":"UP"},"redis":{"status":"UP"},
#  "discoveryComposite":{"status":"UP"} ...}}

看到什么算通过:终端打印 Started SpringAiLabApplication,且 /actuator/health 返回 status: "UP"(数据库 / Redis 此时若未起,先只验证应用进程起来了也行,但后续章节需要它们)。

报错原文:Failed to configure a DataSource: 'url' attribute is not specified 或 Connection refused: mysql。 根因:MySQL / Redis 还没起,或 application.yml 里的连接串写错。空壳阶段可以先把 datasource / redis 段注释掉,让应用先起来;真正用到时再放开。 修复:① 确认本地 MySQL、Redis 已启动;② 用 docker run -d -e MYSQL_ROOT_PASSWORD=root123456 -p 3306:3306 mysql:8 快速起一个;③ 检查 yml 缩进(yml 对缩进敏感,spring: 下一级必须对齐)。
报错原文:ApiKey must not be empty / 一调模型就 401。 根因:环境变量没被运行中的进程继承(IDEA 在 setx 之前启动)。 修复:完全退出并重启 IDEA;或在 Run Configuration → Environment variables 里手动加 DEEPSEEK_API_KEY=sk-xxx。验证:临时打印 System.getenv("DEEPSEEK_API_KEY"),为空就说明没继承到(详见第 0 章 0.6 节「进程继承」)。

知识点揉碎:分模块组装顺序

角度说明
是什么把系统按「接入 → 网关 → 编排 → 能力 → 基础设施」分层,每个包只做一件事。
为什么层与层之间解耦,换向量库只动基础设施层,编排层一行不用改;每一层可独立测试。
没有会怎样所有逻辑堆在 Controller 里,一行报错要翻 2000 行;换模型 / 换数据库要全局改。
类比像快递公司:收件(接入)→ 分拣(网关)→ 调度中心(编排)→ 各专线车队(能力)→ 仓库与道路(基础设施)。调度中心不关心仓库怎么盖,仓库也不懂怎么调度。

成对概念:RAG 链路 vs 工单链路

为什么把它们放在一起对比?因为这两条是项目里最典型的两类链路,一个「只读不写」,一个「写且要人确认」,理解差异才能正确设计安全边界。

维度RAG 链路(知识问答)工单链路(业务处理)
性质只读:检索 + 生成,不改业务数据读写:查订单 → 生成方案 → 创建工单(写库)
安全红线检索为空必须拒答,绝不能编造高风险操作必须挂起等人确认,确认前零副作用
核心组件VectorStore + QuestionAnswerAdvisorToolCallingAdvisor + PendingAction + 人工确认
失败代价答错一句,客服可核对出处纠正错建一张工单,直接影响真实客户与财务
验收看点引用是否带出、拒答率 100%越权拦截率 100%、HITL 是否真的挂起
一句话记忆:RAG 链路「只读不写、答不出就拒」;工单链路「写前要确认、确认前什么都不做」——安全边界因链路性质而异。

11.4数据模型设计

① MySQL:业务数据

为什么设计这么细的表?因为综合项目要「可验收、可审计」——每一张表背后都对应一个验收点:ticket 表对应「工单可复查」、pending_action 表对应「HITL 安全」、qa_audit 表对应「成本核算」、agent_trace 表对应「Agent 可观测」。下面逐表给出建表 SQL,注意索引是为查询场景服务的,不是随手加的。

-- ============================================================
-- 1. 知识库文档(记录文档元信息与索引状态)
-- ============================================================
CREATE TABLE kb_document (
    id            BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id     VARCHAR(64)  NOT NULL,
    file_name     VARCHAR(255) NOT NULL,
    category      VARCHAR(64)  DEFAULT '未分类',
    file_hash     CHAR(64)     NOT NULL COMMENT 'SHA-256,用于判断内容是否变化',
    chunk_count   INT          DEFAULT 0,
    status        VARCHAR(20)  DEFAULT 'PENDING' COMMENT 'PENDING/INDEXED/FAILED',
    indexed_at    DATETIME     NULL,
    created_at    DATETIME     DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_tenant_file (tenant_id, file_name),
    KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 2. 会话(用于会话列表、归属校验、统计)
CREATE TABLE chat_session (
    id            BIGINT AUTO_INCREMENT PRIMARY KEY,
    session_id    VARCHAR(64)  NOT NULL COMMENT '服务端生成的 UUID',
    tenant_id     VARCHAR(64)  NOT NULL,
    user_id       VARCHAR(64)  NOT NULL,
    title         VARCHAR(128) COMMENT '由首轮问题自动生成',
    message_count INT          DEFAULT 0,
    last_active   DATETIME     DEFAULT CURRENT_TIMESTAMP,
    created_at    DATETIME     DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_session (session_id),
    -- ★ 查询"我的会话列表"时用这个索引
    KEY idx_user_active (tenant_id, user_id, last_active DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 3. 工单(业务核心表)
CREATE TABLE ticket (
    id            BIGINT AUTO_INCREMENT PRIMARY KEY,
    ticket_no     VARCHAR(32)  NOT NULL COMMENT '工单号,如 T20260315001',
    tenant_id     VARCHAR(64)  NOT NULL,
    order_no      VARCHAR(32)  NOT NULL,
    user_id       VARCHAR(64)  NOT NULL,
    type          VARCHAR(32)  NOT NULL COMMENT '超时发货/质量问题/退款/其他',
    priority      VARCHAR(10)  NOT NULL COMMENT '低/中/高',
    status        VARCHAR(20)  DEFAULT '待处理',
    suggested_action VARCHAR(500) COMMENT 'AI 建议的处理方案',
    created_by    VARCHAR(20)  COMMENT 'AI / 人工',
    trace_id      VARCHAR(32)  COMMENT '★ 关联到生成它的那次 Agent 执行',
    created_at    DATETIME     DEFAULT CURRENT_TIMESTAMP,
    updated_at    DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_ticket_no (ticket_no),
    -- ★ 幂等关键:同一订单同一类型 24 小时内不允许重复建单
    KEY idx_order_type (tenant_id, order_no, type, created_at),
    KEY idx_status (tenant_id, status, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 4. 待人工确认的操作(HITL,第 9 章)
CREATE TABLE pending_action (
    id            BIGINT AUTO_INCREMENT PRIMARY KEY,
    action_id     VARCHAR(64)  NOT NULL,
    trace_id      VARCHAR(32)  NOT NULL,
    tenant_id     VARCHAR(64)  NOT NULL,
    user_id       VARCHAR(64)  NOT NULL COMMENT '★ 用于确认时的归属校验',
    tool_name     VARCHAR(64)  NOT NULL,
    arguments     TEXT         NOT NULL COMMENT 'JSON 字符串',
    summary       VARCHAR(500),
    risk_level    VARCHAR(20)  NOT NULL,
    status        VARCHAR(20)  DEFAULT 'PENDING',
    expires_at    DATETIME     NOT NULL COMMENT '★ 过期时间,防止长期悬挂',
    created_at    DATETIME     DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_action (action_id),
    KEY idx_user_status (tenant_id, user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 5. Agent 执行轨迹(第 9、10 章)
CREATE TABLE agent_trace (
    id            BIGINT AUTO_INCREMENT PRIMARY KEY,
    trace_id      VARCHAR(32)  NOT NULL,
    tenant_id     VARCHAR(64)  NOT NULL,
    user_id       VARCHAR(64),
    scene         VARCHAR(64)  COMMENT 'ticket-agent / kb-qa …',
    task          VARCHAR(500) COMMENT '原始任务',
    steps_json    JSON         COMMENT '完整步骤数组',
    final_answer  TEXT,
    step_count    INT,
    total_tokens  INT,
    elapsed_ms    BIGINT,
    success       TINYINT(1),
    created_at    DATETIME     DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_trace (trace_id),
    KEY idx_scene_time (scene, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 6. 问答审计(用于成本核算与效果分析)
CREATE TABLE qa_audit (
    id             BIGINT AUTO_INCREMENT PRIMARY KEY,
    trace_id       VARCHAR(32)  NOT NULL,
    tenant_id      VARCHAR(64)  NOT NULL,
    user_id        VARCHAR(64),
    session_id     VARCHAR(64),
    question       VARCHAR(1000),
    intent         VARCHAR(32)  COMMENT 'KNOWLEDGE/ORDER/TICKET/CHITCHAT',
    answer_len     INT,
    citation_count INT          COMMENT '引用数,0 表示可能没检索到',
    retrieval_hits INT,
    top_score      DECIMAL(6,4),
    model          VARCHAR(64),
    prompt_tokens  INT,
    completion_tokens INT,
    cost_yuan      DECIMAL(10,6),
    elapsed_ms     BIGINT,
    feedback       TINYINT      COMMENT '1=点赞 -1=点踩 0=无',
    created_at     DATETIME     DEFAULT CURRENT_TIMESTAMP,
    KEY idx_tenant_time (tenant_id, created_at DESC),
    KEY idx_feedback (feedback),
    KEY idx_intent (intent)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
三张表值得特别说明: ① qa_audit 是「成本与效果的账本」——第 10 章的所有分析都基于它; ② pending_action 的 user_id 是安全关键字段(确认时校验归属); ③ ticket 的 trace_id 把业务数据与 AI 执行链关联起来——出了问题可以从工单反查到当时的 Agent 轨迹。

动手:把建表 SQL 落到数据库

  1. 在 init/ 目录下新建文件 mysql-schema.sql,把上面 6 张表的 SQL 整段粘进去保存。
  2. 登录本地 MySQL,先建库:CREATE DATABASE springai_lab DEFAULT CHARSET utf8mb4;
  3. 执行脚本:在 MySQL 客户端里 USE springai_lab; 然后 SOURCE /绝对路径/init/mysql-schema.sql;
  4. 验证表是否建好:SHOW TABLES; 应看到 6 张表。

运行验证 ②:6 张表存在

mysql -uroot -proot123456 springai_lab -e "SHOW TABLES;"
# 期望输出(节选):
# Tables_in_springai_lab
# agent_trace
# chat_session
# kb_document
# pending_action
# qa_audit
# ticket

看到什么算通过:列出 agent_trace、chat_session、kb_document、pending_action、qa_audit、ticket 共 6 张表,无报错。

报错原文:ERROR 1071 (42000): Specified key was too long; max key length is 3072 bytes。 根因:某些字段建联合索引时累计长度超过 InnoDB 上限(尤其 utf8mb4 下 VARCHAR(255) 占 1020 字节)。 修复:把超长字段索引改成前缀索引(如 KEY idx_file (file_name(191))),或确认表字符集为 utf8mb4 且 MySQL ≥ 5.7。本建表脚本已用合理长度,正常情况下不会触发。

② PostgreSQL + pgvector:向量表

向量表存的是文档切片与它们的 embedding。注意 维度必须与 embedding 模型一致——这是学生最易踩的坑:换了 embedding 模型,维度对不上就写入失败或检索全错。

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS vector_store (
    id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    content   TEXT,
    metadata  JSON,
    -- ★ 维度必须与 embedding 模型一致(1536 = text-embedding-3-small)
    embedding VECTOR(1536)
);

-- HNSW 索引:余弦距离,与 Spring AI 的 COSINE_DISTANCE 对应
CREATE INDEX IF NOT EXISTS idx_vs_hnsw
    ON vector_store USING hnsw (embedding vector_cosine_ops);

-- ★ 元数据索引:加速按租户/来源过滤(过滤条件是先于向量计算执行的)
CREATE INDEX IF NOT EXISTS idx_vs_tenant
    ON vector_store ((metadata->>'tenantId'));
CREATE INDEX IF NOT EXISTS idx_vs_source
    ON vector_store ((metadata->>'source'));
元数据索引很重要,但常被忽略。有了它,查询 WHERE metadata->>'tenantId' = 't1' 才能走索引而不是全表扫描。没有它,数据量到十万级后,每次检索前都要扫全表做过滤,延迟会明显上升。

动手:初始化 pgvector

  1. 在 init/ 下新建 pgvector-init.sql,粘贴上面向量表的 SQL。
  2. 快速起一个带 pgvector 的 PostgreSQL(推荐用容器,避免本机装扩展麻烦):

    docker run -d --name pgvector \
      -e POSTGRES_USER=ai -e POSTGRES_PASSWORD=ai123456 \
      -e POSTGRES_DB=vectordb -p 5432:5432 \
      ankane/pgvector
  3. 执行初始化:cat init/pgvector-init.sql | docker exec -i pgvector psql -U ai -d vectordb

运行验证 ③:向量表与扩展就位

docker exec -i pgvector psql -U ai -d vectordb -c "\dt"
# 期望看到 vector_store 表
docker exec -i pgvector psql -U ai -d vectordb -c "SELECT 1 FROM pg_extension WHERE extname='vector';"
# 期望返回一行(扩展已装)

看到什么算通过:\dt 列出 vector_store,且 vector 扩展查询有返回行。

报错原文:ERROR: relation "vector_store" does not exist 或 extension "vector" is not available。 根因:① 初始化脚本没执行成功;② 用的 PostgreSQL 镜像不带 pgvector 扩展。 修复:① 重新执行 pgvector-init.sql 并确认无报错;② 换成自带扩展的镜像(如上面的 ankane/pgvector),不要用水官方的 postgres 裸镜像。
报错原文(最典型):ERROR: column "embedding" does not match the dimension of the vector 或写入后检索全乱。 根因:建表时 VECTOR(1536) 写死了维度,但你用的 embedding 模型输出维度不是 1536(比如本地 nomic-embed-text 是 768)。维度对不上,写入直接失败或检索语义全错。 修复:先确认你 embedding 模型的输出维度,把建表语句的 VECTOR(N) 改成对应值;重灌库时务必保证「模型维度 = 表维度」一致。
一句话记忆:表是验收点的具象化——每张表背后都站着一个「能不能查、能不能审计、出事能不能反查」的问题。

11.5接口契约

统一响应格式

为什么强制统一响应 + 带 traceId?因为前端只写一套解析逻辑;traceId 是用户反馈问题时定位故障的钥匙——用户报一个 id,你 3 秒就能在 Zipkin 捞出那次请求。

/**
 * 统一响应包装。所有接口返回这个结构,前端处理逻辑统一。
 * ★ 一定要带 traceId——用户反馈问题时,报这个就能精确定位。
 */
public record ApiResponse<T>(
        int code,            // 0=成功,非 0=业务错误码
        String message,
        T data,
        String traceId
) {
    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(0, "ok", data,
                org.slf4j.MDC.get("traceId"));
    }

    public static <T> ApiResponse<T> fail(int code, String msg) {
        return new ApiResponse<>(code, msg, null,
                org.slf4j.MDC.get("traceId"));
    }
}

动手:落统一响应与全局异常

  1. 右键 com.example.springai.common → New → Java Class,名字填 ApiResponse,Kind 选 Record。
  2. 把上面 ApiResponse 整段代码粘贴进去,保存。
  3. 同样在 common 包新建 GlobalExceptionHandler(用 @RestControllerAdvice 包裹,所有异常统一转成 ApiResponse.fail(...)),保证任何报错都走统一结构、绝不把堆栈直接抛给用户。
  4. 在 controller 包新建 ChatController,其方法返回类型统一写成 ApiResponse<ChatResult>(ChatResult 在 11.6 编排层给出)。

接口清单

方法路径说明权限
POST/api/v1/chat/ask★ 统一入口:自动识别意图并分发(问答 / 查订单 / 建工单)登录用户
POST/api/v1/chat/stream流式版本(SSE)登录用户
GET/api/v1/chat/sessions我的会话列表登录用户
GET/api/v1/chat/sessions/{id}/messages某会话的历史消息本人
POST/api/v1/knowledge/reindex重建索引(可指定来源)管理员
GET/api/v1/knowledge/documents文档列表与索引状态管理员
GET/api/v1/tickets工单列表(分页 + 状态筛选)客服/本人
POST/api/v1/agent/confirm/{actionId}★ 确认高风险操作本人
POST/api/v1/agent/reject/{actionId}拒绝高风险操作本人
POST/api/v1/feedback点赞/点踩(效果回流)登录用户
GET/actuator/health健康检查(含依赖状态)运维
GET/actuator/prometheus指标抓取运维

成对概念:同步接口 vs 流式接口

为什么同时给两套?同步接口实现简单、便于测试与评测(拿完整 JSON 断言);流式接口体验好(首字快、打字机效果)。生产里通常「内部逻辑一套,对外暴露同步 + 流式两种入口」。

维度同步 /chat/ask流式 /chat/stream
返回形式一次性返回完整 JSONSSE 分片推送,前端逐字渲染
感知延迟要等模型整段生成完(可能 8s)首字 0.5~2s 就出现
适用场景评测、后台任务、API 调用方客服工作台、聊天界面
实现要点ChatClient.call() 直接拿 content()ChatClient.stream() 返回 Flux<String>,用 SseEmitter 推
验收写法直接 curl 看完整 body用 curl -N 看分片,或前端 EventSource

核心接口契约:统一问答入口

// ==================== 请求 ====================
{
  "sessionId": "web-20260315-a3f2",     // 可空:为空则服务端新建会话
  "message": "订单 2024001 一直没发货,怎么处理?",
  "stream": false
}
// 注意:没有 userId!由服务端从 JWT 解析——这是安全底线

// ==================== 响应(摘要) ====================
{
  "code": 0, "message": "ok", "traceId": "a3f2b1c9",
  "data": {
    "sessionId": "web-20260315-a3f2", "intent": "TICKET",
    "answer": "已查明订单 2024001 自 3 月 1 日起超期未发货 14 天……请确认后继续。",
    "citations": [ { "index": 1, "source": "refund-policy.md", "score": 0.82, "excerpt": "…" } ],
    "pendingAction": { "actionId": "8f3a2b1c-...", "summary": "创建工单…", "riskLevel": "HIGH", "expiresAt": "2026-03-15T12:15:00" },
    "meta": { "model": "deepseek-chat", "steps": 3, "elapsedMs": 8420, "tokens": { "prompt": 9482, "completion": 3391 }, "costYuan": 0.0163 }
  }
}
响应里为什么要带 meta? 三个好处:① 前端可以展示「本次回答用了多少 Token、花了多少钱」,让成本透明; ② 测试与验收时可以直接断言步数、耗时;③ 出问题时用户报 traceId,你立刻能对上。 透明度本身就是企业级产品的竞争力。
一句话记忆:统一响应 + traceId = 前端一套解析、故障一秒定位;同步管测试、流式管体验,两套入口共用一套编排逻辑。

11.6核心编排实现(项目的「大脑」)

编排层的职责:先判断用户想干什么,再交给对应的链路,最后统一组装结果。这是整个项目最核心的 100 行代码,把前面第 3、6、7、9 章的能力缝在一起。

① 意图识别:用结构化输出(第 3 章)

package com.example.springai.orchestrator;

import com.fasterxml.jackson.annotation.JsonPropertyDescription;

/**
 * 意图识别的结果。用 record + 字段描述,让模型输出稳定可控。
 * 注意:意图枚举值要少(4~6 个),多了模型容易判错。
 */
public record IntentResult(

    @JsonPropertyDescription("""
            意图类型,只能取以下四个值之一:
            - KNOWLEDGE:询问公司制度、流程、政策等知识性问题
            - ORDER_QUERY:查询具体订单的状态、物流、金额等信息
            - TICKET_HANDLE:需要处理投诉、创建工单、申请补偿等操作
            - CHITCHAT:打招呼、闲聊、与业务无关的问题
            """)
    String intent,

    @JsonPropertyDescription("从用户问题中提取的订单号,纯数字;未提及则返回 null")
    String orderNo,

    @JsonPropertyDescription("用户的核心诉求,一句话概括,20 字以内")
    String coreDemand,

    @JsonPropertyDescription("判断置信度,0.0~1.0;低于 0.6 时系统会追问用户")
    Double confidence
) {}
@Slf4j
@Component
public class IntentClassifier {

    private final ModelRouter router;

    public IntentClassifier(ModelRouter router) { this.router = router; }

    private static final String PROMPT = """
            你是一个意图分类器。请分析用户的问题,判断它属于哪一类意图。
            分类规则:
            1. 问"怎么做""是什么" → KNOWLEDGE
            2. 问"我的订单怎么样了",且提到了具体订单号 → ORDER_QUERY
            3. 包含"投诉""催""退款""赔偿""处理一下" → TICKET_HANDLE
            4. 打招呼、感谢、与业务无关 → CHITCHAT
            注意:如果同时涉及查询和处理,归为 TICKET_HANDLE。
            不要输出解释,只输出 JSON。
            用户问题:{question}
            最近对话(用于理解指代):{recentHistory}
            """;

    public IntentResult classify(String question, String recentHistory) {
        try {
            // ★ 用便宜的小模型做分类:这类任务占总调用量的大头(第 6 章)
            IntentResult result = router.route(ModelRouter.TaskType.CLASSIFY)
                    .prompt()
                    .user(u -> u.text(PROMPT)
                               .param("question", question)
                               .param("recentHistory", recentHistory == null ? "(无)" : recentHistory))
                    .options(OpenAiChatOptions.builder().temperature(0.0).build())
                    .call()
                    .entity(IntentResult.class);   // ★ 结构化输出,2.0 用 entity()

            // 置信度过低时降级为"再问一句",而不是猜
            if (result.confidence() != null && result.confidence() < 0.6) {
                return new IntentResult("UNCLEAR", null, result.coreDemand(), result.confidence());
            }
            return result;
        } catch (Exception e) {
            // 分类失败不影响主流程:兜底成 KNOWLEDGE,走知识问答链路
            log.warn("意图识别失败,兜底为 KNOWLEDGE", e);
            return new IntentResult("KNOWLEDGE", null, question, 0.5);
        }
    }
}

动手:先让「意图识别」单独跑通(分阶段第 1 步)

不要等整个编排器写好才验证。先把意图识别这个小模块独立跑通——它只依赖一个能返回结构化结果的 ChatClient,最容易单点验证。

  1. 右键 com.example.springai.orchestrator → New → Java Class,名字 IntentResult,Kind 选 Record,粘贴上面的 IntentResult 代码。
  2. 同包新建 IntentClassifier(Kind Class),粘贴上面代码。注意它依赖 ModelRouter——这是你自己封装的多模型路由(分类走便宜模型)。先建一个最简版:注入 ChatModel,route(...) 直接返回 ChatClient.Builder 构建的 client 即可,重点是把 .entity(IntentResult.class) 跑通。
  3. 同包新建一个临时 @SpringBootTest 或直接在 CommandLineRunner 里写一行:log.info(classifier.classify("退款几天到账", null).toString());
  4. 启动应用,观察控制台。

运行验证 ④:意图识别单点

# 控制台期望看到类似:
# IntentResult[intent=KNOWLEDGE, orderNo=null, coreDemand=咨询退款到账时间, confidence=0.97]
# 再试:classifier.classify("订单 2024001 一直没发货,帮我处理", null)
# 期望:intent=TICKET_HANDLE, orderNo=2024001

看到什么算通过:返回的结构里 intent 取值正确、orderNo 能从问题中提取出来、confidence 在 0.6 以上。说明「结构化输出 + 小模型分类」这一环已经打通。

报错原文:JSON parsing error / No suitable constructor found for type。 根因:模型返回的 JSON 字段名和 IntentResult 的字段对不上(比如模型多返回了 reason 字段,或字段名大小写不一致)。 修复:① 用 @JsonPropertyDescription(已加)约束输出;② 在 prompt 里明确「只输出指定字段的 JSON,不要多余字段」;③ 确认 entity() 的 record 有无参构造(record 自动有),字段类型匹配。
报错原文:ModelRouter.route(...) cannot be resolved。 根因:你还没实现 ModelRouter。它是本项目自定义的多模型路由类,课程没有给出其完整实现——它本质是根据 TaskType 返回不同的 ChatClient。 修复:先写一个最简版:注入 ChatModel chatModel,route(TaskType t) 返回 ChatClient.create(chatModel)(统一用 DeepSeek 也行,路由分模型是 P1 加分项);等第 6 章讲模型分级时再扩展。注意 2.0 的结构化输出固定用 .entity(YourClass.class),不要找 .entity() 的旧重载或 1.x 写法。

② 编排服务:路由 + 组装

@Slf4j
@Service
public class ChatOrchestrator {

    private final IntentClassifier classifier;
    private final KnowledgeQaService knowledgeQa;      // 第 5 章
    private final OrderAssistantService orderAssistant; // 第 7 章
    private final TicketAgentService ticketAgent;        // 第 9 章
    private final ModelRouter router;
    private final QaAuditRepository auditRepository;
    private final ChatMemory chatMemory;                 // 第 2、6 章

    public ChatResult ask(String tenantId, String userId, String sessionId, String message) {
        long t0 = System.currentTimeMillis();
        String conversationId = ConversationIds.of(tenantId, userId, sessionId);
        String traceId = org.slf4j.MDC.get("traceId");

        // ---------- 第 1 步:取最近对话(理解"它""这个单"等指代)----------
        String recentHistory = chatMemory.get(conversationId).stream()
                .map(m -> m.getMessageType() + ": " + m.getText())
                .limit(6)
                .collect(java.util.stream.Collectors.joining("\n"));

        // ---------- 第 2 步:意图识别 ----------
        IntentResult intent = classifier.classify(message, recentHistory);

        // ---------- 第 3 步:路由分发 ----------
        String answer;
        List<Citation> citations = List.of();
        PendingAction pending = null;

        switch (intent.intent()) {
            case "KNOWLEDGE" -> {
                KnowledgeQaService.RagAnswer rag = knowledgeQa.ask(message, tenantId);
                answer = rag.answer();
                citations = rag.citations();
                if (rag.citations().isEmpty()) {
                    auditRepository.markKnowledgeGap(message, tenantId);  // ★ 记录知识盲区
                }
            }
            case "ORDER_QUERY" -> answer = orderAssistant.ask(message);
            case "TICKET_HANDLE" -> {
                AgentTrace trace = ticketAgent.handle(message);   // ★ 走 Agent 链路
                answer = trace.finalAnswer();
                pending = pendingActionService.findLatestPending(tenantId, userId);
            }
            case "CHITCHAT" -> {
                // 闲聊:用便宜模型直接回答,不检索不调用工具,省钱省时
                answer = router.route(ModelRouter.TaskType.CLASSIFY)
                        .prompt()
                        .system("你是友好的企业助手。简短应答,并引导用户提出业务问题。")
                        .user(message).call().content();
            }
            default -> answer = "抱歉,我没太理解您的意思。您是想:① 咨询政策;② 查询订单;③ 提交售后?";
        }

        // ---------- 第 4 步:写审计(成本与效果分析的数据来源)----------
        long elapsed = System.currentTimeMillis() - t0;
        auditRepository.save(QaAudit.builder()
                .traceId(traceId).tenantId(tenantId).userId(userId)
                .sessionId(sessionId).question(message).intent(intent.intent())
                .answerLength(answer.length()).citationCount(citations.size())
                .elapsedMs(elapsed).build());

        // ---------- 第 5 步:统一组装返回 ----------
        String newSessionId = sessionId != null ? sessionId
                : sessionService.create(tenantId, userId).sessionId();
        return new ChatResult(newSessionId, intent.intent(), answer, citations, pending, traceId);
    }

    public record ChatResult(String sessionId, String intent, String answer,
                               List<Citation> citations, PendingAction pendingAction, String traceId) {}
}

动手:把编排器接进 Controller(分阶段第 2 步:串问答链路)

  1. 在 orchestrator 包新建 ChatOrchestrator,粘贴上面代码。注意它依赖 KnowledgeQaService、OrderAssistantService、TicketAgentService、ConversationIds、Citation、PendingAction、AgentTrace、QaAudit 等——这些在本阶段先建「最小桩」:返回写死的示例值,让编排器能编译运行,后面逐步替换真实现。
  2. 在 common 包新建 ConversationIds,提供一个静态方法 of(tenantId, userId, sessionId) 拼成唯一字符串(如 tenantId + "|" + userId + "|" + sessionId)——这是传给记忆的 conversationId。
  3. 在 controller 包让 ChatController 注入 ChatOrchestrator,把 /api/v1/chat/ask 的请求转成 orchestrator.ask(...) 调用,用 ApiResponse.ok(result) 包装返回。
  4. 本阶段先把 KNOWLEDGE 分支接通到真实的 KnowledgeQaService(见下一段「运行验证 ⑤」),其余分支先返回桩数据。

运行验证 ⑤:问答链路跑通(先验证 KNOWLEDGE 分支)

curl -s -X POST http://localhost:8080/api/v1/chat/ask \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"demo-001","message":"我买的东西不想要了,钱什么时候能退给我?"}'
# 期望 data.intent = "KNOWLEDGE",answer 是一句基于制度文档的话,
# data.citations 非空(带 source / score / excerpt)

看到什么算通过:返回 code:0、intent:KNOWLEDGE,answer 像「根据《退款政策》第 3 条,退款将在 3 个工作日内原路返回…」且 citations 里有出处。说明「意图识别 → 路由 → 知识问答 → 组装」整条链路打通。

报错原文:答案里没有 citations,或 citations 为空数组。 根因:知识库还没灌数据,或 KnowledgeQaService 没接 QuestionAnswerAdvisor / 没从 VectorStore 检索。编排器本身没问题,是上游召回为空。 修复:① 先把制度文档切块写入 vector_store(用 VectorStore.add(List<Document>),metadata 带 tenantId/source);② 确认 RAG 用 QuestionAnswerAdvisor 做召回;③ 本阶段的「中间验证」意义就在这里——能立刻定位是「编排没路由对」还是「召回没数据」,而不是等全做完一团乱麻。
报错原文:java.lang.NullPointerException 或 conversationId 为空导致记忆读取失败。 根因:记忆没有正确接上,chatMemory.get(conversationId) 拿不到历史,或没传 ChatMemory.CONVERSATION_ID。 修复:① 记忆配置必须用 MessageChatMemoryAdvisor(PromptChatMemoryAdvisor 在 2.0 已移除);② 调用时通过 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) 显式传入会话 id,漏传记忆就不生效;③ 本阶段分支里用 ConversationIds.of(...) 拼出的 id 要前后一致。
这个编排器体现了三个重要的工程决策,请务必理解:
  1. 闲聊和分类用便宜模型:这两类占调用量的大头(可能 70%),用强模型是巨大浪费。
  2. 意图不明确时追问,而不是硬猜:猜错了要重来,代价更高。
  3. 每次请求都落一条审计:成本、效果、知识盲区的所有分析都基于这张表,是后面做优化的前提。

知识点揉碎:链路编排

角度说明
是什么编排层像一个「总机接线员」:听清用户意图 → 把请求转给对应链路 → 把各链路结果拼成统一答复。
为什么让每条业务链路专心做自己的事(检索 / 工具 / Agent),不被「该不该做、先做哪个」干扰;新增意图只加一个 case。
没有会怎样每个入口自己判断意图、自己拼装,逻辑重复且容易不一致;改一处要翻遍所有 Controller。
类比像餐厅前台:顾客说「我要退款」,前台判断是「售后」→ 转给售后专员 → 把专员给的方案整理成统一话术回复顾客,而不是让顾客直接冲进厨房。
一句话记忆:编排层是「大脑」不是「手脚」——它只做判断与组装,真正的活儿交给各条链路;先打通单点再串全链,出错一眼定位。

11.7部署上线

① Dockerfile(多阶段构建,镜像更小)

为什么多阶段 + 非 root + 时区?多阶段把「构建用的 Maven/JDK」和「运行用的 JRE」分开,镜像从约 700MB 降到约 200MB;非 root 运行是安全基线;时区错会让日志和业务时间差 8 小时,排障时对不上时间线。

# ---------- 阶段一:构建 ----------
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build

# 先只拷贝 pom,利用 Docker 层缓存:依赖不变时不会重新下载
COPY pom.xml .
RUN mvn dependency:go-offline -B

COPY src ./src
RUN mvn clean package -DskipTests -B

# ---------- 阶段二:运行 ----------
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

# 时区:日志和业务时间必须准确
RUN apk add --no-cache tzdata curl && \
    ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

# ★ 非 root 用户运行(安全基线要求)
RUN addgroup -S app && adduser -S app -G app
USER app

COPY --from=builder /build/target/*.jar app.jar

# JVM 参数:容器感知 + 虚拟线程友好 + OOM 时 dump 便于排查
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+UseZGC \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/logs -Dfile.encoding=UTF-8"

EXPOSE 8080

# 健康检查:容器编排系统据此判断是否重启
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
注意:课程基准是 Java 17,而上面 Dockerfile 用了 eclipse-temurin-21 与虚拟线程(JDK 21 特性)。若你严格按 Java 17 交付,请把基础镜像换成 eclipse-temurin:17-jre,并把 9.5 节的并行执行里 newVirtualThreadPerTaskExecutor() 改为固定线程池,否则编译或运行会报错。请以官方文档和你的 pom.xml 设定的 Java 版本为准。

动手:写 Dockerfile + 一键编排(分阶段第 3 步:可部署)

  1. 在项目根目录(与 pom.xml 同级)右键 → New → File,文件名 Dockerfile(无扩展名),粘贴上面多阶段构建内容。
  2. 同目录新建 docker-compose.yml(见下一段),把 MySQL / Redis / pgvector / zipkin 与 app 一起编排。
  3. 构建镜像并起服务:docker compose up -d --build(首次会下载依赖并打包,约几分钟)。
  4. 等 app 容器 healthy 后,用宿主机端口 8080 访问。

② docker-compose(应用 + 全部依赖,一键起)

services:
  # ……(mysql / redis / pgvector / milvus / zipkin 保持原样,此处省略)

  app:
    build: .
    container_name: sai-app
    ports: ["8080:8080"]
    environment:
      # ★ 密钥从宿主机环境变量透传,绝不写进 compose 文件
      OPENAI_API_KEY: ${OPENAI_API_KEY}
      DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY}
      # ★ 容器内访问其他服务要用 service 名,不是 localhost!
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/springai_lab?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false
      SPRING_DATASOURCE_USERNAME: root
      SPRING_DATASOURCE_PASSWORD: root123456
      SPRING_DATA_REDIS_HOST: redis
      SPRING_AI_VECTORSTORE_PGVECTOR_DATASOURCE_URL: jdbc:postgresql://pgvector:5432/vectordb
      SPRING_AI_VECTORSTORE_PGVECTOR_DATASOURCE_USERNAME: ai
      SPRING_AI_VECTORSTORE_PGVECTOR_DATASOURCE_PASSWORD: ai123456
      MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: http://zipkin:9411/api/v2/spans
      # 生产环境:关闭 Prompt 日志,降低采样率
      SPRING_AI_CHAT_OBSERVATIONS_LOG_PROMPT: "false"
      MANAGEMENT_TRACING_SAMPLING_PROBABILITY: "0.2"
      SPRING_PROFILES_ACTIVE: prod
    depends_on:
      mysql:    { condition: service_healthy }
      redis:    { condition: service_started }
      pgvector: { condition: service_started }
    restart: unless-stopped
容器化最常见的三个坑:
  1. 用 localhost 连其他容器——容器有各自的网络命名空间,localhost 指的是容器自己。必须用 service 名(如 mysql)。
  2. 忘记 depends_on 的 healthcheck 条件——MySQL 容器"启动了"不等于"可以连接了",应用会在启动时连接失败退出。用 condition: service_healthy 等它真正就绪。
  3. 时区不对——容器默认 UTC,日志时间差 8 小时,排查问题时会对不上时间线。
排查步骤:若容器反复重启,先 docker compose logs app 看启动异常;若是连接失败,确认 depends_on 的 healthcheck 是否就绪,以及连接串用的是 service 名而非 localhost;若是时区问题,进容器执行 date 确认是否为 Asia/Shanghai。

运行验证 ⑥:新机器一键起

# 在一台干净机器上(已装 docker),克隆工程后执行:
docker compose up -d --build
# 等约 1~3 分钟,检查所有容器健康
docker compose ps
# 期望 app 状态为 healthy / running
curl -s http://localhost:8080/actuator/health
# 期望 status: "UP"

看到什么算通过:docker compose ps 里 app 与依赖都 healthy/running,/actuator/health 返回 UP,且能复现验证 ⑤ 的问答请求。即「新环境 5 分钟内系统可用」的验收点达成。

报错原文:app 容器反复 restart,日志报 Failed to connect to mysql。 根因:① 用了 localhost 而非 service 名;② depends_on 没等 mysql healthy,应用抢跑连接失败退出,被 restart: unless-stopped 拉起重试。 修复:把连接串里的 localhost 改成 mysql / redis / pgvector;给 mysql 加 healthcheck 并让 app 的 depends_on 用 condition: service_healthy。
报错原文:manifest for eclipse-temurin:21-jre-alpine:... not found 或构建阶段卡在 mvn dependency:go-offline。 根因:① 镜像标签写错(如 21 写成 17 但本地是 21 项目);② 国内拉 Maven 依赖慢或镜像没配。 修复:① 镜像标签与你的 Java 版本一致(本镜像用 21,纯 Java 17 交付请改成 eclipse-temurin:17-jre);② 在 Docker 构建里给 Maven 配阿里云镜像(在 RUN mvn ... 前加 COPY settings.xml /root/.m2/),或在 docker compose build 时挂代理。

③ 上线前检查清单

知识点揉碎:故障降级

角度说明
是什么当某环节(模型、工具、数据库)出问题时,系统不是直接 500,而是退到「还能用」的下一档:重试 → 熔断 → 切换备用 → 命中缓存 → 友好话术 + 转人工。
为什么AI 依赖的外部服务稳定性不在你手里,模型厂商抖动、网络抖动都会发生;没有降级,一次抖动就变成一次线上事故。
没有会怎样模型服务挂 10 分钟,你的客服系统也跟着挂 10 分钟,用户全排队,投诉爆仓。
类比像电梯停电时有备用发电机、发电机也坏了有应急楼梯——一级级兜底,目标永远是「人能安全到达地面」,而不是「完美运行」。
最后那条「应急开关」经常被忽略,但极其重要。 当模型服务大面积故障、或者 AI 出现了严重的业务错误时,你需要能在 10 秒内把 AI 功能整体关掉,让系统退回人工流程,而不是紧急发版。实现方式很简单:一个配置项 + 在编排层入口判断,返回"AI 助手暂时维护中"。
一句话记忆:部署不是「能跑就行」——非 root、时区、service 名、健康检查四件套;降级是「出事时还能用」,应急开关是「一键回到人工」。

11.8性能与成本优化

① 延迟优化:从 12 秒降到 3 秒

手段做法预期收益代价
流式输出前端用 SSE,首字 0.5s 就出现感知延迟降 80%前端改造成本
意图分类用小模型分类交给本地 Ollama(100ms 级)省 1~2 秒准确率略降
闲聊/FAQ 走缓存相同问题命中 Redis 缓存直接返回命中时 < 50ms需要缓存失效策略
只读工具并行查订单 + 查政策用虚拟线程并行省 1~3 秒实现复杂度上升
压小 topK从 8 降到 4,并在提示词里要求精炼省 1~2 秒可能漏召回
简化 Agent 步数能写死的流程不要交给 Agent 决策(第 9 章)省 3~8 秒灵活性下降

② 成本优化:从每月 1800 元降到 400 元

为什么先算基线?因为优化前必须先知道「钱花在哪」。下面用「每天 300 次咨询」估算,把每次调用的 Token 与单价相乘,得到月成本。注意模型选型对成本影响极大——把 gpt-4o 级换成 deepseek-chat 级,单次成本可能从 0.6 元降到 0.027 元。

# 优化前的成本估算(用强模型 + 每次都走 Agent)
每次咨询:
  · 意图分类      800 token   × 0.8 元/百万 = 0.00064 元
  · RAG 检索生成  6000 token  × 1.0 元/百万 = 0.00600 元
  · Agent 3 步    20000 token × 1.0 元/百万 = 0.02000 元
  合计约 0.027 元/次

月成本 = 0.027 × 300 × 30 ≈ 243 元
# 但如果全部用 gpt-4o 级别(输入 18 元/百万、输出 72 元/百万)
# 单次成本会涨到 0.6 元以上 → 月成本 5400 元
优化手段做法节省
分级用模型分类/闲聊走本地 Ollama(0 成本),生成走中档模型,只有复杂推理用强模型40~60%
结果缓存相同问题(归一化后)1 小时内命中 Redis 缓存15~30%
精简上下文topK 从 8 降到 4;历史只带最近 6 条;工具返回值截断20~35%
控制 Agent 步数把确定流程改成固定工作流,只保留必要的模型决策30~50%
本地向量化embedding 用本地 nomic-embed-text,不走 API重灌库成本降为 0
语义缓存用向量相似度判断"这个问题问过类似的",命中直接返回额外 10~20%
/**
 * 语义缓存:比"完全相同才命中"更聪明的缓存。
 * "退款几天到账" 和 "钱多久能退回来" 语义相同 → 命中同一条缓存。
 */
@Component
public class SemanticCache {

    private static final double SIMILARITY_THRESHOLD = 0.92;

    private final VectorStore cacheStore;      // 独立的缓存向量表
    private final RedisTemplate<String, String> redis;

    public java.util.Optional<String> get(String question, String tenantId) {
        List<Document> hits = cacheStore.similaritySearch(
                SearchRequest.builder()
                        .query(question).topK(1)
                        .similarityThreshold(SIMILARITY_THRESHOLD)   // ★ 阈值必须高
                        .filterExpression("tenantId == '" + tenantId + "'")
                        .build());
        if (hits.isEmpty()) return java.util.Optional.empty();
        String cacheKey = (String) hits.get(0).getMetadata().get("cacheKey");
        return java.util.Optional.ofNullable(redis.opsForValue().get(cacheKey));
    }

    /** ★ 注意:知识库内容更新后,必须清空语义缓存!否则用户会拿到基于旧制度的回答。 */
    public void clearAll(String tenantId) {
        cacheStore.delete("tenantId == '" + tenantId + "'");
    }
}
语义缓存的两个风险: ① 阈值设低了会答错——0.85 可能把「退款要几天」和「退货要几天」判成同一个问题,答案完全不同。建议 ≥ 0.92,并只缓存"标准问答类"内容。 ② 内容更新后不清理缓存 = 用户看到旧答案——这是最容易被忽略的严重问题,必须在重建索引后清缓存。

③ 成本看板:让花钱看得见

-- 每天按意图统计成本(直接查 qa_audit,做成本归因)
SELECT
    DATE(created_at)                            AS 日期,
    intent                                      AS 意图,
    COUNT(*)                                    AS 次数,
    SUM(prompt_tokens + completion_tokens)      AS 总Token,
    ROUND(SUM(cost_yuan), 2)                    AS 成本元,
    ROUND(AVG(elapsed_ms))                      AS 平均耗时ms
FROM qa_audit
WHERE tenant_id = 't1'
  AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY DATE(created_at), intent
ORDER BY 日期 DESC, 成本元 DESC;

-- 找出最"贵"的问题类型——这些是优化的重点
SELECT intent, ROUND(AVG(prompt_tokens)) AS 平均输入Token,
       ROUND(AVG(cost_yuan), 4) AS 平均成本元, COUNT(*) AS 次数
FROM qa_audit
GROUP BY intent
ORDER BY 平均成本元 DESC;
优化的正确顺序:先度量,再优化,最后验证。
  1. 度量:跑一周,看 qa_audit 表,找出最贵和最慢的意图。
  2. 优化:优先做「高收益低成本」的——分级用模型、结果缓存、精简上下文。
  3. 验证:优化后再跑一次评测集,确认准确率没有下降。成本优化绝不能以牺牲效果为代价。

动手:先量后优化(加分项,但验收里要能说清楚)

  1. 先把系统跑一周(或压一批模拟请求),让 qa_audit 表里积攒足够数据。
  2. 执行上面的两条 SQL,导出「按意图的成本 / 延迟」报表。
  3. 找出成本最高的意图(通常是 TICKET_HANDLE,因为走 Agent 步数多),优先对它做优化:压 topK、精简上下文、把固定流程改成工作流。
  4. 优化后再跑一遍 11.10 的 30 条评测集,对比准确率——只有准确率没掉,优化才算有效。

运行验证 ⑦:成本报表能跑出数

mysql -uroot -proot123456 springai_lab -e "SELECT intent, ROUND(SUM(cost_yuan),2) 成本元, COUNT(*) 次数 FROM qa_audit GROUP BY intent;"
# 期望输出(示例):
# intent       成本元   次数
# KNOWLEDGE    1.23     180
# ORDER_QUERY  0.45     60
# TICKET_HANDLE 2.10    30
# CHITCHAT     0.12     30

看到什么算通过:报表按意图分组有数据,能直接指出「哪类问题最贵、最慢」——这就是「让花钱看得见」的验收点。若为空,说明审计没落库,回头检查编排层第 4 步的 auditRepository.save(...) 是否真的执行。

报错原文:cost_yuan 全为 0 / 报表行数为 0。 根因:① 审计表没数据(编排层没调用 save);② 写入 cost 时单价没配置,cost_yuan 算出来是 0。 修复:① 确认每次请求都走到 auditRepository.save(在 ask 方法末尾,异常路径也要补一条);② 在写入前根据模型单价与 token 数算出 cost_yuan(DeepSeek 单价见官网,注意输入/输出分别计价)。优化前必须先有基线,否则你不知道钱花在哪。
一句话记忆:优化三步走「度量 → 优化 → 验证」,缺一不可;没基线的优化是盲调,成本看板就是你的仪表盘。

11.9交付清单与答辩要点

交付物清单

#交付物要求
1源代码仓库结构清晰、有 README、能一键起(docker compose up)
2数据库脚本MySQL 建表 + pgvector 初始化,含注释
3知识库样例文档至少 5 份、覆盖 3 个分类
4评测集与评测报告≥ 30 条,含知识库外问题;报告有指标与未通过明细
5接口文档OpenAPI/Swagger,含请求响应示例与错误码
6架构设计文档分层图、时序图、数据模型说明、技术选型理由
7部署文档环境要求、启动步骤、配置项说明、常见问题
8演示脚本5 分钟能演完的完整流程,含失败场景演示
9成本测算表按调用量估算月成本,含优化前后对比
10监控看板截图Trace 链路 + 指标图表 + 评测报告

演示脚本(建议按这个顺序,5 分钟内完成)

为什么规定演示顺序?因为答辩时间有限,必须把「最能体现系统价值」和「最能体现工程严谨性」的环节放在前面。拒答演示和越权拦截演示一定要主动做——它们直接证明你的系统「安全、不编造」,这是评委最看重的。

  1. 开场(30 秒):一句话讲清业务背景与目标。「客服团队每天处理 300 个咨询,60% 是重复问题。我们用 Spring AI 做了知识问答与工单辅助系统,目标是把常见问题自动化率做到 50%。」
  2. 知识问答(1 分钟):用口语化问题提问(不要用文档里的原话)。演示:「我买的东西不想要了,钱什么时候能退给我?」★ 重点展示引用出处。
  3. 拒答能力(30 秒):问一个知识库里没有的问题。「公司年会在哪天举办?」→ 系统回答"资料中未提及"。★ 主动演示,说明"我们不编造"。
  4. 订单查询与越权拦截(1 分钟):① 问自己的订单 → 正确返回;② 问别人的订单 → 返回"未查询到"。★ 展示 ToolContext 传身份的设计。
  5. 工单处理与人工确认(1.5 分钟):① 输入「订单 2024001 一直没发货,帮我处理」;② 展示 Agent 三步决策链;③ 系统提示"请确认后执行",此时数据库里还没有工单;④ 点击确认 → 工单生成;⑤ 再确认一次 → 提示"已处理",证明幂等。
  6. 可观测性(1 分钟):① 打开 Zipkin,用刚才的 traceId 查出完整链路;② 打开 Grafana,展示 Token 成本曲线与检索命中率;③ 打开评测报告,展示 30 条评测的通过率。
  7. 收尾(30 秒):给出成本测算与后续规划。「当前单次平均成本 0.012 元,按每天 300 次计算月成本约 108 元。下一步计划补充 200 条评测集,把准确率从 80% 提到 88%。」
一句话记忆:演示先讲价值再讲严谨;拒答与越权拦截必须主动演——它们一句顶一万句"我们很安全"。

答辩常见问题(提前准备)

Q:为什么用 pgvector 而不是 Milvus?

数据量 3 万条以内、并发不高的情况下,pgvector 完全够用,且复用已有的 PostgreSQL,运维成本几乎为零,还能用 SQL 做混合查询。当数据量到千万级或向量检索成为瓶颈时,再迁移到 Milvus。关键是要能说出「什么条件下我会换」。

Q:怎么保证 AI 不会胡说?

五层防线:① 检索阈值过滤弱相关内容;② 提示词强制「只依据资料」;③ 检索为空时直接拒答;④ 评测集含 15% 知识库外问题,专门验证拒答;⑤ 生产环境可加 FactCheckingEvaluator 抽查。并用数据说话:拒答率 100%。

Q:模型服务挂了怎么办?

三层:① 重试(只重试 5xx 和网络超时);② 熔断(失败率超 50% 熔断 30 秒);③ 降级(切换备用模型 → 命中缓存 → 返回友好话术 + 转人工)。另外有总开关 feature.ai.enabled 可一键退回纯人工。

Q:用户数据安全怎么保障?

① 身份从服务端登录态取,绝不接受前端传的 userId;② 会话按 租户|用户|会话 三级隔离,向量检索带 tenantId 过滤做物理隔离;③ 日志脱敏,不记录完整 Prompt;④ 高风险操作走人工确认;⑤ 全量审计留痕。安全的关键是「机制约束」而不是「提示词要求」。

Q:这个项目还能怎么改进?

按优先级:① 扩充评测集到 100+ 并把评测接入 CI;② 引入重排模型提升检索精度;③ 做混合检索(向量 + BM25);④ 把订单工具改造成 MCP Server 供其他系统复用;⑤ 加前端反馈闭环自动沉淀难例。

一句话记忆:答辩考的不是「用了多少技术」,而是「每个选型你能说出为什么、什么条件下会换」——能讲清替代方案,才算真懂。

11.10自测与扩展方向

Q1. 用一张图说明本项目的分层架构,并指出「意图识别」在哪一层、为什么放那里。

五层:接入层 → 网关层 → 编排层 → 能力层 → 基础设施层。意图识别属于编排层,因为它是「路由决策」——决定把请求交给哪条业务链路。放在网关层会让网关承担业务逻辑;放在能力层则没有「决策」的语义位置。

Q2. 为什么 qa_audit 表要单独设计,而不是从日志里分析?

① 日志会被轮转清理,无法做长期趋势分析;② 日志是非结构化文本,聚合统计成本高;③ 审计数据有合规要求(保留期限、不可篡改)。用结构化表存关键字段,一次 SQL 就能出报表。

Q3. 语义缓存的阈值为什么必须设得很高(≥0.92)?

因为语义相近的问题可能答案完全不同。「退款要几天」和「退货要几天」在向量空间里非常接近,但答案一个是「3 个工作日到账」,一个是「7 天无理由」。阈值设低就会出现「答非所问」的错误缓存命中。错误缓存比不缓存更危险。

Q4.【核心动手题】把这个项目从零搭起来,按下面的顺序推进:第 1 天环境+建表+灌知识库;第 2 天多轮会话+记忆持久化;第 3 天订单工具+越权拦截;第 4 天 Agent+人工确认;第 5 天意图路由+模型分级;第 6 天可观测性+评测;第 7 天 Docker 部署+演示排练。

每完成一步都跑对应验证:① /api/v1/knowledge/ask 能带引用回答;② 重启后记忆不丢;③ 查别人订单返回"未查询到";④ 挂起期间无工单产生;⑤ 分类走便宜模型(看日志);⑥ Zipkin 有链路、评测报告有指标;⑦ 新机器 docker compose up 能跑起来。

本章(也就是整门课)的最终验收标准:
  • 新环境执行 docker compose up,5 分钟内系统可用
  • 知识问答带引用,且有据可查
  • 知识库外问题 100% 拒答,不编造
  • 越权查询 100% 拦截
  • 高风险操作 100% 人工确认,挂起期间无副作用
  • 服务重启后会话记忆不丢
  • Zipkin 能看到完整链路,Grafana 能看到成本指标
  • 评测报告指标达标(检索命中率 ≥ 80%,答案通过率 ≥ 75%)
  • 能在 5 分钟内完成完整演示,并回答答辩问题

继续深入的方向

方向具体做什么难度
混合检索向量检索 + BM25 关键词检索,结果合并后重排中等
重排模型召回 20 条后用 Rerank 模型精排取前 5中等
多智能体协作分析 Agent + 执行 Agent + 审核 Agent 分工较难
微调用积累的优质问答对微调小模型,替代高频场景的 API 调用较难
GraphRAG把文档抽成知识图谱,支持多跳推理困难
多模态支持发票/截图识别,工单里能附图片中等
主动学习闭环用户点踩的问题自动进入难例集,定期人工标注后优化提示词中等
最后一句忠告:先让系统稳定可用,再追求技术先进性。 见过太多项目,一上来就搞多智能体、GraphRAG、自研框架,结果连「检索不准」和「不编造」这两个最基本的问题都没解决,上线后被业务方质疑 AI 到底有什么用。 把基础做扎实——准确的检索、可靠的拒答、清晰的引用、可观测的成本——这四件事做好了,项目就已经超过大部分同行的水平。
一句话记忆:扩展方向不是「越多越好」——先把检索准、拒答稳、引用清、成本可见这四件基础做扎实,再加花活。

11.11实操作业与本章验收

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

  • 对照 11.6 的 ChatOrchestrator 与 11.3 的分层架构图,把「用户发一条工单处理请求」的完整调用链路画在一张纸上,标出每一层调用了哪个模块、是否走了 Agent 链路。
  • 分组讨论:给定的需求里,哪些是 P0(必须做)、哪些是 P1/P2(加分),并说明你的裁剪理由——每组派代表用 2 分钟汇报。
  • 按 11.9 的演示脚本,用课程工程现场走一遍「知识问答 → 拒答 → 越权拦截 → 工单 HITL」四步,确保每一步都能跑通。

课后作业(期末大作业,15–25 小时):

  • 按本章架构搭建并交付一个可运行的综合项目(P0 全部完成、P1 至少做一项),提交源码仓库 + 数据库脚本 + 部署文档 + 评测报告 + 演示脚本。
  • 撰写 300 字架构设计说明:为什么意图识别放在编排层、为什么每次请求都落一条审计、为什么高风险操作必须 HITL。
  • 跑通自动化评测,报告检索命中率 ≥ 80%、答案通过率 ≥ 75%;并给出按调用量估算的月成本表。
期末项目评分要点(总计 40 分,由本门课程的大作业构成):
  • 一、功能验收(20 分)
    1. 知识问答带引用、知识库外问题 100% 拒答(4 分)
    2. 多轮会话隔离且重启不丢记忆(3 分)
    3. 订单工具越权查询 100% 拦截(3 分)
    4. 工单 Agent 至少 3 步决策链 + 高风险操作 100% HITL、挂起零副作用(4 分)
    5. 知识库可一键重建索引(2 分)
    6. Zipkin 链路 + Prometheus 指标可见(2 分)
    7. 评测报告达标(检索 ≥ 80%、通过 ≥ 75%)(2 分)
  • 二、代码规范(10 分)
    1. 包结构清晰、类名/方法名语义明确、关键代码有中文注释(4 分)
    2. 密钥走环境变量占位符 ${DEEPSEEK_API_KEY:},仓库无硬编码(2 分)
    3. 统一响应体带 traceId、全局异常处理、日志脱敏(2 分)
    4. SQL 有注释、索引为查询场景服务、docker-compose 用 service 名而非 localhost(2 分)
  • 三、答辩(10 分)
    1. 5 分钟现场演示完整流畅、主动演示拒答与越权拦截(4 分)
    2. 能解释每个技术选型的理由与替代方案(3 分)
    3. 能给出成本测算、能回答「为什么不用多智能体」等追问(3 分)

知识点揉碎:验收标准(开发验收 vs 上线验收的落地)

角度说明
是什么验收标准是把「项目算不算做完」变成一张可勾选的清单:技术验收管功能对不对(拒答率、越权拦截率、命中率),业务验收管演示说不说得清(成本、失败兜底、选型理由)。
为什么没有量化标准,学生和评委对「做好了」的理解天差地别;有了标准,每一步做到哪了、差哪分,一目了然。
没有会怎样答辩变成「我觉得我做得挺好」的口水仗,关键安全点(拒答、越权、HITL)没人专门验证,上线后出事。
类比像考驾照的科目二评分表——倒库必须不压线、必须停进框里,不是「差不多就行」;本项目的拒答率 100%、越权拦截 100% 就是这种「硬杠杠」。
一句话记忆:验收标准把「做完了」变成「可勾选的硬杠杠」——拒答 100%、越权 100%、HITL 100%,这三条不过,其他再花哨也是不及格。