深入理解 AI Agent:从黑盒到全链路——生产级 Agent 的可观测性体系怎么建

深入理解 AI Agent:从黑盒到全链路------生产级 Agent 的可观测性体系怎么建

📘 《深入理解 AI Agent》系列 · 第十七篇 | 评估与可观测性子系列 · 第 1 篇(可观测性篇)

前篇回顾:RAG 篇(4)→ Agent 基础篇(3)→ MCP 篇(3)→ 记忆篇(3)→ 编排篇(3)→ 评估与可观测性篇


线上 Agent 出问题,最熟悉的场景是这样的:

用户反馈"这个回答不对",运维同学翻日志,找到一条 POST /api/chat → 200 (8.3s)------HTTP 状态码 200,服务没报错。再往下翻,只有零散的几行 log.info,按 conversationId 一搜,能拼出"调了 LLM、调了工具",但先调的谁、后调的谁、8.3 秒花在哪一步、哪一步返回了什么、Token 烧了多少,全部靠猜。

另一个场景更常见:用户抱怨"今天 Agent 特别慢"。看监控,CPU 正常、内存正常、LLM 网关 P99 正常,接口总耗时确实涨了------但慢在路由判断?慢在 MCP 工具?慢在 LLM 首 Token?慢在记忆写入?传统 APM 给的是一条"HTTP 请求 → 数据库 → HTTP 调用"的调用链,而 Agent 一次请求内部是 Graph 节点跳转 → MCP 工具调用 → 一次或多次 LLM 推理的树状结构,APM 里根本没有这些概念。

可观测性不是"打日志",而是让"哪一步出了问题"这个问题有确定答案。 本篇以 Dream-SaaS 的真实实现为线索,拆解生产级 Agent 可观测性体系的六个核心问题:Trace/Span 模型怎么建、埋点怎么做到零侵入、落库怎么不拖垮业务、采样怎么在成本和覆盖率之间平衡、慢节点怎么一眼定位、Graph ↔ MCP ↔ LLM 三层链路怎么串成一棵树。


一、为什么传统日志在 Agent 面前集体失灵

先把问题摆透。传统后端系统排查问题靠三板斧:日志(Logging)、指标(Metrics)、链路追踪(Tracing)。这三板斧在微服务场景用得很顺,但套到 Agent 上会撞上四堵墙。

第一堵墙:调用结构是树,不是链。 微服务的一次请求是"A → B → C"的线性调用链,Span 之间基本是父子串行。Agent 的一次对话是这样的:

bash 复制代码
chat() [8.3s]
 ├── 规则链拦截判断 [3ms]
 ├── 检索 retrieve() [210ms]
 └── Graph.call() [8.0s]
      ├── route 节点 [1ms]
      ├── tools 节点 [1.2s]
      │    ├── MCP: weather.get [480ms]
      │    └── MCP: ticket.query [690ms]
      └── synthesize 节点 [6.6s]
           ├── LLM chat (第1次,工具结果不满意) [3.4s]
           └── LLM chat (第2次,最终回答) [3.1s]

这是一棵树,有分叉、有同层兄弟节点、节点类型各异(Graph 节点 / 工具 / LLM)。平铺的日志行无法表达父子关系,排查时只能靠时间戳脑补调用栈。

第二堵墙:"成功"的定义变了。 HTTP 200 不代表 Agent 成功了------工具返回空结果但 LLM 脑补了一段回答,状态码是 200,业务上是失败;LLM 返回了内容但答非所问,状态码还是 200。传统监控只看错误率和延迟,Agent 的质量问题(答错、幻觉、该调工具没调)在指标上完全不可见。

第三堵墙:异步和并发打散了上下文。 Graph 的节点可能在线程池里异步执行,MCP 工具回调在另一个线程,@Async 的落库逻辑又是一个线程。基于 ThreadLocal 的 MDC 在跨线程时直接丢失,日志里同一轮对话的记录散落各处,traceId 都串不起来。

第四堵墙:观测对象本身是"非确定性程序"。 传统程序同样的输入必然走同样的代码路径;Agent 同样的输入,LLM 这次决定调工具、下次决定直接回答,路径完全不同。这意味着排查"这次为什么答错"时,必须能复盘这一次实际发生了什么------光有代码和配置不够,得有这一次完整的执行轨迹记录。

一句话总结:传统可观测性回答的是"服务健不健康",Agent 可观测性要回答的是"这一次推理到底发生了什么"。前者是聚合指标问题,后者是单次执行的全量还原问题。


二、Trace/Span 两级模型:先把数据结构定对

可观测性体系的地基是数据模型。Dream-SaaS 用的是业界验证过的两级模型:Trace(一次完整执行)+ Span(一次具体步骤),与 OpenTelemetry 的 Trace/Span 语义对齐,但字段是为 Agent 场景定制的。

2.1 两张表:Trace 是摘要,Span 是明细

落库实体非常直白。Trace 表存一次对话的汇总信息:

bash 复制代码
@TableName("obs_agent_trace")
public class AgentTrace {
    private String traceId;          // 全局唯一,trace-{时间戳}-{uuid8}
    private String conversationId;   // 会话ID,串联多轮对话
    private String agentName;        // 哪个 Agent
    private String provider;         // 用的哪个模型厂商
    private String userInput;        // 用户输入(截断500字)
    private String finalAnswer;      // 最终回答(截断1000字)
    private Long totalDurationMs;    // 总耗时
    private Integer totalTokens;     // Token 总量
    private Integer tokenInput;
    private Integer tokenOutput;
    private String status;           // SUCCESS / ERROR / TIMEOUT
    private Integer spanCount;       // Span 数量
    private String errorCode;
    private String errorMessage;
    private String promptVersion;    // Prompt 版本(评估篇会用到)
}

