Spring AI RAG全链路观测实战:OpenTelemetry埋点与观测云看板告警

📅 发布时间:2026/10/10 7:53:41
Spring AI RAG全链路观测实战:OpenTelemetry埋点与观测云看板告警
1. 从一次线上告警说起RAG链路为什么需要全链路观测去年冬天的一个凌晨我被一条告警叫醒某个基于 Spring AI 搭建的 RAG 问答接口P99 延迟从 800ms 飙到了 6 秒但 CPU、内存、GC 全部正常。运维同学查了半天基础设施没找到任何异常。最后定位下来问题出在检索环节——向量库返回的候选文档数量在某次配置变更后翻了三倍导致后续的 Prompt 拼装和模型推理输入暴涨。基础设施指标一切正常但用户体验已经崩了。这件事让我彻底意识到一个问题传统 APM 观测的是服务健康而 RAG 应用真正需要观测的是链路健康。一个 RAG 请求从用户提问到最终回答中间要经过查询改写、向量化、向量检索、重排序、上下文拼装、Prompt 构造、大模型推理、结果后处理等七八个环节任何一个环节的抖动都会传导到最终结果但基础设施指标往往毫无波澜。这篇内容就是把我这段时间在 Spring AI RAG 场景下做全链路观测的完整思路和落地细节整理出来。核心是回答三个问题RAG 链路的观测点到底该埋在哪里、观测数据怎么串成一条完整的 Trace、以及怎么把观测数据接入观测云这类平台形成可用的看板与告警。适合已经在用 Spring AI 做 RAG、但被出了问题不知道卡在哪一环困扰的开发者也适合正在做 AI 应用可观测性建设的团队参考。需要先说明一点观测云在这里是一个通用的可观测性平台代称本文讲的方法论和埋点思路换成任何支持 OpenTelemetry 协议的后端平台都成立。重点不在平台本身而在于RAG 链路该怎么被观测。2. RAG 链路的观测点到底该埋在哪几层很多人做 RAG 观测第一反应是给接口加个耗时统计。这远远不够。RAG 的复杂度在于它是一条多阶段、异构、有状态的链路每个阶段的失败模式和性能特征完全不同。我把它拆成四层来看。2.1 接入层用户视角的端到端指标接入层观测的是用户真正感知到的东西一次问答请求从发出到收到完整回答的总耗时、首字节时间TTFB、流式输出的 token 速率、成功率、错误码分布。这一层的价值在于建立用户体验的基线。这里有个容易被忽略的点RAG 应用大量使用流式输出SSE传统的请求-响应耗时统计会失真。用户感知的快其实是首字节快用户感知的卡往往是流式输出中途停顿。所以接入层至少要采集三个指标首字节延迟、完整响应延迟、以及流式过程中的最大 token 间隔。最后这个指标特别关键它直接反映模型推理是否出现卡顿。我在实际项目里用 Spring AI 的ChatClient流式接口时会在 Flux 的每个 onNext 回调里记录时间戳算出相邻 token 的间隔取最大值上报。这个数据后来帮我们抓到过一次模型服务端的偶发限流——表现为每隔几十个 token 就停顿两秒但整体响应时间看起来还在可接受范围。2.2 检索层RAG 的命门所在检索层是 RAG 区别于普通 LLM 应用的核心也是问题最集中的地方。这一层要观测的东西比想象中多查询改写耗时如果用了查询改写Query Rewriting或多查询扩展这一步本身可能调用一次 LLM耗时不可忽略。向量化耗时把查询文本转成 embedding 的时间取决于 embedding 模型是本地还是远程。向量检索耗时向量库的检索延迟以及返回的候选文档数量。重排序耗时如果用了 Rerank 模型这一步经常是隐藏的性能杀手。召回质量指标Top-K 相似度分数的分布、召回文档数量、去重后的有效文档数。我特别想强调召回质量指标。性能问题好查质量问题难查。有一次用户反馈回答越来越不准查了半天发现是向量库里混入了一批格式错误的文档它们的 embedding 相似度异常高把真正相关的文档挤出了 Top-K。如果当时有监控 Top-K 相似度分数的分布一眼就能看出异常——正常查询的 Top-1 分数应该在 0.7 以上那段时间大量查询的 Top-1 掉到了 0.4 左右。2.3 推理层模型调用的黑盒要打开推理层观测的是大模型调用。这一层的关键是把黑盒尽量打开Prompt 长度输入 token 数这是成本和质量的双重指标。首 token 延迟模型开始返回的时间反映模型服务的排队情况。输出 token 数直接关联成本。推理总耗时区分是模型慢还是网络慢。模型返回的 finish_reason是正常结束、还是被 max_tokens 截断、还是触发了内容过滤。finish_reason这个字段价值极高但经常被忽略。如果大量请求的 finish_reason 是length说明 max_tokens 设置太小回答被截断了用户看到的是半截话。这种问题从用户侧很难反馈但从观测数据里一目了然。2.4 上下文层拼装环节的隐性成本上下文层是 RAG 里最容易被忽视的一层。检索回来的文档要经过过滤、去重、排序、截断、格式化最后拼进 Prompt。这一步看起来是纯内存操作但在文档数量大、格式化逻辑复杂时耗时可能达到几百毫秒。更重要的是这一层决定了最终送给模型的上下文质量。我会观测拼装后的上下文总长度、实际使用的文档数、被截断丢弃的文档数、以及上下文占 Prompt 的比例。如果上下文占了 Prompt 的 90% 以上说明检索回来的内容太多太杂模型真正看到的有效信息密度低回答质量必然受影响。把这四层拆开之后观测的骨架就清晰了。接下来要解决的是这些数据怎么串起来。3. 用 OpenTelemetry 把四层观测串成一条 Trace四层观测点如果各自为政出了问题还是要人肉拼图。真正的全链路观测要求一次请求的所有环节共享同一个 Trace ID在时间轴上完整呈现。Spring AI 本身对 Micrometer 和 OpenTelemetry 有不错的支持但默认埋点覆盖不到 RAG 的全部环节需要手动补。3.1 为什么选 OpenTelemetry 而不是自己造轮子先说选型逻辑。RAG 观测的数据类型很杂有耗时数值、有 token 数数值、有相似度分布数值数组、有文档 ID字符串、有 finish_reason枚举。自己造一套埋点框架短期能跑长期会陷入每加一个观测点就要改框架的泥潭。OpenTelemetry 的优势在于它的数据模型天然适配这种场景Span 表示一个操作Attribute 表示操作的属性Event 表示操作过程中的离散事件。检索是一个 Span它的耗时是 Span 的 duration召回文档数是 AttributeTop-K 相似度可以作为一个 Event 记录。而且 OTel 的上下文传播机制能自动把跨线程、跨服务的调用串起来这对 RAG 这种异步链路至关重要。Spring AI 从 1.0 版本开始内置了 Micrometer Observation 支持而 Micrometer 又能桥接到 OpenTelemetry。所以基础链路是通的我们要做的是补充 RAG 特有的观测点。3.2 手动埋点的三个关键位置Spring AI 的自动埋点覆盖了 ChatClient 调用和部分向量库操作但查询改写、重排序、上下文拼装这些自定义环节需要手动埋。我一般在三个位置加代码第一个位置是检索入口。用Observation.createNotStarted创建一个检索 Span把查询文本、Top-K 参数、相似度阈值作为 Attribute 打进去。检索完成后把召回文档数、实际返回数、Top-1 相似度补上。Observation observation Observation.createNotStarted(rag.retrieval, observationRegistry) .lowCardinalityKeyValue(rag.retrieval.topK, String.valueOf(topK)) .highCardinalityKeyValue(rag.query.length, String.valueOf(query.length())); observation.start(); try (Observation.Scope scope observation.openScope()) { ListDocument docs vectorStore.similaritySearch( SearchRequest.query(query).withTopK(topK)); observation.highCardinalityKeyValue(rag.retrieval.returned, String.valueOf(docs.size())); if (!docs.isEmpty()) { double topScore docs.get(0).getScore(); observation.highCardinalityKeyValue(rag.retrieval.topScore, String.format(%.3f, topScore)); } return docs; } catch (Exception e) { observation.error(e); throw e; } finally { observation.stop(); }这里有个细节值得说高基数highCardinality和低基数lowCardinality标签要分清。Top-K 这种取值有限的用低基数查询长度、相似度分数这种取值无限的用高基数。分错了会导致指标聚合时维度爆炸观测平台直接被打爆。我踩过这个坑把文档 ID 当低基数标签打进去结果一个下午就产生了上百万个时间序列。第二个位置是上下文拼装。这一步的观测重点是进多少、出多少、丢多少。Observation ctxObs Observation.createNotStarted(rag.context.assembly, observationRegistry); ctxObs.start(); try { ListDocument filtered filterAndDeduplicate(docs); String context formatContext(filtered, maxContextLength); ctxObs.highCardinalityKeyValue(rag.context.inputDocs, String.valueOf(docs.size())); ctxObs.highCardinalityKeyValue(rag.context.usedDocs, String.valueOf(filtered.size())); ctxObs.highCardinalityKeyValue(rag.context.length, String.valueOf(context.length())); return context; } finally { ctxObs.stop(); }第三个位置是模型推理。Spring AI 的 ChatClient 调用会自动产生 Span但默认不带 token 统计。需要在响应回来后手动补充ChatResponse response chatClient.call(prompt); Usage usage response.getMetadata().getUsage(); Span.current().setAttribute(gen_ai.usage.input_tokens, usage.getPromptTokens()); Span.current().setAttribute(gen_ai.usage.output_tokens, usage.getCompletionTokens()); Span.current().setAttribute(gen_ai.response.finish_reason, response.getResult().getMetadata().getFinishReason());注意这里用了gen_ai.*这个命名空间这是 OpenTelemetry 针对生成式 AI 的语义约定Semantic Conventions遵循它能让你在观测平台上直接复用现成的 AI 应用看板模板省去大量配置工作。3.3 异步链路里的上下文传播陷阱RAG 链路里大量使用异步向量检索可能是异步的、模型调用是流式的、重排序可能走线程池。OpenTelemetry 的上下文默认绑定在线程上一旦跨线程就会丢失导致 Trace 断裂。我遇到过一次典型问题主线程的 Trace 完整但检索 Span 变成了独立的 Trace怎么都串不起来。原因是检索用了CompletableFuture.supplyAsync默认的 ForkJoinPool 不传播 OTel 上下文。解决办法有两个。一是用 OTel 提供的上下文包装工具在提交异步任务时手动包装Context context Context.current(); CompletableFuture.supplyAsync( context.wrap(() - vectorStore.similaritySearch(request)), executor );二是配置一个能自动传播上下文的 Executor让所有异步任务都走它。我倾向于第二种因为一劳永逸不用在每个异步调用点都记得包装。Spring 环境下可以自定义一个TaskDecorator把当前上下文捕获并传递到子线程。提示流式响应Flux/Mono的上下文传播更麻烦Reactor 有自己的上下文机制和 OTel 的 Context 是两套东西。Spring AI 的流式接口内部做了桥接但如果你在流式链路里插入了自定义的map、flatMap操作要确认这些操作没有切断上下文。实测下来在flatMap里做异步检索最容易断链。4. 观测数据接入观测云后的看板与告警设计数据埋好了、Trace 串起来了接下来是让数据产生价值。观测云这类平台的价值不在于能看数据而在于能快速定位问题。我按总览-下钻-告警三层来设计。4.1 总览看板四个必须有的图表总览看板是每天第一眼要看的东西不能堆太多图我一般只放四个第一个是端到端延迟的分位数曲线P50/P95/P99。这条曲线反映整体健康度P99 突然抬头就是有问题的信号。第二个是各阶段耗时占比的堆叠图。把检索、重排序、上下文拼装、模型推理的耗时按比例堆叠一眼就能看出当前瓶颈在哪一层。这个图是我用得最多的因为 RAG 的性能问题几乎都是某一层突然变慢堆叠图能直接指出来。第三个是召回质量趋势。把 Top-1 相似度的 P50 和 P10 画在一起。P50 反映整体召回水平P10 反映最差的那批查询。如果 P10 持续走低说明有一批查询根本召不回相关内容这是质量问题不是性能问题。第四个是Token 消耗趋势。输入 token 和输出 token 分开画直接关联成本。我见过一次输入 token 突然翻倍查下来是上下文拼装逻辑的一个 bug 导致文档被重复拼接。4.2 下钻分析从异常指标到具体 Trace看板发现异常后下一步是下钻到具体请求。这里的关键是指标和 Trace 的联动。观测云支持从指标图表直接跳转到对应的 Trace 列表前提是埋点时把关键维度打进了指标标签。我的做法是所有 Span 都带上rag.pipeline.version标签标识当前使用的 RAG 流程版本。这样当某个版本上线后指标恶化可以直接按版本过滤 Trace快速确认是不是新版本引入的问题。这个标签在 A/B 测试或者灰度发布时特别有用。下钻到单条 Trace 后时间轴上能清楚看到每个 Span 的耗时和属性。我排查过最典型的一类问题模型推理 Span 本身很快但它前面的检索 Span 很慢而检索慢的原因又是向量库连接池耗尽在排队。如果没有完整的 Trace只会看到接口慢根本不知道慢在连接池。4.3 告警规则哪些指标值得报警告警设计的原则是宁可少报不可误报。RAG 应用的指标波动天然比普通服务大模型服务本身就有抖动告警阈值设太紧会被淹没。我实际配置的告警规则有这么几条告警项触发条件说明端到端 P99 延迟5 分钟窗口内 P99 基线 2 倍基线按历史同期动态计算检索层错误率5 分钟内错误率 1%向量库连接或查询异常召回质量劣化15 分钟内 Top-1 相似度 P50 0.5可能是知识库数据问题Token 消耗突增1 小时内输入 token 总量 基线 1.5 倍成本异常或上下文 bugfinish_reason 异常5 分钟内 length 占比 20%回答被截断这里有个经验延迟类告警一定要用动态基线不要用固定阈值。模型服务的延迟本身就有周期性波动白天和凌晨能差一倍。固定阈值要么白天疯狂误报要么凌晨漏报。观测云支持基于历史数据的动态基线配置好之后误报率能降一个数量级。5. 那些只有踩过才知道的观测坑前面讲的是应该怎么做这一节讲实际做的时候会踩什么坑。这些都是我在真实项目里交过学费的。5.1 高基数标签把观测平台打爆前面提过一次这里展开说。我最初埋点时把用户 ID、会话 ID、文档 ID 都作为 Span 的 Attribute 打进去了。Span 层面没问题因为 Span 是逐条存储的。但问题出在指标聚合上——观测平台会从 Span 里提取指标如果这些高基数字段被当成指标维度时间序列数量会指数级增长。具体表现是埋点上线当天观测平台的写入量暴涨查询开始超时账单也上去了。排查后发现是文档 ID 作为维度产生了海量序列。正确做法Span 的 Attribute 可以带高基数字段用于单条 Trace 排查但指标维度只能用低基数字段。文档 ID、用户 ID 这类字段要么只放在 Span 里要么做哈希截断后再用。观测云里可以通过配置决定哪些字段参与指标聚合这个配置一定要在上线前确认。5.2 流式响应的耗时统计失真RAG 应用大量用流式输出但很多观测方案对流的支持不好。典型问题是Span 在流开始时就结束了导致记录的耗时是首字节时间而不是完整响应时间。我一开始也踩了这个坑看板上显示的延迟很漂亮但用户投诉回答很慢。原因是流式输出的总时长被漏掉了。解决办法流式场景要埋两个 Span。一个包住发起请求到首字节一个包住整个流的生命周期。前者反映响应速度后者反映完整耗时。Spring AI 的流式接口返回 Flux可以用doOnSubscribe和doFinally分别标记开始和结束。FluxChatResponse stream chatClient.stream(prompt); return stream .doOnSubscribe(s - streamSpan.start()) .doFinally(signal - { streamSpan.setAttribute(rag.stream.completed, signal.toString()); streamSpan.stop(); });5.3 采样策略全采样还是按需采样RAG 请求的 Trace 数据量很大一条完整 Trace 可能有几十个 Span每个 Span 带一堆 Attribute。如果全量采集存储成本很高。我的策略是分层采样正常请求按 10% 采样错误请求和慢请求 100% 采样。这样既控制了成本又保证了问题请求不丢。OpenTelemetry 支持基于 Span 属性的采样策略可以配置如果 Span 有 error 标记或耗时超过阈值则强制采样。观测云侧也支持尾部采样Tail Sampling在数据写入前根据完整 Trace 的特征决定是否保留。这个能力很关键因为有些问题比如整条链路慢只有在 Trace 完整时才能判断头部采样在链路开始时决定会漏掉。5.4 观测本身不能成为性能负担最后一个坑也是最容易被忽视的观测代码本身会拖慢应用。我见过一个项目埋点做得非常细每个文档的相似度都单独打一个 Event结果观测开销占了总耗时的 15%。原则是观测代码要轻重活异步做。Attribute 的设置是内存操作很快但上报是 IO 操作必须异步批量。OpenTelemetry 的 SDK 默认就是批量异步上报但如果你在埋点里做了同步的字符串拼接、JSON 序列化这些开销会累加。我的做法是埋点里只做最简单的取值和赋值复杂的格式化、聚合放到上报后的处理环节。比如相似度分数埋点时只存原始 double格式化留给看板配置。6. 从观测到优化数据怎么反哺 RAG 效果观测的最终目的不是看到问题而是解决问题。这一节讲观测数据怎么指导 RAG 的优化。6.1 用检索耗时数据决定要不要加缓存如果观测数据显示检索层耗时占比高且存在大量重复查询相同或相似的 query那么加一层查询缓存是性价比最高的优化。我在一个项目里通过观测发现30% 的查询是重复的加缓存后检索层 P99 直接降了 60%。判断是否值得加缓存看两个数据检索耗时占比、查询重复率。观测云里可以通过查询文本的哈希值统计重复率这个分析做一次就能指导决策。6.2 用召回质量数据调 Top-K 和阈值Top-K 设多少合适相似度阈值设多少这些参数靠拍脑袋定效果很难保证。观测数据能给出答案。我的方法是把 Top-K 和相似度阈值作为 Span 的 Attribute 打进去然后在观测平台上按这两个维度分组看不同参数组合下的召回质量和最终回答质量如果有反馈数据。通过对比能找到最优参数区间。实测下来很多团队默认的 Top-K10 其实是过大的调到 4-5 配合重排序效果不降反升还省了 token。6.3 用上下文数据发现知识库问题上下文层的观测数据能反映知识库的健康度。如果某个时间段内大量查询的召回文档数正常但有效文档数很低大量被过滤或去重说明知识库里可能有大量重复或低质文档。我遇到过一次知识库导入时没有去重同一份文档有多个版本导致检索回来的 Top-K 里全是同一份文档的不同版本真正相关的其他文档被挤掉了。观测数据里表现为召回文档数10去重后有效文档数2这个信号非常明确。6.4 建立观测驱动的迭代闭环最后想说的是观测不是一次性的工作而是要嵌入 RAG 的迭代流程。我的做法是每次调整 RAG 流程换 embedding 模型、改检索策略、调 Prompt 模板都先在观测平台上对比调整前后的关键指标。没有数据支撑的调整很容易感觉变好了但实际变差了。具体操作上我会给每次变更打一个版本标签观测数据按版本分组对比。这样能清楚看到每次变更的真实影响避免改了 A 影响了 B 但没人发现的情况。这套观测体系跑了大半年最大的感受是RAG 的很多问题不是能不能查出来而是有没有数据可查。把观测点埋全、把 Trace 串通、把看板配好剩下的就是数据驱动地迭代。我现在的习惯是任何 RAG 相关的改动上线前先确认观测数据能覆盖到这次改动的影响面否则宁可不发。这个习惯帮我避免了好几次上线后才发现问题的被动局面。