1. 为什么 RAG 应用需要全链路观测
1.1 从一次线上事故说起
去年冬天,我负责的一个基于 Spring AI 搭建的企业知识问答系统上线第二周,客服反馈"回答驴唇不对马嘴"的比例突然从 3% 涨到了 18%。我第一反应是模型出问题了,查了半天 OpenAI 的调用日志,延迟正常、错误率正常、token 消耗正常。折腾了整整一个下午,最后才发现是上游文档同步任务把一批 PDF 解析成了乱码,向量库里塞进去了一堆"锟斤拷"片段,检索出来的上下文本身就是垃圾,模型再强也救不回来。
这件事给我最大的教训是:RAG 系统的故障,80% 不出在模型本身,而是出在检索链路和数据处理链路上。而传统的 APM 工具只能告诉你"接口耗时 800ms",却没法告诉你"这 800ms 里,向量检索花了 300ms、重排序花了 200ms、模型生成花了 250ms,而且检索回来的 5 个 chunk 里有 3 个相似度低于 0.6"。
这就是全链路观测对 RAG 的意义——它不只是监控"服务活着没",而是要监控"答案是怎么被拼出来的"。从用户提问那一刻起,到 embedding 编码、向量检索、上下文组装、prompt 渲染、模型调用、结果后处理,每一个环节都要有可观测的数据点。观测云这类平台的价值,就在于把这些散落在不同组件里的数据串成一条完整的 trace。
1.2 RAG 链路的特殊性在哪里
普通 Web 服务的调用链是线性的:请求进来、查数据库、返回。RAG 不一样,它是一条带分支、带循环、带外部依赖的链路。我把它拆成几个关键阶段:
| 阶段 | 典型耗时占比 | 主要故障模式 |
|---|---|---|
| 查询改写/意图识别 | 5%-10% | 改写偏离原意,召回跑偏 |
| Embedding 编码 | 5%-15% | 模型超时、维度不匹配 |
| 向量检索 | 10%-30% | 召回为空、相似度普遍偏低 |
| 重排序(Rerank) | 10%-20% | 排序模型拖慢整体响应 |
| 上下文组装 | <5% | token 超限被截断 |
| LLM 生成 | 30%-50% | 幻觉、超时、限流 |
| 后处理/引用标注 | 5%-10% | 引用错位 |
你看,光是一个"回答不准"的问题,可能的原因就有七八种。没有全链路观测,你就是在盲人摸象。而观测云的核心能力——Trace(链路追踪)+ Log(日志)+ Metric(指标)+ RUM(前端体验)四件套,恰好能覆盖这条链路的每个环节。
1.3 观测云能解决什么、不能解决什么
先说清楚边界,免得大家期望错位。观测云能帮你做到:
- 链路可视化:一次问答请求,从 HTTP 入口到 LLM 返回,每个 span 的耗时、状态、标签一目了然。
- 检索质量量化:把每次检索的 top-k 相似度分数、命中 chunk 的 ID、来源文档打进 trace,事后可以统计"低分召回率"。
- 成本归因:按用户、按会话、按知识库维度统计 token 消耗,找出"烧钱大户"。
- 异常告警:检索为空、相似度低于阈值、LLM 超时等业务级异常可以配置告警。
它不能帮你做的:自动判断答案对不对(这需要人工标注或 LLM-as-judge)、自动优化 prompt(那是另一个工程问题)。观测云是"眼睛",不是"大脑"。
2. Spring AI 的可观测性底座怎么搭
2.1 Spring AI 自带的观测能力盘点
Spring AI 从 1.0 版本开始,内置了基于 Micrometer Observation 的可观测性支持。这一点很多人不知道,其实不用自己从零埋点。它的核心机制是:ChatClient、EmbeddingModel、VectorStore 这些核心组件在调用时会自动产生 Observation,只要你的项目里引入了 Micrometer 和对应的 registry,这些观测数据就会自动上报。
具体来说,Spring AI 会为以下操作生成 span:
spring.ai.chat.client—— ChatClient 的调用spring.ai.chat.model—— 底层 ChatModel 的调用spring.ai.embedding—— Embedding 模型调用spring.ai.vectorstore—— 向量库操作(query/add/delete)
每个 span 上会带上关键的低基数标签(low cardinality tags),比如模型名称、操作类型、向量库类型。而高基数信息(比如 prompt 内容、检索到的文档)默认不采集,需要你手动开启或通过自定义 ObservationConvention 补充。
提示:Spring AI 的观测默认只记录元数据,不记录 prompt 和 completion 的原文。这是出于隐私和性能考虑。如果你需要记录,务必做好脱敏,并且评估存储成本。
2.2 接入观测云的三种姿势
观测云(Guance)提供了标准的 OpenTelemetry 接入方式,所以 Spring AI 的观测数据可以通过 OTLP 协议直接推过去。我实测下来有三种接入路径,各有适用场景:
姿势一:OTLP Exporter 直推(推荐)
这是最干净的方式。在 Spring Boot 项目里引入micrometer-registry-otlp,配置好观测云的 OTLP endpoint 和 API Key,Spring AI 的 span 就会自动上报。优点是零侵入、标准协议、后续换平台成本低。
姿势二:OpenTelemetry Java Agent 挂载
如果你不想改代码,可以用 OTel 的 Java Agent 通过-javaagent参数挂载。它会自动增强 Spring MVC、JDBC、Redis 等组件的埋点,Spring AI 的观测也能被捕获。适合老项目快速接入,但 Agent 版本和 Spring Boot 版本的兼容性需要测试。
姿势三:观测云 DataKit 本地采集
在服务器上部署 DataKit,通过它的 OTLP 接收端口采集,再统一转发到观测云。适合内网环境、需要做数据预处理的场景。DataKit 还能顺便采集主机指标、容器日志,一举多得。
我个人的选择是姿势一为主、姿势三为辅:应用侧用 OTLP 直推保证链路数据完整,主机和容器层面用 DataKit 补充基础设施指标。
2.3 依赖与配置清单
以 Spring Boot 3.2 + Spring AI 1.0 为例,pom.xml里需要这些依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-otlp</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-otlp</artifactId> </dependency>application.yml的关键配置:
management: otlp: metrics: export: url: https://otlp-gateway.guance.com/v1/metrics headers: Authorization: "Bearer ${GUANCE_API_KEY}" step: 30s tracing: endpoint: https://otlp-gateway.guance.com/v1/traces tracing: sampling: probability: 1.0 observations: key-values: service.name: spring-ai-rag-demo deployment.environment: prod这里有几个坑我踩过:step默认是 1 分钟,对于 RAG 这种请求量不大的场景够用,但如果你想做实时告警,建议调到 30s 甚至 10s;sampling.probability生产环境不建议设 1.0,除非你请求量很小,否则 trace 存储成本会爆炸,一般 0.1-0.3 比较合理。
3. 核心链路埋点:把 RAG 的每一步都看清楚
3.1 自定义 Observation:给检索环节加"显微镜"
Spring AI 自带的观测粒度比较粗,比如spring.ai.vectorstore这个 span 只告诉你"查了向量库、耗时多少",但不会告诉你"查到了几个、相似度多少、来自哪个知识库"。这些信息恰恰是排查 RAG 问题的关键。所以我们需要自定义 Observation 来补充。
我的做法是写一个RagObservationAspect,用 AOP 切在检索服务的方法上:
@Aspect @Component public class RagObservationAspect { private final ObservationRegistry registry; public RagObservationAspect(ObservationRegistry registry) { this.registry = registry; } @Around("execution(* com.demo.rag.service.RetrievalService.retrieve(..))") public Object observeRetrieval(ProceedingJoinPoint pjp) throws Throwable { String query = (String) pjp.getArgs()[0]; Observation obs = Observation.createNotStarted("rag.retrieval", registry) .lowCardinalityKeyValue("rag.stage", "retrieval") .highCardinalityKeyValue("rag.query.length", String.valueOf(query.length())) .start(); try (Observation.Scope scope = obs.openScope()) { Object result = pjp.proceed(); List<Document> docs = (List<Document>) result; obs.highCardinalityKeyValue("rag.retrieved.count", String.valueOf(docs.size())); if (!docs.isEmpty()) { double avgScore = docs.stream() .mapToDouble(d -> d.getMetadata().get("score") == null ? 0.0 : ((Number) d.getMetadata().get("score")).doubleValue()) .average().orElse(0.0); obs.highCardinalityKeyValue("rag.retrieved.avgScore", String.format("%.3f", avgScore)); obs.highCardinalityKeyValue("rag.retrieved.topDocId", String.valueOf(docs.get(0).getId())); } return result; } catch (Throwable t) { obs.error(t); throw t; } finally { obs.stop(); } } }这段代码的关键点在于:低基数标签用于聚合统计,高基数标签用于单次排查。rag.stage是低基数,可以按它做分组统计;rag.retrieved.avgScore是高基数,每次请求都不同,用于在 trace 详情里看具体数值。
注意:高基数标签会显著增加存储成本,观测云对高基数 tag 的索引也有一定限制。我的经验是,单个 span 的高基数标签控制在 5 个以内,且不要放超长文本(比如完整 prompt),否则查询会变慢。
3.2 上下文组装阶段的观测
上下文组装是 RAG 里最容易被忽视的环节。很多人只关注"检索到了什么",却不关注"最终塞进 prompt 的是什么"。这里有两个关键指标:实际使用的 chunk 数和组装后的 token 数。
我见过一个案例:检索返回了 10 个 chunk,但因为 token 限制,组装时只保留了前 3 个,而恰恰是第 7 个 chunk 才包含正确答案。如果没观测这个环节,你永远不知道"检索明明命中了,为什么答案还是错的"。
public String assembleContext(List<Document> docs, int maxTokens) { return Observation.createNotStarted("rag.context.assemble", registry) .lowCardinalityKeyValue("rag.stage", "assemble") .observe(() -> { StringBuilder sb = new StringBuilder(); int used = 0; int tokenCount = 0; for (Document doc : docs) { int docTokens = estimateTokens(doc.getText()); if (tokenCount + docTokens > maxTokens) break; sb.append(doc.getText()).append("\n---\n"); tokenCount += docTokens; used++; } // 把实际使用情况记录到当前 span Span.current().setAttribute("rag.assemble.usedChunks", used); Span.current().setAttribute("rag.assemble.totalTokens", tokenCount); Span.current().setAttribute("rag.assemble.truncated", used < docs.size()); return sb.toString(); }); }rag.assemble.truncated这个布尔标签特别有用。当它为 true 时,说明有 chunk 被丢弃了,这时候如果答案不准,你就知道该去调大 token 预算或者优化 chunk 切分策略,而不是去怪模型。
3.3 LLM 生成阶段的 token 与成本观测
LLM 调用是 RAG 链路里最贵的一环。Spring AI 的 ChatResponse 里其实带了 token 使用信息,但默认不会进 trace。我们需要手动提取:
ChatResponse response = chatClient.call(prompt); Usage usage = response.getMetadata().getUsage(); Span.current().setAttribute("llm.prompt.tokens", usage.getPromptTokens()); Span.current().setAttribute("llm.completion.tokens", usage.getGenerationTokens()); Span.current().setAttribute("llm.total.tokens", usage.getTotalTokens()); Span.current().setAttribute("llm.model", response.getMetadata().getModel());有了这些数据,你就能在观测云里做几件很有价值的事:
- 按会话统计 token 消耗:找出哪些用户/场景最烧钱。
- 计算单次问答成本:prompt token 单价 + completion token 单价,乘以调用量,就是真实成本。
- 监控 token 突增:如果某天 prompt token 平均值突然翻倍,很可能是检索返回的 chunk 变多了,或者有人往知识库里塞了超长文档。
我做过一个粗略的估算:一个中等规模的企业知识库(约 5 万 chunk),单次问答平均消耗 prompt token 约 2000、completion token 约 300,按主流模型定价,单次成本大约在 0.01-0.03 元。如果日活 1000 次问答,月成本在 300-900 元。这个数字看起来不大,但如果检索策略没优化好,prompt token 翻三倍,成本就上去了。观测云的价值就是让这笔账算得清清楚楚。
4. 观测云侧的配置与看板搭建
4.1 Trace 数据的查询与筛选
数据推上去之后,观测云的"链路"模块就能看到每次问答的完整 trace。我通常用这几个筛选条件快速定位问题:
service.name = spring-ai-rag-demo且rag.retrieved.avgScore < 0.6—— 找出低质量召回。rag.assemble.truncated = true—— 找出上下文被截断的请求。llm.total.tokens > 5000—— 找出异常高消耗的请求。error = true—— 找出报错的链路。
这里有个技巧:观测云的 trace 查询支持 DQL(DataFlux Query Language),你可以把常用查询保存成"快捷筛选",一键调出。比如我保存了一个叫"低分召回"的查询:
T::spring-ai-rag-demo:(rag.retrieved.avgScore < 0.6)4.2 关键指标看板设计
光看单条 trace 是"点",要做趋势分析还得靠看板。我搭的 RAG 观测看板包含这几块:
第一块:请求量与成功率
按分钟统计问答请求数、成功数、失败数,以及 P50/P95/P99 延迟。这块用观测云的时序图,一眼就能看出服务是否健康。
第二块:链路分段耗时
把 retrieval、rerank、llm 三个阶段的 P95 耗时画在同一张图上。正常情况下 retrieval 应该在 100-300ms,rerank 在 200-500ms,llm 在 1-3s。如果某段突然飙升,问题就定位到了。
第三块:检索质量趋势
按小时统计平均相似度分数、低分召回率(avgScore < 0.6 的占比)、空召回率。这三个指标是 RAG 质量的"体温计"。我设的告警阈值是:低分召回率连续 10 分钟超过 20% 就告警。
第四块:Token 成本
按天统计 prompt token、completion token 总量,以及预估成本。再按知识库维度拆一下,看看哪个库最"费钱"。
| 看板模块 | 核心指标 | 告警阈值建议 |
|---|---|---|
| 请求健康度 | QPS、成功率、P95 延迟 | 成功率 < 99% 告警 |
| 链路分段 | retrieval/rerank/llm P95 | 任一段 P95 > 2s 告警 |
| 检索质量 | 平均相似度、低分召回率 | 低分召回率 > 20% 告警 |
| 成本 | 日 token 总量、预估成本 | 日成本 > 预算 120% 告警 |
4.3 日志与 Trace 的关联
观测云支持 trace 和 log 的关联,前提是你在日志里打上 traceId。Spring Boot 里可以通过 MDC 把 traceId 注入日志:
MDC.put("traceId", Span.current().getSpanContext().getTraceId()); MDC.put("spanId", Span.current().getSpanContext().getSpanId());然后在 logback 的 pattern 里加上%X{traceId}。这样在观测云里点开一条 trace,就能直接跳到对应的日志,排查问题时不用在两个系统之间来回切。
提示:Spring Boot 3 配合 Micrometer Tracing,traceId 会自动注入 MDC,一般不需要手动 put。但如果你用的是自定义线程池,要注意 MDC 的传递问题,否则异步任务里拿不到 traceId。
5. 常见问题与排查实录
5.1 检索为空但模型还在"编"
这是最典型的 RAG 幻觉场景。用户问了一个知识库里根本没有的问题,检索返回空列表,但代码没做空判断,直接把空上下文塞给模型,模型就开始自由发挥。
排查思路:在观测云里筛选rag.retrieved.count = 0的 trace,看看这类请求占比多少。如果超过 5%,说明知识库覆盖度不够,或者查询改写有问题。
解决:在检索服务里加空召回兜底逻辑,返回"抱歉,知识库中没有相关信息",而不是让模型硬编。同时在 trace 里标记rag.fallback = true,方便统计兜底率。
5.2 相似度分数"虚高"
有些向量模型对短文本的相似度打分普遍偏高,导致明明不相关的文档也拿到 0.8 分。这时候光看分数没用,得看分数分布。
排查思路:在观测云里做一个直方图,看 top-1 相似度的分布。如果大量集中在 0.75-0.85 这个区间,说明模型的区分度不够,需要换 embedding 模型或者加 rerank。
解决:引入交叉编码器做重排序(比如 bge-reranker),把粗排的 top-20 精排到 top-5。重排序的分数区分度通常比向量相似度高得多。
5.3 链路断在 Embedding 调用
有时候 trace 到 embedding 这一步就断了,后面的 span 都没有。这通常是 embedding 服务超时或者报错,但异常被吞了。
排查思路:检查spring.ai.embeddingspan 的 status,看是否有 error。同时看这个 span 的耗时,如果接近超时阈值,说明是慢查询。
解决:给 embedding 调用加超时和重试,并且在 catch 块里显式调用obs.error(t),确保异常进 trace。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查入口 | 解决方向 |
|---|---|---|---|
| 答案不准 | 召回质量差 | 看 avgScore、topDocId | 优化切分、加 rerank |
| 答案不全 | 上下文被截断 | 看 truncated 标签 | 调大 token 预算 |
| 响应慢 | LLM 或 rerank 慢 | 看分段 P95 | 换模型、加缓存 |
| 成本高 | prompt token 多 | 看 token 统计 | 精简上下文、压缩 prompt |
| 幻觉 | 空召回未兜底 | 看 count=0 比例 | 加兜底逻辑 |
| 链路断 | 异常被吞 | 看 span status | 补 error 记录 |
5.5 几个我踩过的坑
坑一:采样率设太高,账单吓人。一开始我把 sampling 设成 1.0,结果一天下来 trace 存储量惊人。后来降到 0.2,同时开启"错误请求全采样"(观测云支持基于条件的采样),既保证了问题可查,又控制了成本。
坑二:高基数标签滥用。我一度把完整的用户 query 作为 tag 打进去,结果观测云的 tag 索引膨胀,查询变慢。后来改成只记录 query 长度和 hash,需要看原文时去日志里找。
坑三:异步链路 traceId 丢失。RAG 里有些后处理是异步的,默认 MDC 不传递,导致异步部分的日志没有 traceId,关联不上。解决办法是用TaskDecorator把 MDC 上下文复制到异步线程。
坑四:忽略前端体验数据。后来我接入了观测云的 RUM,发现用户感知的"慢"和服务端 P95 对不上——服务端 2s 返回,但用户觉得等了 5s。一查是前端渲染 markdown 和流式输出的问题。所以全链路观测要真的"全",前端也得覆盖。
6. 从观测到优化:让数据反哺 RAG 效果
6.1 用观测数据驱动 chunk 策略调优
chunk 大小和重叠度是 RAG 里最玄学的参数。我的做法是:用观测数据做 A/B 测试。把 chunk size 设成 256、512、1024 三档,各跑一周,对比平均相似度、低分召回率、答案采纳率(如果有反馈按钮)。实测下来,中文技术文档 512 token 配 64 重叠效果最好,但这不是通用结论,得用你自己的数据跑。
6.2 建立 RAG 质量基线
没有基线就没法判断"变好还是变坏"。我建议至少建立三条基线:
- 召回基线:平均相似度 ≥ 0.7,低分召回率 ≤ 10%。
- 性能基线:P95 端到端延迟 ≤ 3s。
- 成本基线:单次问答平均 token ≤ 3000。
这三条线画在看板上,任何一条越界就告警。时间长了,你就能看出知识库增长、模型升级、prompt 调整对系统的影响。
6.3 后续可以扩展的方向
观测云这套东西搭起来之后,其实还能做更多。比如把用户反馈(点赞/点踩)作为标签打进 trace,做"负反馈链路分析",看看点踩的请求有什么共同特征。再比如接入 LLM-as-judge,用另一个模型自动评估答案质量,把评分也写进 trace,这样就有了自动化的质量监控。
我个人在实际操作中的体会是:RAG 的观测不是一次性工程,而是持续迭代的过程。一开始你可能只关心"服务活着没",后来关心"检索准不准",再后来关心"成本高不高"。每加一个观测维度,你对系统的理解就深一层。观测云这类平台的价值,就是让你有能力不断加维度,而不用每次都重造轮子。
最后分享一个小技巧:如果你刚开始搭,别想着一步到位把所有指标都埋上。先把 trace 打通,确保一次请求的完整链路能看见,然后再逐个环节加细节。我见过太多人一上来就设计几十个指标,结果埋点代码比业务代码还多,最后维护不动全删了。从粗到细,按需迭代,这才是可持续的做法。