用 OpenTelemetry 追踪代理完整执行过程
文章提出以单次代理运行为单位建立追踪,使用代理调用、模型调用和工具执行三类 span,记录令牌用量与工具错误。还讨论采样、属性基数、内容采集及跨代理上下文传播等生产取舍。
AI 深度解读
Agent 接口返回 HTTP 200,不代表任务执行得顺利。它可能反复调用模型、重试失败工具,最后还给出一个格式完整的错误答案。只看请求耗时和成功率,很容易漏掉这些问题。
作者提出把一次完整运行(run)作为观测单位,用 OpenTelemetry 记录模型调用、工具执行和重试关系。文章的价值在于给出了一套能跑通的最小结构,也划清了边界:链路能解释执行过程和资源消耗,却不能证明答案正确。
为什么要追踪整次运行,而不只看接口请求
传统 APM 擅长回答哪个接口慢、哪个服务报错。Agent 又多了一层变量:执行路径由模型在运行时决定。同一个入口,这次查一次订单就结束,下次可能调用多个工具,甚至把任务交给另一个 Agent。
请求数量也解释不了成本。模型循环次数、上下文长度都会影响 token 消耗;工具还可能修改记录、发起退款,单看模型调用无法知道系统到底做了什么。
作者因此把一轮任务建模成一棵树:以 invoke_agent 为根,下面挂 chat 和 execute_tool 两类子 span。span 可以理解为链路里一段有起止时间、状态和属性的操作。
一次运行是否成功,和内部每一步是否成功,是两个问题。
模型调用与工具执行都应该是独立 span,因为它们各自有耗时、结果和错误。重试决策则记录成父 span 上的 event:决定再试一次不需要单独计算操作成本,但实际重新执行工具,仍然会产生新的工具 span。
属性遵循 OpenTelemetry GenAI 语义约定,使用 gen_ai.*;规范之外的业务字段放进 app.*,例如 app.agent.model_calls。这样能减少以后规范新增字段时发生命名冲突的机会。
最小实现里,哪些字段必须留下
作者给出的完整 Python 脚本不需要 API key。模型客户端和工具都是桩实现,退款工具被设置为先失败一次、再成功,用来稳定复现一条包含重试的链路。
模型调用的封装负责把 token 和结束原因留在对应 span 上:
def traced_chat(prompt: str) -> dict:
with tracer.start_as_current_span(f"chat {MODEL}") as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", PROVIDER)
span.set_attribute("gen_ai.request.model", MODEL)
resp = call_llm(prompt)
span.set_attribute("gen_ai.usage.input_tokens", resp["input_tokens"])
span.set_attribute("gen_ai.usage.output_tokens", resp["output_tokens"])
span.set_attribute("gen_ai.response.finish_reasons", [resp["finish_reason"]])
return resp
重点看 gen_ai.usage.input_tokens 和 gen_ai.usage.output_tokens:它们记录在每次调用上,以后才能按运行、Agent、模型分别汇总。只存一个总数,事后无法恢复每一步的消耗。
gen_ai.response.finish_reasons 也不能省。输出被截断或过滤时,调用可能没有抛异常,仅凭成功状态看不出来。
工具封装则解决「任务恢复成功后,失败痕迹是否还在」的问题:
def traced_tool(name: str, fn, **args):
with tracer.start_as_current_span(f"execute_tool {name}") as span:
span.set_attribute("gen_ai.operation.name", "execute_tool")
span.set_attribute("gen_ai.tool.name", name)
try:
return fn(**args)
except Exception as exc:
span.set_attribute("error.type", type(exc).__name__)
raise
这里要看最后的 raise。按示例使用的 SDK 默认行为,异常传播出 span 时,会自动记录异常事件,并将状态设为 ERROR;代码额外添加 error.type,方便按异常类型聚合。这个行为有前提,不能把「在内部捕获后吞掉异常」也当成会自动标错。
示例运行产生 7 个 span:1 个 invoke_agent、3 个 chat、3 个 execute_tool。工具部分包括一次订单查询,以及失败和成功各一次的退款尝试。

