跳到主要内容

智能体可识别性:当 Trace 无法分辨哪个智能体执行了哪些操作时

· 阅读需 12 分钟
Tian Pan
Software Engineer

用户报告说助手在上午 9:47 给了他们一个错误答案。你打开 Trace。这里有 340 个 Span。它们几乎都被命名为 agent.runllm.invoketool.call。有些有父节点,有些是兄弟节点。其中三个进行了重试,一个重试后被取消了。没有一个能告诉你错误的输出是来自 Planner、Worker、Critic、Reflection 过程,还是在 Critic 标记后 Worker 的第二次重试。

在接下来的一个小时里,你根据截图中的 UUID 前缀搜索日志行,比对 Slack 通知的时间戳,并根据 Trace 查看器中的缩进模式在脑海中重建 Agent 拓扑。最终,你猜想第三次 Worker 调用使用的模型别名在昨晚被静默切换到了另一个快照。但你无法仅凭 Trace 证明这一点。

Agent 正常运行,Trace 完整无缺。杂乱无章的 Trace 团(Hairball)本身就是 Bug。

默认的 Span 拓扑是一个因省略而产生的谎言

当你用传统的 APM 仪器包装一个 Agent 循环时,你得到的是 Span,而不是身份(Identity)。OpenTelemetry GenAI 语义规范定义了类似 invoke_agentcreate_agentgen_ai.operation.name 值,Span 名称格式为 invoke_agent {gen_ai.agent.name} —— 但截止到今年,这些规范仍处于实验阶段,大多数框架集成要么忽略它们,要么将 Agent 名称设置为框架类名等通用标签。结果是,无论底层拓扑多么复杂,来自 CrewAI、LangGraph 或 Microsoft Agent Framework 的运行 Trace 结构看起来都大同小异。每一次内部调用都显示为难以区分的 agent.run

传统的 APM 是为无状态的请求-响应服务构建的。其心理模型假设:请求从边缘进入,分发到一组固定的命名下游服务,然后返回。服务名称是稳定的,拓扑结构是架构图的一部分。Span 层级就是调用图,因为调用图是静态确定的。

Agent 同时从三个维度打破了这一假设。首先,拓扑是动态的 —— Planner 在运行时决定是生成单个 Worker、三个并行 Worker 的分发、一个 Critic、一个工具调用,还是委派给一个子团队。其次,相同的代码路径根据上下文扮演多个逻辑角色 —— 同一个 agent.run 函数调用在一次调用中是 Planner,在下一次调用中就是 Worker。第三,重试和反思过程意味着同一个逻辑 Agent 可以在同一个 Trace 中出现 N 次,而你无法判断自己看到的是同一个 Agent 的第二次尝试,还是两个恰好接收到相似输入的不同的 Agent。Trace 结构忠实地记录了运行时做了什么,但它没有记录运行时的 意图

随之而来的值班(On-call)体验是可预见的。没有身份模型的团队会花费一小时,通过日志时间戳、函数参数转储以及 Span 事件中可见的 Prompt 片段来重建调用图。而拥有身份模型的团队只需运行一个查询:agent.role = "critic" AND agent.spawn_reason = "self_consistency_check" AND outcome = "rejected",在 30 秒内就能看到答案。

五字段身份模型

解决方案不是一个新工具。而是一项纪律:Agent 生成的每个 Span 都必须携带传统 APM 默认不会生成的五个属性。

Role(角色)。 一个来自团队控制的封闭枚举的稳定字符串 —— plannerworkercriticrouterreflectortool_executorsummarizer。不是框架的类名,不是模型名称,也不是自由格式的描述。这种约束至关重要:封闭枚举使属性可分组、可图表化,而自由格式的描述则会变成无法搜索的噪音。当拓扑演变并出现新角色时,你有意识地将其添加到枚举中。

