Spring AI 可观测与透明化:traceId 串起全链路,思考过程实时直播给用户

0 阅读13分钟

Spring AI 可观测与透明化:traceId 串起全链路,思考过程实时直播给用户

作者:鱼宵 | Spring AI 实战精通营 · 第 12 篇

上次我们把客服的数据落了中间件(记忆落 Redis、向量落 ES),系统"能重启、不丢数据"了。结果刚松一口气,测试同学跑过来问:"用户说刚才问了个问题等了 5 秒才出字,到底是查知识库慢、调工具慢、还是大模型慢?"我翻着控制台几百行日志,一脸茫然——日志倒是不少,但哪条日志属于哪次请求?完全分不清。

更尴尬的是前端:用户盯着空白屏等 5 秒,不知道系统在干嘛,以为卡死了,直接刷新页面。这一课就解决这两件事:可观测(每次请求挂个快递单号,日志全串起来)和透明化(把检索了什么、调了哪个工具实时推给前端)。代码全在仓库 lesson-12/ 目录里,clone 下来跑一遍,你会发现"全链路追踪"听起来玄乎,其实就加了三个依赖。

一、核心原理:日志的"快递单号"和 SSE 的"消息类型标签"

1. MDC 与 traceId:日志怎么自动带上单号

**MDC(Mapped Diagnostic Context)**是 SLF4J 提供的一个"线程级 Map"。你往里塞 traceId=abc123,之后这个线程打的每一行日志,只要日志模板里写了 %X{traceId},就自动带上这个值。

类比:你寄一堆快递,每个包裹贴一个单号。分拣中心(日志框架)按单号把同一个包裹的所有流转记录归到一起。traceId 就是这次请求的单号。

  • traceId:一次请求全局唯一的单号,从进 Tomcat 开始生成,一路传下去。
  • spanId:请求里某一个"动作"的编号(查 ES 是一个 span、调大模型是另一个),父子 span 组成一棵树。

为什么需要?一个请求跨线程(Tomcat 线程 → reactor 工作线程)、跨组件(应用 → ES → 大模型)后,靠 traceId 把这些日志重新拼回"这是同一次问答"。

2. Zipkin:链路追踪的"物流轨迹查询页"

Zipkin 收集各路上报的 span,按 traceId 聚合成"一棵调用树"。浏览器打开它,选最近一次问答,能看到:Tomcat 进来 → ChatClient 调大模型(花了 1.8 秒)→ 工具调用(1ms)各段耗时。

Spring Boot 3 加了 actuator + brave bridge 后,Tomcat 收到 HTTP 请求会自动开 span;Spring AI 的 OpenAiChatModel 也被自动埋点。你不用手写一行埋点代码,链路树就自动长出来了。

3. SSE 命名事件:一次推送多种"消息类型"

L11 的 /chat/stream 只推一种消息(模型正文片段)。本课升级:每帧带一个事件名:

event:thinking
data:正在检索企业知识库…

event:tool
data:{"tool":"queryOrder","args":"orderId=2001","result":"客户张三…","costMs":1}

event:token
data:订单

event:done
data:{"traceId":"6ac3cec8...","steps":[…]}

前端用 addEventListener('thinking'/'tool'/'token'/'done') 分别处理——思考步骤进灰面板、工具调用渲染成徽章、token 拼成打字机。就像微信群里系统消息、红包消息、文本消息长得不一样——事件名就是"消息类型标签"。

4. 跨线程怎么找到"当前请求"的记录器?

这是本课最硬的设计点。@Tool 方法不是在 Tomcat 线程执行的,是在 reactor 的 boundedElastic 线程执行。普通 ThreadLocal 跨不过 Reactor 线程切换(我们实测踩了这个坑)。但 traceId 是被 Micrometer Tracing 自动跨线程传递的(日志可证)。所以我们按 traceId 做注册表:Controller 注册 traceId -> recorder,@Tool 方法用 tracer.currentSpan() 拿当前 traceId 反查记录器。