三次模型调用累计消耗 40 个输入 token、144 个输出 token。整次运行最终成功,但链路保留了失败的退款调用,父 span 上还有 tool_retry 事件。
这些数字验证的是记录结构。模型名称只是 example-model,提供方是 example-provider;输入 token 用 len(prompt.split()) * 2 估算,每次固定输出 48 个 token,并用 time.sleep(0.2) 模拟耗时。它们不能代表真实模型的计费或延迟分布。金额还需要结合实际模型价格计算。
链路进了生产,最容易在哪些地方失真
首先是采样。头部采样(head-based sampling)在运行开始前决定保不保留,可能恰好丢掉罕见的失败、循环和高消耗运行。作者建议使用尾部采样(tail-based sampling),例如 OpenTelemetry Collector 的 tail sampling processor,运行结束后再按错误状态、模型调用次数或 token 用量保留异常样本,同时保留少量普通运行作为基线。
其次是指标基数。会话 ID、订单号放进 span,方便定位具体任务;指标标签使用 Agent 名、模型、工具名、error.type 这类取值可控的字段。每个订单都成为一个指标维度,会让时间序列数量不断膨胀。GenAI 约定也提供了 token 用量和操作时长的客户端指标。
第三是内容采集。prompt 和 completion 体积大,也可能含敏感信息。作者建议默认关闭,按环境开启并做脱敏,优先保存内容引用,而非直接把全文塞进链路。
最后是跨 Agent 的上下文。通过 HTTP 或消息队列交接任务时,需要在请求头或消息元数据里传播 W3C trace context,否则一次任务会裂成几条互不关联的 trace,交接处反而看不清。
这些实践还受规范演进影响。部分 GenAI 约定仍处于实验阶段,作者建议集中管理属性名、固定埋点版本,但没有给出具体版本号。集中管理能降低迁移成本,不能保证所有规范变化都只需改一行。
我的判断
我赞同先把执行过程记录完整,再讨论 Agent 的成功率。尤其是带副作用的工具调用,只保留最终回复,排查时很难回答「哪一步失败过」「后来有没有重试」「实际执行了哪些动作」。
不过,记录工具名和状态,并不等于完整记录了「改了什么」。示例没有保存变更前后内容、审批记录,也没有验证退款的真实结果。作者后面提出,生产运维 Agent 还应记录动作的提出、批准和执行,并固定模型与 prompt 版本、收窄工具权限、支持运行重放。这些是进一步的设计要求,示例并未实现。
尾部采样也属于有理由的工程建议。原文没有提供它与头部采样的成本对照或故障检出率数据,更没有证明普通团队应采用什么阈值,落地仍需结合自己的流量和采集能力。
链路记录行为,评估信号判断结果,两者需要关联到同一次运行。
离线测试集、在线 judge、人工审核都可以补上正确性判断。仅凭低延迟、低消耗、没有异常,无法证明回答可靠。
作者披露自己来自 Bonree 团队,并介绍了 Bonree ONE 对 LangChain、LangGraph、Dify、OpenClaw 应用的观测,以及 Bonree ONE Sage AI 基于拓扑、指标、错误数据进行诊断的方向。这部分没有产品对照实验;可以采纳其链路设计,不必据此推导产品优劣。原文说明代码适用于任何兼容 OTLP 的后端。
能拿来做什么
- 给现有 Node Agent 增加一次任务对应的根 span,把每次模型调用和工具执行挂在下面。优先检查失败后恢复成功的任务,确认内部失败仍然可见。
- 把输入与输出 token、结束原因记录到每次模型调用,再按运行汇总。遇到消耗异常时,沿着调用顺序定位上下文增长或重复调用的位置。
- 为带写入动作的工具补充可核查的执行结果,并把审批记录关联到运行;跨 HTTP 或消息队列交接任务时,同时检查 trace context 是否传递。
- 给日常 AI 编程或业务 Agent 的运行关联测试、评估或人工审核结果,分别检查执行故障和答案错误,避免用接口成功率替代任务质量。