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,建议现在就动手搭,别等到被财务总监堵门才想起来。