- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 开篇 — 6,780 条轨迹里跑出来的 8,042 次重复执行
- 可重试的工具调用 — 幂等性不是框架送给你的
- 预算与步数上限 — 两种不同的失败
- 非确定性控制流的可观测性
- 按工具划分的权限边界
- 自信满满的错误答案与升级路径
- 该对什么设告警
- 收尾 — 可重试性是工具层的属性,而不是提示词的属性
开篇 — 6,780 条轨迹里跑出来的 8,042 次重复执行
几天前,GeekNews 上出现了一篇题为 Ask GN:写给挂了多个 MCP 工具在运维智能体的各位 的帖子。作者分析了 6,780 条基准测试轨迹,找到 8,042 次重复工具执行;在滤掉像宣告任务完成这类无害的重复之后,仍剩下 4,249 次。其中只数能靠实体 ID 确认的部分,改变状态的工具真的创建出重复资源(文档、电子表格等)的案例有 159 次。在此之上,还有 199 次带着「Timed out while waiting for response...10.0 seconds」这种超时特征的记录,以及 74 个看起来像资源冲突的错误。
作者自己说明的局限很重要。这些数据全部是基准测试轨迹,不是真实的生产服务。而且正如他指出的,大多数公开数据集本来就没有为检测重复而设计 — 因为在准确率基准里,重复只会被统计成一个错误答案。也就是说,这些数字不是生产环境发生率的估计值,而更接近一份存在性证明:「这种失败类型是存在的,而且没有人在数它。」
评论里有人建议使用会话级的幂等性键,作者给出了准确的反驳 — 对 Notion 或 GitHub 这类第三方 API,你无法注入属于自己的键。这一来一回,就是本文的起点。
可重试的工具调用 — 幂等性不是框架送给你的
智能体循环的结构制造了这个问题。循环大致是这样的 — 调用模型,如果响应里有工具调用就执行,把结果追加进对话,再调用模型。重试在这里会在三个层次上各自独立地发生。
HTTP 层。SDK 会自动重试 429、5xx 和连接错误。大多数客户端默认开启了 2 次重试。超时同样属于重试对象,所以最坏情况下实际的等待时间是超时时长乘以重试次数再加一。
工具层。工具的实现内部自己重试。
编排器层。整个执行失败时,就从头再跑一遍。
三个层次互相并不知情。最危险的组合是第三种。假设你正按顺序调用三个工具,第二个超时了,编排器于是重试整次执行 — 此时第一个工具已经把邮件发出去了,而第二个其实已经写进了数据库,只是没能把响应返回来。重试之后,你手上会留下两封邮件和两条记录。
超时之所以特别难缠,是因为「没有响应」并不等于失败。上面那份分析把 199 条超时特征单独统计出来,指向的正是这个问题。
解法的骨架是个老东西 — 给每一个有副作用的工具调用附上一个确定性的键,并把执行台账放在自己这一侧。
import hashlib, json
def idempotency_key(run_id: str, step_index: int, tool: str, args: dict) -> str:
# 1) 在两次重试之间会变的字段必须排除掉。
# 时间戳、请求 UUID、重试计数器,以及任何按「当下」算出来的值。
volatile = {"request_id", "timestamp", "now", "attempt", "trace_id"}
stable = {k: v for k, v in args.items() if k not in volatile}
# 2) 把序列化钉死。没有 sort_keys,相同的参数也会算出不同的键。
material = json.dumps(
{"run": run_id, "step": step_index, "tool": tool, "args": stable},
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
)
return hashlib.sha256(material.encode()).hexdigest()
要不要把 step_index 放进键里,是一个设计判断。放进去,就只有「同一次执行的同一步」会被去重,因此不会挡住模型有意把同一个工具调用两次这种正常行为。拿掉它,拦截会更激进,但也会一并吞掉正常的重复调用。我默认放进去,只对不可撤销的工具才使用执行级的全局键。
回到第三方 API 的问题:当你无法注入键时,可用的手段有三个。
第一,自己这边的执行台账。调用工具之前先用键查台账,如果已经有成功的记录,就把存下来的响应原样返回。窗口取 24 小时左右比较稳妥。台账必须把「调用开始」和「调用完成」分开记录,才有办法处理超时的情形。
ledger[key] = {
state: started | succeeded | failed,
started_at: ...,
external_id: 已创建资源的外部 ID(成功时),
response: 缓存下来的响应,
}
# state == started 却已经很旧了 = 我们并不知道结果。
# 此时重试就重复,不重试就丢失。往下一条走。
第二,先查后写(read-before-write)。大多数 API 不收幂等性键,但收搜索。创建文档之前,先找一找有没有同标题、同父级的文档,有就把它返回。这不完美(竞态仍在),但上面数据里那 159 次这类情形,大部分都能拦住。
第三,由我们自己埋自然键。在所创建资源的某个地方 — 标题后缀、描述字段、自定义属性、标签 — 塞进执行键。做法很脏,但能让先查后写变得精确。
另外,在第一次定义工具时就把副作用等级一并声明好,之后所有的策略都会挂在这个等级上。后面的表格会展开讲。
还有一点。把工具结果返回给模型时,失败的调用也一定要作为结果返回去。悄悄把错误吞掉,模型看到的是一个没有响应的工具调用,于是会重复同一个调用。大多数 API 都允许你给工具结果打上错误标记,那就用它。并行工具调用也是一样 — 如果一次响应里含有多个工具调用,结果也必须打成一包一次性返回。拆开发送,模型就会朝着不再做并行调用的方向学习(Anthropic 的工具使用文档明确写了这个行为)。
预算与步数上限 — 两种不同的失败
智能体会朝两个方向坏掉。要么永远转下去,要么放弃得太早。
步数上限是对无限循环的防御。它是循环迭代次数的上界,是一刀硬切。这里常犯的错误,是把撞上限而终止和正常终止当成一回事。撞上限意味着任务并没有完成,可光看返回值却分辨不出来。要显式返回终止原因,并且不能把超上限统计成成功。
预算则是另一种东西。它是 token、成本、墙钟时间的上界。而这里有一个近几年才出现的有用区分 — 模型知道的预算与它不知道的上限。
像响应 token 上限这种,是模型并不知道的硬切。撞上去,句子就会在中途断掉。相反,任务预算是你告知模型的值,于是它会看着剩余预算自行调节节奏,试着把事情收尾。Anthropic API 的任务预算就是这种方式(迁移文档里有说明),其最小值是 2 万 token。两者的差别在实务上很大 — 硬切产出的是被截断的成果,而告知过的预算产出的是被概括的成果。
我建议三者一起设上。
- 步数上限:无限循环防御。超出时说明原因,并统计为失败。
- 成本预算:按执行累计。接近超出时告知模型,超出则中断。
- 墙钟预算:尤其是在用户正等着的同步路径上。它必须是整次执行的截止期,而不是某一个工具的超时。
最后一项我想特别强调。HTTP 客户端的超时设置多半是按块读取的超时,所以只要还有字节零星地到达,它就会不断被重置。也就是说,那并不是总耗时的上界。你必须在执行循环之外用单调时钟去量截止期,并亲手把它切断。
非确定性控制流的可观测性
在一般的服务里,是代码定义控制流,所以看代码就知道走了哪条路径。智能体则是模型每次挑一条不同的路径。同样的输入跑两遍,工具调用的顺序可能不一样。因此你必须能在事后重建「到底发生了什么」,而这件事没法后补。
一次执行就是一条轨迹,每一步生成一个子 span。至少要留下这些。
# 执行级
run.id 重试也不变。执行台账的键材料。
run.trigger user | schedule | webhook | retry
run.terminal_reason completed | step_limit | budget | error | escalated
# 步骤级(循环一轮)
step.index 第几轮迭代
step.stop_reason 模型为什么停下(工具调用 / 结束 / token 上限 / 拒答)
step.model_id 想看模型是否中途换过就需要它
step.input_tokens 与缓存读/写分开记
step.output_tokens
# 工具调用级
tool.name
tool.side_effect none | read | write | irreversible
tool.idempotency_key
tool.attempt 1, 2, 3 ...
tool.outcome ok | error | deduped | denied | timeout
tool.latency_ms
tool.external_id 如果创建了资源
# 累计
budget.cost_spent
budget.wall_clock_ms
escalation.reason 如果交给了人,是因为什么
这里埋着几个设计判断。
tool.outcome 之所以单独设一个 deduped,是因为去重生效的次数本身就是一项指标。这个值突然上升,意味着模型转向了反复调用同一个工具,或者某处的重试正在失控。它防止问题只因为被安静地处理掉,就变得看不见。
把 tool.side_effect 留在 span 上,是为了事后分析。你得能问出「不可撤销的工具在一次执行里跑了几次」。
要不要留下提示词和工具输入输出的正文,是另一个决定。对调试来说是必需的,但个人信息和密钥会被原样存下来。实务上有一个折中比较稳妥 — 默认只留元数据,只对失败的执行以较短的保留期保存正文。而那些正文,本身就是回放夹具 — 把工具响应也一并存下来,改完代码之后就能确定性地复现同一个场景。一次生产故障,就变成了一个回归测试。
标准化的现状也值得了解。OpenTelemetry 的 GenAI 语义约定,截至 2026 年年中仍处在开发阶段,没有 1.0。2026 年 6 月 12 日的 v1.42.0 版本把相关的属性和 span 从主仓库拆到了专用仓库,但那是拆分,不是稳定化。也就是说,属性名仍然可能改变。遵循约定,同时在中间隔一层、别让仪表盘和告警直接挂在属性名上,会更安全。
按工具划分的权限边界
事故从「我给智能体挂了 20 个工具」这句话开始。你得把工具分成等级,并对每个等级施加不同的策略。
| 等级 | 例子 | 默认权限 | 重试策略 | 必须留下的记录 |
|---|---|---|---|---|
| 只读 | 搜索、读文件、查询类 API | 自动允许 | 随意重试 | 调用参数与结果大小 |
| 隔离写入 | 沙箱内写文件、临时表 | 自动允许 | 可重试 | 被改动的路径 |
| 外部写入(可撤销) | 建工单、写文档、推分支 | 自动允许 + 必须带幂等性键 | 查过台账再重试 | 幂等性键、外部资源 ID |
| 外部写入(难以撤销) | 发邮件、发消息、支付、部署 | 需要人工审批 | 禁止重试。失败进死信队列 | 审批人、审批时间、请求全文 |
| 破坏性 | 删除、改权限、改生产配置 | 考虑直接从工具清单里剔除 | 禁止重试 | 不适用(默认就不暴露) |
这里有几条原则。
权限挂在工具上,而不是挂在智能体上。一旦定下「这个智能体是管理员」,每加一个工具,权限表面就悄悄扩大一圈。更好的做法是按工具分别签发满足最小权限的凭据。
凭据绝不能进入模型的上下文。把 API 密钥放进系统提示词或工具参数的做法至今仍然常见,但那个值会永久留在对话历史里,还会被带进日志和摘要。密钥要在调用边界之外注入,让模型只看到占位符。
审批必须以工具调用为单位。像「本次会话内允许发邮件」这种会话级审批很省事,却分不清你批准的那一封和你没批准的那十封。审批请求里必须原样包含真正要发出去的内容 — 收件人、主题、正文。看着一段概括过的说明就点批准,那不是审批,是仪式。
用服务账号去搜索的架构是一次权限提升。对接入公司内部知识库或文件系统的智能体来说尤其如此。要把提问者的身份一路传播到工具调用,并在离资源最近的那个点上强制执行授权。
自信满满的错误答案与升级路径
最难处理的失败类型不是报错。而是智能体报告自己完成了任务,实际上却答错了,或者只做了一半,或者做了没人要求的事。报错可以设告警,这个不行 — 因为所有信号都指向成功。
需要三样东西一起上。
第一,把主张和证据分开。在提示词层面,「在报告完成之前,把每一条主张与本次执行的工具结果对照,只报告你能拿出证据的部分」这样的指令是真的有效。要让它把未经验证的条目明确说成未验证。这里还顺带掉出一个有用的观测指标 — 一次读取工具都没调用过就报告完成的执行,几乎总是可疑的。
第二,用代码去核验终止条件。放一个可以从外部检查的判定标准,而不是依赖智能体的自我报告。测试过了没有,工单状态变了没有,文件在不在。如果某个任务做不到这一点,那它很可能一开始就不适合交给自主执行。
第三,把升级变成正常终止的一种。很多实现把「交给人」当成失败路径,那样一来模型就会被压向不交出去。升级必须是和成功一样正当的终止状态,而且交出去的时候,下面这些要一起走。
escalation = {
reason: 「权限不足」 | 「信息互相矛盾」 | 「预算超出」 | 「把握不足」 | 「按政策需要审批」,
what_was_done: 已经完成的副作用清单(含外部资源 ID),
what_remains: 剩下的工作,
blocking_fact: 卡住的具体位置,
run_id: 以便接手,
}
what_was_done 是关键。接手的人首先要知道的,就是「什么已经被执行过了」。没有它,人就会从头再做一遍,副作用于是发生两次。前面说要设执行台账的理由,在这里再一次被兑现。
升级的条件要写清楚。琐碎的判断(变量命名、在两种等价方案之间选一个)让它自己定、只留记录就好;而范围变更或不可撤销的动作,必须让它来问。不给出这个区分,智能体就会走向两个极端之一 — 什么都不问,或者什么都问。
该对什么设告警
仪表盘一大堆、告警一个没有,这种状态很常见。把真正该把人叫起来的和留到周会上看的分开,就是下面这样。
立即告警
- 不可撤销等级的工具执行次数超过阈值。用绝对次数来设。不是比例。
- 未经审批就执行了的、需要审批的工具。这是一个应该恒为零的值,所以出现 1 次就是告警。
deduped比例的骤增。它是重试失控或循环异常的先行指标。- 升级队列的积压时长。没有人在看,升级就只是丢失。
- 单次执行成本高分位数的跳涨。看 p95 和 p99,不要看均值。失控的执行是少数,会被均值埋掉。
作为趋势来看的
- 终止原因的分布。如果
step_limit和budget的占比在上升,说明任务变难了,或者提示词退化了。 - 单次执行的工具调用数分布。尾巴变长,说明循环在打转。
- 没有调用读取工具就报告完成的执行占比。
- 用回放夹具跑回归测试时的通过率。
也有不该设告警的东西。单次工具调用失败不是告警对象。智能体看到工具失败之后去找别的路径,这是正常行为。要设告警的不是失败本身,而是失败之后没能恢复的那些执行。
收尾 — 可重试性是工具层的属性,而不是提示词的属性
在智能体运维里被反复确认的一件事是:问题的大部分不在模型这一侧,而在它下面的那一层。模型把同一个工具调用两次,本身并不是 bug。调用两次之后冒出两个资源,才是 bug。
- 给每一个有副作用的工具都配上确定性的键和执行台账。第三方不收幂等性键时,就用先查后写和埋自然键来替代。框架不会替你做这件事。
- 上限有两种。模型不知道的硬切产出被截断的成果,你告知模型的预算产出被概括的成果。两者都要设,但要把角色区分开。
- 可观测性没法后补。执行级的轨迹、工具级的 span,以及失败执行的回放夹具,都要从一开始就留。去重生效的次数同样是一项指标。
- 权限挂在工具而不是智能体上,审批按调用逐次拿,并且要把真实内容展示出来。
- 把升级变成正常终止。交接的时候如果没有把已经执行过的副作用清单一起带过去,人就会从零开始,把同样的副作用再触发一遍。
最后,我想再次强调上面那篇 GeekNews 帖子的作者自己加上的限定语。那些数字出自基准测试轨迹,而这种失败在生产环境里到底多常发生,因为没有人在数,所以并不知道。重复执行在准确率基准里会被当成一个普通的错误答案埋掉;在生产环境里,则会随着用户看到两份文档、顺手删掉一份而悄悄消失。没有人数的失败,看上去就像不存在的失败。