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

CH 10可观测性与评估

前九章我们一直在「造」;这一章要解决「怎么证明它好用、坏了怎么查」。大模型应用比传统应用更难观测——同样的输入可能产出不同输出、成本与步数不可预测、错误是「语义错误」而不是「异常」。没有可观测性和评估,你的项目永远停留在「感觉还行」的阶段。

教学目标
能看见链路、能度量成本、能跑评测报告
教学重点
Trace/Span 链路、核心指标、评测集与门禁
教学难点
指标标签基数陷阱、评估体系的投入产出排序
建议学时
2 学时

教学设计说明(教师参考):本章与第 9 章强关联——没有 Agent 就没有可观测的「多步链路」,所以建议接在 9.7 之后讲。重点讲清两件事:① AI 应用的监控和传统监控根本不是一回事(接口 200 但答案是错的,传统三件套发现不了);② 评估必须「先用规则再用模型」。上机以「跑通 10.8 的自动化评测 + 输出报告」为准,要求学生能看到检索命中率与答案通过率两个数字。

本章易掉队点(新增,备课重点):

  • 依赖与配置对不上:学生常只加 actuator 依赖却忘了 micrometer-tracing-bridge-otel 与 opentelemetry-exporter-zipkin,结果应用能起但 Zipkin 永远收不到 trace——讲 10.2 时务必三依赖一起强调,并演示 /actuator/prometheus 与 Zipkin UI 两个验收入口。
  • 配置键名随版本漂移:Spring AI 的观测键在 0.8→1.0→2.0 反复改名。要求学生一律用 IDEA 的 YAML 补全确认键名再写,不要背。2.0 下工具 span 名是 execute_tool <工具名>,不是 1.x 的 tool_call,告警规则升级后会静默失效。
  • 指标标签基数爆炸:学生极易把 userId/query 当 tag,导致 Prometheus 内存暴涨。讲 10.4 时用「每个标签组合 = 一条时间序列」一句话点透,并明确「高基数信息进 Trace,不进指标」。
  • 评估门禁阈值凭感觉拍:10.8 的 80%/75%/8s 不是魔法数字,要讲清每条的由来(检索是地基→最高;答案受模型波动→略低;耗时受网络→留余量)。
  • 「自建」无 lab 的衔接:本章没有配套 lab 代码,所有类都按「在课程工程里新建包自行创建」的口径操作。不要声称与某 lab 逐字一致;代码块可直接复制到新建文件里运行。

10.1为什么 AI 应用比传统应用更难观测

传统应用的「健康」是二值的:要么返回 200,要么抛异常。AI 应用的「健康」是连续且主观的——它很可能返回 200,但内容完全是错的。这一节先把「难在哪」讲清楚,后面所有的观测手段都是为了补这些缺口。

先搞懂:什么是「可观测性」

维度说明
是什么可观测性(Observability)指通过系统对外输出的「日志 / 指标 / 链路」三类信号,反推系统内部状态的能力。你不需要把系统拆开,就能知道它里面发生了什么。
为什么需要大模型应用最大的特点是失败不是二值的:请求成功(200)但答案错、成本飙高、延迟翻倍,这些在传统监控里全表现为「正常」。没有可观测性,你只能等用户投诉,且投诉后无法复现。
没有会怎样出问题时你只有一句「用户说答错了」,却还原不了当时检索了什么、模型看到了什么上下文、走了哪个分支——排查从「看一眼」退化成「猜」。
类比给一辆车的黑箱发动机装上透明观察窗 + 行车记录仪。传统监控只看「车有没有熄火(异常)」;可观测性还能看转速、油耗、每段路的轨迹——车没熄火但快没油了,你也能提前知道。
维度传统应用AI 应用
确定性同输入必同输出同输入可能不同输出(采样随机性)
失败形式抛异常、返回错误码返回 200 + 一段看似合理的错话(语义错误)
成本基本恒定波动巨大(Token 数随内容/步数变化)
延迟毫秒~百毫秒秒~几十秒,且随输出长度线性增长
依赖数据库、缓存数据库 + 外部模型服务(可能限流/宕机/变更行为)
验收接口测试通过即可需要评测集 + 人工/模型打分
最容易被忽略的情况:接口全部 200,但回答全是错的。 传统监控(QPS、错误率、P99 延迟)完全发现不了这种问题—— 因为请求确实成功了。这就是为什么 AI 应用必须有效果评估这一层,而不仅仅是技术监控。

排查步骤:当用户投诉「答错了」而你看到监控全绿时,立刻用用户给的 X-Trace-Id(10.3 节埋的响应头)去 Zipkin 捞出那次请求的完整 span,重点看:① 检索阶段命中了什么文档(是否检索错了);② 模型最终拿到的上下文是否包含答案;③ 工单/Agent 的 steps 是否走了正确分支。定位到根因后才能决定是补知识库、调提示词还是加兜底。

观测能回答的四个问题

上面那张表解释了「难在哪」,下面四个问题才是我们动手建设的目标清单——后面每一节都在回答其中一个。

  1. 出了什么问题?——用户投诉「答错了」,我要能立刻找到那一次的完整请求(提示词、检索结果、工具调用、模型输出)。(由 10.2 链路 + 10.3 埋点回答)
  2. 花了多少钱?——按天/按租户/按接口统计 Token 与费用,能预警、能定价、能找优化点。(由 10.4 指标 + 成本回答)
  3. 效果好不好?——用固定评测集跑分,改一次提示词/换一次模型就知道是变好还是变坏。(由 10.6–10.8 评估回答)
  4. 瓶颈在哪个环节?——检索慢?模型慢?工具慢?还是步数太多?(由 10.4 的耗时指标 + 10.2 的链路回答)

可观测性最小落地清单(先把这 5 件事做掉,再谈高级玩法):

  • 应用暴露 /actuator/prometheus,且能查到 ai.chat.calls 等 AI 指标。
  • 全链路能进 Zipkin,且工具 span 名是 2.0 的 execute_tool <工具名>。
  • 响应头带 X-Trace-Id,用户报障时能直接拿它定位。
  • 有一份 ≥30 条、含知识库外题的评测集,能跑出检索命中率与答案通过率。
  • 评测接了质量门禁,指标下滑时 CI 会红。
一句话记忆:传统监控回答「挂没挂」,AI 可观测性还要回答「对不对、贵不贵、慢在哪」——前者看异常,后者看语义与成本。

10.2三大支柱与 Spring AI 的内置能力

可观测性在经典运维里有「三件套」,AI 应用要在其之上补一件「评估」。下面这张图是本章所有工作的总纲。

可观测性三大支柱(经典 Observability 三件套) ① Logs(日志) → 看单次请求的细节。谁、问了什么、模型答了什么 ② Metrics(指标) → 看整体趋势。QPS、延迟分布、Token 总量、失败率 ③ Traces(链路) → 看调用关系。一次请求经过了哪些环节、各花多久 AI 场景需要额外补充的第四件事: ④ Evaluations(评估)→ 看输出质量。回答是否相关、是否有依据、是否符合格式

三大支柱分别回答什么问题(成对对照)

支柱回答的问题时间视角典型用途类比
日志 Logs「这一次请求内部到底发生了什么」单条、事后追查看某次调用的提示词、检索结果、报错堆栈飞机的黑匣子录音
指标 Metrics「整体趋势健不健康」聚合、长期趋势QPS、P95 延迟、Token 成本、失败率汽车的仪表盘
链路 Traces「一次请求经过了哪些环节、各花多久」单条、跨服务还原「接口→检索→模型→工具」的调用树快递的运单轨迹

三者关系:指标告诉你「系统慢了」(仪表盘报警),链路告诉你「慢在检索那一段」(定位环节),日志告诉你「那条检索为什么慢 / 检索到了什么」(看细节)。只装其一都会缺一块——这是经典 Observability 三件套必须齐备的原因。

Spring AI 内置的观测能力

为什么这么写(为什么不用自己接 SDK):Spring AI 基于 Micrometer Observation 构建,只要你引对了依赖、配对了 management.*,每次 ChatModel 调用、每次工具调用都会自动产生一个 Observation(在 Tracing 体系里就是一个 Span)。你不用在业务代码里手写埋点就能拿到链路——这是 2.0 开箱即用的能力。

