Agent 工具调用可观测性:每一步都要能回放

0阅读10分钟

一次线上故障让我重新理解「工具调用」

上个月,我负责的一个 Agent 服务凌晨报警——用户问「帮我查一下上周的订单状态」,Agent 返回了「订单已取消」。但用户根本没有取消过任何订单。

普通 LLM 应用出问题,排查路径很直接:看输入、看输出、看 prompt。Agent 不一样。它中间经过了计划生成、工具选择、参数拼装、权限校验、远程调用、结果解析,任何一步出错,最终输出都可能面目全非。

那天我花了三小时才定位到根因:工具调用成功了,但返回的 JSON 里有一个 status: "cancelled" 字段指的是「退款流程已完成」,被 Agent 误解成了「订单被用户主动取消」。如果没有完整的 trace,我可能到现在还在改 prompt。

这篇文章讨论的就是这件事:Agent 一旦能调用外部工具,可观测性就不再是锦上添花,而是排障的唯一依据。


为什么工具调用让排障复杂度跳了一个量级

传统 LLM 应用的调试边界很清晰——prompt 进去,文本出来。出了问题,要么是 prompt 写得有问题,要么是模型本身能力不足。可观测性只需要记录 input/output 和 token 用量。

Agent 架构引入了一个全新的失败维度:工具调用链

一个典型的 Agent 任务执行过程包含多个异构步骤,每个步骤有自己的输入、输出、延迟和失败模式:

用户输入 → 模型规划 → 工具选择 → 参数构建 → 权限校验
→ 远程调用 → 结果解析 → 可能再次规划 → 最终输出

每一步都可能出问题,而且问题会级联传播

失败环节表面症状真实根因
模型规划选了错误的工具系统 prompt 中工具描述模糊
参数构建工具调用报参数错误模型幻觉出不存在的字段
权限校验工具返回 403用户 token 过期,但 Agent 没重试
远程调用工具返回空结果上游服务超时,fallback 返回空数组
结果解析最终回答与事实不符工具返回字段含义被模型误读
重试逻辑重复执行了写操作没有幂等键,超时后重试了两次

这六行里,没有一行能通过只看「最终输出」发现。

Chanl 的生产 Agent 可观测性文章把 trace 分成四类 span:LLM 调用gen_ai.chat)、工具执行tool.execute)、记忆操作memory.retrieve)、Agent 编排agent.turn1。这四类 span 构成一棵树,根节点是整个用户回合,子节点按时间排列,每个节点携带自己的元数据。

流程图画布 · 115%
Mermaid 流程图加载中...

这种分层结构不是理论设计,而是我在实际排障中验证过的最小可用粒度。少一层,定位时间就从分钟级退化到小时级。


四类 span 的实战拆解

LLM 调用 span:不只是记 token

LLM 调用 span 最基础的职责是记录 model、input_tokens、output_tokens 和 finish_reason。但在 Agent 场景下,它还需要记录这次调用在任务链中的角色

同一个 Agent 回合中,模型可能被调用三到五次:第一次是理解用户意图,第二次是选择工具,第三次是解析工具返回,第四次是生成最终回答。如果只看 trace 里有四次 LLM 调用,不知道每次的目的是什么,排障效率会大打折扣。

# ❌ 只记基础信息,排障时无法区分调用角色
span.set_attribute("gen_ai.request.model", "gpt-4o")
span.set_attribute("gen_ai.usage.input_tokens", 1200)
span.set_attribute("gen_ai.usage.output_tokens", 350)
span.set_attribute("gen_ai.response.finish_reasons", ["stop"])
 
# ✅ 增加调用角色和上下文关联
span.set_attribute("gen_ai.request.model", "gpt-4o")
span.set_attribute("gen_ai.usage.input_tokens", 1200)
span.set_attribute("gen_ai.usage.output_tokens", 350)
span.set_attribute("gen_ai.response.finish_reasons", ["stop"])
span.set_attribute("agent.call_role", "tool_selection")      # 这次调用的目的
span.set_attribute("agent.tool_candidates", '["query_order", "search_faq"]')  # 模型考虑了哪些工具
span.set_attribute("agent.selected_tool", "query_order")      # 最终选了什么
span.set_attribute("agent.rejection_reason", "search_faq 不匹配用户意图")

