ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

LangChain4j 可观测与透明化:traceId 串起全链路,SSE 直播思考过程

LangChain4j 可观测与透明化:traceId 串起全链路,SSE 直播思考过程

LangChain4j 可观测与透明化:traceId 串起全链路,SSE 直播思考过程

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

上一课我们把记忆落了 Redis、向量落了 ES,客服"不怕重启"了。可真上线第一天,客服发来一句:“刚才那次回答错了,你帮我看看。”

我盯着满屏System.out发呆——用户问了啥、后端是查了知识库、调了工具、还是直接编的?日志散在好几个线程里,我连"哪几行是同一次请求"都分不出来。

说白了,功能做出来只是"能跑",出问题能查才叫"敢上线"。这篇就把客服从黑盒刷成透明盒:给每次请求发个"快递单号" traceId,把检索、工具、模型串起来;再把"它先翻知识库、再决定要不要调工具、临了才组织回答"的全过程用 SSE 直播到前端。代码全在仓库的lesson-12/目录,clone 下来重跑,你也能在网页上看到一条回答怎么一步步冒出来。

一、核心原理:给每次请求贴个"快递单号"

先认识三个词。

MDC(映射诊断上下文):SLF4J/Logback 提供的"线程级 Map"。你往里面塞一个traceId=abc,这个线程接下来打的每一行日志,都能通过日志格式里的%X{traceId}取出来拼进去。

快递单号类比:一个包裹要经过分拣中心 → 运输 → 派送好几个站,每个站扫码都贴同一个单号。你输一个单号,所有站的记录全出来。traceId 就是这个单号——一次请求经过的每个组件打的日志,都带同一个号。

  • traceId:一整串调用的总单号,一次请求一个,全程不变。
  • spanId:其中一小段的子单号("检索 ES"是一个 span,"调工具"是另一个 span)。

光把号打进日志还不够,我们还想在一个网页上看到"这次请求花了 2 秒,检索 50ms、调模型 1.9 秒"——这就是调用链追踪。三个依赖各管一段:

依赖角色类比
micrometer-tracing-bridge-brave把 Spring 观测体系接到 Brave 实现制定"单号格式"的机构
zipkin-reporter-brave把产生的 span 用 HTTP 发给 Zipkin把各站扫码记录上传的网络
brave-context-slf4j把 traceId/spanId 塞进 MDC让每个站小票上都印上单号

Spring Boot 自动配置帮你把这三样粘好,业务代码几乎零。Zipkin 就是收 span、提供网页 UI 看"瀑布图"的服务器——哪段慢、哪段失败一眼看出。

SSE 命名事件是另一回事。上一课的/chat/stream只会推一种token帧。但前端要区分"这是思考过程"“这是工具调用”“这是正文”,SSE 原生支持命名事件:```text
event:thinking
data:{“type”:“retrieval”,“text”:“正在检索…”}

event:tool
data:{“name”:“queryOrder”,“args”:“{“orderId”:“1001”}”,“durationMs”:2"}

浏览器端用 `es.addEventListener("thinking", ...)`、`addEventListener("tool", ...)` 分别收。一条流式连接就变成一套结构化协议——思考画灰框、工具画徽章、正文画气泡。 ## 二、动手:复用 L11 容器,只新加 Zipkin > 关键:**ES(9200)、Redis(6390) 复用 L11 的容器,千万别停它们、别重起**。本课应用端口 **8088**。 **第 0 步:确认中间件在跑。** ```powershell java -version # True [bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY') # True docker ps --filter "name=es-lc-l11" --filter "name=redis-lc-l11" # 必须都是 Up

要是这俩没起,先去lesson-11目录docker compose up -d拉起来,再回来。

第 1 步:起 Zipkin(收 span 的服务器)。

cd langchain4j-journey\lesson-12 docker compose up-d curl.exe-s http://localhost:9411/zipkin/# 看到 HTML 即就绪(首次要拉镜像)

本机拉不到 Zipkin 镜像也别卡死进度:它是旁路,拉不下来只影响 Zipkin UI,日志 traceId 和 SSE 事件这两个主验收项照常工作。

第 2、3 步:编译 + 启动。

cd langchain4j-journey\lesson-12$env:JAVA_HOME="C:\Program Files\Java\jdk-17"mvn clean install-DskipTests# BUILD SUCCESSmvn spring-boot:run# 在 lesson-12 目录启动(要读 docs/company-faq.md)

第 4 步:访问。浏览器开http://localhost:8088/。命令行测 SSE(中文编码):

