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

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

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

学习目标
端到端交付一个可用系统
前置要求
第 1–10 章
本章产出
可部署、可演示、可验收
建议学时
15–25 小时

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
P1MCP 工具服务把订单查询能力独立成 MCP Server,供其他系统复用8
P2效果看板Grafana 展示 QPS、延迟、Token 成本、检索命中率10
P2流式输出前端打字机效果1

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

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

11.3总体架构设计

分层架构

┌──────────────────────────────────────────────────────────────┐ │ 接入层 前端控制台 / 客服工作台 / 第三方系统(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/
├── 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
关于多模块:课堂作业用单模块 + 分包就够(简单,不容易出配置问题)。 只有在「模块要独立部署」「多人协作需明确边界」时才拆多模块。 拆模块的代价是 Maven 配置复杂度上升、启动变慢、调试链路变长——不要为了架构而架构。

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) {}
}
这个编排器体现了三个重要的工程决策,请务必理解:
  1. 闲聊和分类用便宜模型:这两类占调用量的大头(可能 70%),用强模型是巨大浪费。
  2. 意图不明确时追问,而不是硬猜:这是「体验优先」的设计——猜错了要重来,代价更高。
  3. 每次请求都落一条审计:成本、效果、知识盲区的所有分析都基于这张表,是后面做优化的前提。

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
容器化最常见的三个坑:
  1. 用 localhost 连其他容器——容器有各自的网络命名空间, localhost 指的是容器自己。必须用 service 名(如 mysql、pgvector)。
  2. 忘记 depends_on 的 healthcheck 条件——MySQL 容器"启动了"不等于"可以连接了", 应用会在启动时连接失败退出。用 condition: service_healthy 等它真正就绪。
  3. 时区不对——容器默认 UTC,日志时间差 8 小时, 排查问题时会对不上时间线。加 tzdata 并设置 Asia/Shanghai。

③ 上线前检查清单

最后那条「应急开关」经常被忽略,但极其重要。 当模型服务大面积故障、或者 AI 出现了严重的业务错误时, 你需要能在 10 秒内把 AI 功能整体关掉,让系统退回人工流程,而不是紧急发版。 实现方式很简单:一个配置项 + 在编排层入口判断,返回"AI 助手暂时维护中"。

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 + "'");
    }
}
语义缓存的两个风险: ① 阈值设低了会答错——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. 验证:优化后再跑一次评测集,确认准确率没有下降。 成本优化绝不能以牺牲效果为代价——省了钱但答错了,是负收益。

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 分钟):
    ① 问自己的订单 → 正确返回;
    ② 问别人的订单 → 返回"未查询到"(而不是别人的数据,也不是 500)。
    ★ 展示 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 表要单独设计,而不是从日志里分析?

三个原因:① 日志会被轮转清理,无法做长期的成本趋势分析; ② 日志是非结构化文本,做聚合统计需要额外解析,成本高; ③ 审计数据有合规要求(保留期限、不可篡改)。 用结构化表存关键字段(意图、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把文档抽成知识图谱,支持多跳推理困难
多模态支持发票/截图识别,工单里能附图片中等
主动学习闭环用户点踩的问题自动进入难例集,定期人工标注后优化提示词中等
最后一句忠告:先让系统稳定可用,再追求技术先进性。 见过太多项目,一上来就搞多智能体、GraphRAG、自研框架, 结果连「检索不准」和「不编造」这两个最基本的问题都没解决,上线后被业务方质疑 «AI 到底有什么用»。
把基础做扎实——准确的检索、可靠的拒答、清晰的引用、可观测的成本—— 这四件事做好了,项目就已经超过大部分同行的水平。