第二组代码多出来的四个属性,在排障时能直接回答「为什么选了这个工具」和「有没有考虑过其他工具」。这类信息不记录,事后只能靠猜。

工具执行 span:排障价值最高的一层

Chanl 文章里有一句话我印象很深:broken tools paired with clean LLM calls create confident but incorrect answers1。工具坏了,模型调用正常,Agent 会带着十足信心给出错误答案。这种失败模式比模型直接说「我不知道」危险得多。

工具执行 span 的核心属性:

属性用途示例
tool.name调用了哪个工具query_order
tool.parameters传入了什么参数(脱敏后){"order_id": "ORD-***", "days": 7}
tool.success是否成功true
tool.duration_ms执行耗时230
tool.error_type失败分类timeout / auth / data / rate_limit
tool.retry_count重试次数2
tool.idempotency_key幂等键(写操作必填)idk-20260621-abc123

其中 error_type 值得展开说。工具调用失败不是二元的「成功 / 失败」,至少有四种失败模式需要区分:

timeout     → 网络问题,可能需要重试
auth        → 权限问题,重试无意义,需要刷新 token
data        → 数据问题,参数可能有误,需要检查参数构建逻辑
rate_limit  → 流控问题,需要退避或降级

这四种失败在监控面板上应该用不同颜色标识,在告警规则上也应该区别对待。timeout 偶尔出现可以容忍,auth 批量出现说明 token 服务出了问题,rate_limit 频繁出现说明容量规划需要调整。

另一个容易忽略的点是耗时异常。一个正常 200ms 的工具调用突然变成 5ms,不一定是性能优化了——很可能是命中了缓存,返回了过期数据。我在排障中遇到过一次:工具缓存了 30 分钟前的订单状态,用户看到的永远是旧数据。duration 异常是一个常被忽视的信号。

记忆操作 span:Agent 长期运行的关键

当 Agent 需要跨回合保持上下文时,记忆检索的结果质量直接影响后续决策。记忆 span 需要记录:

  • memory.result_count:检索到多少条相关记忆
  • memory.oldest_result_hours:最老的结果是多久以前的
  • memory.query_embedding_similarity:最高相似度得分

result_count = 0 意味着 Agent 在「失忆」状态下运行,可能会重复问用户已经说过的事情。oldest_result_hours > 168(一周前)意味着 Agent 可能基于过时信息做决策。这些信号不记录,问题会在用户投诉后才暴露。


参数审计:写操作必须留痕

Agent 调用只读工具(查询、搜索)时,参数记录的粒度可以相对宽松。但当工具涉及真实系统变更——删除资源、发送消息、执行支付、修改权限——参数必须可审计。

这里有一个张力:日志需要足够详细才能排障,但又不能太详细以至于泄露用户隐私。我目前的做法是对参数做结构化脱敏:

# ❌ 两种极端都不对
 
# 极端 A:全部明文,隐私泄露风险
span.set_attribute("tool.parameters", json.dumps({
    "recipient": "[email protected]",
    "amount": 500.00,
    "note": "还上周借的钱"
}))
 
# 极端 B:全部脱敏,排障时什么都看不到
span.set_attribute("tool.parameters", "***REDACTED***")
 
# ✅ 按字段类型分级脱敏
span.set_attribute("tool.parameters", json.dumps({
    "recipient": "z***@example.com",      # 邮箱保留域名和首字母
    "amount": 500.00,                      # 金额不脱敏,审计必需
    "note": "***SENSITIVE***",             # 自由文本整体脱敏
    "operation": "transfer",               # 枚举值不脱敏
    "idempotency_key": "idk-abc123"        # 系统生成值不脱敏
}))

分级脱敏的规则需要和团队的安全、合规一起定义,不能由开发者自行决定。我在项目里维护了一个 sensitivity_rules.yaml,字段按类型分为 publicinternalpiisensitive 四级,工具执行时自动按规则脱敏后再写入 span。

对于写操作,还有两个属性必须记录:

  1. tool.confirmed_by:这个操作是经过用户确认的,还是 Agent 自主决定的?
  2. tool.idempotency_key:如果发生重试,这个键能确保操作不会重复执行。

