全链路可观测:用 OpenTelemetry + Jaeger 让每次 AI 对话都可追踪可复盘|AgentX 专栏⑦
本文是 AgentX 技术专栏 第七篇。基于真实项目源码(
AgentService/AgentxModelListener/AgentContext/AgentThreadContext/TracingFilterConfig/SpanFilteringConfig),从技术简介到设计思路,从核心代码到生产踩坑,循序渐进拆解 AgentX 如何用 OpenTelemetry + Jaeger 把每一次 LLM 对话都钉在时间轴上------让 Java 程序员一次性看懂"AI 黑盒"是怎么变白的。
本文速览:
- 没有可观测性的 Agent 长什么样?一段日志里看不出来的"消失的 50 秒"
- Agent 可观测性的三件套(Metrics/Logs/Traces)和传统应用有什么不一样
- AgentX 的四条核心设计原则:三层埋点、业务对象带 TraceId、SSE 复用 HTTP Span、三层过滤运维噪音
- 真实代码:从
application.yml到AgentxModelListener,每一层埋点的"为什么" - 采样率怎么配?开发 100% / 生产 1% 背后的成本权衡
- 三个让监控失真的实战大坑:TraceId 跨线程丢失、SSE Span 提早关闭、health 刷爆 Jaeger
文章约 40 KB,每一节为下一节做铺垫------建议从头读。读完文末有完整代码包(含 Docker Compose Jaeger)获取方式。
一、先抛个问题:没有可观测性的 Agent 长什么样
想象一个常见的线上故障:
「报告:今天上午用户反馈"问个问题等了 50 秒还没结果",请定位」
如果你的 Agent 系统只有普通业务日志,你会看到这样一段:
log
14:30:21 INFO AgentService - 接到请求 convId=conv-123
14:30:21 INFO AgentWorkflow - LLM 开始思考
14:31:09 INFO AgentWorkflow - LLM 思考完成
14:31:09 INFO AgentService - 响应已返回 convId=conv-123
你怎么知道这 48 秒里发生了什么?
- LLM 一次思考?两次思考?
- 中间调了几个工具?哪个慢?
- 工具内部调外部 API 又花了多久?
- RAG 检索花了多久?
- 是 CPU 推理慢,还是网络抖动?
全部都是黑盒。 唯一能做的就是去服务器看监控指标------但 CPU、内存、网络都正常,最后只能回复"未发现明显异常",让用户重试。
这就是没有可观测性的 Agent 系统的真实样子:有日志,但日志解释不了任何复杂问题。
而加上完整的 OpenTelemetry + Jaeger 追踪后,同一个请求长这样:
[traceId: c4f9a2e8b1d3] 总耗时 48s
│
├─ http.server.requests 48.2s (HTTP Span)
│ ├─ agent_process_sync 48.0s (业务 Span)
│ │ ├─ workflow.retrieval 1.2s (RAG 检索)
│ │ ├─ workflow.agent 18s (LLM 第一次思考) ← 慢点 1
│ │ ├─ workflow.action 15s (工具调用)
│ │ │ ├─ tool:getWeather 14.5s ← 慢点 2:天气 API 抖动
│ │ │ └─ tool:getTime 12ms
│ │ ├─ workflow.agent 12s (LLM 第二次思考) ← 慢点 3
│ │ └─ workflow.respond 1.8s
哪一段慢、慢在哪、为什么慢,一眼可见。
这种"把 AI 黑盒变成时间轴"的能力就是本文的主题------AgentX 怎么用 OpenTelemetry + Jaeger + Micrometer Tracing 这套组合做到的。
二、技术简介:Agent 可观测性的三件套
2.1 可观测性的三大支柱
业界对"可观测性"的标准定义是三件套:
| 支柱 | 解决什么问题 | 典型工具 |
|---|---|---|
| Metrics(指标) | "QPS 是多少?错误率多少?" | Prometheus + Grafana |
| Logs(日志) | "刚才那一笔出了什么错?" | ELK / Loki |
| Traces(链路) | "这次慢在哪一段?" | Jaeger / Zipkin / Tempo |
普通 Web 应用三件套都重要但相对均衡。Agent 系统的特殊性在于:Traces 的价值远超另外两者------因为 Agent 调用图谱是动态的,单次请求可能有几十个嵌套 Span,靠 Metrics 和 Logs 根本拼不出全貌。
2.2 Agent 可观测性的独特痛点
| 维度 | 传统 Web | AI Agent | 难度跃升 |
|---|---|---|---|
| 单次请求耗时 | < 500ms,可预期 | 5~60s,不可预期 | 监控窗口要拉长 |
| 调用层次 | 固定(Controller → Service → DAO) | 动态(LLM 决定调几次工具) | 调用图谱形态不定 |
| 线程模型 | 平台线程一对一 | 虚拟线程 + CompletableFuture 异步 | TraceId 易丢 |
| 失败模式 | HTTP 500 / 数据库异常 | 模型超时 / Tool 调用失败 / Token 超限 | 错误分类更细 |
| 中间状态 | 几乎没有 | SSE 流式输出每个 token 都是状态 | Span 时长怎么算? |
2.3 技术选型:Spring Boot 3 时代的标准答案
Java 生态里做分布式追踪有多个选项:
| 方案 | 状态 | AgentX 评估 |
|---|---|---|
| Spring Sleuth | ❌ 已废弃(Spring 官方 deprecated) | 不考虑 |
| Brave / Zipkin | ⚠️ 老牌 | 生态偏 OAuth 时代,新 Agent 场景集成成本高 |
| 直接 OpenTelemetry API | ⚠️ 功能完整但与 Spring Boot 解耦 | 手动织入复杂 |
| Micrometer Observation + OTel Bridge | ✅ Spring Boot 3 原生 | AgentX 选择 |
Micrometer Observation 是 Spring Boot 3 时代的"统一可观测性 API" ------你写 Observation.observe(() -> ...) 这种业务无关的代码,底层既可以走 OpenTelemetry 上送 Jaeger,也可以走 Brave 上送 Zipkin。Spring Boot 帮你做好了所有 Bridge 配置。
2.4 AgentX 全链路一览
┌────────────────────────────────────────────────────────────────┐
│ ① 前端 / 客户端 │
│ ↓ HTTP Request │
├────────────────────────────────────────────────────────────────┤
│ ② Spring MVC HTTP Span(自动埋点) │
│ ↓ Servlet 拦截 → http.server.requests │
├────────────────────────────────────────────────────────────────┤
│ ③ 业务 Observation(手动包裹) │
│ ↓ AgentService.process() → "agent_process_sync" │
├────────────────────────────────────────────────────────────────┤
│ ④ AgentxModelListener(事件式埋点) │
│ ↓ 监听 ChatModel onResponse / onError │
├────────────────────────────────────────────────────────────────┤
│ ⑤ OpenTelemetry SDK + OTLP gRPC │
│ ↓ 4317 端口 │
├────────────────────────────────────────────────────────────────┤
│ ⑥ Jaeger Collector / UI(16686 端口可视化) │
└────────────────────────────────────────────────────────────────┘
贯穿全程:日志 MDC 自动注入 TraceId → 控制台日志带 traceId 标签
采样过滤:ObservationPredicate + SpanFilteringConfig
三、设计思路:四条让 AI 系统真正可观测的核心原则
原则一:三层埋点,各管不同尺度
可观测性从来不是只埋一种点能解决的。AgentX 把埋点分成三层,每层职责不重叠:
┌──────────────────────────────────────────────────────────┐
│ ① HTTP Span(自动) │
│ ─ 覆盖范围:HTTP 进入 → 响应结束 │
│ ─ Span 生命周期 = 请求全程(含 SSE 推流时长) │
│ ─ 谁创建:Spring Boot MVC 自动 │
├──────────────────────────────────────────────────────────┤
│ ② 业务 Observation(手动) │
│ ─ 覆盖范围:核心业务逻辑(process / processStream) │
│ ─ 自动嵌套在 HTTP Span 下,形成子 Span │
│ ─ 谁创建:AgentService 手动包裹 │
├──────────────────────────────────────────────────────────┤
│ ③ ChatModelListener(事件式) │
│ ─ 覆盖范围:每次 LLM 调用 + 工具决策 │
│ ─ 谁创建:LangChain4j 在 LLM 调用前后自动触发 │
│ ─ 业务零侵入 │
└──────────────────────────────────────────────────────────┘
为什么不能只埋一层?
| 只埋一层 | 问题 |
|---|---|
| 只埋 HTTP Span | 业务内部什么也看不到,所有问题都是"50 秒的黑盒" |
| 只埋业务 Observation | LLM 工具决策埋不到,每个工具都要写 span.tag(...) 污染严重 |
| 只埋 ChatModelListener | 只看得见 LLM,看不见 HTTP 入口和业务编排 |
三层各管一段,加起来才是完整画面。
原则二:业务对象携带 TraceId,跨线程不丢
Java 21 虚拟线程 + CompletableFuture.supplyAsync 在 Agent 场景几乎是标配------LLM 推理是 IO 密集型,虚拟线程能轻松撑高并发。但这也带来一个经典坑:
Micrometer Tracing 的 Span 上下文存在 ThreadLocal 里。supplyAsync 把任务交给另一个虚拟线程,新线程的 ThreadLocal 是空的,tracer.currentSpan() 拿到的就是 Span.NOOP,TraceId 全 0。
后果:Jaeger 里子 Span 跟主 Span 关联不起来,整个调用链断裂。
AgentX 的解法是业务对象携带 TraceId:
java
// 主线程(Observation 已激活)
String realTraceId = Optional.ofNullable(tracer.currentSpan())
.map(Span::context)
.map(TraceContext::traceId)
.orElse(null);
AgentContext ctx = AgentContext.current(convId, realTraceId);
// 跨线程后:
String tid = ctx.getTraceId(); // 永不丢失
核心思想:把 TraceId 从"上下文存储"提取成"业务字段"。一旦它是字段,无论后续切多少次线程、序列化几次、塞进哪个对象里,永远不会丢。
附带好处:响应 metadata 里能直接返回 TraceId 给前端------用户报问题时附上这个 ID,运维一键定位 Jaeger 记录。
原则三:SSE 流式场景复用 HTTP Span
普通同步对话可以用 observation.observe(() -> ...) 一气呵成。但 SSE 流式有个隐藏陷阱:
1. 客户端 HTTP 连接 → Spring 创建 HTTP Span(开始计时)
2. processStream() 注册 SseEmitter 回调 → ~800ms 返回
3. 真正的 LLM 推理在虚拟线程里跑(30~60s)
4. 推理完成 → emitter.complete() → HTTP Span 关闭
如果用 Observation.observe() 包裹 processStream():
| 真实时间线 | Observation 行为 |
|---|---|
| T+0ms | Observation 开始 |
| T+800ms | processStream 注册完回调 return |
| T+800ms ~ T+40s | AI 异步推理中 |
| T+40s | 推理完成 |
监控结果:Jaeger 显示业务 Span 只跑了 800ms,真实的 40 秒推理时间完全丢失。
AgentX 的解法 :SSE 场景不新建 Observation,直接复用 Spring MVC 的 HTTP Span。HTTP Span 的生命周期天然等于 SSE 连接保持时间 (emitter.complete() 时关闭),在它身上直接打业务 Tag 就好。
java
Span httpSpan = tracer.currentSpan();
if (httpSpan != null) {
httpSpan.tag("agentx.conv_id", convId);
httpSpan.tag("agentx.agent_type", agentType);
}
processStream(...); // emitter.complete() 时 HTTP Span 自动关闭
这条原则反过来推论:任何"先返回再异步工作"的场景,都不能用 Observation.observe() 直接包。要么手动控制 Span 生命周期,要么找一个生命周期匹配的现成 Span 复用。
原则四:三层防御过滤运维噪音
Jaeger 不是免费的。Span 数量上去之后,存储/检索成本飙升。一个真实场景:
- K8s liveness 探针每 15 秒查
/api/v1/agents/health - Prometheus 每 30 秒抓
/actuator/prometheus - Swagger UI 静态资源加载
这些"运维流量"一天下来可能是真实业务流量的 100 倍。Jaeger 里全是运维 Span,真正的业务对话被淹没。
AgentX 用三层防御解决:
请求进入 → ① ObservationPredicate ─┐
│ 命中黑名单
▼
【不创建 Observation】
│
↓ 漏网 Span(Tomcat 内部某些直接走 OTel SDK)
────────────────────────────│────────────────────
↓
② SpanFilteringConfig.FilteringSpanExporter
│ 检查 url.path / http.target 等属性
▼
【导出前丢弃】
│
↓ 仍然漏掉的极少数 Span
────────────────────────────│────────────────────
↓
③ application.yml 不暴露不必要的 Actuator 端点
→ 减少被探针扫描的目标
原则 :能源头掐掉就别在末端过滤,能在末端兜底就别留漏网之鱼。
四条原则讲完,下面看具体怎么落地。
四、代码解析:从配置到核心类的完整链路
4.1 起点:application.yml 配置
可观测性的一切从配置开始。AgentX 的相关配置是这样的:
yaml
management:
tracing:
enabled: true
sampling:
probability: 1.0 # 开发:100% 采样确保每次请求都能看到
# 生产:改 0.01(1%)降低存储和性能开销
otlp:
tracing:
endpoint: http://${MONITOR_SERVER_IP:127.0.0.1}:4317
transport: grpc # gRPC 比 HTTP 快得多,强烈推荐
observations:
key-values:
application: agentx # 全局 Tag,Grafana 按应用筛选
metrics:
distribution:
percentiles-histogram:
http.server.requests: true # 统计 P50/P95/P99 响应时间分布
logging:
pattern:
# 💡 关键:日志里打印 TraceId + SpanId
# 控制台复制 TraceId 直接去 Jaeger 复盘那一场对话
console: "%d{yyyy-MM-dd HH:mm:ss} %5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
level:
io.opentelemetry: WARN # 屏蔽 OTel 内部 DEBUG 噪声
为什么采样率开发 100% / 生产 1%?
| 环境 | 采样率 | 理由 |
|---|---|---|
| 开发 | 1.0(100%) | 每个请求都要看到,方便排查 |
| 测试 | 0.5 ~ 1.0 | 性能压测时降到 0.5 减少干扰 |
| 生产 | 0.01(1%) | 100% 采样会让 Jaeger 存储爆炸;1% 样本已足够发现大多数问题 |
紧急故障时可以临时通过 Actuator 把采样率拉到 100%,问题排查完降回去。
%X{traceId:-} 这一行的妙用 :从 MDC(Mapped Diagnostic Context)里取 traceId。Spring Boot 3 + Micrometer Tracing 自动把当前 Span 的 traceId 注入到 MDC,所以日志里每一行都自带标签:
14:30:21 INFO [agentx,c4f9a2e8b1d3,a1b2c3d4] AgentService - 接待客户 conv-xxx
14:30:31 INFO [agentx,c4f9a2e8b1d3,a1b2c3d4] AgentWorkflow - LLM 思考完成
14:30:32 INFO [agentx,c4f9a2e8b1d3,e5f6g7h8] WeatherTool - 查询北京天气
复制 c4f9a2e8b1d3 粘进 Jaeger,整场对话调用链立刻出来。日志 + Trace 双向联动,是分布式系统排查的核心姿势。
4.2 业务上下文:AgentContext + AgentThreadContext
可观测性的"主语"是业务对象。AgentX 用 AgentContext 装一次请求的所有元信息:
java
@Data
@Builder
public class AgentContext {
/** 会话唯一标识 */
private final String conversationId;
/** 链路追踪 ID(对接 OpenTelemetry) */
private final String traceId; // 👈 主线程提取后注入,跨线程不丢
/** 已调用的工具名(并发写安全) */
@Builder.Default
private Set<String> toolsInvoked = Collections.newSetFromMap(new ConcurrentHashMap<>());
/** 运行时状态:THINKING / CALLING_TOOLS / FAILED ... */
private String currentState;
/** 静态工厂:自动处理 TraceId 缺失场景 */
public static AgentContext current(String conversationId, String traceId) {
if (traceId == null || traceId.isEmpty() || traceId.matches("0+")) {
traceId = "ctx-" + UUID.randomUUID().toString().substring(0, 8);
}
return AgentContext.builder()
.conversationId(conversationId)
.traceId(traceId)
.build();
}
}
为什么 toolsInvoked 用 Collections.newSetFromMap(new ConcurrentHashMap<>())?
| 选项 | 并发性能 | 适用场景 |
|---|---|---|
HashSet |
❌ 不安全 | 单线程 |
Collections.synchronizedSet(new HashSet<>()) |
⚠️ 全锁 | 低并发 |
CopyOnWriteArraySet |
⚠️ 每次写都全量复制 | 读多写极少 |
Collections.newSetFromMap(new ConcurrentHashMap<>()) |
✅ 分段锁,读完全无锁 | AgentX 场景:读多写少 + 偶发并发写 |
LLM 可能并发触发多个工具(同时查天气和股票),多虚拟线程同时往 toolsInvoked 写------必须线程安全。
对应的 ThreadLocal 管理器:
java
@Slf4j
public class AgentThreadContext {
private static final ThreadLocal<AgentContext> CONTEXT_HOLDER = new ThreadLocal<>();
public static void set(AgentContext context) {
CONTEXT_HOLDER.set(context);
}
public static AgentContext get() {
return CONTEXT_HOLDER.get();
}
/**
* 💡 极其重要:必须在 finally 块里调用
* 不清理的后果:线程池复用导致下一个请求看到旧数据
*/
public static void clear() {
CONTEXT_HOLDER.remove();
}
}
ThreadLocal 必须 remove() 而不能依赖 GC------虚拟线程虽轻量,但池化的虚拟线程会被复用:
- 数据污染:下一个请求进来时可能看到上一个的 traceId
- 隐性内存增长:ThreadLocal Map 里的引用一直在
所有用 ThreadLocal 的地方,都必须在 finally 里 clear()------这是 ThreadLocal 使用的第一定律。
4.3 同步入口:AgentService.process()
业务核心代码------同步对话怎么埋点:
java
public AgentResponse process(AgentRequest request) {
validateRequest(request);
final String convId = getOrCreateConvId(request.conversationId());
final String agentType = normalizeAgentType(request.agentType());
// ① 用 Observation 包裹整个执行体
return Observation.createNotStarted("agent_process_sync", observationRegistry)
.contextualName("agent-chat:" + agentType)
.lowCardinalityKeyValue("convId", convId)
.observe(() -> {
long startTime = System.currentTimeMillis();
// ② 关键:Observation 已激活的当前线程里,主动拿 TraceId
String realTraceId = Optional.ofNullable(tracer.currentSpan())
.map(io.micrometer.tracing.Span::context)
.map(io.micrometer.tracing.TraceContext::traceId)
.orElse(null);
// ③ 把 TraceId 装进 AgentContext,再放进 ThreadLocal
AgentContext currentCtx = AgentContext.current(convId, realTraceId);
AgentThreadContext.set(currentCtx);
try {
// ④ 提交到虚拟线程池(跨线程!)
CompletableFuture<String> task = CompletableFuture.supplyAsync(() ->
executeInternal(agentType, convId, request.message()),
virtualExecutor
);
int timeout = ollamaProps.chat().timeoutSeconds() != null
? ollamaProps.chat().timeoutSeconds() : 600;
String responseText = task.orTimeout(timeout, TimeUnit.SECONDS).join();
return AgentResponse.success(responseText, convId)
.withAgentType(agentType)
.withMetadata(buildMetadata(startTime, currentCtx));
} finally {
AgentThreadContext.clear(); // ⑤ 必须清理
}
});
}
五步走,每一步都不能跳:
| 步 | 动作 | 为什么 |
|---|---|---|
| ① | Observation.observe(() -> ...) 包裹 |
让整个业务自动成为 HTTP Span 的子 Span |
| ② | tracer.currentSpan() 提取 traceId |
Observation 已激活,此刻能拿到真实 TraceId |
| ③ | TraceId 注入 AgentContext | 转换成业务字段,跨线程不丢 |
| ④ | supplyAsync(...) 提交虚拟线程 |
释放调度线程,业务在虚拟线程里跑 |
| ⑤ | finally clear() |
防止线程污染(这是必须的) |
对应的 Jaeger 视图:
http.server.requests (HTTP Span,自动)
└─ agent_process_sync (业务 Span,步骤①创建)
└─ workflow.agent (LangGraph 节点,工作流引擎里创建)
└─ chat.model (LLM 调用 Span,AgentxModelListener 加 Tag)
4.4 SSE 流式入口:processStream()
SSE 场景不能 用 Observation.observe() 包裹(详见原则三)。AgentX 的做法是复用 HTTP Span:
java
public void processStream(AgentRequest request, String convId, SseEmitter emitter) {
validateRequest(request);
final String agentType = normalizeAgentType(request.agentType());
// ⚠️ 不用 observation.wrap().run()
// 原因:wrap() 是同步执行,方法返回(~800ms)时 Observation 随即关闭
// 但真正的 AI 推理在异步回调里(分钟级),导致 Jaeger 监控失真
// ✅ 直接给 Spring MVC 的 HTTP Span 追加业务标签
Span httpSpan = tracer.currentSpan();
if (httpSpan != null) {
httpSpan.tag("agentx.conv_id", convId);
httpSpan.tag("agentx.agent_type", agentType);
}
log.info("[AgentX-Core] 📡 直播间开始推流: {}", convId);
try {
if ("workflow".equalsIgnoreCase(agentType)) {
agentWorkflow.executeComplexWorkflowStream(convId, request.message(), emitter);
} else {
agentWorkflow.executeSimpleWorkflowStream(convId, request.message(), emitter);
}
} catch (Exception e) {
log.error("[AgentX-Core] 流式任务异常中止", e);
if (httpSpan != null) httpSpan.error(e);
}
}
关键区别:
| 同步 process | 流式 processStream | |
|---|---|---|
| Span 创建方式 | Observation.observe() 新建子 Span |
复用 HTTP Span |
| Span 生命周期 | 业务方法返回时关闭 | HTTP 连接关闭(emitter.complete)时关闭 |
| 风险 | 无 | 错误用 Observation.observe() 会导致 Span 提早关闭 |
4.5 零侵入埋点:AgentxModelListener
业务代码里到处写 span.tag(...) 会把代码污染得很难看。LangChain4j 提供了 ChatModelListener 钩子,让我们在 LLM 调用前后自动埋点:
java
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentxModelListener implements ChatModelListener {
private final Tracer tracer;
@Override
public void onResponse(ChatModelResponseContext context) {
if (context.chatResponse() == null
|| context.chatResponse().aiMessage() == null) {
return;
}
// 1. 看 LLM 这次响应里有没有"工具调用决策"
List<ToolExecutionRequest> toolRequests =
context.chatResponse().aiMessage().toolExecutionRequests();
if (toolRequests != null && !toolRequests.isEmpty()) {
List<String> toolNames = toolRequests.stream()
.map(ToolExecutionRequest::name)
.toList();
log.info("[影子秘书] 🕵️ AI 决策:调用工具 -> {}", toolNames);
// 2. 给当前 Span 打 Tag(Jaeger 可按工具名搜索)
Span currentSpan = tracer.currentSpan();
if (currentSpan != null) {
currentSpan.tag("agentx.ai.tool_calling", "true");
currentSpan.tag("agentx.tools.invoked_names",
String.join(", ", toolNames));
// 在时间轴上标记一个事件点
currentSpan.event("LLM_REASONING_FINISHED_TOOL_CALL_INITIATED");
}
// 3. 同步更新业务上下文(给前端 metadata 用)
AgentContext ctx = AgentThreadContext.get();
if (ctx != null) {
toolNames.forEach(ctx::recordToolInvocation);
ctx.setCurrentState("CALLING_TOOLS");
}
}
}
@Override
public void onError(ChatModelErrorContext context) {
Throwable error = context.error();
log.error("[影子秘书] 🚨 AI 故障:{} - {}",
error.getClass().getSimpleName(), error.getMessage());
// 1. Span 置为 ERROR(Jaeger 红色横条)
Span currentSpan = tracer.currentSpan();
if (currentSpan != null) {
currentSpan.error(error); // 等价 OTel setStatus(ERROR) + recordException
currentSpan.tag("agentx.error.type", error.getClass().getSimpleName());
currentSpan.tag("agentx.error.message", error.getMessage());
}
// 2. 业务上下文也标记失败
AgentContext ctx = AgentThreadContext.get();
if (ctx != null) {
ctx.setCurrentState("FAILED");
ctx.getMetadata().put("success", false);
ctx.getMetadata().put("error_info", error.getMessage());
}
}
}
关键设计:Span Tag + AgentContext 双写
| 信息流向 | 看的人 | 用途 |
|---|---|---|
| Span Tag | 运维 / SRE(Jaeger 控制台) | 按工具名筛选、统计失败率 |
| AgentContext.toolsInvoked | 业务前端(响应里的 metadata) | 在 UI 上显示"AI 用了哪些工具" |
同样的信息走两条路,前端不需要去查 Jaeger,运维也不需要去翻业务日志,各取所需。
怎么注册 Listener?
java
@Bean
public ChatModel chatModel(AgentxModelListener listener) {
return OllamaChatModel.builder()
.baseUrl(...)
.modelName(...)
.listeners(List.of(listener)) // 👈 这一行让 Listener 生效
.build();
}
业务代码里完全没有埋点污染,所有 LLM 调用都被自动追踪。
4.6 过滤运维噪音第一层:TracingFilterConfig
java
@Configuration
public class TracingFilterConfig {
private static final List<String> EXCLUDED_PREFIXES = List.of(
"/actuator",
"/api-docs",
"/swagger-ui",
"/v3/api-docs",
"/api/v1/agents/health"
);
@Bean
public ObservationPredicate excludeNonBusinessEndpoints() {
return (String name, Observation.Context context) -> {
if (!"http.server.requests".equals(name)) return true;
if (context instanceof ServerRequestObservationContext reqCtx) {
HttpServletRequest request = (HttpServletRequest) reqCtx.getCarrier();
if (request != null) {
String path = request.getRequestURI();
for (String prefix : EXCLUDED_PREFIXES) {
if (path.startsWith(prefix)) {
return false; // 不创建 Observation → 后续 Span 也不会生成
}
}
}
}
return true;
};
}
}
返回 false 的 Observation 不会被创建,源头就掐掉了。
但这一层不够------Spring Boot 的有些埋点不走 ObservationPredicate(比如 Tomcat 内部某些 ServerSpan 直接走 OTel SDK),所以还要第二层兜底。
4.7 过滤运维噪音第二层:SpanFilteringConfig
java
@Configuration
public class SpanFilteringConfig {
// 静态缓存 AttributeKey ------ 高频路径性能优化
private static final AttributeKey<String> KEY_URL_PATH = AttributeKey.stringKey("url.path");
private static final AttributeKey<String> KEY_HTTP_TARGET = AttributeKey.stringKey("http.target");
private static final AttributeKey<String> KEY_HTTP_ROUTE = AttributeKey.stringKey("http.route");
private static final AttributeKey<String> KEY_HTTP_URL = AttributeKey.stringKey("http.url");
private static final String[] EXCLUDED_PREFIXES = {
"/api/v1/agents/health", "/actuator", "/api-docs",
"/swagger-ui", "/v3/api-docs"
};
/**
* 用 static @Bean 保证 BeanPostProcessor 在最早期被实例化,
* 早于其他 SpanExporter Bean 创建,确保能拦截到。
*/
@Bean
public static BeanPostProcessor spanExporterFilteringProcessor() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof SpanExporter exporter
&& !(bean instanceof FilteringSpanExporter)) {
log.info("[SpanFilter] ✅ 包裹 SpanExporter: {}", beanName);
return new FilteringSpanExporter(exporter);
}
return bean;
}
};
}
/** 装饰器:导出前丢弃运维路径 Span */
public static class FilteringSpanExporter implements SpanExporter {
private final SpanExporter delegate;
public FilteringSpanExporter(SpanExporter delegate) { this.delegate = delegate; }
@Override
public CompletableResultCode export(Collection<SpanData> spans) {
// 快速路径:本批没有要丢的,直接转发(避免复制开销)
boolean hasDrop = false;
for (SpanData s : spans) { if (shouldDrop(s)) { hasDrop = true; break; } }
if (!hasDrop) return delegate.export(spans);
// 慢速路径:实际有要丢的,复制保留集合
List<SpanData> kept = new ArrayList<>(spans.size());
for (SpanData s : spans) { if (!shouldDrop(s)) kept.add(s); }
return kept.isEmpty() ? CompletableResultCode.ofSuccess() : delegate.export(kept);
}
private static boolean shouldDrop(SpanData span) {
// 业务 Span (INTERNAL kind) 直接放行
SpanKind kind = span.getKind();
if (kind != SpanKind.SERVER && kind != SpanKind.CLIENT) return false;
Attributes attrs = span.getAttributes();
if (matchesAnyPrefix(attrs.get(KEY_URL_PATH))) return true;
if (matchesAnyPrefix(attrs.get(KEY_HTTP_TARGET))) return true;
if (matchesAnyPrefix(attrs.get(KEY_HTTP_ROUTE))) return true;
String httpUrl = attrs.get(KEY_HTTP_URL);
if (httpUrl != null && containsAnyPrefix(httpUrl)) return true;
String name = span.getName();
return name != null && containsAnyPrefix(name);
}
}
}
为什么这一层用装饰器模式而不是替换 SpanExporter?
| 方案 | 缺点 |
|---|---|
| 自定义一个 SpanExporter 替换 OTLP 的 | 要重新实现 gRPC 上送、批量、重试等复杂逻辑 |
| 改 OTel SDK 配置 | 跨版本兼容差,每次 OTel 升级都要重新检查 |
| 装饰器包裹自动配置的 Exporter | 0 侵入,OTel SDK 升级也不影响 |
BeanPostProcessor 的好处是:不管 Spring Boot 自动配置生成了几个 SpanExporter(OTLP / Logging / Zipkin),全部自动包裹。
AttributeKey 静态缓存的细节 :AttributeKey.stringKey() 每次调用都 new 一个新对象(OTel 内部没有缓存)。shouldDrop() 是高频路径------每条 Span 都要查 4 个 Key,不缓存能吃掉几个 % 的 CPU。这种小细节是把"能跑"和"能扛"的代码区分开的地方。
五、问题解决:三个让监控失真的实战大坑
代码看完,分享生产环境真正会让人头疼的三个坑------任何严肃做 Agent 可观测性的人都会遇到。
坑一:TraceId 在 supplyAsync 之后变成 0000000000
现象
Jaeger 里看主 Span 正常,但子 Span(工具调用、LLM 推理)的 TraceId 全是 00000000000000000000000000000000,互相之间根本关联不起来。结果:整个调用链断裂,"一次完整对话"在 Jaeger 里成了一堆孤立 Span。
原因
Micrometer Tracing 的 Span 上下文存在 ThreadLocal 里:
主线程 ThreadLocal: { traceId=c4f9a2..., spanId=a1b2c3... }
↓ supplyAsync(...) 提交到虚拟线程池
虚拟线程 ThreadLocal: { } ← 空的!
↓ tracer.currentSpan()
Span.NOOP ← 全 0
CompletableFuture.supplyAsync(() -> ..., virtualExecutor) 把任务交给另一个虚拟线程,新线程的 ThreadLocal 是空的。
解决
三种解法对比:
| 方案 | 适用度 | AgentX 选择 |
|---|---|---|
① 用 MDCContext.get() + 显式传递 |
⚠️ Micrometer Tracing 不直接暴露 SpanContext | 不用 |
② 用 OpenTelemetry 的 Context.taskWrapping(executor) |
⚠️ 耦合到底层 OTel API | 不用 |
| ③ 主线程先把 TraceId 抽成字符串存进业务对象 | ✅ 最稳、最通用 | ✅ 选 |
java
// 主线程(Observation 已激活)
String realTraceId = Optional.ofNullable(tracer.currentSpan())
.map(Span::context)
.map(TraceContext::traceId)
.orElse(null);
AgentContext ctx = AgentContext.current(convId, realTraceId);
// 跨线程后再用:
String tid = ctx.getTraceId(); // 永不丢失
教训 :ThreadLocal 是 Java 异步编程最大的陷阱。任何要跨线程使用的上下文,都必须显式提取成普通对象,不能依赖 ThreadLocal 自动传递。
坑二:SSE 流式对话的 Span 显示时长只有几百毫秒
现象
用户实际看到打字机效果跑了 40 秒,但 Jaeger 里业务 Span 只有 800ms------明显不对。SRE 看到这种数据会以为系统飞快无比,但用户体验数据告诉你完全相反。
原因
Observation.observe(() -> processStream(...)) 包裹了一个立刻返回的方法:
T+0ms: Observation 开始
T+800ms: processStream() 注册完 emitter 回调,方法 return
└─ Observation 监测到方法返回,立刻关闭 Span
T+800ms ~ T+40s: 真正的 LLM 推理在另一个线程里跑
└─ 已无 Span 追踪
T+40s: 推理完成,emitter.complete()
Observation 看到方法返回就关闭,时长当然只有几百毫秒。
解决
SSE 场景不要新建 Observation ,复用 Spring MVC 的 HTTP Span。HTTP Span 的生命周期 = 客户端连接保持时间 = SSE 推流总时长,天然匹配。
java
// ❌ 错误:会提早关闭
observation.wrap(() -> processStream(...)).run();
// ✅ 正确:直接在 HTTP Span 上加业务 Tag
Span httpSpan = tracer.currentSpan();
if (httpSpan != null) {
httpSpan.tag("agentx.conv_id", convId);
httpSpan.tag("agentx.agent_type", agentType);
}
processStream(...); // emitter.complete() 时 HTTP Span 自动关闭
教训 :Observation.observe() 不适合包裹"先返回再异步工作"的方法。要么手动管理 Span 生命周期(参考专栏⑧),要么找一个生命周期匹配的现成 Span 复用。
坑三:health 请求把 Jaeger 刷爆,业务 Span 找不到
现象
Jaeger 控制台一打开,前 100 条全是 /actuator/health 和 /api/v1/agents/health ,真正的业务对话被淹没。一个月下来 Jaeger 后端存储扩了三倍。
原因
Spring Boot 默认会给所有 HTTP 请求创建 http.server.requests Observation:
- K8s liveness/readiness 探针每 15 秒查
/api/v1/agents/health - Prometheus 每 30 秒抓
/actuator/prometheus - 前端轮询心跳
这些"运维流量"加起来一天可能是真实业务流量的 100 倍。
解决
三层防御,缺一不可:
| 层级 | 文件 | 作用 | 拦得住的对象 |
|---|---|---|---|
| ① Observation 创建前 | TracingFilterConfig.ObservationPredicate |
在最早期就拒绝创建 Observation | Spring MVC 自动埋点 |
| ② SpanExporter 导出前 | SpanFilteringConfig.FilteringSpanExporter |
兜底过滤漏网之鱼 | Tomcat 内部直接走 OTel SDK 的 Span |
| ③ 配置层 | application.yml 不暴露不必要的 Actuator 端点 |
减少被探针扫描的目标 | 探针本身 |
为什么需要 ① 和 ② 两层? Spring Boot 的有些 Observation 不走标准路径(Tomcat 内部某些 ServerSpan 直接走 OTel SDK,绕过 ObservationRegistry)。两层都拦,才能保证 Jaeger 里干净。
教训 :能源头掐掉就别在末端过滤,能在末端兜底就别留漏网之鱼。这是所有"过滤型"问题的通用原则------日志过滤、消息过滤、监控过滤都是一样。
六、总结:一张表 + 五条经验
设计决策回顾
| 设计决策 | 解决什么问题 |
|---|---|
| Micrometer Observation 抽象 | Spring Boot 3 原生集成,底层可换 OTel / Brave |
| 三层埋点(HTTP / Observation / ChatModelListener) | 各管不同尺度,零业务代码污染 |
| AgentContext 携带 TraceId | 跨虚拟线程不丢,前端响应 metadata 直接可用 |
| SSE 复用 HTTP Span | 异步推流场景的唯一正确解 |
| ChatModelListener 钩子 | LLM 调用 + 工具决策埋点零侵入 |
| 三层 Span 过滤 | 源头 + 兜底 + 配置,运维噪音清零 |
| AttributeKey 静态缓存 | 高频路径性能优化,小细节决定线上抗压 |
| MDC 自动注入 TraceId | 日志 + Trace 双向联动,排查闭环 |
| 采样率分环境 | 开发 100% 全量、生产 1% 抽样 |
五条核心经验
- AI 系统的可观测性比传统 Web 难一个量级 ------ 耗时长、跨线程、动态调用图谱,Traces 的价值远超 Metrics 和 Logs
- ThreadLocal + 虚拟线程会让 TraceId 经典丢失 ------ 必须主动把 TraceId 抽成业务对象字段,不依赖上下文存储
- SSE 流式场景不能用
Observation.observe()包裹 ------ 用 HTTP Span 天然生命周期 - 运维噪音必须用三层过滤 ------ ObservationPredicate + SpanExporter + Actuator 配置
- TraceId 必须打进日志 ------
%X{traceId:-}是日志和 Jaeger 之间的唯一桥梁
演进路线建议
如果你要给自己的 AI 系统加可观测性,建议这样推进:
- 第一阶段 :开
management.tracing+ 配 Jaeger gRPC endpoint,跑通基本链路 - 第二阶段 :写
ChatModelListener自动埋点 LLM 调用和工具决策 - 第三阶段:解决 TraceId 跨虚拟线程问题(业务对象携带 TraceId)
- 第四阶段:加 SpanExporter 装饰器过滤运维路径,控制 Jaeger 存储成本
- 第五阶段:把 Trace 数据接入 Grafana / Tempo,做长期趋势分析
七、写在最后
可观测性是 Agent 系统从 demo 走向生产的分水岭。没有它,AI 永远是个"黑盒"------你只能祈祷它正常工作,出问题只能干瞪眼。有了它,你能用工程化的方式分析每一次推理的瓶颈,让"AI 不可预测"变成"AI 可量化、可优化"。
但你看完这篇文章会发现:可观测性本质上不是 AI 问题,而是 Java 工程问题------跨线程上下文怎么传、SSE 异步生命周期怎么管、运维噪音怎么过滤、性能怎么调优......每一个都是过去十年我们做企业级 Java 已经积累的知识,只是现在在 LLM 场景下重新组合。
这正是 Java 程序员转 AI 工程方向的优势所在------底层的工程素养可以直接迁移,缺的只是对 LLM 调度模式和 Trace 生态的理解。
AgentX 专栏目前已发布 7 篇 ,覆盖了 Agent 系统从底层架构到生产部署的完整链路:技术选型 → 架构设计 → 工具系统 → RAG → 记忆 → 可观测(本文) → 工作流。下一篇(专栏⑧)会写 工作流引擎------AgentWorkflow 怎么把工具、记忆、流程串成一条流水线。
如果你也在做 Agent 项目,或者准备转型 AI 工程方向:
- 代码全公开 --- 公众号回复「可观测」获取本文完整代码包(含 docker-compose-jaeger.yml)
- 专栏持续更新 --- 每篇都基于真实开源项目源码,不水
- 欢迎交流 --- 评论区或公众号私信都行,踩过的坑越分享越值钱
💬 互动话题:你在 AI 系统的链路追踪上踩过哪些坑?TraceId 丢失、Span 错位、Jaeger 存储爆炸......评论区聊聊你的经历,一起把"AI 不再黑盒"做到可量化、可复盘。
关注公众号 【SuniaCoder-AI全栈架构实战】 ,回复「可观测 」获取本文完整配置代码,回复「记忆 」获取记忆系统代码包,回复「RAG」获取知识库代码包。
关于作者 & 联系方式
汪旭 / Sunia --- Java 全栈开发者,AI 应用工程化实践者
专注企业级 AI 落地,擅长极限资源优化,有 RAG、Agent、知识图谱方向的完整实战经验。
| 平台 | 地址 / 说明 |
|---|---|
| CSDN | SuniaCoder-AI|13.5 万+ 阅读,RAG/Agent 系列持续更新 |
| 微信公众号 | 搜索【SuniaCoder-AI全栈架构实战】|关注回复「可观测」获取本文完整配置代码 |
| 掘金 | SuniaCoder-AI |
| 知乎 | SuniaCoder-AI |
| 合作咨询 | 提供企业私有化大模型部署与定制开发(基础部署 / 企业定制 / 年度维保)欢迎私信洽谈 |
如果内容对你有帮助,点赞 + 收藏 + 关注是最大的支持,也能让更多需要的人看到这篇文章。
AgentX 专栏导航
| 篇 | 标题 | 核心内容 |
|---|---|---|
| ① | 一个 Java 开发者的 Agent 实践之路(前言) | 专栏总览 / 选题思路 |
| ② | 没有 GPU、只有 3 台低配云服务器,我如何选出 AgentX 的技术栈 | LangChain4j / Ollama / Milvus / Redis 选型 |
| ③ | AgentX 架构设计全解析:一个请求是如何从 HTTP 走到 LLM 再回来的 | 六层架构 / SSE 流式 / 虚拟线程 / TraceId |
| ④ | 工具系统深度实现:从 @Tool 注解到 MCP 协议,构建企业级 Agent 工具体系 | ToolRegistry / McpToolServer / @Tool |
| ⑤ | RAG 进阶:用 Milvus + bge-m3 构建比 ES 更懂语义的企业知识库 | 向量检索 / bge-m3 / MilvusV2 |
| ⑥ | 记忆系统:用 Redis + Milvus 给 AI 配上短期 + 长期双层记忆 | ChatMemoryStore / 语义召回 / 多轮上下文 |
| ⑦ | 全链路可观测:用 OpenTelemetry + Jaeger 让每次 AI 对话都可追踪可复盘(本文) | OTel / Jaeger / SpanExporter / TraceId |
| ⑧ | 工作流引擎:AgentWorkflow 怎么把工具、记忆、流程串成一条流水线 | AgentWorkflow / LangGraph / 虚拟线程 / SSE |
| ⑨ | 即将发布:风控与限流------LLM Token 配额、并发降级、敏感词审计的工程化实践 | TokenBucket / 配额 / 审计 / 降级 |
↑ 上一篇:记忆系统:用 Redis + Milvus 给 AI 配上短期 + 长期双层记忆|AgentX 专栏⑥
↓ 下一篇:工作流引擎:AgentWorkflow 怎么把工具、记忆、流程串成一条流水线|AgentX 专栏⑧
Tags :#AgentX #OpenTelemetry #Jaeger #Micrometer #可观测性 #分布式追踪 #TraceId #SpringBoot3 #Java21 #虚拟线程