面试金句:跨线程传请求级上下文别迷信 ThreadLocal;选一个"已被框架保证透传的标识"做注册表 key,是更稳的工程做法。

二、动手:复用中间件 + 起 Zipkin + 跑起来

环境:Windows + JDK 17 + Maven 3.9+。ES(9202)和 Redis(6379)全部复用 L11 的容器,不要新起也不要停它们。

第 1 步:复用 L11 中间件 + 起 Zipkin。

# ES 没起就先起(复用 L11 的 compose)
cd spring-ai-journey\lesson-11
docker compose up -d
curl.exe -s http://localhost:9202          # 能看到 ES 版本 JSON

# 起 Zipkin(本课唯一新中间件,端口 9412)
cd spring-ai-journey\lesson-12
docker compose up -d
curl.exe -s http://localhost:9412/health   # 返回 OK 即健康

第 2 步:编译 + 启动。

cd spring-ai-journey\lesson-12
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"
mvn clean install -DskipTests
java "-Dfile.encoding=UTF-8" "-jar" "target\lesson-12-1.0.0.jar"

看到 Tomcat started on port 8104 即成功。

第 3 步:探测框架 + 验收。

# ① 框架探测:reasoning_content 是否透传
curl.exe -s http://localhost:8104/probe/reasoning
# ② 工具题(应看到 event:tool)
curl.exe -sN "http://localhost:8104/chat/stream?sessionId=s1&message=订单2001现在什么状态"
# ③ 浏览器打开 http://localhost:8104 看 v2 前端

三、关键代码:三行配置 + 一个注册表 + 一个命名事件流

第一段:application.yml——可观测三行配置。

logging:
  pattern:
    level: '%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]'
    # 日志模板:在级别后拼 [app,traceId,spanId];没有就打 -

management:
  tracing:
    sampling:
      probability: 1.0          # 教学 100% 采样;生产一般 0.1~0.5
  zipkin:
    tracing:
      endpoint: http://localhost:9412/api/v2/spans   # 本课 zipkin-sa

注意 management.zipkin.tracing.endpoint 是 Boot 3.x 的属性名,不是老的 spring.zipkin.base-url(那是 Sleuth 时代的写法,别照抄老文档)。

第二段:pom.xml——三个新依赖。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>   <!-- ObservationRegistry 入口 -->
</dependency>
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-tracing-bridge-brave</artifactId>  <!-- 观察→span -->
</dependency>
<dependency>
  <groupId>io.zipkin.reporter2</groupId>
  <artifactId>zipkin-reporter-brave</artifactId>          <!-- span→Zipkin -->
</dependency>

反查结论:规范里写的 spring-ai-observability 在 1.0.9 BOM 里不存在。openai starter 已传递引入 chat-observation 模块(ChatModel 自动埋点已就位),我们只需补"桥接+上报"三件套。先查 BOM/本地 jar 再写 pom,别照抄文档。

第三段:StepRecorderRegistry.java——按 traceId 反查记录器。

package com.springai.lesson12.support;

import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * StepRecorder 注册表:按 traceId 索引"当前请求的记录器"。
 *
 * 为什么不用 ThreadLocal?—— @Tool 方法执行在 reactor boundedElastic 线程,
 * 与 Tomcat 请求线程不是同一个,ThreadLocal 跨不过去。
 * 但 traceId 被 Micrometer Tracing 自动跨线程传递(日志可证),按它做 key 反查。
 */
public final class StepRecorderRegistry {

    private static final Map<String, StepRecorder> ACTIVE = new ConcurrentHashMap<>();

    public static void register(String traceId, StepRecorder recorder) {
        if (traceId != null) ACTIVE.put(traceId, recorder);
    }

    public static StepRecorder current(Tracer tracer) {
        if (tracer == null) return null;
        Span span = tracer.currentSpan();
        if (span == null) return null;
        return ACTIVE.get(span.context().traceId());
    }