curl.exe-s-N"http://localhost:8088/chat/stream?sessionId=t1&message=$([uri]::EscapeDataString('订单1001什么状态'))"

三、关键代码:三件套配置 + traceId 抓取 + SSE 回调

① application.yml:让每行日志都带单号。

spring:application:name:lc-l12-cs# 拼进日志前缀,多应用排障一眼分清是谁打的zipkin:base-url:http://localhost:9411# span 上报到这里management:tracing:sampling:probability:1.0# 教学全采样;生产一般 0.1,否则量大了 Zipkin 扛不住logging:pattern:# %X{traceId:-} 从 MDC 取值,取不到用 - 填充。这行让每行日志都带快递单号。level:"%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

brave-context-slf4j会自动在请求开始时把 traceId/spanId 塞进 MDC,结束时清掉——你一行MDC.put都不用写。

② 手动加 span:以检索为例。

packagecom.langchain4j.lesson12.rag;importio.micrometer.tracing.Span;// ★是顶层类,不是 Tracer.Span(本机踩过这个坑)importio.micrometer.tracing.Tracer;importorg.springframework.stereotype.Component;@ComponentpublicclassKnowledgeBase{privatefinalTracertracer;// Spring Boot 自动装配,注入即用publicKnowledgeBase(Tracertracer){this.tracer=tracer;}publicStringsearch(Stringquestion,inttopK){// 在当前 trace 下开一个子 span:起名 rag.search、打个 topK 标签Spanspan=tracer.nextSpan().name("rag.search").tag("topK",String.valueOf(topK)).start();try(Tracer.SpanInScopews=tracer.withSpan(span)){returndoSearch(question,topK);// 这段代码里再开的 span 都挂它下面}finally{span.end();// 必须 end!不 end,这个 span 永不结束,Zipkin 里看不到}}}

注意Span是顶层类io.micrometer.tracing.Span,不是Tracer.Span——我第一次照旧教程写成Tracer.Span,编译直接报"找不到符号",卡了半天才反应过来导错了类。

③ SSE 透明化:traceId 必须在请求线程抓,回调再发。