Span 表存每一个步骤:

bash 复制代码
@TableName("obs_agent_trace_span")
public class AgentTraceSpan {
    private String traceId;
    private String spanId;           // {traceId}-{自增序号}
    private String parentSpanId;     // 父 Span,构成树
    private String nodeName;         // 节点/方法名,如 synthesize、weather.get
    private String spanType;         // CUSTOM / LLM / TOOL / GRAPH ...
    private LocalDateTime startTime;
    private LocalDateTime endTime;
    private Long durationMs;
    private String status;           // RUNNING / SUCCESS / ERROR
    private String inputSummary;
    private String outputSummary;    // 截断500字,防大字段
    private Integer tokenInput;      // LLM 类型 Span 专有
    private Integer tokenOutput;
    private String modelName;
    private String errorCode;
    private String errorMessage;     // 截断1000字
}

这个模型有几个刻意的设计点:

  • Span 树靠 parentSpanId 自组织,不存深度、不存路径。树的构建是查询时的事,写入时只管如实记录父子关系。这样新增节点类型不需要改表结构。
  • Token 字段挂在 Span 上而不是只挂在 Trace 上。一次对话可能调多次 LLM(ReAct 多轮、critique-revise 循环),只有把每次调用的 Token 记在各自 Span 上,才能回答"Token 到底烧在第几轮"。Trace 上的 totalTokens 是汇总值,由 Span 累加而来。
  • 所有大文本字段强制截断(userInput 500、outputSummary 500、errorMessage 1000)。这是踩过坑的:一次工具调用返回了 2MB 的 JSON,直接入库把单行撑到几十 MB,查询列表全表扫描卡死。Trace 是排查用的索引,不是数据归档------要看完整入参,去业务库按业务 ID 查。
  • status 用三态而不是布尔值。RUNNING 态让"正在执行但卡住"的请求可见(线程挂死、死循环的 Graph 会留下永远 RUNNING 的 Span),TIMEOUT 态区分"报错"和"超时"这两种完全不同的故障模式。

2.2 Span 树的构建:一次 map 归组

查询详情时,把平铺的 Span 列表还原成树。实现只有二十行:

bash 复制代码
public static List<TraceSpanTreeNode> build(List<AgentTraceSpan> spans) {
    Map<String, TraceSpanTreeNode> nodeMap = new LinkedHashMap<>();
    for (AgentTraceSpan span : spans) {
        nodeMap.put(span.getSpanId(), TraceSpanTreeNode.from(span));
    }
    List<TraceSpanTreeNode> roots = new ArrayList<>();
    for (TraceSpanTreeNode node : nodeMap.values()) {
        if (node.getParentSpanId() == null || node.getParentSpanId().isBlank()
                || !nodeMap.containsKey(node.getParentSpanId())) {
            roots.add(node);   // 父为空或父不在本批次 → 根节点(孤儿Span兜底挂根)
        } else {
            nodeMap.get(node.getParentSpanId()).getChildren().add(node);
        }
    }
    return roots;
}

注意那个 !nodeMap.containsKey(parentSpanId) 的兜底:异步场景下父 Span 可能因为采样、异常中断等原因没落库,子 Span 找不到父节点时挂为根节点,而不是直接丢弃。观测系统的第一原则是自己不能成为故障源------数据不完整时降级展示,绝不抛异常。


三、零侵入埋点:一个注解 + 一个切面

数据模型定好了,接下来的问题是:埋点代码写在哪?

最容易想到的写法是在业务代码里手动埋:

bash 复制代码
// 反面教材:业务代码被埋点污染
public String chat(String input) {
    String traceId = traceService.startTrace("chat-agent", null, null, input);
    try {
        String span1 = traceService.startSpan(traceId, null, "GRAPH", "route");
        // ... 业务逻辑
        traceService.endSpan(span1, "SUCCESS", result, null, null, null, null, null);
        // 十几个节点每个都要包一遍,漏一个 endSpan 就泄漏上下文
    } catch (Exception e) {
        traceService.failTrace(traceId, e.getClass().getSimpleName(), e.getMessage());
        throw e;
    }
}

这种写法有三个致命问题:业务代码和埋点代码交织,几十个节点写下来噪音盖过逻辑;start/end 必须严格配对,任何一个 early return 或异常漏掉 endSpan,ThreadLocal 上下文就泄漏到下一个请求;采样率、开关、落库方式想调整时,得改几十处。

Dream-SaaS 的做法是 AOP 切面 + 注解声明。业务方法上只贴一个注解:

bash 复制代码
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface WithAgentTrace {
    String agentName() default "";   // 空则取类名
    String spanType() default "CUSTOM";
    String nodeName() default "";    // 空则取方法名
}

切面统一处理"开始 Span → 执行业务 → 结束 Span → 异常标记"的完整生命周期:

bash 复制代码
@Aspect
@Component
@ConditionalOnProperty(prefix = "dream.ai.enhancement", name = "enabled", havingValue = "true")
public class AgentTraceAspect {

    @Around("@annotation(com.zhu.ai.observability.annotation.WithAgentTrace)")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        MethodSignature sig = (MethodSignature) pjp.getSignature();
        WithAgentTrace meta = sig.getMethod().getAnnotation(WithAgentTrace.class);
        String agent = meta.agentName().isBlank() ? sig.getDeclaringType().getSimpleName() : meta.agentName();
        String node = meta.nodeName().isBlank() ? sig.getName() : meta.nodeName();

