《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 #虚拟线程

相关推荐
晚安日记wanna12 小时前
批量请求失败只弹一个 Toast面试官想听五层
前端·面试·架构
一切皆是因缘际会12 小时前
科技的赋能
人工智能
甲维斯12 小时前
RSI“真“”来了,OpenAI和Anthropic把人类逼到墙角!
人工智能
大模型码小白12 小时前
告别造假数据,直接连数据库查真实时序数据喂给 TimechoAI 大模型
java·数据库·人工智能·microsoft·架构
企业数字化笔记12 小时前
固定资产Excel批量导入怎么防重复?资产编码、重复检查和错误回执
java·后端·excel
晚安日记wanna12 小时前
大表 DDL 面试翻车现场Online DDL 为什么还会锁死业务
数据库·面试·架构
IvorySQL12 小时前
PostgreSQL 日报|建库策略致备库静默丢数据(9 月 12 日)
数据库·人工智能·postgresql
晚安日记wanna12 小时前
MySQL 主从延迟别只答并行复制
数据库·面试·架构
来两个炸鸡腿12 小时前
【Datawhale2609】算子开发实战 task01-熟悉沐曦GPU
人工智能·学习·大模型
golang学习记12 小时前
IDEA 2026.3 EAP新特性:自动识别Spring Boot的新 profile 了
java·spring boot·intellij-idea