可观测性是多Agent系统的刹车片:我用OpenTelemetry给LangGraph装上追踪,采购审批从“盲开”变透明

30秒速览

  • 多Agent系统没可观测性等于闭眼开车,OpenTelemetry手动埋点比用现成回调靠谱
  • 自定义Span记录每个Agent的输入输出和LLM调用耗时,异步上下文传递要用context.attach
  • 四Agent采购审批案例显示,瓶颈全在等待人工回调和无超时设计
  • 自研决策看板让业务方自己查审批理由,研发终于不用当查日志工具人
  • Span属性做业务告警比看CPU内存准10倍,误报率从40%压到8%

财务总监堵门那天,我才知道多Agent系统裸奔有多可怕

上周二下午三点,财务总监直接冲进我们研发区,脸色比会议室的投影幕布还白。“你们那个智能审批系统,把一笔80万的服务器采购单吞了!供应商打了三遍电话催款,我这边连审批记录都找不到。”我赶紧打开日志,发现4个Agent组成的采购审批链确实处理了这笔申请,但最终状态丢失了——没有留下任何决策痕迹,也没有把结果写入OA系统。更糟糕的是,日志里只有一堆“Agent 3 调用完成”之类的废话,根本看不出哪个环节卡了壳,哪个Agent改了决策理由。

那天我折腾到凌晨两点,靠逐个节点加print语句才复现出问题:预算Agent调用大模型时超时重试,但异常被静默吞掉,导致后续合规Agent收到了一个不完整的预算报告,整个链路静悄悄地断掉了。类似的事故在接下来的两周又发生了两次,业务部门对我们的系统信任度跌到了冰点。

直到那时我才真正意识到:多Agent系统一旦进入生产环境,没有可观测性就等于闭着眼睛开高铁。不是“最好有”,而是“必须要有”,而且是那种能看清每一步决策、每一次API调用、每一次思路转折的精细追踪。单靠日志去反推十几个Agent协作的链路,跟用筷子喝汤差不多。这篇文章就是我花了两个月给LangGraph搭建的OpenTelemetry追踪方案,包括踩过的坑、自己写的看板,还有那些差点搞崩系统的细节。

多Agent比微服务更需要追踪——不是复杂一点,是维度完全不同

很多人觉得多Agent系统不就是多个微服务串起来吗?加个分布式追踪不就行了。刚接手这个项目时我也这么想,结果被打脸打得啪啪响。

微服务的调用链路是确定性的:订单服务调库存服务,库存服务调支付服务,调用链是树状的,每个节点处理的是明确的业务逻辑。而多Agent系统里,一个“预算审核Agent”内部可能会调用大模型进行推理,推理结果又可能触发它去调用一个内部计算API,甚至再派生出一个子Agent去查政策库。这中间存在大量非确定性的路径,而且大模型的推理耗时波动极大——同一个Prompt,可能这次0.8秒返回,下次因为模型负载高要等3.2秒。微服务那种“服务A→服务B”的Span模型根本表达不了Agent内部复杂的思维过程。

更麻烦的是,Agent会把推理过程中产生的中间状态(比如“我觉得这个预算超标了,但考虑到是紧急采购,可以特殊处理”)当成上下文传递给下游。这些中间文字是决定最终决策的关键,但它们既不是输入参数也不是返回值,传统追踪很容易把它们忽略掉。一旦审批错了,你看到追踪里只有“合规Agent调用成功”,但不知道它为什么会给出“通过”的结论——因为它依赖的上一个Agent提供的理由在追踪链路里是缺失的。

我在设计追踪方案之前,先画了一张多Agent系统的“可观测性需求地图”:

维度 微服务追踪 多Agent追踪
调用路径 固定、可预知 动态、受LLM输出驱动
核心耗时 网络IO、数据库查询 LLM推理(波动大)、工具调用、子Agent派生
状态传递 结构化数据(JSON、Protobuf) 自然语言上下文+结构化数据混合
失败模式 超时、错误码 逻辑错误、幻觉、静默吞异常
排错关键信息 请求响应体、堆栈 推理文字链、中间决策、Prompt内容

看清了这张表,我才决定不用市面上现成的LangChain回调方案(它们只能抓到表面的Chain调用),而是直接用OpenTelemetry的底层API,自己在每个Agent的入口、推理步骤、LLM调用、子任务派发点手动插入Span,把整个思维链结构化地记录下来。