下面把「加依赖 → 改配置 → 启动 → 发请求 → 看链路」拆成可直接照做的步骤。本章没有配套 lab 代码,以下操作都按「在课程工程里自建」的口径进行——类文件用 IDEA 新建,配置片段粘贴到现有文件。

  1. 在 IDEA 左侧 Project 视图里找到工程根目录的 pom.xml,双击打开。

  2. 找到已有的 <dependencies> 标签,在它内部、与现有 <dependency> 平级的位置,粘贴下面四个依赖(缺一不可:① 是 actuator 端点;② 是把 Observation 桥接到 OpenTelemetry 的实现;③ 是把 span 发到 Zipkin 的导出器;④ 是 Prometheus 抓取口):

    <!-- 依赖(pom 里已有) -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
      <groupId>io.micrometer</groupId>
      <artifactId>micrometer-tracing-bridge-otel</artifactId>
    </dependency>
    <dependency>
      <groupId>io.opentelemetry</groupId>
      <artifactId>opentelemetry-exporter-zipkin</artifactId>
    </dependency>
    <dependency>
      <groupId>io.micrometer</groupId>
      <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
  3. 打开 src/main/resources/application.yml,在文件末尾(注意与 spring: 段同级缩进,顶格写 management:)粘贴以下观测配置。先整段复制,再逐项理解注释:

    # application.yml —— 观测相关配置
    management:
      endpoints:
        web:
          exposure:
            # 暴露这些端点:健康检查、指标、Prometheus 抓取口
            include: health,info,metrics,prometheus
      endpoint:
        health:
          show-details: always
      # ---------- 链路追踪 ----------
      tracing:
        # 采样率:1.0 = 全采(开发/测试);生产建议 0.1~0.2 降低成本
        sampling:
          probability: 1.0
      zipkin:
        tracing:
          # Zipkin 接收地址
          endpoint: http://localhost:9411/api/v2/spans
      # 把 Observation 的数据(含 Prompt/响应)也发出去
      observations:
        annotations:
          enabled: true
    
    spring:
      ai:
        chat:
          observations:
            # ★ 是否把提示词、模型响应记录到 span 中
            #   开发期 true(方便排查),生产环境建议 false 或做脱敏
            log-prompt: true
            log-completion: true
        # 工具调用也会产生 span
        tools:
          observations:
            include-content: true
  4. 启动 Zipkin(链路收集与展示服务)。有 Docker 直接起容器;没有则用 jar:

    # 方式一:Docker(推荐,一条命令)
    docker run -d --name zipkin -p 9411:9411 openzipkin/zipkin
    
    # 方式二:没有 Docker,先下载 zipkin.jar(官网),再:
    java -jar zipkin.jar

    浏览器打开 http://localhost:9411,能看到 Zipkin 首页即成功。

  5. 在 Pom 所在目录启动应用(重开终端确保环境变量已加载,密钥用 ${DEEPSEEK_API_KEY:} 占位符读取):

    # 在工程根目录(含 pom.xml 的那层)执行
    mvn spring-boot:run

运行验证:先看 actuator,再看 Zipkin 链路

为什么先验证?链路是后面一切排障的入口。请按顺序确认两个入口都通,再往下走。

第 1 步:确认 Actuator 端点已暴露(这是最容易漏的验收点):

# 访问 actuator 根路径,期望返回含 health / info / metrics / prometheus 的 JSON
curl http://localhost:8080/actuator
# 期望输出(节选,重点是_links 里出现这四个端点)
{
  "_links": {
    "self":  { "href": "http://localhost:8080/actuator" },
    "health":{ "href": "http://localhost:8080/actuator/health" },
    "info":  { "href": "http://localhost:8080/actuator/info" },
    "metrics":{ "href": "http://localhost:8080/actuator/metrics" },
    "prometheus": { "href": "http://localhost:8080/actuator/prometheus" }
  }
}

看到什么算通过:浏览器或 curl 返回里能找到 prometheus 这一条链接,说明端点暴露配置生效。

排查:/actuator/prometheus 访问 404。 最常见原因是 management.endpoints.web.exposure.include 里没写 prometheus,或 micrometer-registry-prometheus 依赖没加(缺依赖时该端点不会注册)。 两步核对:① 打开 application.yml 确认 include 含 prometheus;② 看 IDEA 右侧 Maven 依赖树里有没有 micrometer-registry-prometheus。改完重启应用。

第 2 步:发一个请求,再去 Zipkin 看链路:

# 启动应用并发一个请求(接口路径按你自建工程的控制器来,这里是示例)
curl -X POST http://localhost:8080/api/v1/knowledge/ask \
  -H "Content-Type: application/json" \
  -d '{"question":"退款要几天到账","tenantId":"default"}'

# 2. 打开 Zipkin 界面 http://localhost:9411
#    选择 serviceName = springai-lab,点击 Run Query

# 3. 预期:一条 trace,展开后包含多个 span:
#    http post /api/v1/knowledge/ask          ← 你的接口
#      └─ spring.ai.chat.client               ← ChatClient 层
#           └─ spring.ai.chat                  ← 模型调用(含 model、token 数)
#   或者(带工具时):
#      └─ ToolCallingAdvisor 循环
#           ├─ execute_tool queryOrderStatus   ← ★ 2.0 的 span 名变了
#           └─ execute_tool searchPolicy

# 预期输出(curl 返回):一段带引用的知识问答 JSON,包含 answer 与 citations 字段。

看到什么算通过:Zipkin 里出现一条 trace,展开能看到「接口 → spring.ai.chat.client → spring.ai.chat」的层级;若带工具,能看到 execute_tool <工具名> 的 span。

排查:Zipkin 里看不到任何 trace。 按出现概率从高到低查:① management.tracing.sampling.probability 是不是 0(0 表示不采样,全采要设 1.0);② Zipkin 容器/进程到底起没起(docker ps 或看 jar 日志);③ 三个依赖是否都在——尤其 micrometer-tracing-bridge-otel 和 opentelemetry-exporter-zipkin 缺一不可;④ management.zipkin.tracing.endpoint 地址是否正确(默认 http://localhost:9411/api/v2/spans)。
配置键名会随版本变化。Spring AI 的观测配置项在 0.8 → 1.0 之间改过, 如果上面的键不生效,请在 IDEA 里展开 spring.ai.chat.observations 看当前版本支持什么, 或直接查官方文档的 Observability 章节。 先用 IDE 补全确认键名,再写进 yml,能省掉大量试错。
采样率(sampling.probability)怎么选——不是越大越好:
环境建议值理由
本地开发1.0全采,每条请求都能在 Zipkin 追到,排障最爽
测试 / 预发0.5 ~ 1.0流量不大,可全采;出问题能复盘
生产0.1 ~ 0.2全采会产生海量 span,存储与网络成本高;采样足够定位问题
经验:生产若发现某类问题难复现,可临时把对应服务的采样率调高,定位完再降回——不要长期全采。
2.0 把工具调用的观测信息改了一批,做看板和告警时要跟着改:
项1.x2.0
工具执行 span 名tool_call <工具名>execute_tool <工具名>
gen_ai.operation.name 取值frameworkexecute_tool
新增属性—spring.ai.tool.type、spring.ai.tool.call.id
为什么这个改动值得注意:如果你在 Grafana 里按 span 名过滤告警,升级后规则会静默失效——工具失败不再触发通知,而你不会收到任何报错。升级 2.0 后请回归一遍所有基于 span 名的告警规则。
2.0 还统一了缓存相关的 Token 指标。 Usage 接口新增了 getCacheReadInputTokens() 与 getCacheWriteInputTokens()(无数据时返回 null)。 启用 prompt caching 的厂商(以官方文档为准)会填充它。 做成本核算时别漏了这两项——命中缓存的那部分输入 Token 计费规则不同,忽略它会让你的成本模型算不准。
能在 Zipkin 里看到「一次请求 → 检索 → 模型调用 → 工具调用」的完整链路,你就完成了本章一半的目标。这一步的价值在于:当用户投诉「你这个回答不对」时,你可以拿着 traceId 精确还原当时发生了什么——这是排查 AI 问题的唯一可靠手段。
一句话记忆:Observation 就是「一次可被观测的执行单元」,在 Tracing 里就是一个 Span——像快递运单号,把揽收、运输、派送各节点串成一条可追查的链。

10.3自己埋点:让链路包含业务信息

框架自动产生的 span 只有技术信息(耗时、模型名、Token)。要定位业务问题,还得加上业务维度:哪个租户、哪个用户、哪个场景、用了哪版提示词。这一节教你怎么用手工埋点把这些信息加进去。

① 用 ObservationRegistry 手工埋点

下面这个类把「检索」这一步包成一个自定义 span,并带上业务标签。操作步骤如下:

  1. 在 IDEA 的 src/main/java/com/example/springai/ 上右键 → New → Package,输入 observe,回车(得到 com.example.springai.observe)。
  2. 在 observe 包上右键 → New → Java Class,Name 填 RagObservability,回车。
  3. 把下面整段代码粘贴进 RagObservability.java,Ctrl+S 保存(IDEA 会自动 import)。
package com.example.springai.observe;

import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class RagObservability {

    /** 由 Micrometer Tracing 自动装配;没接追踪时是 Noop 实现,不会报错 */
    private final ObservationRegistry registry;

    public RagObservability(ObservationRegistry registry) {
        this.registry = registry;
    }

    /**
     * 把"检索"这一步包成一个 span,并带上业务维度标签。
     * lowCardinalityKeyValue = 聚合维度(取值少);highCardinalityKeyValue = 单条排查(取值多)。
     */
    public <T> T observeRetrieval(String tenantId, String query, java.util.function.Supplier<T> action) {
        Observation obs = Observation.createNotStarted("ai.rag.retrieval", registry)
                .lowCardinalityKeyValue("tenant", tenantId)
                .lowCardinalityKeyValue("stage", "retrieval")
                .highCardinalityKeyValue("query", truncate(query, 100));

        return obs.observe(action);   // 自动记录开始/结束/异常
    }

    /** 记录检索结果的质量指标(命中数、最高分)——框架 span 里没有这些 */
    public void recordRetrievalResult(int hitCount, double topScore) {
        Observation obs = Observation.createNotStarted("ai.rag.result", registry)
                .lowCardinalityKeyValue("hit", hitCount > 0 ? "yes" : "no");
        obs.start();
        try {
            obs.event(Observation.Event.of("retrieval-result"));
            obs.lowCardinalityKeyValue("hitCount", String.valueOf(hitCount));
            obs.highCardinalityKeyValue("topScore", String.format("%.4f", topScore));
        } finally {
            obs.stop();
        }
    }

    /** 把 traceId 写进 MDC,让每一行日志都自动带上 traceId */
    public void bindTraceIdToLogs(String traceId) {
        org.slf4j.MDC.put("traceId", traceId);
    }

    private String truncate(String s, int max) {
        return s == null ? "" : (s.length() <= max ? s : s.substring(0, max));
    }
}

③ 把埋点接到真实的检索调用上

类建好不会自己生效——要在你的检索方法里用 observeRetrieval 把 similaritySearch 包起来,业务标签才会进 span。典型写法:

// 在你的 KnowledgeQaService 的检索方法里
List<Document> docs = ragObservability.observeRetrieval(
        tenantId, question,
        () -> vectorStore.similaritySearch(question));

// 检索完,把「命中几条、最高分多少」也记一笔(框架 span 里没有这些)
ragObservability.recordRetrievalResult(docs.size(), topScore(docs));

看到什么算接好:再发请求去 Zipkin 看那条 trace,能找到 ai.rag.retrieval 这个 span,且它带 tenant / stage 标签——说明业务维度已经织进了链路。

② 让响应头带上 traceId(排障时用户报给你)

  1. 在 com.example.springai.observe 包上右键 → New → Java Class,Name 填 TraceIdFilter。
  2. 粘贴下面代码,Ctrl+S 保存。
import io.micrometer.tracing.Tracer;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import java.io.IOException;

/**
 * 把当前 traceId 放进响应头。
 * 好处:用户反馈问题时报出 traceId,你 3 秒定位;
 *      前端也可以把它展示在"反馈"弹窗里自动带上。
 */
@Component
public class TraceIdFilter extends OncePerRequestFilter {

    private final Tracer tracer;

    public TraceIdFilter(Tracer tracer) {
        this.tracer = tracer;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                     FilterChain chain) throws ServletException, IOException {
        String traceId = tracer.currentSpan() != null
                ? tracer.currentSpan().context().traceId()
                : "no-trace";

        res.setHeader("X-Trace-Id", traceId);
        org.slf4j.MDC.put("traceId", traceId);
        try {
            chain.doFilter(req, res);
        } finally {
            org.slf4j.MDC.remove("traceId");   // ★ 必须清理,否则线程复用时会串
        }
    }
}
  1. 打开 src/main/resources/logback-spring.xml(没有就新建),在 <pattern> 里加入 %X{traceId:-},让每行日志自动带 traceId:
# logback-spring.xml 片段:让每行日志都自动带 traceId
<pattern>
  %d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] %logger{36} - %msg%n