前者用于事后追溯责任边界——「Agent 有没有权力自己执行这个操作」。后者用于防止超时重试导致的重复写操作。没有幂等键的写操作调用,在我团队里属于 P0 级别代码审查问题。


回放驱动评测:把线上失败变成回归测试

可观测性的价值不止于排障。线上失败的 trace 是最高质量的评测素材——它代表真实用户遇到的真实问题,比人工构造的测试用例有价值得多。

我的团队维护了一个 failure_traces 数据集,每个条目包含:

  • 完整的 trace(所有 span 的输入输出)
  • 失败分类(工具选择错误 / 参数构建错误 / 结果解析错误 / 权限问题 / 超时)
  • 期望的正确行为描述
  • 触发时的模型版本和 prompt 版本

每次修改 prompt、切换模型、调整工具定义,都会在这批 trace 上跑一轮回归。如果之前解析错误的 case 现在能正确处理了,说明改动有效;如果之前正确的 case 现在出错了,说明改动引入了退化。

# 回放评测的核心逻辑
for trace in failure_traces:
    # 用当时的输入重新跑一遍
    result = agent.run(
        user_input=trace.user_input,
        tool_responses=trace.tool_responses,  # 固定工具返回,隔离变量
        model=trace.model_version,
        system_prompt=trace.prompt_version
    )
 
    # 对比期望行为
    score = evaluator.evaluate(
        actual=result.final_output,
        expected=trace.expected_output,
        criteria=trace.evaluation_criteria
    )
 
    # 记录是否退化
    regression_report.log(trace.id, score, trace.failure_category)

这里有一个关键设计:tool_responses 是固定的。我们不重新调用真实工具,而是用 trace 中记录的工具返回值。这样做的好处是隔离了变量——如果测试失败,一定是模型推理或 prompt 的问题,而不是上游服务状态变化导致的。

Langfuse 2026 路线图里提到了一项改进:对 trace 中的单个 span 做评测,而不只是对整个 trace 做端到端评测2。这个方向很对——一个 trace 最终输出正确,不代表每一步都正确。工具选择对了但参数构建靠运气、结果解析错了但碰巧不影响最终输出,这些隐藏问题只有 span 级评测能发现。


方案对比:自建 vs 开源平台 vs 商业 SaaS

选型时我评估过三类方案,各有适用场景:

维度自建(OpenTelemetry + 自建后端)开源平台(Langfuse / Phoenix)商业 SaaS(LangSmith / Arize)
部署复杂度高,需要自建 collector 和存储中,Docker Compose 可启动低,SDK 接入即用
Span 可视化需要自建或用 Jaeger内置 Agent trace 树视图内置,支持 span 级下钻
评测集成需要自建评测流水线内置评分器,支持自定义内置 + 自动标记低分 trace
成本基础设施成本 + 维护人力自托管免费,Cloud 按量付费按 trace 量付费,量大较贵
数据主权完全自有自托管可控,Cloud 在供应商数据在供应商侧
适合阶段有专职基础设施团队大多数团队的首选起点快速验证阶段,不想运维
OpenTelemetry 兼容原生支持兼容 OTLP 导入导出部分支持,有私有协议

我们最终的选型是 Langfuse 自托管 + 自定义 OTel collector。原因:数据涉及用户操作记录,不能完全交给第三方;Langfuse 的 Agent trace 视图开箱可用,省了大量前端开发工作;自定义 collector 让我们在 span 导出前做脱敏和采样。

采样策略也需要专门设计。全量采集在流量大时成本会失控,但随机采样会丢掉关键的工具失败 span。我的做法是按 span 状态采样

成功 span:10% 采样率(够用,主要用于延迟和成本统计)
失败 span:100% 采样率(不能丢,每一个都是潜在问题)
写操作 span:100% 采样率(审计要求,不管成功失败都保留)
慢 span(> p95):100% 采样率(性能问题排查需要)

这种策略下,总采样率大概在 15-20%,但覆盖了所有排障和审计需要的 span。


上下文传播:跨服务 trace 不断链