    public static void unregister(String traceId) {
        if (traceId != null) ACTIVE.remove(traceId);  // doFinally 注销防内存泄漏
    }
}

ConcurrentHashMap 并发请求各占一条,天然隔离;doFinally 注销防内存泄漏。

第四段:ChatController.stream()——命名事件流。

package com.springai.lesson12.web;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.springai.lesson12.support.*;
import com.springai.lesson12.tool.CustomerTools;
import io.micrometer.tracing.Tracer;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import java.util.*;

@RestController
public class ChatController {

    private final ChatClient chatClient;
    private final CustomerTools customerTools;
    private final VectorStore vectorStore;
    private final Tracer tracer;
    private final ObjectMapper objectMapper;

    public ChatController(ChatClient customerChatClient, CustomerTools customerTools,
                          VectorStore vectorStore, Tracer tracer, ObjectMapper objectMapper) {
        this.chatClient = customerChatClient;
        this.customerTools = customerTools;
        this.vectorStore = vectorStore;
        this.tracer = tracer;
        this.objectMapper = objectMapper;
    }

    @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> stream(@RequestParam String sessionId,
                                                @RequestParam String message) {
        String traceId = tracer.currentSpan().context().traceId();

        return Flux.create(sink -> {
            StepRecorder recorder = new StepRecorder(sessionId, sink, traceId);
            StepRecorderRegistry.register(traceId, recorder);   // 注册进 traceId 注册表

            recorder.thinking("收到问题,正在结合多轮记忆与企业知识库检索…");  // thinking 事件

            chatClient.prompt()
                    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
                    .tools(customerTools)
                    .user(message)
                    .stream().chatResponse()
                    .doOnNext(resp -> sink.next(sse("token", resp.getResult().getOutput().getText())))
                    .doOnComplete(() -> {
                        sink.next(sse("citations", "[]"));  // 引用依据
                        sink.next(sse("done", "{\"traceId\":\"" + traceId + "\"}"));
                        sink.complete();
                    })
                    .doFinally(s -> StepRecorderRegistry.unregister(traceId))  // 注销
                    .subscribe();
        }, FluxSink.OverflowStrategy.BUFFER);
    }

    private ServerSentEvent<String> sse(String event, String data) {
        return ServerSentEvent.<String>builder(data).event(event).build();
    }
}

Flux.create 给一个线程安全的 sink,任何线程(包括工具线程)都能 next() 一条事件。tool 事件不在这个文件产生——在 CustomerTools 里通过 StepRecorderRegistry.current(tracer) 反查记录器后推。

四、实测输出:traceId 跨线程传递的铁证

以下是 2026-10-06 00:22 本机真实运行(DeepSeek,端口 8104,ES 容器 es-sa-l11:9202,Redis 6379)。

验收① 启动日志 + 每行带 traceId:

2026-10-06T00:22:09.245  INFO [lesson-12,,] Tomcat started on port 8104
2026-10-06T00:22:10.470  INFO [lesson-12,,] [知识库] 加载完成:共入库 12 条 FAQ
2026-10-06T00:22:32.940  INFO [lesson-12,6ac3cec81cbfc47349f3d5e8c6c5bdfb,49f3d5e8c6c5bdfb] --- [nio-8104-exec-1] ChatController : 收到流式提问
2026-10-06T00:22:34.781  INFO [lesson-12,6ac3cec81cbfc47349f3d5e8c6c5bdfb,4813aed172d3b8b4] --- [oundedElastic-2] CustomerTools : ===== 工具被调用 → queryOrder(orderId=2001) =====

看出关键证据了吗?Tomcat 线程 nio-8104-exec-1 与工具执行线程 boundedElastic-2 的 traceId 完全相同(6ac3cec81cbfc47349f3d5e8c6c5bdfb),spanId 不同(49f3d5e8… vs 4813aed1…,父子 span)——证明 traceId 跨线程自动传递成功。

验收② SSE 事件序列完整(工具题):

