Agent 也要可观测:不然你只是在看一段神秘录像
古董级程序员,大厂出来后一直在创业公司,现在仍在一线做 AI 相关的工程。更完整的技术记录写在微信公众号「字与码」:工作经历、对新工具的看法,以及这些年踩过的坑,会不定期发在那里。若这篇对你有用,欢迎顺手关注。
有一次,一个“供应商健康诊断 Agent”在周报里漏掉了两个低频供应商。
它的最终回答看起来很完整:列了接口成功率、错误分类、主要供应商、环比变化,还给了“建议继续观察”的结论。业务同学看完觉得不对,因为那两个低频供应商虽然调用量小,但一个连续三天超时,另一个最近几次都是鉴权失败。按人工排查标准,它们都应该进风险列表。
如果只看 Agent 的最终回答,你几乎没法判断它错在哪里。它没有报错,HTTP 是 200,模型也没有胡说八道。它只是悄悄漏算了。
这类问题让我越来越确信:Agent 上线以后,可观测性不是锦上添花。没有 trace,你看到的只是剪完的录像;有 trace,才知道它当时看到了什么、跳过了什么、工具怎么返回、错误怎么被分类、为什么最后给了那个结论。

一次漏算的现场记录
这个 Agent 的任务并不花哨。每天早上,它会根据用户选择的时间窗口,调用内部监控查询工具,拉取供应商维度的调用量、成功率、错误类型、延迟分位数和最近异常样本,然后生成一段诊断。目标是帮运营和技术支持在早会上快速看到哪些供应商值得跟进。
产品说明里写的是:
输入:时间范围、业务线、供应商集合或默认全部供应商。
输出:供应商健康分层、异常原因、建议动作、需要人工复核的样本。
成功标准:覆盖时间窗口内所有有调用或有错误记录的供应商;高风险供应商不得漏报;错误分类要区分客户参数错误、供应商故障、平台内部故障和权限问题。
出事那天,用户输入的是:“帮我看一下过去 7 天供应商健康情况,重点标出需要跟进的。”Agent 最终输出了 12 个供应商的概览,其中 3 个黄色预警,没有红色高风险。人工复核时发现,真实数据里有 14 个供应商在窗口内有记录。漏掉的两个分别是:
| supplier_id | 7 天调用量 | 异常 | 人工判断 |
|---|---|---|---|
supplier_delta | 18 | 9 次超时,集中在近 3 天 | 低频但高风险,应列入红色 |
supplier_kappa | 7 | 5 次鉴权失败 | 需要区分是客户配置还是供应商凭证问题 |
这不是普通服务那种“某个接口 500 了”的故障。Agent 的每一步都可能有问题:它可能没把低频供应商查出来,可能查出来后被摘要时丢了,可能工具返回了错误但被当成空结果,可能模型认为调用量太低不值得写,可能错误分类规则把鉴权失败归到了“客户参数问题”,所以没有进入供应商风险。
没有 trace,排障会变成猜谜。
Trace 先回答四个问题
我不喜欢一上来谈“Agent 可观测平台应该长什么样”。排障时,trace 先要回答四个朴素问题:
- 它以为什么是任务?
- 它看到了哪些上下文和数据?
- 它调用了哪些工具,工具真实返回了什么状态?
- 它为什么把某些结果写进结论,把另一些结果丢掉?
那次漏算的 trace 摘要里,最关键的几段是这样的:
{
"trace_id": "tr_health_20260408_091522",
"task": {
"task_type": "supplier_health_diagnosis",
"tenant_hash": "tn_redacted_42",
"requested_window": {
"from": "2026-04-01T00:00:00+08:00",
"to": "2026-04-08T00:00:00+08:00"
},
"business_line": "default",
"success_criteria": [
"cover_all_suppliers_with_calls_or_errors",
"flag_high_risk_even_when_volume_is_low",
"separate_auth_error_from_provider_5xx"
]
},
"outcome": {
"agent_reported": "completed",
"reviewed": "partial",
"review_reason": "missed_low_frequency_suppliers"
}
}
如果 task 里没有 success_criteria,后面就很难判断“漏掉低频供应商”到底是不是故障。很多 Agent 系统的第一个可观测缺口就在这里:它只记录用户说了什么,不记录系统把任务解释成什么。
工具调用没有失败,但参数已经歪了
继续往下看,问题先出在查询阶段。Agent 第一次调用供应商列表工具:
{
"span_id": "sp_002",
"span_type": "tool_call",
"tool_name": "supplier_metrics.query",
"step_index": 2,
"input_summary": {
"window_days": 7,
"business_line": "default",
"min_calls": 20,
"include_error_only": false,
"group_by": ["supplier_id"]
},
"output_summary": {
"status": "ok",
"row_count": 12,
"latency_ms": 842,
"truncated": false
},
"error": null
}
接口是成功的。问题在 min_calls: 20 和 include_error_only: false。这两个参数来自工具 schema 的默认值,原本是为了日常看大盘时过滤噪声。可这次用户要的是健康诊断,成功标准里明明写了“低频高风险也要标出”。Agent 没有显式覆盖默认值,于是工具帮它过滤掉了两个供应商。
如果日志只记录“调用了 supplier_metrics.query,返回 12 行”,这个问题很难发现。trace 必须记录参数摘要,尤其是会影响数据覆盖范围的参数。完整原始参数不一定都能进日志,但像 min_calls、include_error_only、window_days、group_by 这种诊断字段必须可见。
后来我们给工具调用 span 加了一个 coverage_policy:
{
"coverage_policy": {
"intended": "all_suppliers_with_calls_or_errors",
"actual_filters": {
"min_calls": 20,
"include_error_only": false
},
"coverage_risk": "may_drop_low_frequency_suppliers"
}
}
这个字段不是模型自己随便写的,而是工具 wrapper 根据参数规则生成。这样看板上可以直接统计“存在覆盖风险的任务”,不用等业务同学人工发现漏算。
上下文摘要把关键约束挤掉了
第二个问题在上下文。这个 Agent 每次任务前会注入几段上下文:系统规则、业务口径、错误分类说明、上一次周报摘要、工具 schema 摘要。为了节省 token,它有一个 context compactor,会把长规则压缩成短摘要。
出事那天的 context span 是:
{
"span_id": "sp_001",
"span_type": "context",
"step_index": 1,
"context_refs": [
{
"ref_id": "rule_supplier_health_v3",
"title": "供应商健康诊断规则",
"version": "2026-03-20",
"included_tokens": 412,
"compression": "summary"
},
{
"ref_id": "taxonomy_error_v5",
"title": "错误分类口径",
"version": "2026-03-28",
"included_tokens": 368,
"compression": "summary"
},
{
"ref_id": "weekly_report_2026_04_01",
"title": "上周供应商周报摘要",
"included_tokens": 530,
"compression": "full_summary"
}
],
"dropped_context_refs": [
{
"ref_id": "rule_supplier_low_volume_exception",
"reason": "token_budget",
"priority": "medium"
}
]
}
这里已经能看到第二个坑:rule_supplier_low_volume_exception 被丢了。那条规则写的是“当供应商调用量低于 20 但错误率超过 40%,或连续 3 天出现同类错误时,必须进入人工复核列表”。它被标成了 medium priority,压缩器在 token 紧张时优先保留上周报告,反而丢了当日诊断更需要的例外规则。
这类问题很容易被误判成“模型没遵守规则”。实际是模型根本没看到那条规则。Agent 可观测性里,上下文不是一坨 prompt,而是一组可审计的引用:哪些规则被注入,哪些被压缩,哪些被丢弃,丢弃原因是什么。
我们后来改了上下文优先级:任务成功标准和安全/覆盖规则永远高于历史摘要;历史摘要只能作为背景,不能挤掉约束。trace 里也增加了 required_context_missing:
{
"required_context_missing": [
{
"ref_id": "rule_supplier_low_volume_exception",
"impact": "coverage",
"should_block": true
}
]
}
这个字段一旦出现,Agent 不应该继续生成完整报告,而应该降级成“数据覆盖不完整,需要重新查询或人工复核”。
错误分类比异常堆栈更重要
漏掉的另一个供应商 supplier_kappa 不是被 min_calls 单独害的。它的 5 次鉴权失败来自另一个查询工具 supplier_errors.search。工具实际返回过,但错误分类在 Agent 内部被改写了。
当时的工具 span:
{
"span_id": "sp_005",
"span_type": "tool_call",
"tool_name": "supplier_errors.search",
"input_summary": {
"window_days": 7,
"supplier_ids": ["supplier_alpha", "supplier_beta", "supplier_kappa"],
"error_types": ["timeout", "auth", "5xx", "schema"]
},
"output_summary": {
"status": "ok",
"row_count": 31,
"error_class_counts": {
"provider_timeout": 12,
"provider_5xx": 4,
"auth_failed": 5,
"client_schema_error": 10
}
}
}
后面的 decision span 却写成:
{
"span_id": "sp_007",
"span_type": "decision",
"decision_type": "risk_classification",
"input_refs": ["sp_005"],
"model_rationale_summary": "auth_failed usually indicates client-side credential configuration; do not classify as provider health risk unless provider-wide pattern exists.",
"classified_counts": {
"provider_risk": 16,
"client_issue": 15
},
"dropped_suppliers": [
{
"supplier_id": "supplier_kappa",
"reason": "classified_as_client_issue"
}
]
}
这段 rationale 不能说完全错。鉴权失败确实可能是客户配置问题。但业务口径要求的是“供应商健康诊断”,不是“只统计供应商自身 5xx”。如果一个供应商的鉴权失败集中爆发,可能是供应商换了凭证规则、证书过期、回调配置变更,也可能是平台侧同步问题。正确做法不是直接从风险列表删除,而是放进“需区分责任方”的人工复核列表。
所以工具错误不能只记录 exception。更应该有稳定的错误分类和责任口径:
| 字段 | 例子 | 为什么要记 |
|---|---|---|
raw_error_class | auth_failed | 工具或日志原始分类 |
normalized_error_class | authentication | 跨工具统一口径 |
attribution_candidate | client_or_provider_config | 责任还不确定 |
confidence | 0.62 | 低置信度要复核 |
action_policy | include_in_review | 决定是否进入报告 |
修改后,auth_failed 不再被简单归到客户问题。只有当 trace 中出现明确证据,比如同一客户对多个供应商都鉴权失败,或错误样本里的 credential_id 最近变更,才会降级为客户配置问题。否则进入复核。
仪表盘不是给老板看平均值的
这次事故后,我们把 Agent 看板改成了排障入口,而不是汇报页。第一屏只放几类能引导调查的指标:
| 指标 | 解释 |
|---|---|
task_outcome | completed、partial、needs_review、failed、refused |
supplier_coverage_rate | 报告覆盖的供应商 / 窗口内有调用或错误的供应商 |
low_volume_high_risk_missed | 低频高风险漏报数 |
tool_filter_risk_count | 可能影响覆盖的工具过滤次数 |
required_context_missing_count | 必需上下文缺失次数 |
error_attribution_unknown_rate | 错误责任不确定但被强行归类比例 |
manual_review_queue_size | 需要人工复核的供应商或样本 |
cost_per_completed_task | 完成一次任务的模型和工具成本 |
这些字段都能点到 trace。比如 low_volume_high_risk_missed 上升,点进去不是一张空洞折线,而是具体任务列表:每个任务显示时间窗口、供应商数量、工具过滤参数、丢弃的上下文、最终报告是否包含复核列表。值班的人可以直接打开某次任务,看到 min_calls 为什么是 20。
普通服务看 P95 延迟和错误率很自然;Agent 还要看过程质量。步骤数突然升高,可能是规划绕路;工具参数修复次数升高,可能是 schema 描述坏了;上下文缺失升高,可能是 token 预算被历史摘要吃掉;单位任务成本升高,可能是重试循环或过长报告。Agent 的健康不是一个 HTTP 200 能说明的。
发布门禁拦的是“悄悄变坏”
这类 Agent 很容易出现一种危险变化:演示问题更漂亮,生产边界更差。比如把模型换强一点,报告文字更自然;把 prompt 改得更积极,建议更多;把默认过滤调高,报告更短更清爽。但低频异常被漏掉,责任不确定的错误被写死,高风险任务没有进入复核。
所以发布门禁要围绕任务事实,而不是围绕文采。供应商健康诊断 Agent 后来有一组固定回归样本:
{
"case_id": "supplier_health_low_volume_003",
"input": {
"window_days": 7,
"business_line": "default",
"request": "帮我看过去 7 天供应商健康情况,重点标出需要跟进的"
},
"fixture": {
"suppliers": [
{"supplier_id": "supplier_alpha", "calls": 1280, "errors": 13, "dominant_error": "provider_5xx"},
{"supplier_id": "supplier_delta", "calls": 18, "errors": 9, "dominant_error": "provider_timeout"},
{"supplier_id": "supplier_kappa", "calls": 7, "errors": 5, "dominant_error": "auth_failed"}
]
},
"expected": {
"must_include_suppliers": ["supplier_delta", "supplier_kappa"],
"must_include_review_reasons": {
"supplier_delta": "low_volume_high_error_rate",
"supplier_kappa": "auth_error_attribution_uncertain"
},
"must_not_claim": [
"no high risk supplier",
"auth failures are client-only"
],
"required_trace_assertions": [
"supplier_metrics.query.input_summary.include_error_only == true",
"supplier_metrics.query.input_summary.min_calls == 0",
"context.required_context_missing is empty",
"decision.dropped_suppliers does not include supplier_delta"
]
}
}
这个样本的重点不是最终文字,而是 trace 断言。只要工具参数又回到 min_calls: 20,即使模型最后碰巧写出了 supplier_delta,门禁也会报警。因为生产里不能靠碰巧。
发布表也不只看通过率:
| 门禁项 | 阈值 | 失败处理 |
|---|---|---|
| 高风险供应商漏报 | 0 | 阻止发布 |
| 必需上下文缺失 | 0 | 阻止发布 |
| 工具覆盖风险未降级 | 0 | 阻止发布 |
| 错误责任强行归类 | < 1% | 灰度加人工抽检 |
| 任务完成率回退 | 不低于线上版本 1% | 灰度 |
| P95 步骤数增长 | 不超过 20% | 性能复核 |
| 单任务成本增长 | 不超过 15% | 成本复核 |
这里有个细节:task_outcome 允许是 needs_review。Agent 不必所有事情都自动下结论。对低频但高风险、责任不确定、数据覆盖不足的情况,正确行为就是把样本放进人工复核队列,并说明原因。把这种接管算成失败,会逼系统乱答。
成本问题也会藏在 trace 里
修漏算时还顺手发现了另一个问题:有些任务特别贵。平均每次诊断只要 3 次模型调用,但 P95 到了 11 次。打开 trace 发现,Agent 在错误分类不确定时会反复调用模型,让模型自己辩论“这是客户问题还是供应商问题”,却不补充新数据。
一段成本摘要是这样的:
{
"trace_id": "tr_health_20260410_083011",
"cost_summary": {
"model_calls": 9,
"tool_calls": 4,
"input_tokens": 28430,
"output_tokens": 6120,
"estimated_cost_usd": 0.74
},
"loop_summary": {
"repeated_decision_type": "risk_classification",
"iterations": 5,
"new_evidence_after_first_iteration": false,
"stop_reason": "max_iteration"
}
}
这个 trace 说明,成本不是模型单价问题,而是流程问题。没有新证据的重复推理,应该直接停止,转入 needs_review。后来加了规则:同一分类决策如果没有新增工具结果,最多重试一次;低置信度不要继续让模型自问自答,而是输出复核原因。
Agent 成本优化不能只看账单。要能按任务、步骤、工具、模型版本、重试原因拆开。最贵的 1% 任务通常不是“真的复杂”,而是流程没有退出条件。
采样策略不能漏掉你最需要看的东西
有些团队担心 trace 太贵,于是只采样 1%。这在普通高频接口上可能能接受,在 Agent 上很容易踩坑。因为你最想看的往往是低频、高风险、用户投诉、人工接管、策略拦截、成本异常这些样本,随机采样很可能刚好漏掉。
供应商健康诊断 Agent 后来的保留策略是:
| 任务类型 | Trace 保留 |
|---|---|
| 正常完成且低风险 | 结构化摘要全量,详细 span 抽样 |
partial 或 needs_review | 详细 span 全量 |
| 高风险供应商相关 | 详细 span 全量 |
| 用户反馈为错误 | 原始上下文短期保留,脱敏后长期保留 |
| 工具异常或覆盖风险 | 详细工具 span 全量 |
| 成本 Top 1% | 详细 span 全量 |
这套策略的原则很简单:正常样本可以省,失败和高风险不能省。隐私也要一起设计。日志里默认保存结构化摘要、ID、hash、计数和分类;原始工具结果只保留短期,并受权限控制;导出给复盘或评估集的 trace 必须脱敏。
可观测性不是“把所有 prompt 和返回都存下来”。那样既贵又危险。真正有用的是保留能复盘决策的结构化事实。
OpenTelemetry 只是骨架,业务字段才是肉
OpenTelemetry 的 trace/span 结构很适合承载 Agent 过程,GenAI 相关语义约定也让模型调用的字段更统一:模型名、token、延迟、stop reason、请求类型等。但只靠通用字段不够。供应商健康诊断这个案例里,真正帮我们定位问题的是业务字段:
| Span | 通用字段 | 业务字段 |
|---|---|---|
| task | trace_id、duration、status | task_type、success_criteria、business_line |
| context | input token、context size | required_context_missing、context_refs、dropped_context_refs |
| tool_call | tool_name、latency、status | min_calls、include_error_only、coverage_policy |
| model_call | model、tokens、stop_reason | decision_type、confidence、new_evidence_refs |
| decision | span links、attributes | dropped_suppliers、attribution_candidate、action_policy |
| outcome | status、error | task_outcome、manual_review_items、missed_high_risk_count |
这也是我对 Agent 可观测性的基本看法:工具层面的统一标准很重要,但每个业务 Agent 都要定义自己的成功标准和关键字段。没有业务字段,trace 只能告诉你“模型调用了 9 次”;有业务字段,trace 才能告诉你“它因为默认 min_calls 漏掉了低频高风险供应商”。
失败样本要变成评估集
那次漏算最后进入了固定评估集,名字就叫 supplier_health_low_volume_003。后续每次改 prompt、改工具 schema、改上下文压缩、换模型,都会跑它。评估不只检查最终报告,还检查 trace。
一条评估记录大概长这样:
| 字段 | 内容 |
|---|---|
case_id | supplier_health_low_volume_003 |
input_request | 过去 7 天供应商健康诊断 |
fixture_version | 2026-04-low-volume-v2 |
expected_suppliers | supplier_delta, supplier_kappa |
expected_outcome | completed_with_review_items |
trace_assertions | 工具不过滤低频、必需上下文不缺失、错误责任不强行归类 |
answer_assertions | 不得声称无高风险;必须说明复核原因 |
risk_level | high |
owner | agent_platform |
这让事故不再是一次性教训。只要同类问题回来,门禁会挡住。
我更喜欢这种“从 trace 到评估”的闭环,而不是单独维护一套理想化测试题。真实失败会带着噪声、边界和奇怪表达,正好能保护生产。
产品边界也被 trace 逼清楚了
做完这次复盘,产品文档也改了。以前“供应商健康诊断”听起来像自动给结论,后来明确拆成三类输出:
confirmed_risk:证据足够,能明确标红或标黄。needs_review:数据异常但责任不确定,需要人工看样本。insufficient_data:工具失败、上下文缺失或覆盖不足,不能给完整结论。
这三个 outcome 都是正常结果,不是只有 confirmed_risk 才算成功。Agent 的可观测性会反过来约束产品语言:什么叫完成,什么叫部分完成,什么时候必须停下来,什么时候可以建议人工复核。边界不清,trace 里就会充满“completed”,但业务永远觉得它没完成。
这也是 Agent 和传统接口很不一样的地方。传统接口可以用 HTTP 状态表达很多事;Agent 的 HTTP 200 只表示服务返回了,不能表示任务真的完成。任务状态必须单独定义。
我现在会先问这些问题
如果一个 Agent 准备上生产,我会先问可观测性,而不是先问模型多强。
它有没有稳定的 task_id 和 trace_id?任务成功标准有没有进入 trace?上下文注入是否能看到引用、版本、压缩和丢弃原因?工具参数里影响覆盖和权限的字段是否可见?工具错误有没有结构化分类?模型的关键决策有没有输入引用和置信度?人工接管是不是一等 outcome?成本能不能按步骤拆?失败样本能不能进入评估集?发布门禁会不会检查 trace,而不是只看最终回答?
这些问题听起来繁琐,但都来自生产里的真实麻烦。Agent 出错不一定会崩,它可能只是漏算、误归因、绕路、过度自信、吞掉工具失败、把不确定写成确定。最终回答越漂亮,越容易掩盖这些过程问题。
供应商健康诊断那个案例最后的修复并不神秘:工具默认参数改成由任务类型显式决定;低频高风险规则提升为必需上下文;鉴权失败改为责任不确定时进复核;trace 增加覆盖风险、上下文缺失和错误归因字段;仪表盘能点到样本;发布门禁加入 trace 断言。没有哪一步需要玄学,前提是你能看见过程。
Agent 可观测性真正要解决的不是“日志够不够多”,而是“系统做出结论时,事实链是否能被还原”。能还原,问题就能归类、修复、回归;不能还原,每次事故都只能重新猜一遍。