把OpenTelemetry塞进每一个Agent里——我花了三天踩了三个深坑

我用的技术栈是LangGraph 0.2.5(基于LangChain 0.2.0)加上自研的审批引擎。OpenTelemetry的Python SDK版本是1.24.0,导出器用了OTLP协议接到自建的Jaeger后端。最开始我觉得这事简单:写个装饰器,把每个Agent节点的函数包一层Span不就行了?结果踩的坑足够我写一篇长文。

坑一:全局tracer的上下文传递在异步图里直接裂开

LangGraph的StateGraph默认使用asyncio来调度节点,而OpenTelemetry的context propagation默认基于thread-local存储。当一个Agent节点内部通过asyncio.create_task启动子任务时,新协程会丢失父Span的上下文,导致子Span成为孤儿或挂在错误的trace上。

我最初写的装饰器长这样(错误示范):


from opentelemetry import trace

tracer = trace.get_tracer("purchase-approval")

def traced_agent(func):
    async def wrapper(state):
        span = tracer.start_span(func.__name__)
        with trace.use_span(span, end_on_exit=True):
            result = await func(state)
            span.set_attribute("output", str(result))
            return result
    return wrapper

这段代码在同步图里还好,异步情况下子协程里的LLM调用Span直接挂到了根Trace上,整个链路乱成蜘蛛网。折腾了三个小时,最后发现OpenTelemetry的context包提供了attach/detach机制,必须手动把Span挂到当前asyncio的contextvars上:


from opentelemetry import context, trace
from opentelemetry.trace import format_trace_id
import asyncio

_tracer = trace.get_tracer("agent-decision")

async def run_agent_with_trace(agent_name, state, logic_func):
    # 手动获取当前上下文中的Span(如果有父Span)
    ctx = context.get_current()
    span = _tracer.start_span(agent_name, context=ctx)
    # 将span绑定到当前协程的context
    token = context.attach(context.set_value(ctx, span))
    try:
        span.set_attribute("input_state", str(state)[:2000])  # 限制长度
        result = await logic_func(state)
        span.set_attribute("output", str(result)[:2000])
        span.set_status(trace.Status(trace.StatusCode.OK))
    except Exception as e:
        span.record_exception(e)
        span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
        raise
    finally:
        context.detach(token)
        span.end()
    return result

每个Agent节点我都改成这样调用,上下文继承问题才解决。这段代码看起来丑,但够稳。后来我在LangGraph的每个node定义里都显式传入了trace上下文,甚至把trace_id打进日志,方便串起来查。

坑二:大模型调用的Span需要自动注入,手工包会累死

一个Agent内部可能调用好几次LLM(比如先评估预算合理性,再生成审批意见),如果每次都要手动创建Span,代码会变成意大利面条。我一开始试了LangChain自带的tracing callback,但它生成的Span属性太泛(只有“LLM call”),无法区分业务含义。最终我用了OpenTelemetry的instrumentation机制,写了一个针对langchain-community ChatModel的自动埋点补丁,在运行时动态包装了_agenerate方法。


from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.trace import SpanKind, get_tracer
from wrapt import wrap_function_wrapper

class LLMTracingInstrumentor(BaseInstrumentor):
    def _instrument(self, **kwargs):
        tracer = get_tracer(__name__)
        
        def traced_generate(wrapped, instance, args, kwargs):
            # 获取当前上下文中的业务Span,作为父Span
            ctx = context.get_current()
            span = tracer.start_span(
                "ChatModel.generate", 
                kind=SpanKind.INTERNAL,
                attributes={
                    "model": getattr(instance, "model_name", "unknown"),
                    "temperature": kwargs.get("temperature", 0),
                    "prompt_length": len(str(kwargs.get("messages", ""))),
                }
            )
            start = time.time()
            try:
                result = wrapped(*args, **kwargs)
                duration_ms = (time.time() - start) * 1000
                span.set_attribute("duration_ms", duration_ms)
                span.set_attribute("output_tokens", result.usage_metadata.get("output_tokens", 0))
                span.set_status(Status(StatusCode.OK))
                return result
            except Exception as e:
                span.record_exception(e)
                span.set_status(Status(StatusCode.ERROR, str(e)))
                raise
            finally:
                span.end()
        
        # 动态包裹langchain的异步生成方法
        wrap_function_wrapper(
            "langchain_community.chat_models.base",
            "BaseChatModel._agenerate",
            traced_generate
        )

