Skip to content

可观测与链路追踪:知道慢在哪一环 ​

1. 本节产出 ​

一条完整链路的埋点:查询改写 / 检索 / 精排 / 生成 各段耗时与 Token 可分开看,一个 Grafana 面板能看到「慢在哪一环、哪类查询最慢、缓存命中率多少」。

2. 前置依赖 ​

3. 为什么 RAG 特别需要可观测 ​

普通服务的链路是 A → B → C,慢了看哪一跳慢就行。RAG 的链路特殊在:

特点带来的问题
有多个外部依赖(向量库、ES、Rerank、LLM)任何一环慢都拖垮整体
各环节耗时差异大(检索 50ms、生成 3s)平均延迟掩盖了分布问题
效果问题表现为速度问题检索召回差 → 模型生成更长 → 更慢
成本与延迟强相关慢往往意味着贵

没有分段埋点时,你只能看到「问答 P95 = 2.8s」,然后开始猜。有了分段埋点,一眼就看出「精排 P95 = 2.1s」,问题在 Rerank 服务。

4. 核心原理 ​

4.1 必须埋的四个维度 ​

维度指标用途
延迟各分段耗时(P50/P95/P99)定位瓶颈
流量QPS、并发数容量规划
质量缓存命中率、拒答率、召回率(离线)效果监控
成本Token、金额(按租户/类型)预算控制

四个都要。只有延迟没有质量指标,会出现「系统很快但全是拒答」而你浑然不觉的情况。

4.2 分段计时 ​

一次问答的耗时构成(典型):
  ┌──────────────────────────────────────┐
  │ 改写      60ms   ▏                    │
  │ 向量检索  45ms   ▏                    │
  │ 关键词    30ms   ▏ (并行,取最大值)  │
  │ 精排     280ms   ██████               │
  │ 生成    2400ms   ████████████████████ │
  └──────────────────────────────────────┘
                     总计 ≈ 2.8s

这张图是本节最有用的产出。它让所有人立刻明白:优化生成环节(换更快的模型、限制输出长度)的收益远大于优化检索。

4.3 关键指标清单 ​

指标类型告警阈值建议
rag.query.durationTimerP95 > 3s
rag.retrieve.durationTimerP95 > 200ms
rag.rerank.durationTimerP95 > 500ms
rag.generate.durationTimerP95 > 4s
rag.cache.hit_ratioGauge< 20%(缓存可能失效了)
rag.reject_ratioGauge> 40%(知识库可能有缺口)
rag.tokens.totalCounter按预算设
rag.ingest.failedCounter> 0 立即告警

reject_ratio 这个指标特别有价值:它突然升高通常意味着知识库缺文档或检索出了问题,是用户投诉的先行指标。

4.4 链路追踪 vs 指标 ​

工具回答什么
指标(Metrics)「整体 P95 是多少、趋势如何」
链路(Tracing)「这一个请求为什么慢」

两者都要。指标用于发现问题和告警,链路用于排查具体个案。只做指标的话,你能知道「慢了」但不知道「为什么慢」。

5. 代码走查 ​

5.1 依赖与配置 ​

xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
yaml
management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus
  metrics:
    distribution:
      percentiles-histogram:
        rag.query: true        # 开启直方图才能算分位数
      percentiles:
        rag.query: 0.5,0.95,0.99

提示:不开启 percentiles-histogram 的话,Prometheus 只能拿到计数和总和,算不出 P95。这是最常见的配置遗漏。

5.2 分段计时 ​

java
// src/main/java/com/example/rag/metrics/RagMetrics.java
@Component
public class RagMetrics {

    private final MeterRegistry meters;

    public <T> T timed(String stage, Supplier<T> fn) {
        Timer.Sample sample = Timer.start(meters);
        try {
            return fn.get();
        } finally {
            sample.stop(meters.timer("rag.stage.duration", "stage", stage));
        }
    }

    public void recordCacheHit(String level) {
        meters.counter("rag.cache.hit", "level", level).increment();
    }

