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

CH 10可观测性与评估

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

学习目标
能看见、能度量、能改进
前置要求
第 9 章(有 Agent 才有得观测)
本章产出
调用链看板 + 评测报告
建议学时
5–6 小时

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

维度传统应用AI 应用
确定性同输入必同输出同输入可能不同输出(采样随机性)
失败形式抛异常、返回错误码返回 200 + 一段看似合理的错话(语义错误)
成本基本恒定波动巨大(Token 数随内容/步数变化)
延迟毫秒~百毫秒秒~几十秒,且随输出长度线性增长
依赖数据库、缓存数据库 + 外部模型服务(可能限流/宕机/变更行为)
验收接口测试通过即可需要评测集 + 人工/模型打分
最容易被忽略的情况:接口全部 200,但回答全是错的。 传统监控(QPS、错误率、P99 延迟)完全发现不了这种问题—— 因为请求确实成功了。这就是为什么 AI 应用必须有效果评估这一层, 而不仅仅是技术监控。

观测能回答的四个问题

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

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

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

Spring AI 内置的观测能力

Spring AI 基于 Micrometer Observation 构建,开箱即有:每次 ChatModel 调用、每次工具调用 都会自动产生一个 Observation(在 Tracing 体系里就是一个 Span)。

<!-- 依赖(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>
# 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 接收地址(0.4.4 起的那个容器)
      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
配置键名会随版本变化。Spring AI 的观测配置项在 0.8 → 1.0 之间改过, 如果上面的键不生效,请在 IDEA 里展开 spring.ai.chat.observations 看当前版本支持什么, 或直接查官方文档的 Observability 章节。先用 IDE 补全确认键名,再写进 yml,能省掉大量试错。

验证:打开 Zipkin 看链路

# 1. 启动应用并发一个请求
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
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 的厂商(Anthropic、Bedrock 双向,OpenAI / Google 只读)会填充它。 做成本核算时别漏了这两项——命中缓存的那部分输入 Token 计费规则和普通输入不同, 忽略它会让你的成本模型算不准。
能在 Zipkin 里看到「一次请求 → 检索 → 模型调用 → 工具调用」的完整链路, 你就完成了本章一半的目标。这一步的价值在于:当用户投诉「你这个回答不对」时, 你可以拿着 traceId 精确还原当时发生了什么——这是排查 AI 问题的唯一可靠手段。

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

框架自动产生的 span 只有技术信息(耗时、模型名、Token)。要定位业务问题,还得加上业务维度: 哪个租户、哪个用户、哪个场景、用了哪版提示词。

① 用 ObservationRegistry 手工埋点

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,并带上业务维度标签。
     * 这样在 Zipkin/Grafana 里就能按 tenantId、topK 等维度筛选分析。
     */
    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")
                // ★ 高基数标签(取值种类多,如 traceId、query)——只用于单条排查,不要用于聚合
                .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 {
            // 把命中数和最高分记录为 span 的事件与标签
            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));
    }
}

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

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);
        // 放到 MDC,日志里就能看到(配合 logback pattern %X{traceId})
        org.slf4j.MDC.put("traceId", traceId);
        try {
            chain.doFilter(req, res);
        } finally {
            org.slf4j.MDC.remove("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 条

10.4指标与成本看板

① 需要采集的核心指标

指标类型标签(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 采集(一个统一的观测切面)

@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);
    }
}

③ 成本怎么算

不同模型价格不同,所以「Token 数」不等于「钱」。做法是把价格表配置化,在统计时按模型换算:

# 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 查询

# 最近的模型调用 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 看板(步数分布、平均耗时、工具失败率)。
这四块看板做完,你对系统的掌控力就远超大多数团队了。

10.5日志规范与脱敏

AI 应用该打什么日志

// ✅ 推荐:结构化 + 关键字段 + 可追溯
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());

log.info("【Agent步进】traceId={} step={} 类型={} 工具={}",
        traceId, step, type, toolName);

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

内容风险处理方式
API Key泄露即被盗刷永不打印;配置读取处打码为 sk-****
用户手机号 / 身份证违反个人信息保护法规掩码:138****8888
完整 Prompt可能含用户隐私、业务机密生产环境默认关闭 log-prompt;需要时只记录前 N 字符 + 哈希
完整模型响应可能含内部数据同上,或只记长度与摘要
数据库连接串含密码框架默认不打,自定义配置类里注意
/** 一个简单的脱敏工具 */
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()) + "]";
    }
}
一个真实事故的教训:有团队把 logging.level.org.springframework.ai: debug 带到了生产环境,结果完整的提示词和用户对话被写进了日志文件, 包括用户身份证号、银行卡号。日志收集系统又把这些数据同步到了第三方平台。 这个问题的成本远高于它带来的排障便利。 上线前请务必检查:生产环境的日志级别、脱敏策略、日志留存的合规性。

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

这是本章最有价值也最容易被跳过的一节。没有评估,你的调优就是盲猜: 改了提示词——「好像好一点」;换了模型——「感觉差不多」;调了 topK——「应该有用吧」。 有了评估,你能明确说出:「这次改动让准确率从 76% 提升到 84%,成本增加 12%」。

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

评测集就是一个「问题 + 期望」的清单。建议用 JSON 或 CSV 维护在代码仓库里(能走 Code Review):

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"
  }
]
评测集的四个构建原则:
  1. 来自真实用户提问(脱敏后),不要自己编——你编的问题模型往往答得很好。
  2. 必须包含「知识库外」的问题(约 10~15%),专门考查「不编造」能力。
  3. 必须包含口语化/同义改写的问题,专门考查语义检索。
  4. 难例单独存放:每次线上发现答错的 case,都补充进难例集。这个集合会越来越有价值。

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