Parent agent 和 spawn reason(父 Agent 与生成原因)。 这是两个字段,而非一个。parent_agent_id 将调用图重建为有向无环图,而不是 Trace 查看器渲染的扁平列表。spawn_reason 捕捉了 为什么 创建这个子节点:initial_decompositionself_consistency_checktool_failure_retryescalation_threshold_exceeded。两者的结合能让你查询“由于 Worker 超时而生成的每一次 Critic 调用”,如果没有这些字段,你的 Trace 无法回答这个问题。

Agent instance ID(Agent 实例 ID)。 一个确定性的标识符,在单个任务的生命周期内作用于一个逻辑 Agent。如果 Worker 在 Critic 拒绝后重试,两次尝试都会获得相同的实例 ID。如果编排器生成了三个并行 Worker,每个 Worker 都会获得自己的 ID。这个字段使 Trace 在多次重试之间具有可查性 —— 没有它,你可以问“给我看这个 Span”,但不能问“给我看同一个逻辑 Worker 在所有尝试中所做的一切”。

Model 和 prompt version pair(模型与 Prompt 版本对)。 gen_ai.request.model 和一个 Prompt 产物标识符 —— 内容哈希、注册版本或 git SHA。必须是这两个字段,而不仅仅是一个。如果你的 Prompt 注册表在 09:31 推送了热重载,而性能回退在 09:33 开始,那么异常 Span 上的 Prompt 版本就是证据,能让复盘指向 Prompt 而不是模型。如果模型提供商在夜间将一个快照升级到别名 claude-x-latest 下,异常 Span 上的 request-model 字段就是证据,能让复盘指向提供商而不是 Prompt。如果没有这两个字段,每一次性能回退都是模糊不清的。

Outcome(结果)。 不是状态码,而是结果。succeededtool_errorvalidation_failedcritic_rejectedtimeoutcancelled_by_parent。APM 默认提供 HTTP 状态和异常类型,但两者都无法捕捉 Agent 循环的语义,而这些语义决定了一个 Span 是有用功还是浪费了计算资源。添加了 Outcome 字段的团队可以发现错误率告警永远无法发现的一类性能回退 —— 运行完成且没有异常,但 Critic 拒绝了所有 Worker 的输出,导致循环耗尽了预算。

这五个字段并不是完整的 Schema。它们只是底线。在此之上,你可以添加特定领域的字段 —— 检索 Span 命中的文档库、委派去往的子 Agent 团队、Trace 起源的用户层级。但是,如果这五个底线字段缺失了任何一个,Trace 就会变成一团乱麻,再多的高级属性也救不了你。

为什么 OpenTelemetry 的 GenAI 规范无法独立解决问题

这些规范是真实存在的。gen_ai.agent.idgen_ai.agent.name 已经存在于注册表中。gen_ai.operation.name 用于区分 create_agentinvoke_agent。主要的供应商——Datadog、Grafana、Langfuse、Arize——都已经开始原生支持这些规范。这是真正的进步,对于任何刚起步的团队来说,遵循这些规范进行埋点是正确的基准线。

但在三个重要方面,这些规范还未达到上述身份模型的标准。首先,gen_ai.agent.id 是一个供应商标识符——通常是 OpenAI Assistants API 调用或类似调用返回的 Assistant ID——而不是一个允许你关联重试的单次任务实例 ID。其次,在应用层面上没有标准的 agent.role 属性——规范描述的是操作,而不是 Agent 在系统逻辑架构中扮演的角色。第三,规范没有指定 parent-agent 或 spawn-reason 字段,因为 OpenTelemetry 的 parent-span 关系是最接近的类比,而 OTel 并不倾向于重复定义它。

parent-span 关系与 parent-agent 并不是一回事。在典型的 Agent 框架中,编排器(orchestrator)会生成子 Agent,这些子 Agent 作为编排器 span 的子 span 运行——但它也会生成作为兄弟节点(siblings)运行的重试任务,调用作为工作线程子节点而非编排器子节点运行的工具,并授权给内部 span 嵌套很深的子团队。OpenTelemetry 的 parent-span 图是对“哪个函数调用了哪个函数”的忠实记录。而你真正想要的是 parent-agent 图,它是“哪个 Agent 决定调用了哪个其他 Agent 以及为什么要调用”的逻辑图。两者有重叠,但并不相同。你需要两者,并为后者提供显式的属性。

