Spring AI 的 RAG 项目做多了以后你会发现最折磨人的不是模型答得差而是出了问题根本不知道在哪一环。一次用户提问从进入系统到把答案流式吐出来链路少说也有五六个环节文档解析、切片、embedding、向量检索、prompt 拼装、大模型调用之后可能还有 rerank、引用标注、限流降级。普通 Web 项目那套看日志、查监控、找报错的打法在这种多阶段异步加外部模型调用的场景里基本是瞎子摸象。所以我把观测云接进了 Spring AI RAG 项目用全链路观测把一次请求从头到尾拆开来看这篇文章就是把这段落地方案完整盘一遍适合正在做 RAG 知识库问答、或者已经遇到能跑但不敢上生产的朋友直接参考。1. 先想明白RAG 项目的可观测性到底要观测什么1.1 传统监控解决不了 RAG 的排查痛点传统 Web 项目的监控核心是健康度QPS、错误率、平均耗时、CPU、内存、慢 SQL。这些指标对 RAG 项目来说不是没用而是太粗糙。一个检索链路里慢 50 毫秒放大到用户体感上可能就是转圈圈很久才出答案但如果你只看应用整体耗时只会觉得偶发卡顿根本定位不到是向量库查询慢还是 embedding 模型那 10 秒超时拖了后腿。比慢更难搞的是错。普通接口错就错了状态码一清二楚RAG 项目错的方式太隐蔽——检索没命中返回一堆无意义内容prompt 上下文把无关文档拼进去了模型开始一本正经地胡说八道甚至 token 计算错误直接把请求干超时。这些都不是异常不会打堆栈不会进 error log但用户体验就是实打实的差。这时候你需要的不是监控系统怎么了而是观测这一次交互到底发生了什么。1.2 全链路观测要回答的四个关键问题我给 RAG 链路做观测时核心就盯四件事。第一一次请求的完整生命周期里从入口到出口一共经过哪些阶段每个阶段花多长时间哪个阶段是瓶颈。第二用户提交的原始 query 在检索阶段变成了什么向量检索到底命中了多少条文档命中质量得分是多少如果答案是空的或者答非所问是不是第一步检索就废了。第三真正发给模型的内容有多大prompt 里拼接了多少上下文这一次调用烧了多少 token成本是不是在悄悄失控。第四整条链路有没有哪一个环节是假成功——HTTP 状态码是 200但业务语义上是失败的。这四个问题靠加日志能部分回答但效率太低。你需要的是把 traceId 贯穿全文把每个阶段的关键参数记录下来再塞进一个能按时间线展开、按维度筛选的观测平台里。观测云这类平台能干的就是这件事把指标、链路、日志三块数据对到同一个 trace 维度上。2. 选型思路为什么我没有用定制 SDK而是走 OpenTelemetry2.1 观测云接入的常见途径把数据送进观测云目前有几条路。一种是直接用观测云提供的 Agent SDK在项目里引入它们的依赖手动打点上报。另一种是走标准 OpenTelemetry后面简称 OTel协议应用只做埋点数据统一发给观测云的采集端 Datakit由它转存到观测云数据中心。我最终选了 OTel 这条路线很大一个原因是 Spring AI 项目的技术栈本身就高度依赖 HTTP 调用和各种中间件而这些组件 OTel 的实现已经非常成熟。Spring Boot 3 项目的自动埋点覆盖了 MVC、WebClient、RestTemplate、JDBC、Redis、Kafka 一大堆常用组件相当于我一行代码不写入口 HTTP 请求、内部 HTTP 调用这些通用链路就已经被采集了。我只需要在 RAG 业务语义的关键位置补几个自定义 span就能把整条链路串起来。如果走专用 SDK反而要自己处理这些基础设施埋点工作量完全不是一个量级。2.2 整体架构从应用到观测云数据流怎么走我实际部署的环境里数据流是这样Spring Boot 应用里挂了 OTel Java Agent所有自动埋点和自定义埋点产生的 span、指标、日志统一输出给本地的 Datakit。Datakit 是一个独立部署的采集器部署在应用服务器上它负责接收 OTLP 协议的数据然后转发到观测云平台。这个设计的好处是应用侧不需要感知观测云的具体接入地址只需要知道 Datakit 的地址后续如果观测环境切换或者数据要同时发给多个平台应用侧完全不用动。有一点要注意Java Agent 和手动埋点 API 并不冲突。Agent 负责自动拦截通用组件我代码里用 OTel API 创建自定义 span这两者使用同一个 SDK 时会自动合并到同一条 trace 里。千万不要既上 Agent 又在代码里手动给 HTTP 调用再打一层 span那样会导致链路重复缠绕看起来就是同一段逻辑被记录了两次。通俗点说Agent 是自动挡覆盖日常路段手动埋点是手动挡只在需要精细标记业务语义的路段去用。2.3 RAG 核心链路里哪些环节值得埋点不是所有环节都需要特别照顾但 RAG 项目里下面这几个点是我强烈建议做自定义埋点的。文档加载阶段尤其是你支持多种格式文档上传的场景不同格式的解析耗时完全不一样加载失败也会影响后续检索。向量化阶段调用 embedding 模型生成向量是高耗时也是高成本环节需要记录请求的 token 消耗和调用时长。向量检索阶段命中了几条、每条相似度多少、最终是否拿这些结果去生成回答这些是解答答案为什么不对的关键线索。Prompt 拼装和模型生成阶段记录最终 prompt 的字符数和 token 估算、模型返回的 token 数、是否流式完成。3. 落地方案从 OTel Agent 到自定义 span一步步接进去3.1 部署采集端Datakit 启用 OpenTelemetry 采集器接入的第一步是让应用产生的数据有地方收。在观测云环境里先部署一个 Datakit配置它的 OpenTelemetry 采集器。Datakit 的配置目录下通常有conf.d/opentelemetry/opentelemetry.conf确认里面的 HTTP 和 gRPC 端口开启即可默认情况下 HTTP 服务监听 4318gRPC 服务监听 4317。如果你的应用和 Datakit 不在同一台机器记得在 Kubernetes 或防火墙层面放通对应端口。一个小经验如果项目已经接入了 Prometheus 体系Datakit 也支持用 Prometheus 采集器直接抓取应用的/actuator/prometheus指标不需要额外改应用配置。但 RAG 链路的 trace 数据还是要走 OTLP因为 Prometheus 只能覆盖指标没法承载 trace 和日志的关联。3.2 应用改造引入 OTel Agent统一上报地址应用侧我用 OpenTelemetry Java Agent 作为接入主体。启动 JVM 时加上参数java -javaagent:/opt/otel/opentelemetry-javaagent.jar \ -Dotel.service.namespring-ai-rag \ -Dotel.exporter.otlp.endpointhttp://127.0.0.1:4318 \ -Dotel.metrics.exporterotlp \ -Dotel.logs.exporterotlp \ -jar app.jarotel.service.name决定了这条服务在观测云里的命名建议直接叫spring-ai-rag后面查链路和指标都用这个名字。otel.exporter.otlp.endpoint是本地 Datakit 的 OTLP 地址。如果你的应用有多个环境比如 staging 和 prod最好通过环境变量覆盖这个地址避免把测试数据混到生产环境里。加了 Agent 之后Spring AI 调用大模型的部分会自动被拦截到吗要看情况。Spring AI 底层如果用 RestClient 或 WebClient这些 HTTP 客户端在 Agent 的自动埋点覆盖范围内所以模型调用延迟这类基础信息是能拿到的。但如果你想要更明确的业务语义比如查询了哪个模型、prompt 有多大、token 用了多少这些还是得自己埋。3.3 手动埋点把 RAG 的每个关键阶段变成可见 span我自定义埋点的核心类是RagTracingSupport负责用 OpenTelemetry API 创建 span并把关键业务参数塞进去。代码大致长这样import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.StatusCode; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.context.Scope; public class RagTracingSupport { private static final Tracer TRACER GlobalOpenTelemetry.getTracer(spring-ai-rag, 1.0.0); public static Span start(String spanName) { return TRACER.spanBuilder(spanName).startSpan(); } public static void recordRetrieve(String query, int topK, int hitCount, float topScore) { Span span TRACER.spanBuilder(rag.vector.search).startSpan(); try (Scope scope span.makeCurrent()) { span.setAttribute(rag.query, query); span.setAttribute(rag.topK, topK); span.setAttribute(rag.hitCount, hitCount); span.setAttribute(rag.topScore, topScore); } finally { span.end(); } } public static void recordLlmCall(String model, String prompt, long promptTokens, long completionTokens) { Span span TRACER.spanBuilder(rag.llm.call).startSpan(); try (Scope scope span.makeCurrent()) { span.setAttribute(rag.llm.model, model); span.setAttribute(rag.prompt.chars, prompt.length()); span.setAttribute(rag.token.prompt, promptTokens); span.setAttribute(rag.token.completion, completionTokens); } finally { span.end(); } } public static void markError(String message) { Span current Span.current(); current.setStatus(StatusCode.ERROR, message); } }这段代码看着简单但有一个地方要特别注意makeCurrent()把 span 放到当前上下文里这样在同一个线程后续产生的子 span 会自动挂到它下面。RAG 场景里如果知识检索是异步执行的或者用了 reactive 的Mono/Flux线程上下文是会被切换的这时候span.makeCurrent()只在当前执行线程有效跨线程就得手动把 traceId 和 spanId 传到下一个任务里去否则链路会断掉。我后面在第四节会专门说这个问题。在 RAG 的业务代码里我把这些埋点插在对应流程上。比如在 Service 层的answer方法里public String answer(String userQuestion) { Span rootSpan RagTracingSupport.start(rag.query.process); try (Scope scope rootSpan.makeCurrent()) { // 1. 文档加载如果这一步发生在请求链路内 // 2. embedding 向量检索 ListDocument hits vectorStore.similaritySearch(userQuestion); RagTracingSupport.recordRetrieve(userQuestion, 5, hits.size(), hits.isEmpty() ? 0f : hits.get(0).getScore()); // 3. 拼装 prompt交给 LLM String prompt buildPrompt(userQuestion, hits); RagTracingSupport.recordLlmCall(qwen-max, prompt, estimateTokens(prompt), 0); // 4. 调用模型生成回答 String answer chatClient.call(prompt); RagTracingSupport.recordLlmCall(qwen-max, prompt, estimateTokens(prompt), estimateTokens(answer)); return answer; } finally { rootSpan.end(); } }你可能会疑惑既然 Agent 会自动创建 HTTP 入口的 span为什么还在answer方法里再建一个rag.query.process我的目的是给业务链路定义一个稳定的锚点。HTTP 入口 span 的命名和路径强相关如果接口入口经过网关重写或者有定时任务调用同一个 Service锚点 span 可以让你在观测云里用统一的rag.query.process来筛选所有 RAG 查询不必关心入口方式。这种接口无关的业务链路定义在排障的时候特别好用。3.4 关键步骤梳理剔除噪音只留有效观测点如果你不想埋那么细我建议至少保留下面这张表里的点。这些信息能覆盖绝大多数 RAG 排障场景埋点名称记录属性解决什么问题rag.document.loadsource, docSize, duration文档解析慢、格式兼容问题rag.splitterchunkCount, chunkSize切片粒度不合理导致检索效果差rag.vector.searchquery, hitCount, topScore检索为空、命中质量差rag.llm.callmodel, promptTokens, completionTokenstoken 超限、模型调用失败、成本异常rag.rerankinputCount, topScorererank 逻辑异常或效果不明显rag.stream.completeisCompleted, firstTokenLatency流式响应中断、首字延迟过高3.5 把日志和 trace 关联起来光看链路还不够具体到报错细节还是得翻日志。观测云里把 trace 和日志做关联关键是让日志带上 traceId。Spring Boot 3 项目里只要引入了micrometer-tracing-bridge-otellogback 的 MDC 会自动注入traceId和spanId。你只需要在日志格式里把它们打印出来property nameCONSOLE_LOG_PATTERN value%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId}/%X{spanId}] - %msg%n/这样应用日志里每一行都会带着 traceId。到观测云里查一条链路时可以直接根据 traceId 跳到对应的日志列表或者反过来从某条报错日志反查整条调用链。我平时排查检索出问题跟模型因为上下文太长报错这种前后台问题基本都是先用日志搜 traceId再跳到链路详情里看是哪个环节把时间吃掉或者把请求打挂了来来回回快很多。3.6 指标采集给观测云配一套 RAG 专属看板链路数据适合逐条排查看板适合看全局趋势。我给 RAG 加了几组自定义指标用 Micrometer 的MeterRegistry直接上报Service public class RagMetricsService { private final Timer retrieveTimer; private final Counter tokenCounter; public RagMetricsService(MeterRegistry registry) { this.retrieveTimer Timer.builder(rag.retrieve.duration) .description(vector search time) .register(registry); this.tokenCounter Counter.builder(rag.llm.token.usage) .register(registry); } public void recordRetrieve(long ms) { retrieveTimer.record(Duration.ofMillis(ms)); } public void recordTokens(long prompt, long completion) { tokenCounter.increment(prompt completion); } }这些指标会通过 Agent 暴露的 Prometheus 端点被 Datakit 抓取这样我在观测云里可以建一个 RAG 专属看板关注这五组曲线RAG 请求量、平均检索耗时和 P95 检索耗时、LLM 调用耗时、平均 token 消耗、从检索到生成完成的端到端时长。一旦哪天的趋势异常基本可以第一时间锁定是大模型变慢、向量库抖动还是业务流量突增。4. 实操中掉过的坑链路断了、采样丢了、日志看不清4.1 异步和响应式场景下链路为什么总是断Spring AI 的流式接口和 RAG 的异步处理是链路断裂的重灾区。如果你使用了FluxString流式返回用户请求从 Controller 返回给前端之后底层事件循环线程还在继续产生 token这部分生成过程产生的 span 很可能不会挂到原来那条 trace 下面。解决办法是不要只在开始和结束时记录 span而是要在响应式链路上用Context传递 traceId或者用Hooks.enableAutomaticContextPropagation()启动 reactor 的自动上下文传播。这个开关简单有效但要记得它会对所有 reactor 链生效如果项目里有大量处理器线程依赖会多一些不过对 RAG 这种场景来说完全可接受。异步消息也常见。用 Kafka 或者 RocketMQ 去预处理文档、异步生成向量如果消息生产者和消费者各自开一条 trace就会出现文档处理成功但不知道对应哪次上传请求的问题。解决方式是生产者把当前 span 的 traceId 写进消息头消费者消费消息时把 traceId 捞出来作为父 span 重新注入上下文这样整条异步链路就能串起来。4.2 采样策略太激进关键 span 被丢掉OTel 默认的采样策略应该保留 100% 的 trace 吗我建议不要。RAG 项目单次调用往往涉及多个外部服务和模型计费全量上报的数据量不小。但直接调低采样率也不行我之前把采样率配成 10%结果线上出问题的那些请求链路一查都是空的因为那条链路根本没被采样到。比较务实的方案是头部采样加上尾部采样或者直接使用 OTel Java Agent 里基于请求属性的动态采样。最简单也够用的是把采样率调到 50% 以上然后把 RAG 查询的关键入口请求设置为必须采样。比如用户明确反馈回答质量差的请求通常可以靠客户端手动标记一个请求头服务端根据这个头强制sampledtrue这样既控制了数据量又能保证用户反馈的问题随时可以追查。4.3 日志里看不到完整上下文RAG 里一个问题困扰了我很久日志里只有零散的 hit 3 docs没有把实际检索到的文档 id、内容摘要打出来。排查为什么答错时光看文档数根本不够你得知道是哪几篇文档被送进了 prompt。我的做法是在rag.llm.call这个 span 的属性里塞一个字段记录检索结果的文档 id 列表和每篇的相似度得分但注意别把完整文档正文塞进去。之前我压测时试过把全文塞进 span attribute结果单条 trace 大小直接超过观测平台限制链路查询都变卡了。经验是只记录摘要、文档 ID、来源文件名详细的文档内容放在日志里通过 span 里记录的 traceId 可以再跳过去看。4.4 常见问题速查现象可能原因处理办法链路详情只有入口 span后续子步骤缺失异步线程上下文没传递启用 reactor 自动上下文传播或手动透传 traceId检索耗时高但模型调用正常embedding 或向量库慢看 span 里的具体耗时再定位是网络还是查询语句问题用户报回答不对但链路耗时都正常检索命中质量差或 prompt 拼装错误查rag.hitCount和rag.topScore属性确认命中的文档是否相关token 成本异常突增prompt 里拼进了重复或无关文档通过rag.doc.digest属性排查每次请求拼进 prompt 的文档列表流式返回中断但无异常日志客户端断开、模型输出截断或网络闪断检查rag.stream.complete是否置为 false最后分享一个小经验接入观测云之后我的排查效率提升最大的不是链路查询本身而是团队协作方式的改变。原来收到线上问题第一反应是看日志现在我会先打开观测云定位到那一次 trace把检索耗时、命中文档、模型 token 消耗这三段截图扔到群里问题基本就能对上一半。建议你也把 RAG 的关键属性固化下来比如检索命中的文档名、分数、token 数一旦形成团队共识后续排查新问题时能省掉大量来回沟通的时间。Spring AI 的 RAG 项目是否成熟很多时候不是看功能多全而是看这些关键时刻能不能被记录、被回放、被复现这才是全链路观测真正的价值所在。