</pattern>

# 效果:
# 14:23:01.123 [http-nio-8080-exec-1] INFO  [a3f2b1c9d8e7] c.e.s.c.KnowledgeQaService - 【RAG检索】命中 3 条

运行验证:traceId 进了响应头,也进了日志

# 发请求并打出响应头,重点看 X-Trace-Id
curl -s -D - -o /dev/null http://localhost:8080/api/v1/knowledge/ask \
  -X POST -H "Content-Type: application/json" \
  -d '{"question":"退款要几天到账","tenantId":"default"}'

# 期望响应头里出现(具体值每次不同):
# X-Trace-Id: a3f2b1c9d8e7f0a1b2c3d4e5f6a7b8c9

看到什么算通过:① 响应头里有 X-Trace-Id;② 应用日志里对应那行带上了同样的 traceId(如 [a3f2b1c9d8e7]);③ 在 Zipkin 用这个 traceId 能搜到那条链路。

MDC 串号的典型故障:如果「在 finally 里 MDC.remove」漏写,同一个 Tomcat 线程池复用时,下一请求会带着上一个请求的 traceId 打印日志,导致你查错时定位到完全无关的链路。 排查步骤:发现日志里 traceId 与响应头不一致时,先确认 Filter 的 finally 块是否执行;其次确认没有第二个组件在别处 MDC.put 后又没清理;最后确认日志 pattern 里用的是 %X{traceId:-} 而非固定字符串。
成对概念:lowCardinality vs highCardinality 标签。
维度取值特征例子放哪
lowCardinality
(低基数)
种类少、可枚举tenant、stage、model、status指标 tag / span 标签(可聚合)
highCardinality
(高基数)
几乎每条都不同query 全文、traceId、userIdTrace 明细 / 日志(只查单条,不可聚合)
一句话记忆:低基数才能「按维度聚合算趋势」,高基数只能「按条追查细节」——把高基数当指标标签,就是给 Prometheus 埋雷。
链路 span 上能看到哪些「gen_ai.*」属性(排障时重点看):
属性含义
gen_ai.operation.name操作类型,2.0 工具调用为 execute_tool
gen_ai.request.model实际调用的模型名(如 deepseek-chat)
gen_ai.usage.input_tokens / output_tokens本次输入/输出 token 数(成本依据)
gen_ai.prompt / gen_ai.completion开启 log-prompt 时记录的提示词与响应
spring.ai.tool.type / spring.ai.tool.call.id2.0 新增:工具类型与本次调用 ID
排障口诀:看 request.model 确认走对了模型、看 usage 确认 token 是否异常膨胀、看 prompt/completion 确认上下文与回答内容——这三样齐了,一次失败基本能定性。

10.4指标与成本看板

① 需要采集的核心指标

指标回答的是「整体趋势」问题。下面这张表列了 AI 应用最该有的几个指标——注意「标签(tag)」列,它直接关系到下一节的「基数陷阱」。

指标类型标签(tag)用途
ai.chat.callsCountermodel, scene, tenant, status调用量与成功率
ai.chat.durationTimermodel, scene延迟分布(P50/P95/P99)
ai.tokens.promptCountermodel, tenant输入 Token 成本
ai.tokens.completionCountermodel, tenant输出 Token 成本
ai.rag.retrieval.hitsDistributionSummarytenant检索命中数分布
ai.rag.empty.rateCountertenant检索为空的比例(知识库覆盖度)
ai.tool.callsCountertool, status工具调用量与失败率
ai.agent.stepsDistributionSummarysceneAgent 步数分布(发现异常长的链路)
ai.fallback.countCounterreason降级次数(模型可用性)
标签基数(cardinality)陷阱:绝不能把 userId、traceId、query 这类高基数值作为指标的标签!一个有 1 万用户的系统,会瞬间产生 1 万个时间序列,把 Prometheus 打爆。 规则:标签只放「取值种类少于 100 个」的维度(如模型名、场景名、租户名)。 高基数的信息请放到 Trace 里。

② 用 Micrometer 采集(一个统一的观测切面)

  1. 在 com.example.springai 包(或新建 metrics 子包)上右键 → New → Java Class,Name 填 AiMetrics。
  2. 粘贴下面代码,Ctrl+S 保存。它用 MeterRegistry 把调用量、延迟、Token 记录成上面的指标。
@Slf4j
@Component
public class AiMetrics {

    private final MeterRegistry registry;

    public AiMetrics(MeterRegistry registry) {
        this.registry = registry;
    }

    /** 记录一次模型调用 */
    public void recordChat(String model, String scene, String tenant,
                            long elapsedMs, int promptTokens, int completionTokens,
                            boolean success) {
        // 计数:调用量 + 成功率(标签都是低基数的)
        registry.counter("ai.chat.calls",
                "model", model, "scene", scene,
                "tenant", tenant, "status", success ? "ok" : "fail").increment();

        // 耗时分布。P95/P99 由 Prometheus 的 histogram_quantile 计算
        registry.timer("ai.chat.duration", "model", model, "scene", scene)
                .record(elapsedMs, java.util.concurrent.TimeUnit.MILLISECONDS);

        // Token 用量:用于成本核算
        registry.counter("ai.tokens.prompt", "model", model, "tenant", tenant)
                .increment(promptTokens);
        registry.counter("ai.tokens.completion", "model", model, "tenant", tenant)
                .increment(completionTokens);
    }

    /** 记录检索质量 */
    public void recordRetrieval(String tenant, int hits) {
        registry.summary("ai.rag.retrieval.hits", "tenant", tenant).record(hits);
        if (hits == 0) {
            // ★ 这个指标极其重要:它直接反映"知识库覆盖不了用户问的问题"
            registry.counter("ai.rag.empty.rate", "tenant", tenant).increment();
        }
    }

    /** 记录工具调用(含耗时) */
    public void recordTool(String toolName, long elapsedMs, boolean success) {
        registry.timer("ai.tool.duration", "tool", toolName).record(elapsedMs,
                java.util.concurrent.TimeUnit.MILLISECONDS);
        registry.counter("ai.tool.calls", "tool", toolName,
                "status", success ? "ok" : "fail").increment();
    }

    /** 记录 Agent 步数——用于发现"某个场景总是要跑 8 步"这类问题 */
    public void recordAgentSteps(String scene, int steps) {
        registry.summary("ai.agent.steps", "scene", scene).record(steps);
    }
}