        String existing = AgentTraceService.currentTraceId();
        boolean root = existing == null;   // 当前线程没有 Trace → 这是根调用
        String traceId = root
                ? traces.startTrace(agent, null, null, firstStringArg(pjp.getArgs()))
                : existing;
        String spanId = traces.startSpan(traceId, null, meta.spanType(), node);
        try {
            Object result = pjp.proceed();
            traces.endSpan(spanId, "SUCCESS", String.valueOf(result), null, null, null, null, null);
            if (root) {
                traces.endTrace(traceId, String.valueOf(result), "SUCCESS");
            }
            return result;
        } catch (Throwable t) {
            traces.endSpan(spanId, "ERROR", null, null, null, null,
                    t.getClass().getSimpleName(), t.getMessage());
            if (root) {
                traces.failTrace(traceId, t.getClass().getSimpleName(), t.getMessage());
            }
            throw t;   // 观测逻辑吞异常是大忌,原样抛出
        }
    }
}

这段切面里藏着四个关键设计:

① 根 Span 自动开 Trace,嵌套 Span 自动挂接。 currentTraceId() 从 ThreadLocal 取当前 Trace:取不到说明这是调用链入口,startTrace 开一棵新树;取到了说明外层已有 Trace,只 startSpan 挂进去。业务方法之间互相调用时,Trace 的开闭完全由调用栈自动决定,不需要任何手动传递。

② 异常路径和正常路径对称处理。 catch 块里 endSpan 标 ERROR、根调用 failTrace,然后 throw t 原样抛出。观测代码绝不吞异常、绝不改变业务方法的控制流------切面存在与否,业务行为必须完全一致。

③ 整个切面挂在开关上。 @ConditionalOnProperty(prefix = "dream.ai.enhancement", name = "enabled") 控制 Bean 装配,开关关闭时切面根本不存在于 Spring 容器中,零开销、零风险。生产出问题时一键回退,不需要改代码。

④ 首个 String 入参自动作为 userInput。 firstStringArg() 反射扫描方法参数,取第一个 String 类型参数记入 Trace。这是个务实的约定:Agent 的入口方法(chat、ask、handle)第一个参数几乎都是用户输入,约定优于配置,避免每个注解都手写 input 表达式。

零侵入的本质不是"用了 AOP",而是埋点逻辑的生命周期管理权从业务代码收归到框架。 start/end 配对、异常对称、上下文清理、开关降级,这些在手写埋点里会反复出错的模板代码,在切面里只写一次、对所有标注方法生效。

3.1 新旧两套埋点的桥接

系统里总有一部分老代码在注解体系上线前就写了自己的工具调用日志(一个 Map<String,Object> 记录 tool、success、detail)。重写全部老代码不现实,Dream-SaaS 用一个静态适配器把老日志"翻译"成 Span:

bash 复制代码
public static void adapt(Map<String, Object> legacyTrace, String agentName) {
    if (traceService == null || legacyTrace == null || legacyTrace.isEmpty()) return;
    String traceId = AgentTraceService.currentTraceId();
    if (traceId == null) return;   // 不在观测上下文中,直接跳过
    String tool = strVal(legacyTrace.get("tool"));
    boolean success = Boolean.TRUE.equals(legacyTrace.get("success"));
    String spanId = traceService.startSpan(traceId, null, "TOOL",
            tool == null ? agentName : tool);
    traceService.endSpan(spanId, success ? "SUCCESS" : "ERROR",
            String.valueOf(legacyTrace.get("detail")),
            null, null, null,
            success ? null : strVal(legacyTrace.get("errorCode")),
            success ? null : strVal(legacyTrace.get("errorCode")));
}

适配器在 ObservabilityConfig@PostConstruct 里注入 TraceService,老代码里原本打日志的地方改成一行 LegacyTraceAdapter.adapt(traceMap)。迁移可以渐进完成,不需要"大爆炸"式重构。


四、异步落库:观测流量绝不能反压业务

埋点有了,Span 数据什么时候写库?

同步写库是新手最常踩的坑:每个 Span 结束时 insert 一次,一次对话十几个 Span 就是十几次数据库写入,全部算在用户请求的 8.3 秒里。数据库一抖,Agent 接口跟着抖------观测系统反而成了拖慢业务的元凶。

Dream-SaaS 在两个层面做了异步化。

4.1 应用层:@Async 写入 + 失败降级

E1 观测模块的写入器非常薄:

bash 复制代码
@Component
public class TraceAsyncWriter {

    @Async
    public void asyncWrite(AgentTrace trace, List<AgentTraceSpan> spans) {
        write(trace, spans);
    }

    public void write(AgentTrace trace, List<AgentTraceSpan> spans) {
        try {
            traceMapper.insert(trace);
            if (spans != null) {
                for (AgentTraceSpan span : spans) {
                    spanMapper.insert(span);
                }
            }
        } catch (Exception e) {
            // 落库失败只 warn,不影响业务
            log.warn("[Trace] persist fail traceId={} err={}", trace.getTraceId(), e.getMessage());
        }
    }
}

注意 Trace 的生命周期管理:Span 在执行过程中先累积在线程内上下文 里(ThreadLocal 中的 List<AgentTraceSpan>),请求结束时一次性把整棵树交给异步写入器,而不是每个 Span 单独入队。这样写入是"一次 Trace 一批 Span"的批量操作,数据库压力和队列开销都最小。开关 dream.ai.observability.async.enabled 还支持切回同步写,方便测试环境断言。

4.2 Foundation 层:有界队列 + 批量刷盘 + 丢弃计数

