2026/10/10 5:10:44

Spring AI可观测性实战:traceId贯穿全链路与思考过程实时直播

Spring AI可观测性实战:traceId贯穿全链路与思考过程实时直播 1. 从一次线上排障说起为什么AI应用的链路追踪比普通服务更难去年冬天我接手了一个基于Spring AI搭建的智能问答服务。上线第三天用户反馈回答到一半突然断了重试又好了。我打开日志平台看到的是这样的场景一次请求在网关层有一个traceId到了业务服务层变成了另一个调用大模型的那段代码里压根没有traceId流式返回的每个chunk散落在不同线程的日志里根本拼不出一次完整的对话。更麻烦的是用户说它想了一半不说了可我在日志里只能看到最终结果中间模型到底输出了什么、在哪一步被截断完全是个黑盒。这件事让我意识到一个被很多人忽略的事实AI应用的可观测性和传统微服务的可观测性根本不是一回事。传统服务调用是请求-响应的确定性链路一次调用要么成功要么失败链路清晰。而AI应用尤其是接入大模型之后一次用户请求背后可能是提示词组装、向量检索、多轮工具调用、流式token生成、后处理过滤每一步都可能是异步的、流式的、耗时的而且模型输出本身带有不确定性。你如果还用老一套的日志埋点思路最后拿到的就是一堆碎片。这篇内容我想聊的就是怎么用Spring AI把这条链路真正串起来核心抓手是traceId贯穿全链路以及一个很多人想做但没做好的事情——把模型的思考过程实时直播给用户看。所谓直播不是真的把模型内部权重暴露出来而是把推理过程中的关键节点、工具调用、阶段性输出通过流式通道实时推给前端让用户看到AI正在想什么。这两件事合在一起就是标题里说的可观测与透明化。适合读这篇的人正在用Spring AI或类似框架做AI应用的后端开发被链路追踪和流式输出折磨过的想给产品加思考过程展示但不知道怎么落地的以及单纯想搞清楚AI应用可观测性该怎么设计的架构同学。我会从原理讲到代码从踩坑讲到优化尽量把每个为什么这么设计说透。2. traceId贯穿全链路从网关到模型调用的完整埋点方案2.1 为什么MDC那套老办法在AI场景下会失效传统Java服务里我们习惯用SLF4J的MDCMapped Diagnostic Context来传递traceId配合拦截器在请求入口塞进去线程内一路透传。这套东西在同步阻塞的MVC服务里工作得很好因为一个请求从头到尾基本在一个线程里跑完。但AI应用有几个特性直接把它打穿了。第一流式返回天然跨线程。Spring AI的流式接口返回的是FluxChatResponse或者StreamingResponseBody数据在Reactor的调度器线程上产生和你处理HTTP请求的线程不是同一个。MDC是基于ThreadLocal的线程一换traceId就丢了。第二工具调用可能触发新的异步任务。模型决定调用某个工具时这个工具执行可能是异步的甚至可能再发起一次HTTP请求链路在这里分叉。第三多轮对话的上下文跨越多次请求。用户第一轮问、第二轮追问这两次HTTP请求在服务端是两个独立trace但业务上它们是同一条会话链路需要有个sessionId或者conversationId把它们关联起来。我踩过的第一个坑就是以为在Controller入口塞了MDC就万事大吉结果流式接口的日志里traceId全是空的。排查了半天才发现是Reactor线程切换导致的。2.2 用Reactor Context替代ThreadLocal做上下文传递正确的做法是拥抱Reactor的上下文机制。Reactor提供了一个Context它能在响应式链路中自动传递不依赖线程。核心思路是在请求入口把traceId写入Reactor Context然后在需要的地方通过Mono.deferContextual或者contextWrite读取。具体落地分几步。首先定义一个上下文键和工具类public final class TraceContext { public static final String TRACE_ID_KEY traceId; public static final String CONVERSATION_ID_KEY conversationId; public static MonoString getTraceId() { return Mono.deferContextual(ctx - Mono.justOrEmpty(ctx.getOrEmpty(TRACE_ID_KEY))); } }然后在WebFilter里生成并写入。这里要注意WebFilter对流的处理要用contextWrite而不是直接操作Component public class TraceWebFilter implements WebFilter { Override public MonoVoid filter(ServerWebExchange exchange, WebFilterChain chain) { String traceId exchange.getRequest().getHeaders() .getFirst(X-Trace-Id); if (traceId null || traceId.isBlank()) { traceId UUID.randomUUID().toString().replace(-, ); } String conversationId exchange.getRequest().getHeaders() .getFirst(X-Conversation-Id); final String finalTraceId traceId; final String finalConvId conversationId; return chain.filter(exchange) .contextWrite(ctx - ctx .put(TraceContext.TRACE_ID_KEY, finalTraceId) .put(TraceContext.CONVERSATION_ID_KEY, finalConvId null ? : finalConvId)); } }关键点在于contextWrite是作用在返回的Mono上的它会把上下文向下游传递。这样即使后面线程切换了只要还在这个响应式链路里traceId就能取到。2.3 把traceId注入到日志和模型调用参数里光有上下文还不够日志里得能看到。因为MDC在异步场景失效我采用的方式是在日志输出时动态拼接。可以写一个工具方法在记录日志前从Context取traceId。但更省事的做法是自定义一个Logback的Converter或者干脆在关键日志点手动带上。我实际项目里用的是组合方案对于同步代码块仍然用MDC在进入响应式链路前设置好对于响应式链路用doOnEach在信号发出时把traceId塞进MDC再清理public static T MonoT withTraceLogging(MonoT mono, String operation) { return mono.doOnEach(signal - { if (signal.isOnNext() || signal.isOnError() || signal.isOnComplete()) { TraceContext.getTraceId().subscribe(traceId - { MDC.put(traceId, traceId); log.info(operation{} signal{}, operation, signal.getType()); MDC.remove(traceId); }); } }); }注意doOnEach里再subscribe一个Mono是有副作用的生产环境更推荐用Hooks.onEachOperator做全局处理或者用Micrometer的Observation API它原生支持响应式上下文。我这里为了讲清楚原理用了简化写法实际项目建议直接上Observation。另一个重点是把traceId传给模型调用。Spring AI的ChatClient支持在请求里带metadata很多模型服务商支持在请求头里带自定义字段用于对账。即使模型侧不消费你在自己的调用日志里带上traceId排查时也能把用户请求和模型调用对上。2.4 跨服务传递HTTP头与消息队列两条路径如果AI服务不是单体而是拆成了网关-编排服务-模型代理服务traceId还得跨进程传。HTTP场景下最简单用请求头X-Trace-Id透传下游服务在WebFilter里优先读这个头读不到再生成。这里有个细节不要用W3C traceparent格式硬套除非你整套链路都接了OpenTelemetry。如果只是自己排查用自定义头更灵活还能顺便带上conversationId。消息队列场景比如异步的批量推理任务稍微麻烦点。我的做法是把traceId写进消息的header里消费端在反序列化时先取出traceId写入Reactor Context再处理业务。Kafka的话可以用ProducerRecord.headers()RocketMQ用Message.putUserProperty()。核心原则就一条traceId必须在业务逻辑开始执行之前就进入上下文否则后面所有埋点都是白搭。3. 思考过程实时直播把流式输出拆成可读的阶段事件3.1 直播思考过程到底直播什么先澄清一个概念。很多人一听直播思考过程以为要把模型的chain-of-thought原文吐给用户。这里有两个问题一是很多模型的思维链并不对外暴露二是直接把原始推理过程给用户看体验未必好可能又长又乱。我理解的透明化直播是把一次AI响应的生命周期拆成若干个语义清晰的阶段每个阶段实时推送状态和阶段性内容。具体来说一次典型的AI问答可以拆成这些阶段接收请求并组装提示词、检索相关知识RAG场景、模型开始生成、模型决定调用工具、工具执行中、工具返回结果、模型继续生成、生成完成、后处理过滤。每个阶段都可以作为一个事件推给前端。用户看到的不再是转圈等待然后突然蹦出一大段字而是正在检索资料...找到了3篇相关文档...正在组织回答...文字逐字出现。这种体验上的差异是巨大的。我做过对比测试同样的响应时间有阶段提示的版本用户主观等待感明显更低中途放弃率下降了不少。这就是透明化的价值——它不改变实际耗时但改变了用户对耗时的感知。3.2 用SSE还是WebSocket流式通道的选型逻辑Spring AI的流式输出默认走SSEServer-Sent Events也就是text/event-stream。选SSE而不是WebSocket理由很实在AI问答是典型的服务端单向推送场景用户发一次请求服务端持续推流不需要双向实时通信。SSE基于HTTP天然支持断线重连、天然穿透大多数代理和网关实现成本低。WebSocket虽然更灵活但要处理心跳、连接管理、负载均衡的粘性问题对AI问答来说是过度设计。不过SSE有个坑默认的EventSource不支持自定义请求头。这意味着你没法在浏览器原生EventSource里带Authorization或者X-Conversation-Id。解决方案有两个一是用fetch ReadableStream手动解析SSE流二是把认证信息放到URL参数或者Cookie里。我推荐前者虽然要多写点解析代码但可控性强。下面是一个前端消费SSE的简化示例async function streamChat(prompt, conversationId, onEvent) { const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, X-Conversation-Id: conversationId, Accept: text/event-stream }, body: JSON.stringify({ prompt }) }); const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n\n); buffer lines.pop(); for (const line of lines) { if (line.startsWith(data:)) { onEvent(JSON.parse(line.slice(5).trim())); } } } }3.3 定义事件协议让前端能区分阶段和内容直播的核心是事件协议设计。我建议把推送的每条消息都包装成一个统一的事件对象用type字段区分类型。这样前端可以根据type决定是显示状态提示还是追加正文还是渲染工具调用卡片。事件类型含义前端处理stage阶段状态变更更新顶部状态条文字content正文增量token追加到回答区域tool_call模型发起工具调用渲染工具调用卡片tool_result工具返回结果更新卡片为完成态thinking阶段性思考摘要折叠区展示done流结束关闭loading展示耗时error出错展示错误提示后端在Spring AI里怎么产出这些事件ChatClient的流式接口返回FluxChatResponse每个ChatResponse里包含增量的文本。但工具调用、检索这些阶段框架不会自动帮你发事件需要你自己在编排逻辑里手动往流里merge。3.4 在Spring AI编排层手动注入阶段事件我的做法是用Flux.merge或者Flux.concat把不同来源的事件流拼起来。假设一次请求先做检索再调模型代码结构大致是这样public FluxChatEvent streamWithStages(String prompt, String conversationId) { FluxChatEvent retrievalStage Flux.just( ChatEvent.stage(正在检索相关知识) ); MonoChatEvent retrievalResult retrieveDocuments(prompt) .map(docs - ChatEvent.stage( 找到 docs.size() 篇相关文档)) .onErrorReturn(ChatEvent.stage(检索失败直接回答)); FluxChatEvent modelStream chatClient.prompt() .user(prompt) .stream() .chatResponse() .map(resp - { String text resp.getResult().getOutput().getText(); return ChatEvent.content(text); }); return Flux.concat( retrievalStage, retrievalResult.flux(), Flux.just(ChatEvent.stage(正在组织回答)), modelStream, Flux.just(ChatEvent.done()) ); }这里有个关键细节阶段事件和内容事件必须严格有序。如果用merge检索阶段的事件可能和模型输出交错前端就乱了。所以用concat保证顺序。但concat的代价是串行如果检索和模型准备可以并行那就要用更复杂的编排比如先并行发起但用concatMap控制事件发射顺序。提示工具调用的直播是最容易出彩的地方。当模型决定调用某个工具时你可以推一个tool_call事件前端渲染成正在查询天气...工具返回后再推tool_result前端更新为查询完成北京今天晴25度。用户会觉得AI真的在做事而不是在编。4. 可观测性落地指标、日志、追踪三件套怎么配4.1 该采集哪些指标别只盯着响应时间AI应用的指标体系和普通服务有重叠也有差异。重叠的是QPS、错误率、P99延迟这些基础项。差异在于AI应用需要额外关注首token延迟TTFT、token生成速率、单次请求token消耗量、工具调用次数与成功率、检索命中率。这几个指标直接决定用户体验和成本。首token延迟尤其重要。用户感知的快慢很大程度上取决于第一个字什么时候出现而不是整段回答什么时候结束。我见过响应总耗时5秒但首token 0.5秒的服务用户觉得挺快也见过总耗时3秒但首token 2.5秒的服务用户觉得卡死了。所以TTFT必须单独埋点。用Micrometer的话可以这样记录Timer.Sample sample Timer.start(meterRegistry); // ... 发起模型调用 sample.stop(Timer.builder(ai.chat.ttft) .tag(model, modelName) .tag(conversation, conversationId) .register(meterRegistry));token消耗量则从ChatResponse的metadata里取Spring AI的ChatResponse.getMetadata().getUsage()能拿到prompt tokens和completion tokens。把这些数据按traceId打点既能做成本核算也能在排查为什么这次特别慢时提供线索——比如发现是prompt太长导致的。4.2 日志怎么打才有用结构化是底线AI应用的日志如果还是log.info(调用模型成功)这种基本等于没打。必须结构化至少包含traceId、conversationId、阶段名、耗时、token数、模型名。我习惯用JSON格式输出方便日志平台检索。一个典型的模型调用日志长这样{ ts: 2025-01-15T10:23:45.123Z, level: INFO, traceId: a1b2c3d4e5f6, conversationId: conv-789, stage: model_call, model: gpt-4o-mini, promptTokens: 1250, completionTokens: 340, ttftMs: 480, totalMs: 3200, toolCalls: 1, status: success }这里有个经验不要把完整的prompt和response原文打进日志。一是体积大二是可能含敏感信息。我的做法是只记录长度和hash需要排查时再根据traceId去专门的审计存储里捞原文且审计存储要有访问控制和脱敏。4.3 追踪OpenTelemetry接入的取舍如果你的团队已经有OpenTelemetry体系那Spring AI的调用应该作为span接入。Spring AI本身对Micrometer Observation有支持可以自动为ChatClient调用生成span。但要注意流式调用的span结束时机是个坑。普通调用是请求发出到响应返回span就结束了。流式调用如果等整个流结束才结束span那span时长会很长而且中途出错不好标记。我的处理是把span拆成两段一段是建立连接并收到首token一段是流式消费完成。这样TTFT和总时长在追踪里都能看到。如果没有OTel体系用traceId 结构化日志也能满足大部分排查需求。不必为了追踪而追踪工具要服务于实际排障场景。4.4 一个真实的排障案例从traceId到根因回到开头那个回答到一半断了的问题。接入完整可观测之后我通过traceId把链路拼了出来发现日志里是这样的序列模型调用成功、首token 400ms、生成了120个token、然后突然出现一个stagepost_process的日志、接着流就结束了。问题出在后处理环节——我们有个敏感词过滤逻辑它在流式过程中逐段检查遇到疑似敏感内容时直接中断了流但没有给前端发任何结束事件前端就一直等。根因找到后修复很简单后处理中断时也要发一个error或done事件并带上中断原因。但如果没有traceId把模型输出和后处理中断这两段日志关联起来我可能还在怀疑是模型服务的问题。这就是可观测性的价值——它不能防止bug但能让bug无处遁形。5. 性能与体验的平衡直播带来的额外开销怎么控5.1 事件推送的频率控制直播思考过程听起来美好但如果每个token都推一个事件网络开销和前端渲染压力都会上来。一个中文字大约1-2个token一段500字的回答就是近千个事件。SSE虽然轻量但频繁的小包推送在弱网环境下体验很差。我的做法是对内容事件做批量合并。在服务端用一个缓冲区每积累N个token或者每过M毫秒推一次。N取5-10M取50-100ms实测下来既保证了逐字出现的观感又把事件数量降了一个数量级。阶段事件则实时推因为它们数量少且重要。FluxChatEvent batched modelStream .bufferTimeout(8, Duration.ofMillis(80)) .map(list - ChatEvent.content( list.stream().map(ChatEvent::getText) .collect(Collectors.joining())));bufferTimeout这个操作符正好满足需求攒够8个或者超过80ms就发一批两个条件谁先满足用谁。5.2 阶段事件的粒度太细反而干扰阶段划分不是越细越好。我一开始把组装提示词序列化请求建立连接都做成阶段推给用户结果用户看到状态条疯狂闪烁反而焦虑。后来精简成用户能理解的几个大阶段理解问题、检索资料、思考中、组织回答、完成。每个阶段至少停留几百毫秒状态条变化才有意义。这里的原则是阶段要对应用户能理解的工作单元而不是代码里的函数调用。用户不关心你序列化了什么用户关心它有没有在为我干活。5.3 断线重连与状态恢复SSE断线是常态尤其是移动网络。如果断了之后用户刷新页面之前直播到一半的内容就没了体验很割裂。解决方案是服务端把已生成的内容按conversationId缓存一段时间重连时带上Last-Event-ID或者conversationId服务端从断点继续推。SSE协议本身支持Last-Event-ID每条事件可以带id:字段。服务端收到重连请求时读取这个头从对应位置继续。实现上可以用一个带TTL的缓存比如Caffeine存每个conversation的已发事件列表。注意缓存时间别太长几分钟足够覆盖大多数重连场景太长会占内存。5.4 成本视角透明化会不会推高token消耗有人担心把思考过程展示出来是不是意味着要让模型输出更多内容从而增加token成本。其实不会因为阶段事件大部分是编排层产生的不是模型产生的。正在检索找到3篇文档这些是你在代码里根据实际动作发的不消耗模型token。真正消耗token的只有模型生成的内容本身而这部分无论你展不展示都要生成。唯一需要注意的是如果你为了让模型说出思考过程而特意在提示词里要求它输出推理步骤那确实会增加token。但这是产品设计选择和可观测性本身无关。我的建议是推理过程用编排层的事件来表达而不是让模型自己复述。前者可控、便宜、稳定后者又贵又不可控。6. 几个容易翻车的细节和我的处理习惯6.1 上下文丢失的三个高发位置即使按上面的方案做了实际项目里还是有三个地方特别容易丢traceId。第一个是自定义线程池如果你在业务里用了Async或者手动提交任务到线程池Reactor Context不会自动传过去需要手动捕获再传递。第二个是工具调用的回调工具执行完的回调如果不在原响应式链路里上下文就断了。第三个是异常处理分支onErrorResume里如果重新发起调用要确保上下文被重新写入。我的习惯是在这些位置统一加一个contextWrite把当前traceId显式写进去。宁可多写一行也不要事后排查时抓瞎。6.2 前端渲染的性能陷阱直播内容逐字追加如果前端用简单的字符串拼接然后innerHTML长回答会导致频繁重排页面卡顿。正确做法是用requestAnimationFrame批量更新或者用虚拟DOM框架的响应式更新。另外自动滚动到底部这个功能要小心如果用户正在往上翻看历史内容你强制滚动会让人抓狂。我的处理是只有当用户已经在底部附近时才自动滚动否则显示一个有新内容的提示按钮。6.3 敏感内容的实时过滤与直播的冲突直播意味着内容边生成边展示但敏感内容过滤往往需要看到完整上下文才能判断。这两者有天然矛盾。我的折中方案是对明显的敏感模式做实时拦截对需要上下文的判断做延迟过滤。实时拦截命中时立即中断并替换延迟过滤则在流结束后异步检查发现问题再撤回或标记。这个策略不是完美的但比要么全放要么全拦要实用。6.4 我个人的配置清单最后分享一份我在多个项目里复用的基础配置清单可以直接抄traceId生成UUID去横线16字节够用别用自增ID会泄露业务量上下文传递Reactor Context为主MDC为辅仅同步段事件批量8个token或80ms二选一先到先发阶段数量控制在5个以内每个阶段有明确用户语义缓存TTL重连缓存5分钟审计日志30天指标重点TTFT、token速率、工具调用成功率这三个优先于总耗时日志脱敏prompt和response只存hash和长度原文进加密审计库这套东西不是一次设计出来的是踩了无数坑之后慢慢收敛的。每个参数背后都有具体的翻车经历比如80ms这个值是因为试过50ms太频繁、150ms又显得卡顿最后定在中间。你在自己项目里落地时也建议先跑起来再根据实际体感调参别一上来就追求完美配置。