运行验证:从 /actuator/prometheus 看到 Token 指标

# 先发几个请求产生调用,再抓取 Prometheus 指标,过滤出 AI 相关项
curl -s http://localhost:8080/actuator/prometheus | grep -E "ai_chat_calls|ai_tokens"
# 期望看到类似这样的行(数字随你的请求变化):
# HELP ai_chat_calls_total
# TYPE ai_chat_calls_total counter
ai_chat_calls_total{model="deepseek-chat",scene="kb-qa",tenant="default",status="ok",} 12.0
# HELP ai_tokens_prompt_total
# TYPE ai_tokens_prompt_total counter
ai_tokens_prompt_total{model="deepseek-chat",tenant="default",} 1843.0
ai_tokens_completion_total{model="deepseek-chat",tenant="default",} 526.0

看到什么算通过:抓到的指标里出现 ai_chat_calls_total 且计数 > 0,ai_tokens_prompt_total / ai_tokens_completion_total 有非零的 Token 数字——说明你的埋点真正在累计成本数据。

排查:Prometheus 内存暴涨 / OOM。 几乎一定是指标标签放了高基数维度(如把 userId 或 query 当 tag)。每个标签组合会生成一条独立时间序列,1 万用户 = 1 万条序列。 两步处理:① 立刻把高基数标签从 registry.counter/timer 的 tag 里删掉,只保留低基数维度;② 高基数信息改放到 Trace(10.3 的 highCardinalityKeyValue)或日志里。改完重启,观察内存回落。

成本怎么看(把计数器变成「每天花多少」)

指标已累计到 ai_tokens_prompt_total / ai_tokens_completion_total(单位:token)。要算「昨天花了多少钱」,用 Prometheus 的 increase 取一天增量,再乘单价(价格表单位是「元 / 百万 token」):

# 昨天各租户的输入 token 增量(再 × 单价/1e6 即金额)
sum by (tenant) (increase(ai_tokens_prompt_total[1d]))
# 输出同理用 ai_tokens_completion_total

# 金额 = 增量token × 单价 ÷ 1,000,000
# 例如:增量 2,000,000 token,deepseek-chat 输入 1 元/百万 → 2 元

为什么除以 100 万:价格表单位是「元 / 百万 token」,而计数器是「个」,所以必须除以 1,000,000。把单价误写成「元 / token」(少六个零)是最常见的算账错误,会让成本看着像零。

③ 成本怎么算

为什么用 BigDecimal 而不是 double?因为财务数据不容许浮点误差——累积到月度账单时,0.1 + 0.2 != 0.3 这类误差会变成实打实的钱。下面的价格表配置化,统计时按模型换算。

先在 application.yml 的 spring.ai 段下方同级追加价格表(注意缩进与 spring: 平级):

# application.yml —— 价格表(单位:元 / 百万 token)
ai:
  pricing:
    models:
      deepseek-chat:
        input: 1.0        # 输入 1 元/百万 token
        output: 2.0       # 输出 2 元/百万 token
      qwen-plus:
        input: 0.8
        output: 2.0
      gpt-4o-mini:
        input: 1.1
        output: 4.4
      ollama-local:
        input: 0.0        # 本地模型:不花钱,但占机器
        output: 0.0
/** 把 Token 换算成费用。用 BigDecimal 而不是 double——财务数据不容许浮点误差。 */
public BigDecimal calculateCost(String model, long promptTokens, long completionTokens) {
    ModelPrice price = priceTable.get(model);
    if (price == null) {
        log.warn("模型 {} 未配置价格,按 0 计算", model);
        return BigDecimal.ZERO;
    }
    BigDecimal million = new BigDecimal("1000000");
    return price.input().multiply(BigDecimal.valueOf(promptTokens)).divide(million, 6, RoundingMode.HALF_UP)
            .add(price.output().multiply(BigDecimal.valueOf(completionTokens)).divide(million, 6, RoundingMode.HALF_UP));
}

③ 指标在 Prometheus 里叫什么名字

你在代码里写的 registry.counter("ai.chat.calls", ...),进到 Prometheus 后会自动加上 _total 后缀、并把标签变成 {model="...",tenant="..."}。所以查的时候要写 ai_chat_calls_total,而不是 ai_chat_calls——这是学生最常见的「查不到」原因。

代码里的名字Prometheus 里的名字
ai.chat.callsai_chat_calls_total(Counter 必带 _total)
ai.tokens.promptai_tokens_prompt_total
ai.chat.durationai_chat_duration_seconds(Timer 以秒为单位,额外带 _bucket / _sum / _count)
ai.rag.retrieval.hitsai_rag_retrieval_hits(DistributionSummary 不带 _total)

看到什么算对:访问 /actuator/prometheus 时,你能搜到带下划线、点号变下划线的指标名;PromQL 里用这些名字才能出图。

④ 几个可以直接抄的 Prometheus 查询

# 最近的模型调用 QPS(按模型分组)
sum by (model) (rate(ai_chat_calls_total[5m]))

# 模型调用的 P95 延迟(毫秒)
histogram_quantile(0.95, sum by (le, model) (rate(ai_chat_duration_seconds_bucket[5m]))) * 1000

# 失败率(按场景)
sum by (scene) (rate(ai_chat_calls_total{status="fail"}[5m]))
  / sum by (scene) (rate(ai_chat_calls_total[5m]))

# 每小时的输入 Token 总量(按租户)——成本看板的核心图
sum by (tenant) (increase(ai_tokens_prompt_total[1h]))

# 检索为空的比例——知识库覆盖度指标,超过 30% 说明知识库该补内容了
sum(rate(ai_rag_empty_rate_total[1h])) / sum(rate(ai_rag_retrieval_hits_count[1h]))
建议在 Grafana 上做四块看板: ① 健康总览(QPS、成功率、P95 延迟、降级次数); ② 成本看板(按租户/场景的 Token 与金额、环比增长); ③ RAG 质量看板(检索命中数分布、空检索率、最高分分布); ④ Agent 看板(步数分布、平均耗时、工具失败率)。 这四块看板做完,你对系统的掌控力就远超大多数团队了。

⑤ 把指标变成「自动报警」

