Agent 生产环境的真正隐患是返回正确结果但不抛异常,通过 OpenTelemetry 追踪和成本监控才能发现,基于指标的可自动回滚是解法。
真正会在生产环境中伤到你的 Agent 失败,不是那些会崩溃的,而是那些返回了看起来完全正确的结果、不抛任何异常、通过了代码审查——然后悄无声息地让每次请求多做三倍工作的。没有 try/except 能捕获这个。Trace 能。
Project 11 是我的《零上手写 Agentic AI》系列中让 Agent 可运维的那一层:基于 OpenTelemetry 形状的链路追踪、从 Span 构建的延迟与成本仪表盘、针对循环和失败的告警规则,以及一个根据数字自动回滚的金丝雀部署。
两个版本的客服 Agent,差异仅在一个 tuple 条目:
STABLE = AgentVersion(required_evidence=("status", "total_cents"), max_steps=3)
CANARY = AgentVersion(required_evidence=("status", "total_cents", "refund_eta"), max_steps=3)
上游的 lookup_order 工具还没有返回 refund_eta。所以金丝雀版本的证据检查永远通过不了:它会重复拉取同一条记录、重复起草答案、再检查一次,直到走到步骤上限才停下。
它从不抛异常。每次都返回一个正确、合理的回复。但每次请求的成本是稳定版的 2.2 倍。
退出条件是纯 Python 代码,故意不用模型判断,所以失败会在每次运行中精确复现:
def _missing_evidence(record, version):
return [f for f in version.required_evidence if record.get(f) in (None, "")]
路线图上写的是 LangSmith 或 Arize Phoenix。但它们实际消费的是 OTLP Span,承载的是 OpenTelemetry GenAI 语义约定——所以这里发出的就是这种东西,来自约 120 行 contextvar 嵌套的上下文管理器:
with tracer.span("llm.plan", kind="llm") as sp:
resp = client.chat.completions.create(...)
record_llm_usage(sp, model, resp.usage, "plan") # gen_ai.usage.* + cost.usd
每个导出的 Span 都是一个 dict,包含 traceId / spanId / parentSpanId / startTimeUnixNano / attributes / status。Exporter 是一个接缝:JsonlExporter 写出的内容正好是 OTLP/HTTP Exporter 会 POST 的格式,所以只要把它指向一个 collector,这些链路追踪就原样落入 Phoenix 或 LangSmith,Agent 本身无需任何修改。无需账号、无需密钥、无锁定——而且整层遥测在离线状态下都可测试。
12 个工单,按 md5(request_id) % 100 的结果将 30% 路由到金丝雀版本,这样切分是确定性的,一次运行可以回放。真实调用 NVIDIA NIM 上的 meta/llama-3.1-8b-instruct——64 个 Span,12 条链路,32 次实时模型调用。
version reqs errors err rate p50 ms p95 ms $ total $ / request
-------------------------------------------------------------------------------------
v1.4-stable 8 1 12.5% 4689.9 9529.2 0.000706 0.000088
v1.5-canary 4 0 0.0% 6826.9 13909.3 0.000778 0.000194
4 个金丝雀请求的总花费比 8 个稳定版请求还多。Token 数量是提供商的实际使用量;美元数字是对应量乘以官方公布参考价算出来的。
真正有用的规则并不精巧。统计每条链路中相同的工具签名:
sig = span.attributes["tool.signature"] # "lookup_order(order_id=ORD-4107)"
counts[(span.trace_id, sig)] += 1
# >= 3 in one request -> CRITICAL: not making progress
触发了 4 个循环告警,全部发生在金丝雀版,稳定版一个都没有——规则本身完全不知道 Agent 当时在试图做什么。
第一次跑时我有一个细节搞错了:我测量的是所有请求池化后的失败率。注入的故障是 12 个里有 1 个 = 8.3%,在 10% 阈值以下悄无声息。按版本分开后,稳定版 8 个里有 1 个 = 12.5%,触发了——而且正确地没有怪到金丝雀头上。用一个混合数字做告警,就是让坏版本得以晋升的方式。
✅ error-rate canary 0.0% vs stable 12.5% (Δ -12.5%, max +5%)
✅ latency-p95 canary p95 13909ms vs stable 9529ms (1.46x, max 1.50x)
❌ cost-per-request canary $0.000194 vs stable $0.000088 (2.20x, max 1.30x)
❌ no-critical-alerts 5 critical alert(s) on the canary: latency-slo; loop-detected
DECISION: ROLLBACK → active version is now v1.4-stable
error-rate 关卡通过了:按那个指标,坏掉的版本反而更健康。p95 关卡也通过了,1.46 倍对 1.50 倍的上限——8 个基线样本下,p95 就是最慢的那个基线请求,一次慢速冷调用就把它拉高了。单独用 error rate 做门禁,这个构建就会被放行。回滚来自于 cost 门禁和 loop 告警,这就是同时用多个独立信号做门禁的全部论据。
有一个工单,Agent 回复说"总金额是 £207.08",而记录里写的是 21050 便士——£210.50。那条请求上的每个 Span 都是绿的。正常延迟、正常成本、无循环、无错误、无告警。
可观测性告诉你循环、延迟、支出和失败。它不告诉你答案是否正确。这需要另一种机制—— rubric 加上 LLM-as-judge,由确定性硬检查共同把关,那就是 Project 10 在做的事。两层都需要,但谁也替代不了谁。
如果可观测性是产品,它就需要自己的测试,所以 run.py 最后对刚产生的 Span 做了 15 条断言:每个 Span 都关闭了、父链接能解析、每条链路有一个根、JSONL 导出逐行匹配、成本核算恒等式(Span 成本之和 = 链路成本之和 = 版本成本之和)、最近排名百分位数、一个指纹证明重新计算同一组 Span 产生的告警集合字节级一致,以及回滚真的把流量切回去了。15/15 全过,任意一条失败脚本就非零退出。
模型规划工具调用并撰写回复。每个 Span、每个百分位数、每美元、每个阈值和每个门禁都是确定性的 Python——因为凌晨三点你必须信任的那些部分,应该是你能读、能重跑、能 diff 的代码。
完整演练含捕获的链路树:https://dev48v.infy.uk/agentic/project11-observability.html——代码在 https://github.com/dev48v/agentic-ai-from-zero
下一篇,Project 12:向一个开源 Agent 框架贡献代码。