    public void recordReject(String reason) {
        meters.counter("rag.reject", "reason", reason).increment();
    }
}

5.3 接入链路 ​

java
// src/main/java/com/example/rag/chain/ObservableRagChain.java
@Service
public class ObservableRagChain {

    public Answer answer(String question, String tenantId, List<String> acl) {
        Timer.Sample all = Timer.start(meters);

        try {
            String q = metrics.timed("rewrite",
                    () -> rewriter.rewrite(question, history));
            List<RetrievalResult> hits = metrics.timed("retrieve",
                    () -> pipeline.retrieve(q, tenantId, acl));
            List<RetrievalResult> top = metrics.timed("rerank",
                    () -> reranker.rerank(q, hits));
            String raw = metrics.timed("generate",
                    () -> generator.generate(q, top));

            return citationEnricher.enrich(raw, top);
        } finally {
            all.stop(meters.timer("rag.query.duration",
                    "tenant", tenantId));
        }
    }
}

每一段都用一个 timed() 包起来,简单且不会遗漏。

5.4 链路追踪(OpenTelemetry) ​

java
// 用 Observation API,Micrometer 自动串联
@Observed(name = "rag.query", contextualName = "rag-query")
public Answer answer(String q) { ... }
yaml
management:
  tracing:
    sampling:
      probability: 0.1      # 采样 10%,避免链路数据爆炸
  otlp:
    tracing:
      endpoint: http://otel-collector:4318/v1/traces

采样率不要设 1.0。全量采样在高 QPS 下会产生海量链路数据,存储和查询都扛不住。10% 通常足够排查问题。

5.5 Grafana 面板要点 ​

面板分四行:
  第一行:总览(QPS、P50/P95/P99、错误率)
  第二行:分段耗时堆叠图(改写/检索/精排/生成)
  第三行:缓存命中率、拒答率、平均 Token
  第四行:按租户的成本 Top10

第二行的堆叠图最有价值:一眼看出耗时构成随时间的变化(比如某天精排突然变慢)。

6. 跑起来 ​

bash
git checkout ch02-19-observability
docker compose up -d          # 含 Prometheus + Grafana
mvn spring-boot:run

# 产生流量
for i in {1..50}; do
  curl -s -o /dev/null -X POST http://localhost:8080/api/ask \
    -d '{"q":"年假怎么算","tenantId":"acme"}'
done

# 看指标
curl http://localhost:8080/actuator/prometheus | grep rag_

期望输出:

rag_query_duration_seconds{quantile="0.95"} 2.84
rag_stage_duration_seconds{stage="rewrite",quantile="0.95"} 0.061
rag_stage_duration_seconds{stage="retrieve",quantile="0.95"} 0.052
rag_stage_duration_seconds{stage="rerank",quantile="0.95"} 0.288
rag_stage_duration_seconds{stage="generate",quantile="0.95"} 2.410
rag_cache_hit_total{level="exact"} 12
rag_reject_total{reason="low_score"} 3
检查项通过标准
分段可见四个阶段各有独立耗时指标
分位数可用能查到 P95(说明直方图已开)
面板可用Grafana 能看到堆叠图
缓存/拒答可观测有对应计数器
采样率合理Tracing 采样 10%,不过量

7. 生产避坑 ​

  1. 不开 percentiles-histogram 就永远看不到 P95。Prometheus 默认只记录计数与总和,平均值会掩盖长尾——而 RAG 的长尾恰恰很严重(生成环节方差极大)。这是上线后最常见的「指标看不到想看的」的原因。
  2. 不要给高基数标签建指标。把 question(每条都不同)或 userId 作为标签会导致指标基数爆炸,Prometheus 内存撑不住。标签只放低基数维度:租户、类型、模型、阶段。
  3. 链路追踪必须设采样率。全量采样在几千 QPS 下会产生 TB 级数据。10% 采样既能排查问题,成本也可控。另外要注意链路里不能记录完整 prompt(含敏感信息),只记 ID 和元数据。

8. 延伸与锚点 ​