看板是给人看的,报警是系统自己叫的。下面两条 Prometheus 告警规则(放到 prometheus/rules/*.yml)能在异常刚出现时就通知你,而不是等用户投诉:

# 规则一:检索为空率过高 → 知识库覆盖出问题
- alert: RagEmptyRateHigh
  expr: sum(rate(ai_rag_empty_rate_total[1h])) / sum(rate(ai_rag_retrieval_hits_count[1h])) > 0.3
  for: 10m
  labels: { severity: warning }
  annotations:
    summary: "检索为空率超过 30%(知识库可能漏了高频问题)"

# 规则二:调用失败率突增 → 模型服务可能限流/宕机
- alert: ChatFailRateSpike
  expr: sum(rate(ai_chat_calls_total{status="fail"}[5m])) / sum(rate(ai_chat_calls_total[5m])) > 0.05
  for: 5m
  labels: { severity: page }
  annotations:
    summary: "模型调用失败率超过 5%"

看到什么算接好:在 Prometheus 的 Alerts 页面能看到这两条规则处于 INACTIVE(正常)或 PENDING/FIRING(异常);故意把知识库清空跑评测,第一条应在 10 分钟内变 FIRING。

一句话记忆:Token 成本统计 =「累计计数 × 单价」——它让「这个回答花了多少钱」从玄学变成折线图;而 BigDecimal 是财务数据的底线,double 的 0.1+0.2≠0.3 会真变成账单误差。

10.5日志规范与脱敏

AI 应用该打什么日志

为什么日志规范在 AI 应用里比传统应用更重要?因为 Prompt 和用户对话里可能含有身份证号、银行卡号、商业机密。一旦你把完整 Prompt 打到 INFO 级并进了日志收集系统,等于把这些数据明文同步到了第三方平台。下面是正确的日志写法与脱敏工具。

  1. 确认你的 KnowledgeQaService(或等价服务类)里,模型调用前后用结构化 + 关键字段 + 脱敏后的方式打日志,而不是 log.info(answer) 一把梭:
// ✅ 推荐:结构化 + 关键字段 + 可追溯(敏感字段先脱敏)
log.info("【模型调用】traceId={} scene={} model={} 耗时={}ms promptTokens={} completionTokens={} 状态={}",
        traceId, scene, model, elapsedMs, promptTokens, completionTokens, "ok");

log.info("【RAG检索】traceId={} tenant={} query={} 命中={} 最高分={}",
        traceId, tenant, mask(query), hits.size(), topScore);

log.info("【工具调用】traceId={} tool={} 参数={} 耗时={}ms 结果长度={}",
        traceId, toolName, maskArgs(args), cost, result.length());

脱敏:什么绝对不能进日志

内容风险处理方式
API Key泄露即被盗刷永不打印;配置读取处打码为 sk-****
用户手机号 / 身份证违反个人信息保护法规掩码:138****8888
完整 Prompt可能含用户隐私、业务机密生产环境默认关闭 log-prompt;需要时只记录前 N 字符 + 哈希
完整模型响应可能含内部数据同上,或只记长度与摘要
数据库连接串含密码框架默认不打,自定义配置类里注意
  1. 在 com.example.springai 包上右键 → New → Java Class,Name 填 Mask(建为不可实例化工具类),粘贴下面代码并保存:
/** 一个简单的脱敏工具 */
public final class Mask {
    private Mask() {}

    /** 手机号:138****8888 */
    public static String phone(String s) {
        return s == null ? null : s.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
    }

    /** 身份证:110***********1234 */
    public static String idCard(String s) {
        return s == null ? null : s.replaceAll("(\\d{3})\\d+(\\d{4})", "$1***********$2");
    }

    /** 通用长文本:只保留前 n 字符,其余用哈希代替(可对比是否同一条) */
    public static String text(String s, int n) {
        if (s == null) return null;
        if (s.length() <= n) return s;
        return s.substring(0, n) + "…[len=" + s.length() + ",hash="
                + Integer.toHexString(s.hashCode()) + "]";
    }
}

脱敏在日志里的实际效果对比:

# ❌ 错误:直接打原始对话(含手机号/身份证)
log.info("用户说:{}", "我的手机号13812345678,身份证110101199003071234");
# ✅ 正确:先 mask 再打
log.info("用户说:{}", Mask.text("我的手机号13812345678,身份证110101199003071234", 20));
# 期望日志输出:用户说:我的手机号138****8888,身份证110***********1234…

用 JSON 日志 + MDC,让 traceId 可被日志系统索引

纯文本日志难检索。生产建议用 JSON 格式输出(Logback 的 JsonEncoder),并把 traceId 作为固定字段,这样在 ELK / Loki 里能直接按 traceId 过滤整条链路的所有日志:

# logback-spring.xml 片段:JSON 输出 + traceId 字段
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
  <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
    <providers>
      <timestamp/>
      <logLevel/>
      <message/>
      <mdc/>   # ★ MDC 里的 traceId 会被自动展开成 "traceId": "a3f2..." 字段
    </providers>
  </encoder>
</appender>

看到什么算对:一条日志是 {"level":"INFO","message":"【RAG检索】命中 3 条","traceId":"a3f2..."} 这样的 JSON,traceId 是顶层字段,能被日志平台直接检索。

运行验证:日志里不再出现明文敏感信息

# 发一个含手机号的请求(示例),然后看应用日志
grep -R "1381234" logs/        # 期望:找不到完整手机号
grep -R "sk-" logs/           # 期望:找不到完整 API Key
# 期望日志里看到的是 138****8888 这种脱敏形态

看到什么算通过:日志里手机号/身份证被掩码,API Key 无明文;同时业务字段(traceId、命中数、耗时)仍清晰可查。

一个真实事故的教训:有团队把 logging.level.org.springframework.ai: debug 带到了生产环境,结果完整的提示词和用户对话被写进了日志文件,包括用户身份证号、银行卡号,日志收集系统又把这些数据同步到了第三方平台。 上线前请务必检查:生产环境的日志级别、脱敏策略、日志留存的合规性。

排查步骤:若怀疑敏感信息泄露到日志,先用 grep -R "sk-" logs/ 和 grep -R "[0-9]\{17\}" logs/(身份证 18 位近似)在日志目录搜一遍;再检查 logback-spring.xml 中是否有 %m 直接吐出完整 message;最后把生产 profile 的 log-prompt 设为 false 并重新部署。
一句话记忆:日志是排障的眼睛,但也是隐私的漏斗——生产环境默认关 log-prompt、对敏感字段脱敏,是合规底线而非可选项。

10.6评估:从「感觉还行」走向「有数据」

这是本章最有价值也最容易被跳过的一节。没有评估,你的调优就是盲猜。有了评估,你能明确说出:「这次改动让准确率从 76% 提升到 84%,成本增加 12%」。

① 先建评测集(最重要的一步)

为什么评测集最重要?因为它是后面所有指标、所有质量门禁的「标尺」。没有一把稳定的尺子,你连「变好了还是变坏了」都说不清。建议用 JSON 维护在代码仓库里——能走 Code Review,能进 CI。

  1. 在 IDEA 的 src/test/resources/ 上右键 → New → Directory,输入 eval,回车。
  2. 在 eval 目录上右键 → New → File,Name 填 qa-dataset.json,把下面这份评测集整段粘贴保存(先 4 条跑通,再扩到 30+)。
  3. (可选)再建 qa-dataset-hard.json 放难例——每次线上答错的 case 都补进来。
src/test/resources/eval/ ├── qa-dataset.json # 主评测集(先做 30 条,再逐步扩到 100+) └── qa-dataset-hard.json # 难例集(之前答错过的、边界情况)
[
  {
    "id": "q001",
    "question": "退款要几天到账?",
    "expectedAnswer": "审核通过后 3 个工作日内退回原支付账户。",
    "relevantKeywords": ["3 个工作日", "原支付账户"],
    "expectedSources": ["refund-policy.md"],
    "category": "售后",
    "difficulty": "easy"
  },
  {
    "id": "q002",
    "question": "我买的东西不想要了,钱什么时候能退给我?",
    "expectedAnswer": "订单完成后 7 天内可提交退款申请;审核通过后 3 个工作日内退回原支付账户。",
    "relevantKeywords": ["7 天", "3 个工作日"],
    "expectedSources": ["refund-policy.md"],
    "category": "售后",
    "difficulty": "medium",
    "note": "口语化提问,原文无'退款'二字,考查语义检索"
  },
  {
    "id": "q003",
    "question": "公司年会在哪一天举办?",
    "expectedAnswer": "资料中未提及。",
    "relevantKeywords": [],
    "expectedSources": [],
    "category": "知识库外",
    "difficulty": "easy",
    "note": "★ 考查'不瞎编'能力。这类题必须占评测集的 10~15%"
  },
  {
    "id": "q004",
    "question": "生鲜商品签收后还能退吗?",
    "expectedAnswer": "生鲜类商品一经签收不支持无理由退款。",
    "relevantKeywords": ["生鲜", "不支持"],
    "expectedSources": ["refund-policy.md"],
    "category": "售后",
    "difficulty": "medium"
  }
]

运行验证:评测集是合法 JSON 且能被解析

# 用 python 快速校验 JSON 语法(无需任何依赖)
python -c "import json,sys; d=json.load(open('src/test/resources/eval/qa-dataset.json')); print('OK, 共', len(d), '条')"

# 期望输出:OK, 共 4 条

看到什么算通过:命令打印 OK, 共 N 条 且无 JSON 解析报错;每条都含 question 与 relevantKeywords 字段(知识库外的题这两项为空数组是正常且必要的)。

评测集的四个构建原则:
  1. 来自真实用户提问(脱敏后),不要自己编——你编的问题模型往往答得很好。
  2. 必须包含「知识库外」的问题(约 10~15%),专门考查「不编造」能力。
  3. 必须包含口语化/同义改写的问题,专门考查语义检索。
  4. 难例单独存放:每次线上发现答错的 case,都补充进难例集。
评测集要进版本库、并冻结「版本号」。评测集是标尺,标尺自己天天变,量出来的数字就没法横向对比。建议:① 把 qa-dataset.json 提交到 Git,走 Code Review;② 每次大改评测集就升一个版本(如 v1 → v2),报告里同时记「评测集版本 + 提示词版本」,这样「准确率从 76%→84%」才有意义——否则你分不清是模型变好还是题变简单了。

② 三层评测:分别度量不同环节

评测要分层次,因为「检索错了」和「检索对了但模型答偏了」是两种完全不同的故障,修复手段也不同。

层次评什么指标怎么算
检索层 该找到的文档找到了吗 命中率 / 召回率
MRR(平均倒数排名)
期望来源是否出现在检索结果里、排第几。不用调模型,成本为零,必做
生成层 答案是否忠于资料、是否相关 忠实度(Faithfulness)
答案相关性
用模型做裁判(LLM-as-a-Judge)打分
端到端 用户满意吗 通过率、人工评分、
线上点赞/点踩率
关键词命中 + 模型判定 + 人工抽检
离线评测 vs 在线监控——别把两件事混为一谈:
维度离线评测(本章重点)在线监控(10.2–10.4)
何时跑改提示词/换模型/发版前,主动跑系统运行时,持续自动采集
回答什么「这次改动变好还是变坏」「现在系统健不健康」
数据来源固定评测集(标尺不变)真实流量(每天都在变)
典型产物评测报告 + 质量门禁看板 + 告警
两者互补:监控告诉你「现在挂了没」,评测告诉你「改动是不是把质量搞坏了」。只有监控没有评测,上线全靠运气;只有评测没有监控,线上真出问题你后知后觉。
为什么强调「先用关键词/来源做判定,不要一上来就用模型打分」? 因为规则判定零成本、零延迟、完全可复现,适合放进 CI 每次提交都跑。 模型打分有随机性,适合作为补充。建议的比例是:规则判定为主(70%),模型打分为辅(30%)。
一句话记忆:评测集就是「质量的一把尺」——先用真实问题 + 知识库外题 + 口语化题这三类把尺子做准,后面所有「变好/变坏」才有据可依。

10.7内置与自定义 Evaluator

Spring AI 的两个内置评估器

为什么用评估器而不是直接比字符串?因为答案可能换了个说法但意思对("3 个工作日" vs "三个工作日")。模型做裁判(LLM-as-a-Judge)能理解语义等价,比字符串精确匹配更接近真实质量。

评估器适合判断成本/注意
RelevancyEvaluator答案是否与「问题 + 检索到的资料」相关(有没有答非所问)调一次模型;打分模型要够强
FactCheckingEvaluator答案是否有资料支撑(查幻觉/编造)调一次模型;最需要「强模型当裁判」
KeywordCoverageEvaluator(自定义)关键词覆盖度(规则判定)零成本、可进 CI;对表述差异较敏感
  1. 在测试或业务代码里,用 ChatClient.builder(chatModel) 构造评估器。先构造 RelevancyEvaluator 与 FactCheckingEvaluator(注意它们内部就是再调一次模型,所以每次评估都有成本和延迟):
import org.springframework.ai.evaluation.*;
import org.springframework.ai.document.Document;

/** ① RelevancyEvaluator —— 答案是否与"提供的资料 + 用户问题"相关。 */
RelevancyEvaluator relevancy = RelevancyEvaluator.builder()
        .chatClientBuilder(ChatClient.builder(chatModel))
        .build();

/** ② FactCheckingEvaluator —— 答案是否有资料支撑(查幻觉)。 */
FactCheckingEvaluator factCheck = FactCheckingEvaluator.builder()
        .chatClientBuilder(ChatClient.builder(chatModel))
        .build();

String question = "退款要几天到账?";
List<Document> retrievedDocs = vectorStore.similaritySearch(question);
String answer = ragService.ask(question).answer();

// 构造评估请求:用户问题 + 检索到的资料 + 模型回答
EvaluationRequest request = new EvaluationRequest(question, retrievedDocs, answer);

EvaluationResponse res = relevancy.evaluate(request);
System.out.println("通过:" + res.isPass());
System.out.println("得分:" + res.getScore());       // 0.0 ~ 1.0
System.out.println("反馈:" + res.getFeedback());    // 模型给出的理由(价值很高)

运行验证:跑一次内置评估器看 isPass / score

# 期望控制台输出(数值随模型与问题变化):
# 通过:true
# 得分:0.92
# 反馈:答案与提供的退款政策资料一致,明确给出了 3 个工作日的时限。

看到什么算通过:res.isPass() 返回布尔、getScore() 是 0~1 之间的数、getFeedback() 有可阅读的判分理由——说明评估器真的调了模型并给出了语义判断。

排查:评估器总是返回 pass=true,分数恒为 1.0。 两种可能:① 打分模型太弱或提示词过松——换更强的模型做裁判(见下),并读 getFeedback() 看它到底凭什么给满分;② 你传入的 EvaluationRequest 里 retrievedDocs 为空,导致评估器「无资料可比对」而直接放行。 处理:确认 vectorStore.similaritySearch 真返回了文档;用更强的模型(如比被评估模型高一代)做裁判;把 getFeedback() 打印出来人工复核。
两个使用注意点: ① 这些评估器内部就是调一次模型,所以每次评估都有成本和延迟。 100 条评测集 × 2 个评估器 = 200 次模型调用。跑之前算一下钱。 ② 评估用的模型建议比被评估的模型更强(用强模型给弱模型打分才合理),至少不能是同一个模型自己评自己。具体怎么配置更强模型,以官方文档为准。

自定义 Evaluator:更贴合业务的判定

  1. 在 com.example.springai.eval 包上右键 → New → Java Class,Name 填 KeywordCoverageEvaluator,让它 implements Evaluator,粘贴下面代码保存。它用关键词覆盖做零成本规则判定,适合进 CI。
import org.springframework.ai.evaluation.Evaluator;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;

/** 自定义评估器:规则判定 + 关键词覆盖。零成本、零延迟,适合放进 CI。 */
@Component
public class KeywordCoverageEvaluator implements Evaluator {

    private final List<String> requiredKeywords;

    public KeywordCoverageEvaluator(List<String> requiredKeywords) {
        this.requiredKeywords = requiredKeywords;
    }

    @Override
    public EvaluationResponse evaluate(EvaluationRequest request) {
        String answer = request.getResponseContent();

        // 情况一:期望"查不到"(知识库外的问题)
        if (requiredKeywords.isEmpty()) {
            // ★ 关键校验:回答里不能出现具体数字/日期,否则说明它在编
            boolean looksFabricated = answer.matches(".*\\d{4}[-/年].*")
                    || answer.contains("应该是") || answer.contains("一般来说");
            return new EvaluationResponse(!looksFabricated,
                    looksFabricated ? 0.0f : 1.0f,
                    looksFabricated ? "疑似编造了具体信息" : "正确地表示无法回答");
        }

        // 情况二:统计关键词覆盖数
        int hit = 0;
        List<String> missing = new java.util.ArrayList<>();
        for (String kw : requiredKeywords) {
            // 归一化:去掉空格,统一全半角数字
            if (normalize(answer).contains(normalize(kw))) {
                hit++;
            } else {
                missing.add(kw);
            }
        }

        float score = (float) hit / requiredKeywords.size();
        boolean pass = score >= 0.6f;      // 阈值:覆盖 60% 以上算通过

        return new EvaluationResponse(pass, score,
                missing.isEmpty() ? "全部关键词覆盖" : "缺失关键词:" + missing);
    }

    /** 归一化:把中文数字和常见变体统一,提高规则判定的宽容度 */
    private String normalize(String s) {
        if (s == null) return "";
        return s.replace(" ", "").replace(" ", "")
                .replace("三个", "3个").replace("七个", "7个")
                .replace("三天", "3天").replace("七天", "7天")
                .toLowerCase();
    }
}
人工评分 vs 自动评分——各司其职:
维度人工评分自动评分(模型/规则)
成本高(人力)低(规则零成本,模型按调用计费)
速度慢快、可批量
可复现差(人会有主观波动)好(规则 100% 复现;模型有采样波动)
质量上限高(懂业务语义)规则偏低,强模型裁判接近人但仍有偏差
适用抽检、难例、争议样本CI 每提交必跑、大批量回归
结论:自动评分做「广覆盖、高频次」的回归,人工评分做「深度、争议」的兜底——前者守门,后者校准。
评估体系的建设顺序(按投入产出比排):
  1. 先做检索层评测:只需知道「期望来源是否被检索到」,零模型成本,能立刻发现 80% 的问题。
  2. 再做关键词覆盖率评测:用自定义规则评估器,成本低,能进 CI。
  3. 最后加模型裁判:RelevancyEvaluator + FactCheckingEvaluator,用于补充语义判断。
  4. 线上补真实反馈:点赞/点踩 + 人工抽检,形成闭环。
一句话记忆:提示词版本与效果之间必须靠「评测数字」建立因果——改一版提示词就跑一遍评测,让「v2 比 v1 好 8 个点」可被证明,而不是「我觉得好像更好了」。

10.8完整示例:自动化评测 + 报告

把评测做成一个可以随时运行的测试,每次改提示词/换模型/调参数后跑一次,看指标变化。这是本章最实用的交付物。

src/test/java/com/example/springai/eval/ ├── EvalDataset.java # 评测集的 Java 映射 ├── EvalRunner.java # 跑评测 + 统计指标 └── RagEvaluationTest.java # JUnit 测试入口 src/test/resources/eval/ └── qa-dataset.json # 10.6 节那份评测集

① 评测集映射

  1. 在 src/test/java/com/example/springai/ 上右键 → New → Package,输入 eval,回车。
  2. 在 eval 包上右键 → New → Java Class,Name 填 EvalDataset,粘贴下面 record 保存(字段要和 10.6 的 JSON 一一对应):
package com.example.springai.eval;

import java.util.List;

public record EvalDataset(
        String id,
        String question,
        String expectedAnswer,
        List<String> relevantKeywords,
        List<String> expectedSources,
        String category,
        String difficulty
) {}

② 评测执行器

  1. 在 eval 包上右键 → New → Java Class,Name 填 EvalRunner,粘贴下面代码保存。它是评测的「发动机」:逐条调用 RAG 服务、判定检索与答案、汇总成报告。
@Slf4j
@Component
public class EvalRunner {

    private final KnowledgeQaService qaService;      // 第 5 章的 RAG 服务

    public EvalRunner(KnowledgeQaService qaService) {
        this.qaService = qaService;
    }

    /** 单条评测的明细记录 */
    public record ItemResult(
            String id, String question, String answer,
            boolean retrievalHit,          // 检索层:期望来源是否命中
            boolean answerPass,            // 生成层:关键词覆盖是否达标
            double retrievalScore,        // 关键词覆盖率
            long elapsedMs,
            String note
    ) {}

    /** 整体指标汇总 */
    public record EvalReport(
            String modelName, String promptVersion,
            int total,
            double retrievalHitRate,       // ★ 检索命中率
            double answerPassRate,         // ★ 答案通过率
            double avgScore, long avgElapsedMs, long p95ElapsedMs,
            List<ItemResult> details
    ) {}

    /** 跑完整评测。 */
    public EvalReport run(List<EvalDataset> datasets, String scene) {
        List<ItemResult> results = new java.util.ArrayList<>();

        for (EvalDataset item : datasets) {
            long t0 = System.currentTimeMillis();
            String answer;
            List<String> actualSources = new java.util.ArrayList<>();

            try {
                // 调用真实的 RAG 服务(等价于线上链路)
                var ragAnswer = qaService.ask(item.question(), "default");
                answer = ragAnswer.answer();
                ragAnswer.citations().forEach(c -> actualSources.add(c.source()));
            } catch (Exception e) {
                log.error("评测 {} 执行失败", item.id(), e);
                answer = " + e.getMessage() + ">";
            }
            long elapsed = System.currentTimeMillis() - t0;

            // ---------- 检索层判定 ----------
            boolean retrievalHit;
            if (item.expectedSources().isEmpty()) {
                retrievalHit = actualSources.isEmpty();   // 期望"检索不到"的题
            } else {
                retrievalHit = item.expectedSources().stream().anyMatch(actualSources::contains);
            }

            // ---------- 生成层判定(关键词覆盖) ----------
            double score = keywordCoverage(answer, item.relevantKeywords());
            boolean pass = score >= 0.6;

            results.add(new ItemResult(item.id(), item.question(),
                    truncate(answer, 120), retrievalHit, pass, score, elapsed,
                    pass ? "" : "关键词未覆盖:" + missingKeywords(answer, item.relevantKeywords())));

            log.info("评测 {} {} | 检索={} 通过={} 分数={:.2f} 耗时={}ms",
                    item.id(), pass ? "✅" : "❌",
                    retrievalHit, pass, score, elapsed);
        }

        // ---------- 汇总 ----------
        int n = results.size();
        double hitRate = results.stream().filter(ItemResult::retrievalHit).count() * 1.0 / n;
        double passRate = results.stream().filter(ItemResult::answerPass).count() * 1.0 / n;
        double avgScore = results.stream().mapToDouble(ItemResult::retrievalScore).average().orElse(0);

        List<Long> latencies = results.stream().map(ItemResult::elapsedMs).sorted().toList();
        long avgLatency = (long) latencies.stream().mapToLong(Long::longValue).average().orElse(0);
        long p95 = latencies.isEmpty() ? 0
                : latencies.get((int) Math.min(latencies.size() - 1, latencies.size() * 0.95));

        return new EvalReport(scene, "v1", n,
                round(hitRate), round(passRate), round(avgScore), avgLatency, p95, results);
    }

    private double keywordCoverage(String answer, List<String> keywords) {
        if (keywords.isEmpty()) {
            boolean fabricated = answer.matches(".*\\d{4}[-/年].*");
            return fabricated ? 0.0 : 1.0;
        }
        long hit = keywords.stream().filter(k -> normalize(answer).contains(normalize(k))).count();
        return hit * 1.0 / keywords.size();
    }

    private List<String> missingKeywords(String answer, List<String> keywords) {
        return keywords.stream()
                .filter(k -> !normalize(answer).contains(normalize(k)))
                .toList();
    }

    private String normalize(String s) {
        return s.replace(" ", "").replace("三个", "3个")
                .replace("三天", "3天").replace("七天", "7天");
    }

    private double round(double v) { return Math.round(v * 10000) / 10000.0; }

    private String truncate(String s, int n) {
        return s == null ? "" : (s.length() <= n ? s : s.substring(0, n) + "…");
    }
}

③ 输出 Markdown 报告(方便对比历史)

  1. 在 EvalRunner.java 里追加下面 toMarkdown 方法(与已有方法平级),保存:
public String toMarkdown(EvalReport r) {
    StringBuilder sb = new StringBuilder();
    sb.append("# RAG 评测报告\n\n");
    sb.append("| 指标 | 值 |\n|---|---|\n");
    sb.append("| 场景 | ").append(r.modelName()).append(" |\n");
    sb.append("| 提示词版本 | ").append(r.promptVersion()).append(" |\n");
    sb.append("| 样本数 | ").append(r.total()).append(" |\n");
    sb.append("| **检索命中率** | **").append(pct(r.retrievalHitRate())).append("** |\n");
    sb.append("| **答案通过率** | **").append(pct(r.answerPassRate())).append("** |\n");
    sb.append("| 平均分数 | ").append(r.avgScore()).append(" |\n");
    sb.append("| 平均耗时 | ").append(r.avgElapsedMs()).append(" ms |\n");
    sb.append("| P95 耗时 | ").append(r.p95ElapsedMs()).append(" ms |\n\n");

    sb.append("## 未通过明细(改进重点)\n\n");
    sb.append("| ID | 问题 | 检索 | 失败原因 |\n|---|---|---|---|\n");
    r.details().stream().filter(d -> !d.answerPass()).forEach(d ->
            sb.append("| ").append(d.id()).append(" | ")
              .append(truncate(d.question(), 40)).append(" | ")
              .append(d.retrievalHit() ? "命中" : "**未命中**").append(" | ")
              .append(d.note()).append(" |\n"));
    return sb.toString();
}

private String pct(double v) { return String.format("%.1f%%", v * 100); }

④ JUnit 测试入口

  1. 在 eval 包上右键 → New → Java Class,Name 填 RagEvaluationTest,把种类(Kind)设为 Test(或建完后在类上加 @SpringBootTest),粘贴下面代码保存:
@SpringBootTest
@ActiveProfiles("local")
class RagEvaluationTest {

    @Autowired EvalRunner evalRunner;
    @Autowired ObjectMapper objectMapper;

    @Test
    void 跑一遍RAG评测并输出报告() throws Exception {
        // 1. 加载评测集
        List<EvalDataset> dataset = objectMapper.readValue(
                new ClassPathResource("eval/qa-dataset.json").getInputStream(),
                new TypeReference<List<EvalDataset>>() {});

        // 2. 执行评测
        EvalReport report = evalRunner.run(dataset, "kb-qa-v1");

        // 3. 输出报告到 target 目录(不污染源码目录)
        String md = evalRunner.toMarkdown(report);
        Files.writeString(Path.of("target/eval-report.md"), md);
        System.out.println(md);

        // 4. ★ 质量门禁:低于阈值就让 CI 红掉,防止劣化上线
        assertThat(report.retrievalHitRate())
                .as("检索命中率不能低于 80%%").isGreaterThanOrEqualTo(0.80);
        assertThat(report.answerPassRate())
                .as("答案通过率不能低于 75%%").isGreaterThanOrEqualTo(0.75);
        assertThat(report.avgElapsedMs())
                .as("平均耗时不能超过 8 秒").isLessThan(8000L);
    }
}

运行验证:跑通测试,看到两个核心比率

# 在工程根目录执行(Maven 测试,只跑这一个测试类)
mvn -q test -Dtest=RagEvaluationTest
# 期望:BUILD SUCCESS,且控制台打印出报告,含这两个关键数字:
# | **检索命中率** | **86.7%** |
# | **答案通过率** | **80.0%** |
# 同时 target/eval-report.md 文件被生成

# 验收文件是否落盘
ls -l target/eval-report.md

看到什么算通过:① BUILD SUCCESS;② 控制台或报告里有「检索命中率」和「答案通过率」两个非零数字;③ target/eval-report.md 存在。若门禁不达标,测试会 BUILD FAILURE 并打印 检索命中率不能低于 80% 之类断言信息——这正是门禁在起作用。

排查:评测结果每次跑都不一样(尤其分数上下浮动)。 根因是模型采样随机性 + 并发打乱顺序。这会让「这次 80%、下次 82%」无法判断是真改善还是噪声。 处理:① 评测时固定 temperature=0(关闭随机性);② 串行跑评测(不要并行),保证顺序稳定;③ 固定评测集版本(JSON 别随手改)。三件事都做了,同一次改动前后对比才有意义。

另一个常见失败:BUILD FAILURE 且断言报「答案通过率不能低于 75%」——这不是 bug,是门禁拦住了质量下滑。去报告「未通过明细」里看哪些题没过:检索未命中→补知识库/调切分;检索命中但答案不过→改提示词。

⑤ 报告长什么样

# RAG 评测报告

| 指标 | 值 |
|---|---|
| 场景 | kb-qa-v1 |
| 提示词版本 | v1 |
| 样本数 | 30 |
| **检索命中率** | **86.7%** |
| **答案通过率** | **80.0%** |
| 平均分数 | 0.812 |
| 平均耗时 | 3240 ms |
| P95 耗时 | 6180 ms |

## 未通过明细(改进重点)

| ID | 问题 | 检索 | 失败原因 |
|---|---|---|---|
| q007 | 会员积分怎么兑换 | **未命中** | 缺失关键词:500分、10元 |
| q013 | 发票什么时候能开出来 | 命中 | 缺失关键词:3个工作日 |
| q021 | 我要投诉 | 命中 | 缺失关键词:投诉渠道 |

# 读报告的方法:
# ① 检索"未命中"的题 → 去补知识库 / 调切分 / 调阈值
# ② 检索命中但答案不过的题 → 去改提示词
# ③ 全对但耗时高的题 → 去优化 topK / 减少上下文长度
回归评测(Regression Evaluation)是什么:
维度说明
是什么每次改动后用同一份评测集重跑,对比新旧指标,防止「改一处、坏一片」在无人察觉时上线。
为什么需要大模型链路是「牵一发动全身」的:调提示词可能顺手把另一类问题搞坏。没有回归,你只能靠用户踩雷才发现。
没有会怎样「优化退款问题」上线后,悄悄把「发票问题」答崩了,一周后客诉才暴露——且你已无法定位是哪次提交引入的。
类比像软件的「单元测试门禁」——每次提交跑一遍,红了就拦下。只不过这里测的是「语义质量」而非「代码逻辑」。
这份报告就是「调优的方向盘」。 没有它,你改提示词只能靠感觉;有了它,每一次改动都有明确的数字反馈。 建议把「跑评测」写进 CI:每次提交都跑,指标下降就阻断合并。
把评测接进 CI(让门禁自动生效):在 CI 流水线里加一步跑这个测试即可,例如 GitHub Actions / GitLab CI 的脚本里写:
# CI 脚本片段:每次 push/PR 都跑评测,失败则流水线红、阻止合并
mvn -q test -Dtest=RagEvaluationTest
看到什么算接好:你故意把提示词改坏(比如删掉关键指令)再提交,CI 应该 BUILD FAILURE 并打印「答案通过率不能低于 75%」之类断言——说明门禁真的在守门,而不是摆设。
一句话记忆:把评测变成「可重复跑的测试 + 质量门禁」,它就是回归评测——让每一次提示词/模型改动都被同一把尺量过,劣化在合并前就被拦下。

10.9常见坑排错

现象原因解决
Zipkin 里看不到任何 trace采样率为 0 / 端点地址错 / 缺依赖设 sampling.probability: 1.0;确认 Zipkin 容器在跑;确认三个依赖都在
trace 里没有 Prompt 内容观测配置没打开开启 spring.ai.chat.observations.log-prompt(键名以当前版本为准)
Prometheus 抓不到指标端点没暴露 / registry 依赖缺失include: ...,prometheus;加 micrometer-registry-prometheus;访问 /actuator/prometheus 验证
Prometheus 内存暴涨指标标签基数过高(放了 userId / query)删掉高基数标签;高基数信息放 Trace
MDC 里的 traceId 串了线程复用,没有清理 MDC在 finally 里 MDC.remove("traceId")
评测结果每次都不同模型采样随机 + 并发导致顺序不同评测时 temperature=0;串行跑评测;固定评测集版本
评测跑得太慢/太贵每条都调模型 + 打了多轮评估器先用规则评估器;把模型裁判改成抽样(如只评失败的)
评估器总是返回 pass打分模型太弱或提示词过于宽松换更强的模型做裁判;检查 getFeedback() 里的理由
关键词判定误杀(明明答对了)表述差异("3 个工作日" vs "三个工作日")关键词做归一化;或改用 relevantKeywords 多写几种变体
日志量爆炸debug 级别带上了完整 Prompt生产设 info;只记录长度、hash、摘要
别忘了:可观测性本身也有成本。 全量采样 + 记录完整 Prompt,在 QPS 高的时候会产生海量数据,存储和网络开销都不小。 生产环境的典型配置是:采样率 10%、不记录 Prompt 内容、只保留指标和高基数的 traceId; 需要排查时再针对具体 traceId 提高采样或从数据库里捞完整记录。

排查步骤:若发现 Zipkin/存储成本异常,先查 management.tracing.sampling.probability 是否仍是开发期的 1.0;再查 log-prompt 与 include-content 是否在生产为 true;最后若仍高,考虑对 Prompt span 单独降低采样。
质量门禁的「假绿」陷阱:门禁阈值设得太低(如答案通过率 ≥ 30%),会导致「再烂也能过」,CI 永远绿、评测形同虚设。 设定方法:阈值要来自基线——先跑一遍当前系统得到真实数字(如检索 86%、答案 80%),再把门禁设在「略低于基线」的位置(检索 ≥ 80%、答案 ≥ 75%),让任何回退都能被拦下,又不至于因正常波动频繁误杀。千万别拍脑袋写个 50%。
排查:Zipkin 有 trace,但展开后没有「检索 / 工具」这些业务 span。 根因通常是自定义 Observation 没接进调用链(例如你建了 RagObservability 却没在检索方法里调用 observeRetrieval),或者 observations.annotations.enabled 没开导致注解式埋点不生效。 处理:① 确认在业务方法里真正调用了 Observation 的 observe/start;② 确认 management.observations.annotations.enabled: true;③ 重启后在 Zipkin 搜 ai.rag.retrieval 看是否出现。
排查:评测集「自己泄漏」进了训练/上下文。 如果你把评测题直接当作 Few-shot 示例塞进提示词,模型会「背答案」导致分数虚高,上线后真实用户问同样问题却答错。 处理:评测集与提示词示例必须隔离;跑评测时确认提示词里不含评测题原文。分数突然接近 100% 反而要警惕——多半是泄漏而非真变好。
一句话记忆:可观测性不是「开得越全越好」——开发期全采全记图省事,生产期必须降采样、关 Prompt,否则监控自己先把你拖垮。

10.10自测题

Q1. 为什么传统监控(QPS、错误率)发现不了 AI 应用的主要问题?

因为 AI 应用最典型的故障是「接口 200,但内容是错的」——模型答非所问、基于错误资料回答、编造不存在的信息。这些在技术指标上全部表现为「成功」。必须引入效果评估(评测集 + 打分)才能发现。

Q2. 指标的标签为什么不能放 userId?

因为指标系统会为「每个标签组合」维护一条独立的时间序列。1 万个用户 = 1 万条时间序列,内存和存储会迅速被耗尽(基数爆炸)。规则:标签值的种类要少(< 100 种)。

Q3. 评测集为什么必须包含「知识库里没有的问题」?

因为「能不能承认自己不知道」是 RAG 系统最重要的能力之一。如果评测集全是「库里有的问题」,模型靠瞎编也能拿到不错的分数。建议这类题占 10~15%,并把「没有编造具体数字/日期」作为通过标准。

Q4. 检索层评测为什么优先于生成层评测?

① 零成本零延迟,不需要调用任何模型;② 完全可复现,没有模型采样带来的波动,适合放 CI;③ 能定位 80% 的问题——RAG 答错绝大多数是「检索错了」。

Q5.【动手题】为第 9 章的工单智能体设计一套评测方案,包括指标、数据集和门禁阈值。

建议:数据集 20 条真实工单任务,覆盖「能处理的」「信息不足的」「越权的」「高风险的」四类;指标含任务完成率、步数分布、工具调用正确率、高风险操作挂起率(应为 100%)、平均耗时与 Token;门禁:任务完成率 ≥ 80%、高风险挂起率 = 100%、平均步数 ≤ 5、单次 Token ≤ 30000;必须固定 temperature=0 并串行执行。

Q6. 检索命中率很高(比如 95%)但答案通过率很低(比如 50%),说明什么?该怎么修?

说明「找对了文档,但模型没用好」——典型原因是提示词没要求「严格依据资料作答」、上下文太长把关键句淹没、或模型爱自由发挥。修复方向是改提示词(加「只依据以下资料回答,资料没有就拒答」)和调 topK/截取策略,而不是去补知识库——因为检索本身没问题。

Q7. 为什么评估集要「冻结版本」,且每次改模型都要重跑?

评估集是标尺,标尺在测量过程中被改(比如悄悄删了难题),测出来的分数就失去可比性。冻结版本 + 每次改动都重跑同一份评测集,才能让「v2 比 v1 好 8 个点」成立;否则你分不清是模型变好还是题变简单了。

一句话记忆:AI 应用的可观测性与评估是一枚硬币两面——监控回答「现在挂没挂」,评测回答「改动好不好」,两套都齐才算真正「看得见」。

10.11实操作业与本章验收

课堂练习(课上完成,约 40 分钟):

  • 启动配套工程(或自建工程),发一个知识问答请求,打开 Zipkin 找到对应的 trace,截图 span 树(接口 → 检索 → 模型 → 工具),并指出 2.0 的工具执行 span 名是 execute_tool <工具名> 而非 1.x 的 tool_call。
  • 运行 10.8 的 RagEvaluationTest,确认 target/eval-report.md 生成,且能看到「检索命中率」和「答案通过率」两个数字。
  • 把 ai.rag.empty.rate 这个指标的含义讲给同桌听,并举一个「它超过 30% 说明什么」的例子。

课后作业:

  • 给课程工程的 RAG 链路补上 10.3 节的 TraceIdFilter 与 RagObservability.observeRetrieval,提交代码并附 100 字说明「为什么业务标签要用 lowCardinality / highCardinality 区分」。
  • 为评测集再补充 10 条真实口语化问题(脱敏后),要求其中至少 2 条是「知识库外」问题;跑一遍评测,把命中率变化写进实验报告。
  • 写 200 字说明:为什么评估体系要「检索层 → 关键词覆盖 → 模型裁判」这个顺序,而不是一上来就用强模型打分。
本章验收标准:
  • 能在 Zipkin 里看到完整的调用链(接口 → 检索 → 模型 → 工具)。
  • 能说出至少六个核心指标及其用途,并解释为什么标签不能用 userId。
  • 建立了一份 30 条以上的评测集,含「知识库外」和「口语化」两类题目。
  • 跑通自动化评测,输出 Markdown 报告,报告里能看到检索命中率与答案通过率。
  • 为评测设置了质量门禁,且能解释每个阈值的由来。
  • 生产日志做了脱敏,Prompt 内容不会被明文记录。