packagecom.langchain4j.lesson12.web;importdev.langchain4j.service.TokenStream;importorg.slf4j.MDC;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.RestController;importorg.springframework.web.servlet.mvc.method.annotation.SseEmitter;@RestControllerpublicclassChatController{@GetMapping(value="/chat/stream",produces=org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE)publicSseEmitterstream(@RequestParam(defaultValue="default")StringsessionId,@RequestParamStringmessage){SseEmitteremitter=newSseEmitter(120_000L);// ★关键技巧:traceId 是 Brave 在【请求线程】上塞进 MDC 的。// 后面 onPartialResponse / onToolExecuted 跑在 HTTP 客户端的回调线程,那时 MDC 已清。// 所以必须现在(请求线程)就把 traceId 抓下来,后面 done 事件才能带上它。StringtraceId=MDC.get("traceId");TokenStreamts=customerService.stream(sessionId,message,sink);// 工具调用完:模型真调了 @Tool 方法,回调带工具名/参数/结果/耗时ts.onToolExecuted(te->sink.tool(te.request().name(),te.request().arguments(),te.result(),te.duration().toMillis()));ts.onPartialResponse(token->emitter.send(SseEmitter.event().name("token").data(token)))// 正文逐字.onCompleteResponse(complete->{emitter.send(SseEmitter.event().name("done").data(java.util.Map.of("traceId",traceId)));emitter.complete();}).onError(err->emitter.send(SseEmitter.event().name("error").data(err.getMessage()))).start();// 别忘了 start(),否则流式根本不开始returnemitter;}}

这串回调里有个本机真实踩过的坑:onToolExecuted是 LangChain4j 1.21TokenStream的内置回调,模型每真调一次工具就回调一次,ToolExecution里带工具名/参数/结果/耗时。而onPartialThinking这个钩子本课留着却不触发——原因见第四节那个"框架丢字段"的故事。

四、实测输出:日志带单号,事件全程直播

以下是 2026-10-06 00:09~00:12 本机真实运行(应用 8088,复用 L11 的 ES 9200 / Redis 6390,JDK17)。

① 日志从"裸奔"变成带单号。无请求时是空占位,一发请求立刻带上真实单号:

无请求: INFO [lc-l12-cs,,] ... Tomcat started on port 8088 有请求: INFO [lc-l12-cs,6ac3cbd1bda8c58fd512db18a85069e7,d512db18a85069e7] ...ChatController : 收到流式问答请求...

[lc-l12-cs, 6ac3cbd1..., d512db18...]= 应用名, traceId, spanId。拿这个 traceId 去日志里 grep,这次请求的所有日志就全捞出来了。

② 一条工具题的 SSE 六类事件统计:

event:thinking ×3 event:tool ×1 event:token ×61 event:citations ×1 event:done ×1

关键帧原文:

event:thinking data:{"type":"retrieval","text":"正在知识库检索相关制度/产品资料…"} event:thinking data:{"type":"retrieval","text":"命中 3 条资料(最高相似度 0.801)"} event:tool data:{"name":"queryOrder","args":"{\"orderId\": \"1001\"}","result":"订单号1001:客户 张三,...状态 已支付,收货城市 郑州。","durationMs":2} event:token data:订单 ...(共 61 个 token 帧)... event:done data:{"traceId":"6ac3cb6456d0e0e307064406d7e4f121"}

思考面板收到 3 步(检索开始→命中几条→开始生成);模型真调了queryOrder;正文 61 个 token 逐字推。六类事件齐全。

③ traceId 前后端打通——这就是可观测的最后一公里:

前端收到: event:done data:{"traceId":"6ac3cbd1bda8c58fd512db18a85069e7"} 日志里: INFO [lc-l12-cs,6ac3cbd1bda8c58fd512db18a85069e7,...] ...ChatController

网页上看到一个 traceId,拿它去日志里一搜,这次请求的全过程就全出来了。

④ Zipkin 上报(这里要实话说):本机openzipkin/zipkin镜像经代理多次拉取卡层,UI 没在交付前跑通。但应用确实在向 Zipkin 发 span——容器没起时的 WARN 就是证据:

WARN [lc-l12-cs,,] ... z.r.i.AsyncReporter ResourceAccessException: I/O error on POST "http://localhost:9411/api/v2/spans": Connection refused

看出来了吗?POST 地址/api/v2/spans已经对了,只是接收端没起。镜像拉起来后,跑几次问答再curl.exe -s "http://localhost:9411/api/v2/traces?limit=5"就能看到 trace。span 发送失败只打 WARN,不影响业务——监控是旁路,不能因为监控挂了把主流程拖死。

⑤ 复用 L11 容器但数据隔离:

$ docker exec redis-lc-l11 redis-cli --scan --pattern "lc:l12:*" lc:l12:chat:memory:mem1 ... $ curl.exe -s http://localhost:9200/lc-l12-faq/_count {"count":12}

Redis 用lc:l12:前缀、ES 用独立索引lc-l12-faq,和 L11 完全不串。

顺带说个"框架丢字段"的故事:deepseek-reasoner 流式确实逐段返回delta.reasoning_content(下划线),但 LangChain4j 1.21.0 内部那个字段的 Jackson 注解没指定 name,按驼峰reasoningContent反序列化,和下划线对不上 → 恒为 null。实测onPartialThinking回调 0 次。结论:API 支持、框架丢字段。所以本课走"自己推 Agent 步骤"的方案 B,钩子留着等框架修映射。

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

  1. ⭐玩采样率:把management.tracing.sampling.probability从1.0改成0.5,连问 4 次,观察(Zipkin 起了的话)是不是大约只有一半请求有 trace。答案就在application.yml那行——跑过你就懂"采样"为什么是生产刚需。
  2. ⭐⭐验证 traceId 跨线程会丢:故意在onToolExecuted回调里打一行MDC.get("traceId"),观察它在回调线程上是不是 null——然后想清楚为什么 Controller 非要在请求线程先抓 traceId。这是本机真实踩过的坑,答案在第三节代码注释里。

六、生产环境进阶:三个加分项

1. 采样率别学教学全量。本课probability=1.0是为了每次都能看到 trace,生产设 0.1(采样 10%),否则 QPS 上来 Zipkin 和网络都扛不住。

2. 追踪是旁路,故障别拖主流程。Zipkin 挂了应用照常跑(只打 WARN)。事件管道、指标上报这些监控组件,出问题都只能打 warn,不能反过来把业务打挂。

3. 跨线程传 traceId 要手动。TokenStream 回调跑在模型 HTTP 客户端线程,MDC 是空的。生产更优雅的做法是用ThreadPoolTaskExecutor的TaskDecorator统一装饰,或上 Micrometer Context Propagation 库。

七、面试回答模板

面试官:traceId / spanId / MDC 分别是什么?怎么把一次请求的日志串起来?

一句话:一次请求一个 traceId(总单号),每个处理环节一个 spanId(子单号),MDC 是线程级 Map,%X{traceId}把它打进日志。展开:Brave 在请求线程把 traceId 放进 MDC,日志格式[应用名,traceId,spanId]每行都带;出问题拿 traceId 一搜,本次请求全链路日志就出来了。(指向本课第一节 / lesson-12 的 application.yml)

追问:Micrometer Tracing + Brave + Zipkin 三件套各管什么?

micrometer-tracing-bridge-brave桥接到 Brave 实现,zipkin-reporter-brave用 HTTP 上报 span,brave-context-slf4j把 traceId 塞进 MDC。Spring Boot 自动配置粘好,业务几乎零代码;手动加 span 用tracer.nextSpan().name("x").tag(k,v).start()+finally{span.end()},注意Span是顶层类不是Tracer.Span。(指向本课第三节)

追问:SSE 怎么做命名事件?怎么把工具调用实时推到前端?

emitter.name("tool").data(obj)发命名帧,前端addEventListener("tool")接收;一条连接混流 thinking/tool/token/citations/done/error 多种事件。LangChain4j 1.21 的TokenStream.onToolExecuted回调带工具名/参数/结果/耗时,直接推前端画"工具徽章"。至于 deepseek-reasoner 的 reasoning_content 为什么业务代码拿不到——那是框架 Jackson 字段映射丢了,靠抓包对比 snake_case 才定位,API 有 ≠ 框架透传。(指向本课第三、四节)

八、总结表

坑现象解法
日志没 traceId一次请求的日志散在各处串不起来logging.pattern.level加%X{traceId:-}+ 引brave-context-slf4j
Tracer.Span找不到符号编译报错"找不到符号 Span"import io.micrometer.tracing.Span;(顶层类)
SSE 收不到 thinking/tool前端用 onmessage 啥也没有命名事件必须addEventListener("事件名")
回调线程 MDC 丢 traceIddone 事件 traceId 永远 null请求线程先MDC.get("traceId")抓下来
Zipkin 镜像拉不下来UI 打不开别卡死进度,WARN 证明管道已接好,业务不受影响
ES/Redis 和 L11 串数据记忆/知识混在一起Redis key 前缀改lc:l12:、ES 索引改lc-l12-faq
采样率设 1.0生产量大了 Zipkin 扛不住教学 1.0,生产 0.1
reasoning_content 不透传思考面板空走方案 B 自己推步骤,钩子留着等框架修

九、关于这个系列

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

👉LangChain4j 实战精通营:https://gitee.com/j67mk2/langchain4j-journey

  • 本文对应源码位置:lesson-12/(在 L11 上升级,核心看resources/application.yml的 tracing 配置、web/ChatController.java的 SSE 六类事件与 traceId 抓取、rag/KnowledgeBase.java手动 span;ES/Redis 复用 L11 容器,只新增 Zipkin 容器)

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

篇主题
1LangChain4j 第一个对话程序:手写 LLM 调用链路,看清模型怎么"开口"
2LangChain4j 提示词工程:模板 + few-shot + 温度,提示词从写死到复用
3LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析
4LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比
5LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒
6LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测
7LangChain4j 向量检索:文本怎么变成可搜索的坐标
8LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试
9LangChain4j Agent:多步推理自主调用,放手让"实习生"自己安排工作
10LangChain4j 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,验收 5 项全过
11LangChain4j 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢
12LangChain4j 可观测与透明化:traceId 串起全链路,SSE 直播思考过程

下一篇预告:《LangChain4j 企业级收官:事件总线 + Redis Pub/Sub + 断线补拉,客服终于敢上线》——这一课的 SSE 事件还是业务层直接拿 SseEmitter 发的,单实例爽、多实例全废:浏览器连 A、请求打 B,事件根本推不回去。收官课用 Spring 事件总线 + Redis Pub/Sub 重做事件管道,加限流/重试/熔断/指标,再支持断线重连补拉,把客服真正推到"敢上线"。

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


标签建议:LangChain4j、链路追踪、SSE
摘要建议(≤256 字):线上 AI 问答慢了错了怎么定位?本文给每次请求发 traceId:Micrometer Tracing + Brave + Zipkin 三件套把检索/工具/模型串成调用链,日志每行带 [应用,traceId,spanId];SSE 从 3 种事件扩到 6 种命名事件(thinking/tool/token/citations/done/error),把工具调用和思考过程实时直播到前端。还复盘了 deepseek-reasoner 的 reasoning_content 被框架丢字段的定位过程。附完整命令与面试模板,源码在 gitee lesson-12 可 clone 重跑。

返回列表