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 事件),而不是假装是模型逐字推理——框架没透传就是没透传,别硬凹。
五、挑战题:改参数,看看会怎样
- ⭐ 改采样率:把
management.tracing.sampling.probability从 1.0 改成 0.5,连续发 10 次请求,数一下有几次日志带 traceId——体会"采样"。答案在application.yml的sampling.probability那行。 - ⭐⭐ 加一个 span:给"ES 检索"这段手工加一个
@Observed注解,让它在 Zipkin 树里单独成段。提示:在searchCitations方法上动手,答案在源码 ChatController.java。 - ⭐⭐ 扩展事件协议:加一个
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 跨不过 Reactor | tool 事件丢失 | 改用 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 篇。系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用 |
| 2 | Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用 |
| 3 | Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON |
| 4 | Spring AI 工具调用:@Tool 让大模型自己查订单查库存 |
| 5 | Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒 |
| 6 | Spring AI 多模态:给大模型一双眼睛,图片它也能看懂 |
| 7 | Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步 |
| 8 | Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话 |
| 9 | Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定 |
| 10 | Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官 |
| 11 | Spring AI 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢 |
| 12 | Spring 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 直接跑。