这样,任何Agent里调用LLM都会自动生成一个带有模型名、温度、提示长度、耗时和输出token数的Span,并且能挂在正确的父Agent Span下面。这个自动埋点节省了我至少200行重复代码。

坑三:Sampler没配好,高并发下关键Trace被无情丢弃

系统上线第一周,日均审批单量爬到了3000笔,高峰时段QPS大概15。结果我发现Jaeger里经常漏掉那些处理时间超过5秒的慢审批单——全被采样策略丢弃了。原来我用了默认的parentbased_always_on采样器,但在OTLP导出器侧又配置了tail-based采样,想只保留耗时>2秒的trace。结果两者的组合逻辑没理清,导致一部分耗时长的trace因为父Span在本地被判定为“不保存”,整个Trace根本不上报。

纠正方法:在SDK端干脆用了100%采样(因为我们单量不大),所有trace全量发送到一个本地OTel Collector再在Collector端做尾部采样和转发。配置如下:


# otel-collector-config.yaml
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: latency-policy
        type: latency
        latency:
          threshold_ms: 2000  # 保留超过2秒的trace
      - name: error-policy
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: probabilistic-policy
        type: probabilistic
        probabilistic:
          sampling_percentage: 10  # 其他按10%抽样

折腾了这一通,我们的Agent系统才开始产生完整、结构化的Trace数据,每个Span都带着业务上下文,而不是只告诉你“调用了什么函数”。

四重审批,八小时延时?我把采购审批链条拆成慢镜头分析

我们这个采购审批系统一共四个Agent:需求Agent(解析申购单)、预算Agent(核算预算池)、合规Agent(检查采购政策)、审批Agent(生成最终意见)。一个完整的审批请求走完这四步,正常应该在15秒内完成(包括大模型推理)。但上线第三周,业务部门投诉说审批慢得要命,有的单子等了8个小时。

借助追踪数据,我快速拉出了一个典型的“慢审批”Trace:

Trace ID: af3b9c8d1e2f...
├── Agent:需求解析 [2.1s]  
│   ├── LLM:parse_form [0.9s]
│   └── 字段提取逻辑 [0.1s]
├── Agent:预算审核 [4523.5s]   ← 问题出在这
│   ├── LLM:check_budget [2.4s]
│   ├── 工具:ERP查询余额 [0.3s]
│   └── 子Agent:部门额度复核 [1.2s]
├── Agent:合规审查 [1.3s]
└── Agent:决策生成 [2.6s]

一眼就能看到预算审核Agent耗时4523秒(75分钟),但它的子Span加起来只有几秒。剩下那4519秒去哪儿了?我一开始怀疑是网络抖动,查了Span属性里的日志发现:预算Agent在调用完ERP接口后,触发了一个“部门额度复核”的子Agent,但这个子Agent内部又调用了审批人的OA接口去查询线下余额,而OA系统返回的是一个待办任务——需要人为确认才能继续。Agent傻傻地等着回调,但没有设置超时,也没有把“等待人工”这件事作为一个状态记录到Span里。结果就是整个链路挂在预算审核节点上,一动不动。

修复很简单:给“部门额度复核”子Agent加一个3分钟超时,超时自动采用历史均值作为额度继续往下走,同时把“超时回退”作为Span属性记录。上线后,同类慢审批从占比12%降到了0.3%。

这就是可观测性的威力:不需要翻几百兆的日志,直接看Span的父子耗时差值就能定位假死点。我在后续的版本里,给每个Agent都定了一个p95耗时基线,一旦某类单子的Agent耗时超过基线3倍,自动触发告警并保留完整Trace,这样即使以后出现新的性能坑,5分钟内就能收到通知。

花三天写一个决策看板,业务方终于不骂我了,反而来要权限

Trace数据是有了,但让业务人员看Jaeger的火焰图不现实。我需要一个更直观的看板,能展示以下信息:

  • 每个采购单当前走到了哪个Agent
  • 每个Agent的决策理由(提取自Span属性)
  • 整体审批通过率、驳回原因分布
  • Agent协作瓶颈排行(哪类Agent最慢)

我用FastAPI+Ant Design快速撸了一个,后端直接查Jaeger的gRPC API获取Trace,再根据自定义属性分组。最难的是如何把“决策理由”从Span属性里结构化地提取出来。我们的做法是:每个Agent在设置output属性时,除了完整输出文本,还会把关键判断(通过/驳回、金额、理由摘要)以JSON格式塞进一个专门的属性“decision_summary”里。这样前端可以直接渲染成卡片。