Agent 调用的工具如果是远程服务(MCP Server、独立微服务),trace 需要从 Agent 主进程传播到工具服务端。否则工具服务端的执行细节在 trace 里是一个黑盒——你只知道调用花了 500ms,不知道这 500ms 花在了哪里。

OpenTelemetry 的 W3C Trace Context 协议通过 HTTP header 传播 trace_id 和 span_id。但现实是,很多工具服务端没有集成 OTel,或者用的是不兼容的 tracing 库。

# Agent 侧:注入 trace context 到工具调用请求
from opentelemetry import trace, context
from opentelemetry.trace.propagation import TraceContextTextMapPropagator
 
def call_remote_tool(tool_url, parameters):
    propagator = TraceContextTextMapPropagator()
    carrier = {}
    propagator.inject(carrier)
 
    headers = {
        "traceparent": carrier.get("traceparent", ""),
        "tracestate": carrier.get("tracestate", ""),
    }
 
    response = httpx.post(tool_url, json=parameters, headers=headers)
    return response.json()

即使工具服务端不支持 OTel,Agent 侧也应该创建一个 client span 来记录调用耗时1。这个 client span 会在 trace 里留下一个时间区间,配合服务端的日志(如果能关联 trace_id),排障时至少能确定「时间花在了网络传输还是服务端处理」。


上线前检查清单

一个 Agent 服务要上线,我要求团队过一遍这张清单。不是所有条目都适用于每个项目,但跳过的每一条都需要显式说明原因。

Trace 覆盖度

  • 每个 Agent 回合有且仅有一个根 span(agent.turn),携带 conversation_iduser_id
  • 每次 LLM 调用记录调用角色(planning / tool_selection / result_parsing / final_generation
  • 每次工具执行记录 tool.nametool.parameters(脱敏后)、tool.successtool.duration_ms
  • 工具失败记录 tool.error_typetimeout / auth / data / rate_limit
  • 写操作记录 tool.confirmed_bytool.idempotency_key

数据安全

  • 工具参数按 sensitivity_rules.yaml 分级脱敏后再写入 span
  • span 导出前经过脱敏 collector 中间件
  • 审计日志和 trace 分离存储,审计日志保留期 ≥ 180 天
  • PII 字段不出现在 span attributes 中,只在审计日志中保留(加密存储)

采样与成本

  • 采用按状态采样策略,失败 span 和写操作 span 100% 保留
  • 设置每日 trace 存储上限和告警阈值
  • 定期(每月)审查采样率是否需要调整

评测与回放

  • 线上失败 trace 自动归档到 failure_traces 数据集
  • 每次 prompt / 模型 / 工具定义变更前跑回归评测
  • 评测覆盖 span 级别(工具选择是否正确)和 trace 级别(最终输出是否正确)
  • 设置评测分数低于阈值的 trace 自动标记为需人工复核

告警与值班

  • 工具失败率 > 5% 触发 P1 告警
  • 工具平均耗时 > p95 基线 2 倍触发 P2 告警
  • 写操作重复执行(相同幂等键出现多次)触发 P0 告警

写在最后

Agent 可观测性的核心不是「记录更多数据」,而是「让每一步都可回放、可归因、可评测」。没有 trace,排障是猜;有 trace 但没有结构化,排障是翻;有结构化但没有评测闭环,问题修了一个还会冒出三个。

我见过太多团队在 Agent 上线初期觉得「先跑起来再说,可观测性以后补」。补的成本是建设成本的三到五倍,因为你不知道哪里需要 trace,只能到处加。更麻烦的是,上线后前几周可能一切正常,等第一次线上故障发生,才发现没有任何 trace 能帮你定位问题。

工具调用让 Agent 的能力上了一个台阶,也让排障复杂度上了一个台阶。可观测性是匹配这个复杂度的必要投入。


参考资料

Footnotes

  1. What to Trace When Your AI Agent Hits Production — Chanl Blog, 2026.03. 提出生产 Agent 四类 span 模型(LLM / Tool / Memory / Orchestration),以及按状态采样的策略。 2 3

  2. AI Agent Observability, Tracing & Evaluation with Langfuse — Langfuse Blog, 2026.02. Agent trace 到 span 级别的评测与自动标记机制。

评论 0

0 / 1000