务实的立场是:在适用之处遵循 OpenTelemetry GenAI 规范进行埋点,并在其之上添加应用级的身份属性,作为带有你自己命名空间前缀的自定义字段。当规范赶上进度时(它们一定会赶上的),你可以迁移这些自定义字段。跑在规范前面的成本很小;而等待规范成熟的代价就是下一次值班时的手忙脚乱。

故障发生前一天必须确认的事项

大多数团队在故障期间才发现这一差距,原因是身份模型在正常运行时是不可见的。当一切正常时,trace 看起来没问题。每个 span 都有名称,层次结构渲染清晰,你可以逐步查看。

万事顺遂之日,正是验证身份模型是否稳健之时,因为一旦发生故障,你将没有时间去添加字段。日常工作中应包含三项检查:

第一项检查是拓扑审计(topology audit)。提取最近的一百个生产环境 trace。按 agent.role 对 span 进行分组,并确认分布是否符合架构设计意图——如果你在设计中有一个“评审者(critic)”角色,但 agent.role = "critic" 在所有 span 中从未出现,那么你的埋点就在无声无息地进行错误标注。如果出现了一个你并没有定义的角色,说明有人添加了设计之外的代码路径。无论哪种情况,审计都能在故障发生前捕获漂移。

第二项检查是重试可解析性检查(retry resolvability check)。找到一个带有重试的 trace,并确认通过 agent.instance_id 查询能够将所有尝试作为一个逻辑组返回。如果重试出现在不同的实例 ID 下,说明你的 ID 生成是非确定性的,你无法跨尝试进行关联。这种失效模式会将五分钟的调查变成五个小时,因为每一次重试看起来都像是一个从未被显式调用过的独立 Agent。

第三项检查是回归溯源演练(regression-traceability rehearsal)。选择一个使用已知 prompt 版本和已知模型快照运行的 trace。模拟一次故障——更改 prompt、更改模型、更改路由逻辑——并确认生成的 trace 仅凭 span 就能体现出这些变化,而无需查阅部署日志。如果 trace 没有记录版本,值班工程师也无法得知,事后复盘时就会说“我们不确定是哪项改动导致的”。

这三项检查都可以纳入每周的观测性审查(observability review)中。跳过它们在故障发生前感觉不到代价,但一旦发生故障,代价就是整个调查窗口期。

问题的一半在于文化

身份属性不会凭空出现。它们是由那些曾因缺乏这些属性而吃过苦头、并承诺绝不重蹈覆辙的工程师添加的。这种承诺才是最难维持的。

Agent 埋点的默认轨迹是衰减。第一个版本有丰富的属性,因为有人刚刚调试了一个乱成乱麻的 trace。第二个版本丢掉了其中两个属性,因为框架升级更改了包装器签名而没人注意到。第三个版本在代码库中增加了一个新的 Agent 角色,但没有将其添加到角色枚举(enum)中,导致一半的新 span 显示为 unknown。到了第六个版本,trace 在结构上看起来与第一版完全相同,但传达的信息量仅剩 60%。

防御手段是将身份模型视为契约(contract),而非为了图方便。角色枚举是代码库中版本化的产物,而不是散落在各处调用点的字符串字面量。实例 ID 生成器是一个带有测试的单一函数。模型和 prompt 版本属性由加载 prompt 产物的同一个库生成,而不是在每个 span 边界手动处理。当这些基础组件分散时,第六个版本的漂移就会发生;当它们被集中管理时,漂移大多是可以避免的。

在生产环境中部署 Agent 一年而不发生故障的团队是罕见的。能够在故障发生后三十秒内回答“哪个 Agent 在何时、为何、使用哪个版本做了什么”的团队,才是其身份模型在代码库磨砺中幸存下来的团队。Trace 不是运行时的副作用。它是一个设计出来的产物,而这种设计先于团队最终将要撰写的每一份事后复盘。

References:Let's stay in touch and Follow me for more thoughts and updates