foundation 观测模块走得更彻底,参考 OpenTelemetry BatchSpanProcessor 的思路实现了 BatchTraceProcessor:业务线程只做 offer 入队(微秒级),单条守护线程定时定量刷盘:

bash 复制代码
public boolean offer(TraceRecord record) {
    if (queue.offer(record)) {
        if (queue.size() >= batch.getBatchSize()) {
            scheduler.execute(this::flushSafe);   // 凑够一批立即刷
        }
        return true;
    }
    // 队列满:丢弃队头最旧的一条,给新记录腾位置
    TraceRecord droppedRec = queue.poll();
    if (droppedRec != null) {
        dropped.incrementAndGet();
        log.warn("[observe-batch] queue full, dropped oldest traceId={}", droppedRec.getTraceId());
    }
    return queue.offer(record);
}

三个设计决策值得展开:

  • 有界队列,拒绝反压。 队列容量有上限(maxQueueSize,最小 16),满了丢最旧的 Trace 而不是阻塞业务线程。观测数据丢一条可以接受(采样本身就在丢),业务线程被卡住不可接受。丢弃数有 dropped 计数器,监控这个指标就能知道观测容量是否需要扩容。
  • 双触发刷盘。 定时刷(flushIntervalMs,最小 50ms)保证延迟上限,凑够批量立即刷保证吞吐。刷盘用 AtomicBoolean flushing 做 CAS 互斥,避免定时任务和批量触发并发写。
  • 优雅关闭。 实现 DisposableBean,容器关闭时先 flush 再 shutdown 线程池,等待 3 秒强制退出------尽量不丢停机瞬间的 Trace,但也绝不拖住应用下线。

观测链路的性能原则:业务线程上只允许做"入队"这一件事。 序列化、插库、网络导出全部移到后台线程;后台链路的任何故障(队列满、库挂了、导出超时)都只能导致观测数据丢失,不能传导到业务。


五、自适应采样:全量采不起,漏采不行

异步化解决了"写库不拖业务",但成本问题还在:一条 Trace 连同十几个 Span,一天百万次对话就是千万级行的写入和存储。全量采集在演示环境没问题,上生产必须采样。

固定采样率(比如 10%)是最简单的方案,但它有个硬伤:错误 Trace 也按 10% 采。排查线上故障时最需要的就是错误现场,结果 90% 的错误 Trace 被丢了,采样策略正好在最该有数据的地方失明。

Dream-SaaS 的 foundation 层提供三种策略,配置切换:

bash 复制代码
// SamplingStrategies.java --- 按配置装配
return switch (strategy) {
    case "always_on", "always-on", "alwayson" -> new AlwaysOnSamplingStrategy();
    case "adaptive" -> AdaptiveSamplingStrategy.from(sampling, tracing.getSamplingRate());
    default -> new FixedRateSamplingStrategy(tracing.getSamplingRate());  // fixed 默认
};

adaptive(自适应)策略的规则链是整个采样设计的核心:

bash 复制代码
public double resolveRate(SamplingDecisionContext context) {
    // 规则零:错误 Trace 强制全采,不参与任何概率
    if (context != null && context.isErrorStatus()) {
        return 1.0;
    }
    // 规则链:source / status / agentName 多维匹配,首条命中生效
    for (ObservabilityProperties.SamplingRule rule : rules) {
        if (rule != null && matches(rule, context)) {
            return clamp(rule.getRate());
        }
    }
    return clamp(defaultRate);   // 未命中 → 默认采样率
}

@Override
public boolean shouldSample(SamplingDecisionContext context) {
    double rate = resolveRate(context);
    if (rate >= 1.0) return true;
    if (rate <= 0.0) return false;
    return ThreadLocalRandom.current().nextDouble() < rate;
}

规则匹配支持三个维度(均可空,空表示不约束):source(来源,如 orchestrator)、status(状态)、agentName(Agent 名),多维同时配置时全部命中才生效。一条典型的生产配置长这样:

bash 复制代码
dreamsaas:
  observability:
    tracing:
      sampling:
        strategy: adaptive
        default-rate: 0.1          # 默认采 10%
        rules:
          - status: error          # 错误请求:100%
            rate: 1.0
          - agent-name: seller-audit-agent   # 高风险审核 Agent:100%
            rate: 1.0
          - source: orchestrator   # 编排入口:50%
            rate: 0.5

这套规则背后的采样哲学是按"排查价值"分配采样预算,而不是按流量平均分配:

Trace 类型 排查价值 采样策略
错误 / 超时 Trace 极高(故障现场) 强制 100%
高风险业务 Agent(审核、支付) 高(合规审计要求) 100%
编排入口完整链路 中高(还原用户路径) 50%
普通成功对话 低(统计指标够了) 10%

还有一个容易忽略的细节:采样决策发生在 Trace 结束时而不是开始时。开始时根本不知道这次执行会不会报错、会走哪条路径;结束时 status、agentName、source 都已确定,规则链才能做出准确判断。代价是 Span 数据要在内存里攒到请求结束------这也是为什么第三章里 Span 先累积在线程上下文、结束时才统一交给写入器。

另外,内容采集和采样是两个独立开关:capture-content=false 时只落元数据和 Span 结构(耗时、状态、Token 数),不落用户输入和模型输出文本。公网环境降低敏感内容留存,内部环境再打开内容排查,一套系统两种合规姿态。


六、瀑布图:让慢节点自己"浮出水面"

