CH 11综合项目:企业知识问答 + 工单辅助系统
前九章你学了一堆「零件」,本章把它们装成一台机器。这个项目会用到前面全部章节的能力:RAG(第 4、5 章)、记忆(第 2、6 章)、结构化输出(第 3 章)、工具调用与 MCP(第 7、8 章)、Agent 与人工确认(第 9 章)、可观测性与评估(第 10 章)。更重要的是,本章会讲清「怎么把 Demo 变成产品」——这是企业开发真正的门槛。
教学设计说明(教师参考):本章是整门课的「收口」,建议用两节课讲架构与关键代码(重点放在 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 答得准不准、哪些问题答不了 | 效果看板 + 未命中问题清单(反哺知识库) |
| 知识库管理员 | 制度更新后能快速生效 | 文档上传 + 一键重建索引 + 版本管理 |
| 终端用户 | 自助解决问题,不用排队 | 多轮对话 + 自助查订单 + 工单进度查询 |
| 运维 | 系统稳定、成本可控 | 健康检查 + 指标看板 + 降级策略 |
动手:用一句话画出项目边界(课前热身)
在动手写第一行代码之前,先用纸笔把「这个项目不做什么」写下来——边界比功能更重要,它决定了你后面不会被「再加个小功能」拖垮。
- 打开一张纸,中间写「企业知识问答 + 工单辅助系统」。
- 左侧列「做」:制度问答、多轮记忆、订单查询、工单辅助、知识库管理、可观测。
- 右侧列「不做」:不替代人工最终决策(高风险必须确认)、不做多轮复杂谈判、不接第三方支付(只查不付)。
- 把右侧三条念一遍——答辩时如果有人让你「顺手加个自动退款」,你就用这三条挡回去。
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 |
| P1 | MCP 工具服务 | 把订单查询能力独立成 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,只动基础设施层,编排层完全不用改。
模块划分(工程结构)
课堂作业用单模块 + 分包就够,不要一上来拆多模块。下面给出两种工程结构,先按「单模块分包」落地,等真的需要独立部署再拆。
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/ # 启动模块
动手:从零搭出工程骨架(先让应用跑起来)
本章无配套 lab 代码,按「自建」口径操作:所有包、类、配置文件都自己建,代码块照抄本节后面给出的源码。下面这一步的目标是先把空壳应用跑起来,后面每加一块能力都基于它验证。
-
打开 start.spring.io,按下表选择并 Generate 下载 zip,解压后用 IDEA 打开:
选项 选择 Project / Language Maven / Java Spring Boot 4.1.x(不要 3.x,不要 SNAPSHOT) Group / Artifact com.example/springai-labJava 17 Dependencies Spring Web + Spring AI Model Chat OpenAI + Spring Data JPA + Spring Data Redis + Actuator -
打开 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 即可。 -
在
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。若文件已存在同名项,以这一段为准。 -
在
com.example.springai包下确认启动类存在(Initializr 已生成SpringAiLabApplication)。右键com.example.springai→ New → Package,依次新建common、orchestrator、rag、tools、agent、controller、repo七个空包,先不用填内容。 -
确保环境变量
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 + QuestionAnswerAdvisor | ToolCallingAdvisor + PendingAction + 人工确认 |
| 失败代价 | 答错一句,客服可核对出处纠正 | 错建一张工单,直接影响真实客户与财务 |
| 验收看点 | 引用是否带出、拒答率 100% | 越权拦截率 100%、HITL 是否真的挂起 |
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 落到数据库
- 在
init/目录下新建文件mysql-schema.sql,把上面 6 张表的 SQL 整段粘进去保存。 - 登录本地 MySQL,先建库:
CREATE DATABASE springai_lab DEFAULT CHARSET utf8mb4; - 执行脚本:在 MySQL 客户端里
USE springai_lab;然后SOURCE /绝对路径/init/mysql-schema.sql; - 验证表是否建好:
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
- 在
init/下新建pgvector-init.sql,粘贴上面向量表的 SQL。 -
快速起一个带 pgvector 的 PostgreSQL(推荐用容器,避免本机装扩展麻烦):
docker run -d --name pgvector \ -e POSTGRES_USER=ai -e POSTGRES_PASSWORD=ai123456 \ -e POSTGRES_DB=vectordb -p 5432:5432 \ ankane/pgvector - 执行初始化:
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"));
}
}
动手:落统一响应与全局异常
- 右键
com.example.springai.common→ New → Java Class,名字填ApiResponse,Kind 选Record。 - 把上面
ApiResponse整段代码粘贴进去,保存。 - 同样在
common包新建GlobalExceptionHandler(用@RestControllerAdvice包裹,所有异常统一转成ApiResponse.fail(...)),保证任何报错都走统一结构、绝不把堆栈直接抛给用户。 - 在
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 |
|---|---|---|
| 返回形式 | 一次性返回完整 JSON | SSE 分片推送,前端逐字渲染 |
| 感知延迟 | 要等模型整段生成完(可能 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,你立刻能对上。
透明度本身就是企业级产品的竞争力。
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,最容易单点验证。
- 右键
com.example.springai.orchestrator→ New → Java Class,名字IntentResult,Kind 选Record,粘贴上面的IntentResult代码。 - 同包新建
IntentClassifier(KindClass),粘贴上面代码。注意它依赖ModelRouter——这是你自己封装的多模型路由(分类走便宜模型)。先建一个最简版:注入ChatModel,route(...)直接返回ChatClient.Builder构建的 client 即可,重点是把.entity(IntentResult.class)跑通。 - 同包新建一个临时
@SpringBootTest或直接在CommandLineRunner里写一行:log.info(classifier.classify("退款几天到账", null).toString()); - 启动应用,观察控制台。
运行验证 ④:意图识别单点
# 控制台期望看到类似:
# 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 步:串问答链路)
- 在
orchestrator包新建ChatOrchestrator,粘贴上面代码。注意它依赖KnowledgeQaService、OrderAssistantService、TicketAgentService、ConversationIds、Citation、PendingAction、AgentTrace、QaAudit等——这些在本阶段先建「最小桩」:返回写死的示例值,让编排器能编译运行,后面逐步替换真实现。 - 在
common包新建ConversationIds,提供一个静态方法of(tenantId, userId, sessionId)拼成唯一字符串(如tenantId + "|" + userId + "|" + sessionId)——这是传给记忆的conversationId。 - 在
controller包让ChatController注入ChatOrchestrator,把/api/v1/chat/ask的请求转成orchestrator.ask(...)调用,用ApiResponse.ok(result)包装返回。 - 本阶段先把
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 要前后一致。
- 闲聊和分类用便宜模型:这两类占调用量的大头(可能 70%),用强模型是巨大浪费。
- 意图不明确时追问,而不是硬猜:猜错了要重来,代价更高。
- 每次请求都落一条审计:成本、效果、知识盲区的所有分析都基于这张表,是后面做优化的前提。
知识点揉碎:链路编排
| 角度 | 说明 |
|---|---|
| 是什么 | 编排层像一个「总机接线员」:听清用户意图 → 把请求转给对应链路 → 把各链路结果拼成统一答复。 |
| 为什么 | 让每条业务链路专心做自己的事(检索 / 工具 / 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"]
eclipse-temurin-21 与虚拟线程(JDK 21 特性)。若你严格按 Java 17 交付,请把基础镜像换成 eclipse-temurin:17-jre,并把 9.5 节的并行执行里 newVirtualThreadPerTaskExecutor() 改为固定线程池,否则编译或运行会报错。请以官方文档和你的 pom.xml 设定的 Java 版本为准。
动手:写 Dockerfile + 一键编排(分阶段第 3 步:可部署)
- 在项目根目录(与
pom.xml同级)右键 → New → File,文件名Dockerfile(无扩展名),粘贴上面多阶段构建内容。 - 同目录新建
docker-compose.yml(见下一段),把 MySQL / Redis / pgvector / zipkin 与app一起编排。 - 构建镜像并起服务:
docker compose up -d --build(首次会下载依赖并打包,约几分钟)。 - 等
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
- 用 localhost 连其他容器——容器有各自的网络命名空间,
localhost指的是容器自己。必须用 service 名(如mysql)。 - 忘记 depends_on 的 healthcheck 条件——MySQL 容器"启动了"不等于"可以连接了",应用会在启动时连接失败退出。用
condition: service_healthy等它真正就绪。 - 时区不对——容器默认 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 时挂代理。
③ 上线前检查清单
- 所有密钥通过环境变量注入,仓库里搜不到
sk-开头的字符串 spring.profiles.active=prod,且 prod profile 里日志级别为info- Prompt 与响应内容不写入日志(或已脱敏)
- 追踪采样率降到 0.1~0.2
- 所有外部调用都有超时设置(模型 60~120s、数据库 5s、工具 3~15s)
- 降级方法已验证(人为断开模型网络,确认返回友好话术而非 500)
- 数据库连接池上限合理(不要超过数据库 max_connections / 实例数)
- 健康检查接口能正确反映依赖状态
- 评测集跑过一遍,指标达标
- 有一个"应急开关":能一键关闭 AI 功能、退回纯人工(
feature.ai.enabled=false)
知识点揉碎:故障降级
| 角度 | 说明 |
|---|---|
| 是什么 | 当某环节(模型、工具、数据库)出问题时,系统不是直接 500,而是退到「还能用」的下一档:重试 → 熔断 → 切换备用 → 命中缓存 → 友好话术 + 转人工。 |
| 为什么 | AI 依赖的外部服务稳定性不在你手里,模型厂商抖动、网络抖动都会发生;没有降级,一次抖动就变成一次线上事故。 |
| 没有会怎样 | 模型服务挂 10 分钟,你的客服系统也跟着挂 10 分钟,用户全排队,投诉爆仓。 |
| 类比 | 像电梯停电时有备用发电机、发电机也坏了有应急楼梯——一级级兜底,目标永远是「人能安全到达地面」,而不是「完美运行」。 |
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 + "'");
}
}
③ 成本看板:让花钱看得见
-- 每天按意图统计成本(直接查 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;
- 度量:跑一周,看
qa_audit表,找出最贵和最慢的意图。 - 优化:优先做「高收益低成本」的——分级用模型、结果缓存、精简上下文。
- 验证:优化后再跑一次评测集,确认准确率没有下降。成本优化绝不能以牺牲效果为代价。
动手:先量后优化(加分项,但验收里要能说清楚)
- 先把系统跑一周(或压一批模拟请求),让
qa_audit表里积攒足够数据。 - 执行上面的两条 SQL,导出「按意图的成本 / 延迟」报表。
- 找出成本最高的意图(通常是
TICKET_HANDLE,因为走 Agent 步数多),优先对它做优化:压 topK、精简上下文、把固定流程改成工作流。 - 优化后再跑一遍 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 分钟内完成)
为什么规定演示顺序?因为答辩时间有限,必须把「最能体现系统价值」和「最能体现工程严谨性」的环节放在前面。拒答演示和越权拦截演示一定要主动做——它们直接证明你的系统「安全、不编造」,这是评委最看重的。
- 开场(30 秒):一句话讲清业务背景与目标。「客服团队每天处理 300 个咨询,60% 是重复问题。我们用 Spring AI 做了知识问答与工单辅助系统,目标是把常见问题自动化率做到 50%。」
- 知识问答(1 分钟):用口语化问题提问(不要用文档里的原话)。演示:「我买的东西不想要了,钱什么时候能退给我?」★ 重点展示引用出处。
- 拒答能力(30 秒):问一个知识库里没有的问题。「公司年会在哪天举办?」→ 系统回答"资料中未提及"。★ 主动演示,说明"我们不编造"。
- 订单查询与越权拦截(1 分钟):① 问自己的订单 → 正确返回;② 问别人的订单 → 返回"未查询到"。★ 展示 ToolContext 传身份的设计。
- 工单处理与人工确认(1.5 分钟):① 输入「订单 2024001 一直没发货,帮我处理」;② 展示 Agent 三步决策链;③ 系统提示"请确认后执行",此时数据库里还没有工单;④ 点击确认 → 工单生成;⑤ 再确认一次 → 提示"已处理",证明幂等。
- 可观测性(1 分钟):① 打开 Zipkin,用刚才的 traceId 查出完整链路;② 打开 Grafana,展示 Token 成本曲线与检索命中率;③ 打开评测报告,展示 30 条评测的通过率。
- 收尾(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 | 把文档抽成知识图谱,支持多跳推理 | 困难 |
| 多模态 | 支持发票/截图识别,工单里能附图片 | 中等 |
| 主动学习闭环 | 用户点踩的问题自动进入难例集,定期人工标注后优化提示词 | 中等 |
11.11实操作业与本章验收
课堂练习(课上完成,约 1 学时):
- 对照 11.6 的
ChatOrchestrator与 11.3 的分层架构图,把「用户发一条工单处理请求」的完整调用链路画在一张纸上,标出每一层调用了哪个模块、是否走了 Agent 链路。 - 分组讨论:给定的需求里,哪些是 P0(必须做)、哪些是 P1/P2(加分),并说明你的裁剪理由——每组派代表用 2 分钟汇报。
- 按 11.9 的演示脚本,用课程工程现场走一遍「知识问答 → 拒答 → 越权拦截 → 工单 HITL」四步,确保每一步都能跑通。
课后作业(期末大作业,15–25 小时):
- 按本章架构搭建并交付一个可运行的综合项目(P0 全部完成、P1 至少做一项),提交源码仓库 + 数据库脚本 + 部署文档 + 评测报告 + 演示脚本。
- 撰写 300 字架构设计说明:为什么意图识别放在编排层、为什么每次请求都落一条审计、为什么高风险操作必须 HITL。
- 跑通自动化评测,报告检索命中率 ≥ 80%、答案通过率 ≥ 75%;并给出按调用量估算的月成本表。
- 一、功能验收(20 分)
- 知识问答带引用、知识库外问题 100% 拒答(4 分)
- 多轮会话隔离且重启不丢记忆(3 分)
- 订单工具越权查询 100% 拦截(3 分)
- 工单 Agent 至少 3 步决策链 + 高风险操作 100% HITL、挂起零副作用(4 分)
- 知识库可一键重建索引(2 分)
- Zipkin 链路 + Prometheus 指标可见(2 分)
- 评测报告达标(检索 ≥ 80%、通过 ≥ 75%)(2 分)
- 二、代码规范(10 分)
- 包结构清晰、类名/方法名语义明确、关键代码有中文注释(4 分)
- 密钥走环境变量占位符
${DEEPSEEK_API_KEY:},仓库无硬编码(2 分) - 统一响应体带 traceId、全局异常处理、日志脱敏(2 分)
- SQL 有注释、索引为查询场景服务、docker-compose 用 service 名而非 localhost(2 分)
- 三、答辩(10 分)
- 5 分钟现场演示完整流畅、主动演示拒答与越权拦截(4 分)
- 能解释每个技术选型的理由与替代方案(3 分)
- 能给出成本测算、能回答「为什么不用多智能体」等追问(3 分)
知识点揉碎:验收标准(开发验收 vs 上线验收的落地)
| 角度 | 说明 |
|---|---|
| 是什么 | 验收标准是把「项目算不算做完」变成一张可勾选的清单:技术验收管功能对不对(拒答率、越权拦截率、命中率),业务验收管演示说不说得清(成本、失败兜底、选型理由)。 |
| 为什么 | 没有量化标准,学生和评委对「做好了」的理解天差地别;有了标准,每一步做到哪了、差哪分,一目了然。 |
| 没有会怎样 | 答辩变成「我觉得我做得挺好」的口水仗,关键安全点(拒答、越权、HITL)没人专门验证,上线后出事。 |
| 类比 | 像考驾照的科目二评分表——倒库必须不压线、必须停进框里,不是「差不多就行」;本项目的拒答率 100%、越权拦截 100% 就是这种「硬杠杠」。 |
- 项目能在一台新机器上
docker compose up5 分钟内启动并可用。 - 知识问答带引用来源,且知识库外问题 100% 拒答不编造。
- 越权订单查询 100% 拦截;高风险操作 100% 人工确认,挂起期间数据库无副作用。
- 服务重启后会话记忆不丢失;全链路可在 Zipkin 查看;评测报告指标达标。
- 交付物齐全(源码、建表脚本、评测报告、接口文档、部署文档、演示脚本)。
- 答辩能讲清架构分层、意图识别位置、HITL 安全设计与成本测算。