《AgentX 专栏》07-全链路可观测:用OpenTelemetry+Jaeger让每次AI对话都可追踪可复盘

全链路可观测:用 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.ymlAgentxModelListener,每一层埋点的"为什么"
  • 采样率怎么配?开发 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();
    }
}

为什么 toolsInvokedCollections.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------虚拟线程虽轻量,但池化的虚拟线程会被复用:

  1. 数据污染:下一个请求进来时可能看到上一个的 traceId
  2. 隐性内存增长: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% 抽样

五条核心经验

  1. AI 系统的可观测性比传统 Web 难一个量级 ------ 耗时长、跨线程、动态调用图谱,Traces 的价值远超 Metrics 和 Logs
  2. ThreadLocal + 虚拟线程会让 TraceId 经典丢失 ------ 必须主动把 TraceId 抽成业务对象字段,不依赖上下文存储
  3. SSE 流式场景不能用 Observation.observe() 包裹 ------ 用 HTTP Span 天然生命周期
  4. 运维噪音必须用三层过滤 ------ ObservationPredicate + SpanExporter + Actuator 配置
  5. 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 #虚拟线程

相关推荐
码语智行1 小时前
Spring Security自定义AuthenticationManager实现手机号/密码双认证
java·后端·spring
程序猿阿伟1 小时前
《ClawHub产线落地技能的识别指南》
人工智能
fengxin_rou1 小时前
【从零开始的JUC并发第五章】:线程池详解
java·jvm·spring
Hotchip_MEMS1 小时前
单节锂电池充电管理:如何平衡充电速度与电池寿命
人工智能·单片机·嵌入式硬件·物联网
apcipot_rain1 小时前
计科八股20260602——YOLO、弱监督学习、nnu-net、SAM
人工智能·神经网络·yolo·计算机视觉
糖果店的幽灵1 小时前
LangChain 1.3 完全教程:从入门到精通-Part 9: RAG(检索增强生成)
人工智能·langchain
义嘉泰1 小时前
把显示、触控和手写笔都管起来
人工智能·芯片
咖啡八杯1 小时前
GoF设计模式——装饰模式
java·算法·设计模式·装饰器模式
Soari1 小时前
GitHub 开源项目解析:supermemoryai/supermemory —— AI 时代的持久记忆引擎
人工智能·github·开源项目·mcp·ai记忆引擎·下文搜索