看板的核心数据模型大概这样:


# 从Span属性中解析的结构
class AgentDecision:
    agent_name: str
    status: str  # "approved", "rejected", "need_review"
    reason: str
    amount: float
    spent_time_ms: float
    sub_decisions: list  # 如果有子Agent

性能方面,高峰期每秒大约要解析50个Trace,每个Trace包含8-15个Span,用gRPC流式拉取毫无压力。这个看板上线后最直接的效果:业务部门自己就能查某个单子为什么被驳回,不用再找我们查日志。甚至后来财务总监主动找我要了一个只读账号,说每天早晨要先看一下前一天有多少超标采购被自动拦截了。

用Span属性做智能报警,比盯着CPU内存不知道高到哪里去了

传统的APM报警都是按服务维度:CPU超80%、内存超90%、接口错误率超过5%。这些指标在多Agent系统里几乎没用——我们的Agent都跑在K8s里,资源使用非常平稳,但审批逻辑却可能因为Prompt设计偏差而大量误杀合规单。

我基于OTel的Span属性搞了一套业务级告警规则:

告警名 条件 逻辑
Agent决策剧烈反转 某Agent在过去1小时内拒绝率突增300% 对比Span属性decision_summary.status的时序变化
审批链路断裂 存在只有需求Agent Span但无后续Agent Span的Trace 检查Trace完整性,缺Span则可能是异常中断
大模型幻觉嫌疑 合规Agent引用了不存在的政策编号 在output属性中正则匹配政策库,未命中则告警
工具调用失败堆积 工具Span的status_code=ERROR占比>10% 按工具名分组统计

这些告警直接接入我们的Prometheus Alertmanager,通过webhook发到飞书。其中“链路断裂”告警帮我提前发现了两次因为数据库连接池耗尽导致的Agent静默退出,在业务方投诉之前就恢复了。

实现上,我写了一个小的流处理服务,从Kafka里消费OTel Collector转发过来的Span数据(用OLTP over Kafka),然后按Trace ID聚合,实时计算这些业务指标。代码片段:


def analyze_span_attributes(span: dict):
    attrs = span.get("attributes", {})
    # 检查是否有异常吞没迹象
    if attrs.get("agent.name") and "error" in span.get("status", {}).get("code", "").lower():
        # 记录错误agent
        publish_metric("agent.error", 1, {"agent": attrs["agent.name"]})
    # 检查决策总结
    summary_json = attrs.get("decision_summary")
    if summary_json:
        decision = json.loads(summary_json)
        if decision.get("status") == "rejected":
            publish_metric("agent.reject", 1, {"agent": attrs["agent.name"], "reason": decision.get("reason", "unknown")[:50]})

这种告警比资源型告警准确得多,因为它直接盯住了业务行为。我们最终把告警误报率从原来的40%降到了8%,值班同事终于能睡个安稳觉了。

从可观测性到可控性——这套底座真正降低了人工兜底率

折腾了两个月,整套方案带来的变化可以用几个数字概括:

  • 审批平均耗时从部署追踪前的32秒降到12秒(识别并优化了预算Agent的重复LLM调用)
  • 因系统错误导致的人工兜底率从7%降到0.5%
  • 慢审批(超过5分钟)的发现时间从“第二天用户投诉”变成“5分钟内飞书告警”
  • 业务人员自助查询占比达70%,减少了对研发的依赖

但最让我觉得值回票价的是:当老板问我“AI审批系统到底靠不靠谱”时,我直接打开了看板,把最近一周的几百笔审批决策、每个Agent的推理过程、大模型调用的参数和耗时全部展示出来。透明到这种程度,质疑自然就消失了。

我现在的观点很明确:多Agent系统的落地,可观测性不是可选项,是前提条件。没有追踪的Agent,就像没有刹车的车,你敢开上马路吗?基于OpenTelemetry的标准化方案,虽然自己手动埋点前期费点劲,但带来的长期收益远超微服务时代的Trace。因为它记录的不是“调用了什么”,而是“怎么思考的”。

接下来我打算把这套追踪扩展到Agent的重规划循环(ReAct模式)中,把Thought-Action-Observation每一步都作为一个Span挂载,让推理过程完全透明。如果你也在搞多Agent,建议现在就动手搭,别等到被财务总监堵门才想起来。

发表评论