CH 10可观测性与评估
前九章我们一直在「造」;这一章要解决「怎么证明它好用、坏了怎么查」。 大模型应用比传统应用更难观测——同样的输入可能产出不同输出、成本与步数不可预测、错误是「语义错误」而不是「异常」。 没有可观测性和评估,你的项目永远停留在「感觉还行」的阶段。
10.1为什么 AI 应用比传统应用更难观测
| 维度 | 传统应用 | AI 应用 |
|---|---|---|
| 确定性 | 同输入必同输出 | 同输入可能不同输出(采样随机性) |
| 失败形式 | 抛异常、返回错误码 | 返回 200 + 一段看似合理的错话(语义错误) |
| 成本 | 基本恒定 | 波动巨大(Token 数随内容/步数变化) |
| 延迟 | 毫秒~百毫秒 | 秒~几十秒,且随输出长度线性增长 |
| 依赖 | 数据库、缓存 | 数据库 + 外部模型服务(可能限流/宕机/变更行为) |
| 验收 | 接口测试通过即可 | 需要评测集 + 人工/模型打分 |
观测能回答的四个问题
- 出了什么问题?——用户投诉「答错了」,我要能立刻找到那一次的完整请求(提示词、检索结果、工具调用、模型输出)。
- 花了多少钱?——按天/按租户/按接口统计 Token 与费用,能预警、能定价、能找优化点。
- 效果好不好?——用固定评测集跑分,改一次提示词/换一次模型就知道是变好还是变坏。
- 瓶颈在哪个环节?——检索慢?模型慢?工具慢?还是步数太多?
10.2三大支柱与 Spring AI 的内置能力
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.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
| 项 | 1.x | 2.0 |
|---|---|---|
| 工具执行 span 名 | tool_call <工具名> | execute_tool <工具名> |
gen_ai.operation.name 取值 | framework | execute_tool |
| 新增属性 | — | spring.ai.tool.type、spring.ai.tool.call.id |
Usage 接口新增了 getCacheReadInputTokens() 与
getCacheWriteInputTokens()(无数据时返回 null)。
启用 prompt caching 的厂商(Anthropic、Bedrock 双向,OpenAI / Google 只读)会填充它。
做成本核算时别漏了这两项——命中缓存的那部分输入 Token 计费规则和普通输入不同,
忽略它会让你的成本模型算不准。
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.calls | Counter | model, scene, tenant, status | 调用量与成功率 |
ai.chat.duration | Timer | model, scene | 延迟分布(P50/P95/P99) |
ai.tokens.prompt | Counter | model, tenant | 输入 Token 成本 |
ai.tokens.completion | Counter | model, tenant | 输出 Token 成本 |
ai.rag.retrieval.hits | DistributionSummary | tenant | 检索命中数分布 |
ai.rag.empty.rate | Counter | tenant | 检索为空的比例(知识库覆盖度) |
ai.tool.calls | Counter | tool, status | 工具调用量与失败率 |
ai.agent.steps | DistributionSummary | scene | Agent 步数分布(发现异常长的链路) |
ai.fallback.count | Counter | reason | 降级次数(模型可用性) |
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]))
这四块看板做完,你对系统的掌控力就远超大多数团队了。
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):
[
{
"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"
}
]
- 来自真实用户提问(脱敏后),不要自己编——你编的问题模型往往答得很好。
- 必须包含「知识库外」的问题(约 10~15%),专门考查「不编造」能力。
- 必须包含口语化/同义改写的问题,专门考查语义检索。
- 难例单独存放:每次线上发现答错的 case,都补充进难例集。这个集合会越来越有价值。
② 三层评测:分别度量不同环节
| 层次 | 评什么 | 指标 | 怎么算 |
|---|---|---|---|
| 检索层 | 该找到的文档找到了吗 | 命中率 / 召回率 MRR(平均倒数排名) |
期望来源是否出现在检索结果里、排第几。不用调模型,成本为零,必做 |
| 生成层 | 答案是否忠于资料、是否相关 | 忠实度(Faithfulness) 答案相关性 |
用模型做裁判(LLM-as-a-Judge)打分 |
| 端到端 | 用户满意吗 | 通过率、人工评分、 线上点赞/点踩率 |
关键词命中 + 模型判定 + 人工抽检 |
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()); // 模型给出的理由(非常有价值!)
自定义 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();
}
}
- 先做检索层评测:只需知道「期望来源是否被检索到」,零模型成本,能立刻发现 80% 的问题。
- 再做关键词覆盖率评测:用自定义规则评估器,成本低,能进 CI。
- 最后加模型裁判:RelevancyEvaluator + FactCheckingEvaluator,用于补充语义判断。
- 线上补真实反馈:点赞/点踩 + 人工抽检,形成闭环。
10.8完整示例:自动化评测 + 报告
把评测做成一个可以随时运行的测试,每次改提示词/换模型/调参数后跑一次,看指标变化。 这是本章最实用的交付物。
① 评测集映射
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 / 减少上下文长度
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、摘要 |
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 内容不会被明文记录。