数据采上来了,怎么用?排查慢请求的最高效界面不是表格,是瀑布图(Waterfall)------每个 Span 一条横条,横条的左偏移是开始时间、宽度是耗时,层级缩进表示父子关系。慢节点在图上就是一条突兀的长条,一眼定位。

Dream-SaaS 的 Trace 详情页(TraceDetail.vue)用纯 CSS 实现瀑布图,核心是两个计算函数。

时间窗口取所有 Span 的最小开始时间和最大结束时间:

bash 复制代码
const windowMs = computed(() => {
  const spans = flatSpans.value
  if (!spans.length) return { start: 0, total: Math.max(1, Number(trace.value?.totalDurationMs) || 1) }
  const starts = spans.map((s) => Number(s.startEpochMs) || 0).filter(Boolean)
  const start = starts.length ? Math.min(...starts) : 0
  const end = Math.max(
    ...spans.map((s) => (Number(s.startEpochMs) || start) + (Number(s.durationMs) || 0)),
    start + (Number(trace.value?.totalDurationMs) || 1),
  )
  return { start, total: Math.max(1, end - start) }
})

每个横条的位置按百分比换算,缩进按树深度:

bash 复制代码
function barStyle(node) {
  const { start, total } = windowMs.value
  const s = Number(node.startEpochMs) || start
  const dur = Number(node.durationMs) || 0
  return {
    marginLeft: `${Math.max(0, ((s - start) / total) * 100)}%`,
    width: `${Math.max(2, (dur / total) * 100)}%`,   // 最小2%,保证毫秒级节点可见
  }
}
// 树扁平化时记录深度,模板里 paddingLeft: 8 + depth * 16

Span 树先在后端由 SpanTreeBuilder 构造成 JSON,前端递归拍平成带 depth 的行数组。横条按 Span 类型着色(LLM / TOOL / GRAPH / CUSTOM),ERROR 状态标红。最终效果:

bash 复制代码
节点                                        耗时
[GRAPH]  chat                                8300ms ████████████████████
  [GRAPH] route                                 1ms ▏
  [TOOL]  tools                              1200ms ███
    [TOOL] weather.get                        480ms █▌
    [TOOL] ticket.query                       690ms ██
  [LLM]   synthesize                         6600ms ████████████████
    [LLM] chat (qwen-max)                    3400ms ████████
    [LLM] chat (qwen-max)                    3100ms ███████

瀑布图比"看日志猜耗时"强在哪?强在它同时回答三个问题:谁慢(长条)、为什么慢(父子归属------synthesize 慢是因为里面两次 LLM 调用)、能不能优化(兄弟节点之间有无空隙、串行能否并行)。表格里的 6600ms 只是个数字,瀑布图上 6600ms 占了整条时间轴的 80%,优化优先级无需讨论。

列表页和大盘提供宏观视角。TraceList 支持按 agentName / status / conversationId / 时间范围过滤分页;ObservabilityDashboard 展示四个核心卡片(总调用量、成功率、P95 延迟、Token 总量)加趋势图和 Agent 维度聚合表。统计接口的 P50/P95/P99 用最近邻法计算,结果带 Redis 缓存避免每次全表扫:

bash 复制代码
public static long percentile(List<Long> sorted, int p) {
    if (sorted == null || sorted.isEmpty()) return 0L;
    int idx = (int) Math.ceil(p / 100.0 * sorted.size()) - 1;
    idx = Math.max(0, Math.min(sorted.size() - 1, idx));
    return sorted.get(idx) == null ? 0L : sorted.get(idx);
}

为什么强调 P95 而不是平均值?Agent 延迟分布是典型的长尾:LLM 重试、工具超时、流式首 Token 抖动都会制造长尾。平均值会把"5% 的用户等了 30 秒"掩盖成"平均 3 秒,体验良好"。面向用户的 SLA 必须看分位数。


七、跨三层串联:Graph ↔ MCP ↔ LLM 拼成一棵树

前面讲的 E1 模块解决了应用层(Graph 编排)内部的 Trace。但生产 Agent 的一次执行横跨三个技术层:

  • Graph 层:Spring AI Alibaba Graph 的节点跳转(应用进程内);
  • MCP 层:MCP Client 调用外部 MCP Server 的工具(可能跨进程、跨机器);
  • LLM 层:经过 LlmGateway / Executor 发出的模型推理调用(跨网络到模型厂商)。

这三层各自有独立的埋点机制,如果各记各的,排查时就得在三个系统之间来回跳。Dream-SaaS foundation 层的解法是:用 runId 作为串联键 + 进程内缓冲区(RunObserveBuffer)汇聚各层 Span,运行结束统一 drain 落库

7.1 串联键与缓冲区

bash 复制代码
// RunObserveBuffer.java --- 按 runId 汇聚一次运行中所有层产生的 Span
private static final ConcurrentHashMap<String, CopyOnWriteArrayList<AgentSpanSnapshot>> BY_RUN =
        new ConcurrentHashMap<>();

public static void append(String runId, AgentSpanSnapshot span) {
    if (runId == null || runId.isBlank() || span == null) return;
    BY_RUN.computeIfAbsent(runId, k -> new CopyOnWriteArrayList<>()).add(span);
}

public static List<AgentSpanSnapshot> drain(String runId) {
    CopyOnWriteArrayList<AgentSpanSnapshot> list = BY_RUN.remove(runId);
    return list == null ? List.of() : new ArrayList<>(list);
}

runId 存放在 ObserveCallerContext 中,由编排入口在一次运行开始时设置。各层埋点不直接写库,只往缓冲区 append 自己的 Span 快照;运行结束时由 DefaultAgentTraceCapture 统一 drain,合并成一棵完整 Trace 树,过采样策略后落库。

