CH 11综合项目:企业知识问答 + 工单辅助系统
前九章你学了一堆「零件」,本章把它们装成一台机器。 这个项目会用到前面全部章节的能力:RAG(第 4、5 章)、记忆(第 2、6 章)、结构化输出(第 3 章)、 工具调用与 MCP(第 7、8 章)、Agent 与人工确认(第 9 章)、可观测性与评估(第 10 章)。 更重要的是,本章会讲清「怎么把 Demo 变成产品」——这是企业开发真正的门槛。
11.1需求与业务场景
先讲一个真实的业务痛点
「智汇科技」有一支 20 人的客服团队,每天处理约 300 个用户咨询,典型的一天是这样的:
😫 现状(成本高、体验差)
- 60% 的问题是重复的:「退款几天到账」「发票怎么开」「怎么改地址」——答案都写在制度文档里,但客服要手动翻找。
- 新人上手慢:制度文档 200 多页,新人要 2 周才能独立应对常见问题。
- 答复口径不一致:同一个问题,不同客服给的答案不一样,引发二次投诉。
- 工单靠手工建:查完订单、翻完政策,再手工录工单,平均 8 分钟一单。
- 知识库更新滞后:制度改了,客服还在按旧版回答。
🎯 目标(可量化的收益)
- 常见问题自动回答,客服只处理疑难——目标自动化解率 50%
- 回答必带出处,客服可一键核对,杜绝口径不一致
- 工单处理从 8 分钟降到 2 分钟(自动查订单 + 自动生成方案)
- 知识库一键重建索引,制度更新当天生效
- 全链路可观测:成本、效果、异常都有数据支撑
用户角色与核心诉求
| 角色 | 核心诉求 | 系统要提供什么 |
|---|---|---|
| 客服专员 | 快速拿到"可以直接发给客户"的答案 | 带出处的问答 + 一键复制 + 转人工 |
| 客服主管 | 知道 AI 答得准不准、哪些问题答不了 | 效果看板 + 未命中问题清单(反哺知识库) |
| 知识库管理员 | 制度更新后能快速生效 | 文档上传 + 一键重建索引 + 版本管理 |
| 终端用户 | 自助解决问题,不用排队 | 多轮对话 + 自助查订单 + 工单进度查询 |
| 运维 | 系统稳定、成本可控 | 健康检查 + 指标看板 + 降级策略 |
11.2功能清单与验收标准
功能模块(P0 必须做,P1 加分)
| 优先级 | 模块 | 功能点 | 用到章节 |
|---|---|---|---|
| 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 次咨询,月成本约多少)
- 能解释:每个技术选型的理由与替代方案
11.3总体架构设计
分层架构
模块划分(工程结构)
springai-lab/
├── docker-compose.yml
├── init/pgvector-init.sql
├── springai-common/ # 公共模块:配置、异常、工具类、Advisor
│ ├── ChatClientConfig
│ ├── ModelRouter
│ ├── ConversationIds
│ ├── TokenUsageAdvisor / SafeGuardAdvisor
│ └── GlobalExceptionHandler
├── springai-rag/ # 知识检索模块
│ ├── KnowledgeIndexService
│ ├── KnowledgeQaService
│ └── docreader/ (多格式解析)
├── springai-agent/ # 智能体模块
│ ├── SimpleAgent(循环 + 刹车)
│ ├── PendingActionService(HITL)
│ └── TicketAgentService
├── springai-tools/ # 工具模块
│ ├── OrderTools / TicketTools
├── springai-eval/ # 评测模块
│ ├── EvalRunner / RagEvaluationTest
└── springai-app/ # 启动模块(组装以上全部)
├── controller/
└── SpringAiLabApplication
11.4数据模型设计
① MySQL:业务数据
-- ============================================================
-- 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 轨迹。
② PostgreSQL + pgvector:向量表
沿用 0.4.4 节的初始化脚本,但生产版本要补上多租户与来源字段:
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'));
-- 排查用:看看每个租户灌了多少块
-- SELECT metadata->>'tenantId' AS tenant, COUNT(*) FROM vector_store GROUP BY 1;
WHERE metadata->>'tenantId' = 't1'
才能走索引而不是全表扫描。没有它,数据量到十万级后,每次检索前都要扫全表做过滤,延迟会明显上升。
11.5接口契约
统一响应格式
/**
* 统一响应包装。所有接口返回这个结构,前端处理逻辑统一。
* ★ 一定要带 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"));
}
}
接口清单
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| 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 | 指标抓取 | 运维 |
核心接口契约:统一问答入口
// ==================== 请求 ====================
{
"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 天。\n\n依据《超时发货处理规范》,可申请订单金额 5% 的补偿(899 × 5% = 44.95 元)。\n\n我准备创建一张【超时发货】工单,建议方案:催促仓库发货并申请 44.95 元补偿。\n\n请确认后继续。",
"citations": [ // ★ 引用来源,第三方可核对
{ "index": 1, "source": "refund-policy.md", "score": 0.82,
"excerpt": "订单超过承诺发货日期 7 天未发货的,按订单金额 5% 补偿…" }
],
"pendingAction": { // 待确认操作(有则返回)
"actionId": "8f3a2b1c-...",
"summary": "创建工单:订单 2024001,类型 超时发货,优先级 高",
"riskLevel": "HIGH",
"expiresAt": "2026-03-15T12:15:00"
},
"meta": { // 透明化:让前端能展示与统计
"model": "deepseek-chat",
"steps": 3,
"elapsedMs": 8420,
"tokens": { "prompt": 9482, "completion": 3391 },
"costYuan": 0.0163
}
}
}
// ==================== 错误码约定 ====================
// 0 成功
// 1001 参数非法
// 2001 模型服务不可用(已降级)
// 2002 达到调用配额上限
// 3001 无权访问该资源(会话/订单/操作)
// 3002 待确认操作已过期
// 4001 知识库为空或索引未建立
meta?
三个好处:① 前端可以展示「本次回答用了多少 Token、花了多少钱」,让成本透明;
② 测试与验收时可以直接断言步数、耗时;
③ 出问题时用户报 traceId,你立刻能对上。
透明度本身就是企业级产品的竞争力。
11.6核心编排实现(项目的「大脑」)
编排层的职责:先判断用户想干什么,再交给对应的链路,最后统一组装结果。 这是整个项目最核心的 100 行代码。
① 意图识别:用结构化输出(第 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);
// 置信度过低时降级为"再问一句",而不是猜
if (result.confidence() != null && result.confidence() < 0.6) {
log.info("意图识别置信度较低:{},将追问用户", result.confidence());
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);
}
}
}
② 编排服务:路由 + 组装
@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; // 第 6 章
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);
log.info("意图识别:intent={} orderNo={} confidence={}",
intent.intent(), intent.orderNo(), intent.confidence());
// ---------- 第 3 步:路由分发 ----------
String answer;
List<Citation> citations = List.of();
PendingAction pending = null;
switch (intent.intent()) {
case "KNOWLEDGE" -> {
// 知识问答链路:RAG + 引用溯源
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" -> {
// Agent 链路:多步决策 + 高风险操作挂起
AgentTrace trace = ticketAgent.handle(message);
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) {}
}
- 闲聊和分类用便宜模型:这两类占调用量的大头(可能 70%),用强模型是巨大浪费。
- 意图不明确时追问,而不是硬猜:这是「体验优先」的设计——猜错了要重来,代价更高。
- 每次请求都落一条审计:成本、效果、知识盲区的所有分析都基于这张表,是后面做优化的前提。
11.7部署上线
① Dockerfile(多阶段构建,镜像更小)
# ---------- 阶段一:构建 ----------
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
# ---------- 阶段二:运行 ----------
# 用 JRE 而非 JDK,用 alpine 变体,镜像从 ~700MB 降到 ~200MB
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"]
② docker-compose(应用 + 全部依赖,一键起)
# 在 0.4.4 节的基础设施 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、pgvector)。 - 忘记 depends_on 的 healthcheck 条件——MySQL 容器"启动了"不等于"可以连接了",
应用会在启动时连接失败退出。用
condition: service_healthy等它真正就绪。 - 时区不对——容器默认 UTC,日志时间差 8 小时,
排查问题时会对不上时间线。加 tzdata 并设置
Asia/Shanghai。
③ 上线前检查清单
- 所有密钥通过环境变量注入,仓库里搜不到
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)
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 次咨询估算):
# 优化前的成本估算(用强模型 + 每次都走 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");
String cached = redis.opsForValue().get(cacheKey);
// 缓存过期则视为未命中(TTL 由写入时设置)
return java.util.Optional.ofNullable(cached);
}
/**
* ★ 注意:知识库内容更新后,必须清空语义缓存!
* 否则用户会拿到基于旧制度的回答。
* 做法:reindex 完成后调用 clearAll()。
*/
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表,找出最贵和最慢的意图。 - 优化:优先做「高收益低成本」的——分级用模型、结果缓存、精简上下文。
- 验证:优化后再跑一次评测集,确认准确率没有下降。 成本优化绝不能以牺牲效果为代价——省了钱但答错了,是负收益。
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 分钟):
① 问自己的订单 → 正确返回;
② 问别人的订单 → 返回"未查询到"(而不是别人的数据,也不是 500)。
★ 展示 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 表要单独设计,而不是从日志里分析?
三个原因:① 日志会被轮转清理,无法做长期的成本趋势分析; ② 日志是非结构化文本,做聚合统计需要额外解析,成本高; ③ 审计数据有合规要求(保留期限、不可篡改)。 用结构化表存关键字段(意图、Token、成本、引用数、反馈),一次 SQL 就能出报表。
Q3. 语义缓存的阈值为什么必须设得很高(≥0.92)?
因为语义相近的问题可能答案完全不同。 「退款要几天」和「退货要几天」在向量空间里非常接近,但答案一个是「3 个工作日到账」, 一个是「7 天无理由」。阈值设低就会出现「答非所问」的错误缓存命中。 错误缓存比不缓存更危险,因为它看起来"很快",实际上是错的。
Q4.【核心动手题】把这个项目从零搭起来,按下面的顺序推进,每完成一步运行一次对应的验证:
第 1 天:环境 + 建表 + 灌知识库 → 验证:/api/v1/knowledge/ask 能带引用回答。
第 2 天:多轮会话 + 记忆持久化 → 验证:重启后记忆不丢。
第 3 天:订单工具 + 越权拦截 → 验证:查别人订单返回"未查询到"。
第 4 天:Agent + 人工确认 → 验证:挂起期间无工单产生。
第 5 天:意图路由 + 模型分级 → 验证:分类走便宜模型(看日志)。
第 6 天:可观测性 + 评测 → 验证:Zipkin 有链路、评测报告有指标。
第 7 天:Docker 部署 + 演示脚本排练 → 验证:新机器 docker compose up 能跑起来。
- 新环境执行
docker compose up,5 分钟内系统可用 - 知识问答带引用,且有据可查
- 知识库外问题 100% 拒答,不编造
- 越权查询 100% 拦截
- 高风险操作 100% 人工确认,挂起期间无副作用
- 服务重启后会话记忆不丢
- Zipkin 能看到完整链路,Grafana 能看到成本指标
- 评测报告指标达标(检索命中率 ≥ 80%,答案通过率 ≥ 75%)
- 能在 5 分钟内完成完整演示,并回答答辩问题
继续深入的方向
| 方向 | 具体做什么 | 难度 |
|---|---|---|
| 混合检索 | 向量检索 + BM25 关键词检索,结果合并后重排 | 中等 |
| 重排模型 | 召回 20 条后用 Rerank 模型精排取前 5 | 中等 |
| 多智能体协作 | 分析 Agent + 执行 Agent + 审核 Agent 分工 | 较难 |
| 微调 | 用积累的优质问答对微调小模型,替代高频场景的 API 调用 | 较难 |
| GraphRAG | 把文档抽成知识图谱,支持多跳推理 | 困难 |
| 多模态 | 支持发票/截图识别,工单里能附图片 | 中等 |
| 主动学习闭环 | 用户点踩的问题自动进入难例集,定期人工标注后优化提示词 | 中等 |
把基础做扎实——准确的检索、可靠的拒答、清晰的引用、可观测的成本—— 这四件事做好了,项目就已经超过大部分同行的水平。