event:thinking
data:收到问题,正在结合多轮记忆与企业知识库检索…

event:tool
data:{"tool":"queryOrder","args":"orderId=2001","result":"客户张三,订单已支付,星辰CRM专业版 5 账号,1194 元/月,收货地郑州","costMs":1}

event:token
data:订单 …(逐字拼接:订单2001:客户为张三,状态为「已支付」…收货地郑州。)

event:thinking
data:回答已生成,整理引用依据…

event:citations
data:[{"snippet":"### 4. 报销流程是什么?…","distance":0.488}, …]

event:done
data:{"traceId":"6ac3cec81cbfc47349f3d5e8c6c5bdfb","steps":[
      {"type":"thinking","text":"收到问题…","elapsedMs":0},
      {"type":"tool","tool":"queryOrder","args":"orderId=2001","costMs":1,"elapsedMs":1820},
      {"type":"thinking","text":"回答已生成…","elapsedMs":2656}]}

事件序列 thinking → tool → token* → thinking → citations → done 完整。done 事件带 traceId 和 3 步汇总。

验收③ 框架探测结论:reasoning_content 拿不到。

$ curl.exe -s http://localhost:8104/probe/reasoning
API 层 deepseek-reasoner 流式确实逐段返回推理内容(实测 122 个数据块),
但 AssistantMessage 没有 reasoningContent 字段(javap + 反射双重确认),
OpenAI 模块的 ChatCompletionMessage record 也没有对应 accessor——
结论:API 支持、框架丢字段。

这就是为什么前端"思考过程面板"用方案 B(Agent 步骤流 thinking/tool 事件),而不是假装是模型逐字推理——框架没透传就是没透传,别硬凹。

五、挑战题:改参数,看看会怎样

  1. ⭐ 改采样率:把 management.tracing.sampling.probability 从 1.0 改成 0.5,连续发 10 次请求,数一下有几次日志带 traceId——体会"采样"。答案在 application.yml 的 sampling.probability 那行。
  2. ⭐⭐ 加一个 span:给"ES 检索"这段手工加一个 @Observed 注解,让它在 Zipkin 树里单独成段。提示:在 searchCitations 方法上动手,答案在源码 ChatController.java。
  3. ⭐⭐ 扩展事件协议:加一个 citation-loading 事件(检索 ES 前推一条"正在查知识库…"),前端给它做个转圈 loading 动画。答案在 StepRecorder.java 的事件类型枚举里。

六、生产环境进阶:三个踩坑,每个都值钱

1. 【最硬】ThreadLocal 跨不过 Reactor 线程,tool 事件丢失。 最初设计用 ThreadLocal 存"当前请求的 StepRecorder",实测工具题收不到 tool 事件。jstack 发现 @Tool 执行在 boundedElastic-2 线程,不是 Tomcat 线程,ThreadLocal 是 null。解法:改用"traceId 注册表"——traceId 被 Micrometer Tracing 自动跨线程传递。

2. 规范里的 spring-ai-observability 坐标不存在。 1.0.9 BOM 里没有这个 artifact。真实要加的是 actuator + brave bridge + zipkin reporter 三件套。教训:先查 BOM 再写 pom,别照抄文档。

3. spring.zipkin.base-url 是老属性名。 那是 Spring Cloud Sleuth 时代的写法。Boot 3.x + Micrometer Tracing 对应的是 management.zipkin.tracing.endpoint。

4. ES/Redis 复用 L11 容器。 本课不另起 ES/Redis。ES 索引名用 springai-lesson12-faq(与 L11 隔开);Redis key 前缀 springai:lesson12:chatmemory: 与 L11 隔离,绝不清库。

七、面试回答模板

面试官:traceId 是怎么生成、怎么自动写进每一行日志的?