为什么用缓冲区而不是各层直接写库?三个原因:一是采样决策必须看到全貌(第五章讲过,错误 Trace 强制采依赖最终 status);二是各层 Span 的 spanId/parentSpanId 需要在合并时统一编排在同一棵树下;三是把 N 次写库收敛成 1 次批量写。

7.2 LLM 层:切面拦截 Gateway,属性对齐 OTel GenAI

LLM 调用经过统一的 LlmGateway.chat() / LlmChatExecutor.chat() 入口,切面拦截这两个 pointcut:

bash 复制代码
@Around("execution(* com.zhu.dream.ai.llm.api.LlmGateway.chat(..)) "
        + "|| execution(* com.zhu.dream.ai.llm.api.LlmChatExecutor.chat(..))")
public Object aroundChat(ProceedingJoinPoint pjp) throws Throwable {
    String runId = ObserveCallerContext.getRunId();
    if (runId == null || runId.isBlank()) {
        return pjp.proceed();   // 不在观测上下文:直接放行,零开销、不留孤儿Span
    }
    long start = System.currentTimeMillis();
    // ... proceed,finally 中从 ChatResponse 提取真实 token 数和模型名
    ChatRequest req = findRequest(pjp.getArgs());
    if (result instanceof ChatResponse cr) {
        responseModel = cr.modelUsed();          // 实际使用的模型(可能被路由改写)
        inTok = Math.max(0L, cr.promptTokens()); // 真实 usage,不是字符数估算
        outTok = Math.max(0L, cr.completionTokens());
    }
    Map<String, Object> attrs = GenAiSpanAttrs.chatAttrs(
            DEFAULT_PROVIDER, requestModel, responseModel,
            req.temperature(), req.maxTokens(), inTok, outTok, agent, errType);
    // prompt 模板信息:优先取上下文绑定,兜底用 scenario + 内容 hash
    attachPromptAttrs(attrs, req);
    RunObserveBuffer.append(runId, RunObserveBuffer.chatSpan(
            "chat-" + UUID.randomUUID().toString().substring(0, 8),
            null, agent, ok, start, System.currentTimeMillis(), attrs));
}

两个细节体现工程成熟度:

  • 无 runId 直接 proceed。非观测路径(后台批处理、健康检查、未开启观测的调用方)不产生任何 Span,避免"孤儿 Span"污染数据,也把 AOP 开销降到一次 ThreadLocal 读取。
  • Span 属性对齐 OpenTelemetry GenAI 语义约定gen_ai.usage.*、model、temperature、prompt template id/version/hash)。这意味着同一份数据未来可以通过 OTLP exporter 直接导到 Tempo/Jaeger,用标准工具链查看,不被自研系统锁死。prompt 版本属性优先取上下文显式绑定的 templateId/version,兜底用 scenario 名加 prompt 内容 hash------即使 Prompt 没接入版本管理,也能靠 hash 发现"线上跑的 Prompt 和仓库里的不是同一份"。

7.3 MCP 层:装饰器包装 ToolCallback,四道工序一次完成

MCP Client 侧,Spring AI 的 ToolCallback 是工具调用的统一接口。用装饰器模式包一层 LoggingToolCallback,对业务完全透明(LLM 拿到的还是 ToolCallback 接口):

bash 复制代码
@Override
public String call(String toolInput) {
    String toolName = toolName();
    String connection = McpToolConnectionResolver.resolve(toolName, configuredConnections);
    long startMs = System.currentTimeMillis();
    log.info("[MCP-TOOL] invoke | connection={} | tool={} | inputChars={}", connection, toolName, ...);
    if (guard != null) {
        guard.beforeCall(connection, toolName, toolInput, getToolDefinition());  // ① 安全门禁
    }
    try {
        String result = delegate.call(toolInput);
        long elapsed = System.currentTimeMillis() - startMs;
        recordMetrics(connection, toolName, elapsed, true);                       // ② Micrometer指标
        McpToolCallAuditLogger.record(connection, toolName, true, elapsed, ...);  // ③ 审计日志
        appendToolSpan(toolName, connection, true, startMs, ...);                 // ④ Trace Span
        guard.afterCall(connection, toolName, true);
        return result;
    } catch (RuntimeException ex) {
        // 失败路径四道工序同样走一遍,然后原样抛出
        ...
        throw ex;
    }
}

一次工具调用,装饰器同时完成四件事:安全门禁(McpToolCallGuard,安全篇展开)、Micrometer 指标(Timer + Counter,打 Prometheus)、独立审计日志(单独的 logger com.zhu.dream.ai.mcp.audit,可独立落盘、独立配置保留期,满足合规审计要求)、Trace Span(append 到 RunObserveBuffer)。

指标维度打全了排查时的聚合路径:

bash 复制代码
Timer.builder("mcp.tool.call.duration")
        .tag("connection", conn)    // 哪个 MCP Server
        .tag("tool", toolName)      // 哪个工具
        .tag("success", String.valueOf(success))
        .register(registry)
        .record(durationMs, TimeUnit.MILLISECONDS);

注意 Span 的 append 同样有 runId 守卫:

bash 复制代码
private static void appendToolSpan(...) {
    String runId = ObserveCallerContext.getRunId();
    if (runId == null || runId.isBlank()) return;
    RunObserveBuffer.append(runId, RunObserveBuffer.toolSpan(
            "mcp-tool-" + UUID.randomUUID().toString().substring(0, 8), ...));
}