层次评什么指标怎么算
检索层 该找到的文档找到了吗 命中率 / 召回率
MRR(平均倒数排名)
期望来源是否出现在检索结果里、排第几。不用调模型,成本为零,必做
生成层 答案是否忠于资料、是否相关 忠实度(Faithfulness)
答案相关性
用模型做裁判(LLM-as-a-Judge)打分
端到端 用户满意吗 通过率、人工评分、
线上点赞/点踩率
关键词命中 + 模型判定 + 人工抽检
为什么强调「先用关键词/来源做判定,不要一上来就用模型打分」? 因为规则判定零成本、零延迟、完全可复现,适合放进 CI 每次提交都跑。 模型打分有随机性,适合作为补充。建议的比例是:规则判定为主(70%),模型打分为辅(30%)。

10.7内置与自定义 Evaluator

Spring AI 的两个内置评估器

import org.springframework.ai.evaluation.*;
import org.springframework.ai.document.Document;

/**
 * ① RelevancyEvaluator —— 答案是否与"提供的资料 + 用户问题"相关。
 *    用途:判断 RAG 的回答有没有跑题。
 */
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());    // 模型给出的理由(非常有价值!)
两个使用注意点: ① 这些评估器内部就是调一次模型,所以每次评估都有成本和延迟。 100 条评测集 × 2 个评估器 = 200 次模型调用。跑之前算一下钱。 ② 评估用的模型建议比被评估的模型更强(用强模型给弱模型打分才合理), 至少不能是同一个模型自己评自己。

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

import org.springframework.ai.evaluation.Evaluator;
import org.springframework.ai.evaluation.EvaluationRequest;
import org.springframework.ai.evaluation.EvaluationResponse;

/**
 * 自定义评估器:规则判定 + 关键词覆盖。
 * 优点:零成本、零延迟、完全可复现,适合放进 CI。
 * 缺点:无法判断语义等价("3 个工作日" vs "三个工作日" 需特殊处理)。
 */
@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) {
            // 归一化:去掉空格,统一全半角数字("3" vs "三")
            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();
    }
}
评估体系的建设顺序(按投入产出比排):
  1. 先做检索层评测:只需知道「期望来源是否被检索到」,零模型成本,能立刻发现 80% 的问题。
  2. 再做关键词覆盖率评测:用自定义规则评估器,成本低,能进 CI。
  3. 最后加模型裁判:RelevancyEvaluator + FactCheckingEvaluator,用于补充语义判断。
  4. 线上补真实反馈:点赞/点踩 + 人工抽检,形成闭环。

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 节那份评测集

① 评测集映射

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
) {}

② 评测执行器

@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
    ) {}

    /**
     * 跑完整评测。
     *
     * @param datasets 评测集
     * @param scene    场景名(用于报告标识)
     */
    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 报告(方便对比历史)

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 测试入口

@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);
    }
}

⑤ 报告长什么样

# 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 / 减少上下文长度
这份报告就是「调优的方向盘」。 没有它,你改提示词只能靠感觉;有了它,每一次改动都有明确的数字反馈。 建议把「跑评测」写进 CI:每次提交都跑,指标下降就阻断合并—— 这就是防 AI 应用「越改越差」最有效的手段。

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 提高采样或从数据库里捞完整记录。

10.10自测题

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

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

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

因为指标系统会为「每个标签组合」维护一条独立的时间序列。 1 万个用户 = 1 万条时间序列,内存和存储会迅速被耗尽(基数爆炸)。 规则:标签值的种类要少(< 100 种),如模型名、场景名、租户名。 需要按 userId 排查的信息,应该放在 Trace 或日志里,而不是指标里。

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

因为「能不能承认自己不知道」是 RAG 系统最重要的能力之一。 如果评测集全是「库里有的问题」,模型靠瞎编也能拿到不错的分数, 你会得到一个在演示时表现很好、一上生产就胡说八道的系统。 建议这类题占 10~15%,并把「没有编造具体数字/日期」作为通过标准。

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

三个原因:① 零成本零延迟,不需要调用任何模型(只判断期望来源是否出现在检索结果里); ② 完全可复现,没有模型采样带来的波动,适合放 CI; ③ 能定位 80% 的问题——RAG 答错绝大多数是「检索错了」,先把这一层做到 90%+ 命中率, 再去优化生成层,投入产出比最高。

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

建议方案:
· 数据集:20 条真实工单任务,覆盖「能处理的」「信息不足的」「越权的」「高风险的」四类;
· 指标:① 任务完成率(最终是否给出了可执行的方案);② 步数分布(是否出现异常长的链路); ③ 工具调用正确率(是否调了正确的工具);④ 高风险操作挂起率(应为 100%); ⑤ 平均耗时与 Token;
· 门禁:任务完成率 ≥ 80%、高风险挂起率 = 100%、平均步数 ≤ 5、 单次 Token ≤ 30000。
· 特别注意:Agent 评测必须固定 temperature=0 并串行执行,否则结果不可比。

本章验收标准:
  • 能在 Zipkin 里看到完整的调用链(接口 → 检索 → 模型 → 工具)。
  • 能说出至少六个核心指标及其用途,并解释为什么标签不能用 userId。
  • 建立了一份 30 条以上的评测集,含「知识库外」和「口语化」两类题目。
  • 跑通自动化评测,输出 Markdown 报告,报告里能看到检索命中率与答案通过率。
  • 为评测设置了质量门禁,且能解释每个阈值的由来。
  • 生产日志做了脱敏,Prompt 内容不会被明文记录。