一句话:Micrometer Tracing + Brave 在 HTTP 入口开 span,把 traceId 写进 MDC;日志模板 %X{traceId} 自动带上;Reactor 线程切换时上下文桥接保证 traceId 不断。展开:加三个依赖(actuator + brave bridge + zipkin reporter),yml 配日志模板和采样率,不用手写埋点。(指向本课第一、三节 / lesson-12 的 application.yml)

追问:Spring Boot 3 里上报 Zipkin 到底加哪几个依赖?

三个:spring-boot-starter-actuator(ObservationRegistry 入口)、micrometer-tracing-bridge-brave(观察→span)、zipkin-reporter-brave(span→Zipkin)。规范里写的 spring-ai-observability 在 1.0.9 BOM 里不存在,别照抄。(指向本课第三节 pom.xml)

追问:SSE 怎么发命名事件?前端怎么按事件名分别处理?

服务端用 ServerSentEvent.builder(data).event("tool") 构造;前端 addEventListener('tool', handler) 按事件名分别渲染。本课事件协议 6 种:thinking/tool/token/citations/done/error。(指向本课第一节)

追问:Spring AI 1.0.9 能拿到 DeepSeek 的 reasoning_content 吗?

拿不到。API 层 deepseek-reasoner 流式确实返回推理内容(实测 122 个数据块),但 AssistantMessage 没有 reasoningContent 字段(javap + 反射双重确认)。这是"API 支持、框架丢字段"。展示思考过程要用"Agent 步骤流"(thinking/tool 事件),不假装是模型逐字推理。(指向本课第四节验收③)

八、总结表

坑现象解法
日志分不清哪条属于哪次请求几百行日志看不出因果加 MDC + traceId,日志模板带 %X{traceId}
ThreadLocal 跨不过 Reactortool 事件丢失改用 traceId 注册表(traceId 被框架自动透传)
spring-ai-observability 找不到坐标pom 加不上依赖反查 BOM,真实三件套是 actuator+brave+zipkin reporter
spring.zipkin.base-url 不生效span 发不出去Boot 3 用 management.zipkin.tracing.endpoint
reasoning_content 拿不到前端思考面板空白框架未透传,用 Agent 步骤流方案 B 展示
ES/Redis 重复起端口冲突复用 L11 容器,索引名/key 前缀按课号隔离
model.onnx 假文件启动卡死沿用 L11 解法,指 file:./models/model.onnx

九、关于这个系列

本文是「Java 后端实战精通营」系列第 12 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。

👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…

  • 本文对应源码位置:lesson-12/(StepRecorderRegistry traceId 注册表 + Flux 命名事件流 + 可观测三件套配置)

主线 10 篇 + 生产增强 3 篇。系列文章一览(按发布顺序):

篇主题
1Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
2Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
3Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON
4Spring AI 工具调用:@Tool 让大模型自己查订单查库存
5Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒
6Spring AI 多模态:给大模型一双眼睛,图片它也能看懂
7Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步
8Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话
9Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定
10Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官
11Spring AI 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢
12Spring AI 可观测与透明化:traceId 串起全链路,思考过程实时直播给用户

下一篇预告:《Spring AI 企业级收官:事件总线 + Redis Pub/Sub + 断线补拉,客服终于敢上线》——本课的事件管道是自研的 traceId 注册表,生产采样率一低就丢事件、多实例部署更顶不住。下一课把它全推倒换成事件总线 + Redis Pub/Sub + 断线补拉 + 限流熔断,客服终于敢上线。

跑完有任何报错,把终端输出发评论区,一起排查。


标签建议:SpringAI、链路追踪、SSE 摘要建议(≤256 字):线上一次问答慢了/答错了,怎么知道卡在哪?本文给每次请求挂 traceId(MDC+Micrometer Tracing+Zipkin 三件套),把 SSE 从逐字推字符串升级为 6 种命名事件(thinking/tool/token/citations/done/error),并实测验证 DeepSeek reasoning_content 在框架层拿不到。附 ThreadLocal 跨线程丢失、spring-ai-observability 坐标不存在等 4 个踩坑,源码在 gitee lesson-12 可 clone 直接跑。