三层串联后的完整链路:编排入口设置 runId → Graph 节点的 @WithAgentTrace Span 进缓冲 → MCP 装饰器的 tool Span 进缓冲 → LlmObserveAspect 的 chat Span 进缓冲 → 运行结束 capture 统一 drain、采样、批量落库。一次"用户提问 → 最终回答"的所有步骤,无论发生在哪一层、哪个线程,最终都在同一棵 Span 树上。

bash 复制代码
Trace (runId=trace-1756xxxx-ab12cd34)
├── GRAPH  dreamAgentGraph.call              8010ms
│   ├── GRAPH  route                            1ms
│   ├── TOOL   tools (mcp)                   1170ms
│   │   ├── TOOL   mcp:weather.get    [conn=weather-server]   480ms
│   │   └── TOOL   mcp:ticket.query   [conn=ticket-server]    690ms
│   └── LLM    synthesize                     6600ms
│       ├── LLM  chat  qwen-max  in=1820 out=210  3400ms
│       └── LLM  chat  qwen-max  in=2430 out=860  3100ms
└── (审计日志 / Micrometer 指标走各自通道,与 Trace 互补)

八、Trace 智能分析:从"给数据"到"给结论"

瀑布图解决了"人看"的效率,但还有一层可以自动化:Trace 本身就是一份诊断报告,为什么要人肉看? Dream-SaaS 在 runtime 模块提供了 Trace 智能分析服务(AgentTraceAnalyzeServiceImpl),对一次会话的执行步骤做规则化体检,直接输出慢步骤定位和优化建议。

分析输入是一次会话的步骤序列(action/observation/thought 等 ReAct 步骤),输出三类结论。

① 耗时分解 + 最慢步骤定位。 每个步骤计算绝对耗时和占比:

bash 复制代码
for (int i = 0; i < steps.size(); i++) {
    ExecutionTraceStepDto s = steps.get(i);
    Long d = s.durationMs();
    int pct = (d != null && d > 0 && totalMs > 0)
            ? (int) Math.round(100.0 * d / totalMs) : 0;
    timings.add(new AgentTraceStepTimingDto(i, s.type(), s.toolName(), d, pct));
}
// 只在 action / observation 类型里找最慢(thought 是 LLM 思考,归并到 LLM 视角)

② 分级优化建议。 规则引擎按严重程度给 danger / warning / info 三级建议。瓶颈判定是双阈值------占比和绝对耗时同时达标才告警,避免"总共 200ms 的请求里某个步骤占 80%"这种无意义告警:

bash 复制代码
if (p >= 40 && slowest.durationMs() >= 400) {
    suggestions.add(new AgentTraceSuggestionDto("danger", String.format(Locale.ROOT,
            "步骤 #%d(%s %s)耗时 %d ms,占总时长 %d%%,易形成单点瓶颈。建议优化实现、加缓存或拆分调用。",
            slowest.stepIndex(), slowest.type(), slowest.toolName(),
            slowest.durationMs(), p), slowest.stepIndex()));
} else if (p >= 25 && slowest.durationMs() >= 500) {
    suggestions.add(new AgentTraceSuggestionDto("warning", String.format(Locale.ROOT,
            "工具 %s 耗时 %d ms,占总时长 %d%%。建议检查该工具的实现或增加结果缓存。",
            slowest.toolName(), slowest.durationMs(), p), slowest.stepIndex()));
}

③ 反模式自动检测。 除了慢,还有两类典型 Agent 病症可以从步骤序列里机械识别:

  • 连续重复工具调用:相邻两个 action 步骤调用同一工具且参数高度相似(归一化后前缀重合度 ≥ 88%),提示"可考虑合并调用"------这通常意味着 LLM 在循环里重复拉取相同数据:
bash 复制代码
if ("action".equalsIgnoreCase(a.type()) && "action".equalsIgnoreCase(b.type())
        && a.toolName().equals(b.toolName())
        && similarToolArgs(a.toolInput(), b.toolInput())) {
    out.add(new AgentTraceSuggestionDto("info", String.format(Locale.ROOT,
            "步骤 %d 与 %d 连续调用相同工具 %s 且参数高度相似,可考虑合并调用。",
            i, i + 1, na), i + 1));
}
  • observation 含错误 :某步观察结果以 ERROR: 开头或包含 exception,标 danger------错误的工具返回会污染后续推理上下文,往往就是"答错"的直接原因:
bash 复制代码
if (t.startsWith("ERROR:") || t.toLowerCase(Locale.ROOT).contains("exception")) {
    out.add(new AgentTraceSuggestionDto("danger", String.format(Locale.ROOT,
            "步骤 #%d 的 observation 含错误或异常输出,可能影响后续推理与耗时。", i)));
}
  • 总时长红线:整轮会话超过 45 秒,提示排查冗余工具轮次或生成长度失控。

最后汇总风险等级:存在 danger/warning 建议或命中慢热点则标记为有风险,否则给一条"未发现显著性能问题"的 info 结论。

这一步的价值在于把"资深工程师看 Trace 的经验"固化成规则:瓶颈双阈值、重复调用、错误观察体,都是排查 Agent 慢/错时最先检查的模式。规则分析的结果是结构化的(建议带 stepIndex 可深链回具体步骤),未来接 LLM Judge 做更复杂的归因也有了结构化输入。当然,规则能覆盖的是"已知病症",更深的质量归因(回答为什么不好)需要评估体系------那是下一篇的主题。


九、整体架构回顾:一张图看清六层设计

把全篇串起来,Dream-SaaS 可观测性体系的完整分层:

bash 复制代码
┌─────────────────────────────────────────────────────────────┐
│  展示层  TraceList(过滤分页)│ TraceDetail(Span瀑布图)       │
│         Dashboard(成功率/P95/Token/趋势/Agent维度聚合)       │
├─────────────────────────────────────────────────────────────┤
│  分析层  AgentTraceAnalyzeService:慢步骤定位/反模式检测/建议   │
│         TraceStatsService:P50/P95/P99 + Redis 缓存           │
├─────────────────────────────────────────────────────────────┤
│  采集层  @WithAgentTrace AOP(Graph/应用层)                   │
│         LlmObserveAspect(LLM层,OTel GenAI 属性)             │
│         LoggingToolCallback 装饰器(MCP层,指标+审计+Span)    │
│         LegacyTraceAdapter(老埋点桥接)                       │
├─────────────────────────────────────────────────────────────┤
│  汇聚层  RunObserveBuffer(runId 汇聚跨层 Span)               │
│         AgentTraceService(ThreadLocal 上下文 + Span 累积)    │
├─────────────────────────────────────────────────────────────┤
│  处理层  DefaultAgentTraceCapture:采样(fixed/adaptive/always) │
│         → 内容截断 → 批量/同步落库 → OTLP 导出 → 指标/告警     │
├─────────────────────────────────────────────────────────────┤
│  存储层  obs_agent_trace / obs_agent_trace_span(JDBC)       │
│         BatchTraceProcessor 有界队列 + 批量刷盘 + 丢弃计数     │
└─────────────────────────────────────────────────────────────┘

几个贯穿全篇的设计原则,值得在自建体系时对照检查:

  1. 模型先行:Trace/Span 两级 + parentSpanId 自组织树 + Span 挂 Token,字段为 Agent 场景定制但语义对齐 OTel,不锁死在自研工具链。
  2. 零侵入:注解 + AOP 收管埋点生命周期,根 Span 自动开 Trace、异常对称处理、开关一键回退;老埋点用适配器渐进迁移。
  3. 业务隔离:业务线程只入队,有界队列满了丢旧不反压,落库失败只 warn 不抛出,观测系统自己永不成为故障源。
  4. 按价值采样:错误和高风险链路强制全采,普通成功链路按比例采,采样决策放在 Trace 结束时做。
  5. 跨层串联:runId 一个键贯穿 Graph ↔ MCP ↔ LLM,进程内缓冲汇聚、结束统一落库,三层 Span 拼成一棵树。
  6. 数据到结论:瀑布图解决人肉定位效率,规则化分析把排查经验固化成自动建议,让 Trace 从"记录"变成"诊断"。

结语

可观测性是 Agent 从 Demo 走向生产的第一道门槛。Demo 阶段答错了刷新重来,生产环境每一次答错和变慢都要能回答"哪一步、为什么、怎么修"。这套体系的建设顺序也有讲究:先定 Trace/Span 模型和零侵入埋点(没有数据一切免谈),再做异步落库和采样(控制成本),然后是瀑布图和统计大盘(提升排查效率),最后才是跨层串联和智能分析(追求极致)。跳过前两步直接上大盘,只会得到一堆漂亮但查不了问题的图表。

但可观测性回答的是"发生了什么",还有一个它回答不了的问题:这一次的回答,到底好不好? 慢和错能靠 Trace 抓,"答得平庸""检索对了但总结偏了""Prompt 改版后质量悄悄下滑"这类质量问题,藏在 200 状态码和正常耗时下面,需要的是另一套体系------评估。

下篇预告:《深入理解 AI Agent(十八):Agent 评估体系------九维度自动评分与 Prompt 发布门禁》。 下一篇将拆解 Dream-SaaS 的评估子系统:九维度评分卡(相关性/忠实度/完整性/工具使用合理性......)怎么设计、LLM Judge 怎么保证评分自身可信、评估数据集怎么构建、Prompt 改版如何接入"评分不达标不许发布"的 CI 门禁,以及评估结果如何与本篇的 Trace 体系双向联动(从低分回答一键跳转到对应 Trace 复盘)。


--- 深入理解 AI Agent · 评估与可观测性子系列 · 第 1 篇(可观测性篇)---

作者:宋哥 | Java 后端 → AI Agent 工程师

项目:Dream-SaaS · 多 Agent 协作平台

有问题评论区见,欢迎交流~

相关推荐
Mr.朱鹏16 分钟前
科技周报(2026-09-01期):版权战与机器人潮
人工智能·科技·机器人
打呵欠的猫17 分钟前
前端接口超时从 15s 改到动态值后,用户投诉降了 60%——AI 帮我分析出的方案
前端·ai编程
岁月宁静17 分钟前
二、《从零手撸 Agent》 — 聊聊 LLM 的失忆真相
前端·python·agent
苏灵凯24 分钟前
IT疑难杂症诊疗室:从故障定位到根治的技术实战指南
笔记·ai·域名·agent·deepseek
秦先生在广东27 分钟前
reverse-skill:面向 AI 编码客户端的逆向工程技能路由包
人工智能
天远Date Lab28 分钟前
分布式微服务实战:基于天远车辆估值构建自动化车价评估网关
人工智能·分布式·微服务·自动化
Proaiapi32 分钟前
2026 年 API 中转与聚合平台选型指南
人工智能
AI工具测评家32 分钟前
2026 知网 & 维普 AIGC 检测底层逻辑解析|快降重 / 笔过 AI / 快将 AI 改写技术差异对比
人工智能·aigc·降重·ai检测·查重·降ai
衡石科技32 分钟前
构建软件公司的JARVIS:AI Native研发中枢的方法论
人工智能·ai·数据分析