Agent工具接口也要防重试与越权
文章以Agent遇到502后重复退款为例,说明工具封装缺少幂等机制会直接造成业务事故。作者主张按公共API标准设计工具层,落实版本、超时、权限范围和限流,并按操作风险分类。
AI 深度解读
一个 Agent 在测试环境里跑了六周,上线第二天却把同一笔退款执行了三次。原因并不玄乎:支付 API 返回 502,模型决定重试,工具封装层照单转发,却没有幂等保护。System Prompt 里那句「执行破坏性操作前先确认」,没能阻止事故。
作者借此提出一个判断:把模型当成 API 调用方,把工具层当成公共 API 来设计。对程序员来说,这篇值得读的地方,是把 Agent 的风险拆成了熟悉的工程问题:重复请求、执行预算、超时取消、权限范围,以及模拟环境与生产环境的偏差。
工具怎么分组,决定了运行时能拦住什么
作者最初的 Agent 有 11 个工具。工具数量并非事故关键,更关键的是工具注册表有没有表达风险。
常见的 stripe.*、github.*、s3.* 按供应商分组,只能告诉你调用谁,无法说明调用后会发生什么。作者建议给每个工具增加 side_effect,分成三类:
read不改变外部状态,可以重试、并行或循环执行。write创建或修改状态,需要通过幂等键避免重复执行。destructive包括删除、撤销、向人发送消息、转移资金,需要幂等键和模型之外的审批关口。
这里的「破坏性」比删除文件更宽。邮件发出后,即使数据库可以回滚,对方也已经收到;退款执行后,也不能靠撤销一次函数调用恢复现场。
风险分类只有进入运行时的执行规则,才算真正生效。
作者主张,只有 read 可以放宽超时和重试限制,destructive 则必须经过独立审批,或者仅在 dry-run 模拟环境中运行。分类无法确定的工具,暂时就不该暴露给模型。
幂等键要由程序生成,不能让模型自由发挥
不要靠提示词要求模型「别重试」。输出被截断、JSON 解析失败、遇到 429,甚至升级模型,都可能改变它的重试行为。作者建议按「每次工具调用至少会发生两次」来设计。
幂等解决的是:同一项操作即使收到多次请求,也只产生一次效果。作者用 run_id、工具名和参数生成稳定键,让下游 API 负责去重。
下面这段代码避免参数字段顺序改变去重结果:
def _stable_key(run_id: str, name: str, args: dict) -> str:
# Sorted JSON so dict ordering from the model can't change the key.
blob = json.dumps(args, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(f"{run_id}:{name}:{blob}".encode()).hexdigest()
重点是 sort_keys=True。模型先输出 {"a":1,"b":2},再输出 {"b":2,"a":1},两次生成的键仍然相同。使用 run_id 而非时间戳,则让同一轮重试复用键,新一轮请求获得新键。
调用入口负责扣预算和注入幂等键:
def call(run_id: str, name: str, args: dict, budget: Budget) -> dict:
budget.charge()
spec = REGISTRY[name]
if spec.side_effect != "read" and "idempotency_key" not in args:
args = {**args, "idempotency_key": _stable_key(run_id, name, args)}
# Production: run in a worker with spec.timeout_s and kill it on overrun.
return spec.fn(**args)
要盯住包含 "idempotency_key" not in args 的那一行:它只在参数缺少键时补齐,实际上仍接受调用方传入的键。这与作者后文「绝不能让模型提供幂等键」的要求存在缺口。照搬时,应从模型可填写的参数中移除该字段,并由运行时统一生成。
这套方案还有一个前提:下游确实支持去重。示例中的 refund.create 把键传给 payments.refund;哈希函数自身不能阻止重复退款。
预算、超时和 dry-run,必须真的约束执行
Token 用量容易看到,但便宜的工具调用也可能产生昂贵后果。一次只花 80ms、消耗零 Token 的动作,仍然可能发送客户邮件、创建法律工单,或者启动每小时 4 美元的 VM。
作者提出三种独立预算:每轮工具调用次数、每轮实际耗时、每轮修改状态或破坏性动作次数。他给交互式 Agent 默认设置 20 次调用,批处理设为 100 次;破坏性工具的独立上限通常更小,往往只有 1 次。代码中的 Budget 默认是 20 次、120 秒,并在每次调用前检查。
超限后应抛出 RuntimeError("tool budget exhausted"),让运行时终止后续工具调度,并允许模型收尾。只记日志没有约束力;直接杀掉生成进程,又可能丢掉排障所需的轨迹。重试也必须逐次扣费,不能把多次派发算成一次。
超时同样不能只停在配置上。原文 ToolSpec 默认 timeout_s=5.0,退款工具设为 8.0,但示例没有实现超时执行器。作者提醒,asyncio.wait_for 用于 HTTP 调用可以成立,线程里的 subprocess.run 却可能在停止等待后继续运行,需要终止底层进程组。
dry-run 则应返回与真实调用相同结构的结果,同时不产生实际副作用。它可以便宜到合成响应,也可能贵到维护影子数据库。作者更倾向通过凭证和 sandbox endpoint 切换环境,减少 if dry_run: 提前返回的分支;否则六个月后,真实路径新增的校验可能没有同步进模拟路径。
还有两个容易引发连锁反应的入口:把 40KB JSON 硬截成 2KB,可能制造格式错误并诱发重试,应返回摘要和完整结果的句柄;网页、工单正文等外部文本可能包含提示注入,应隔离标记或清理,并配合每轮短期、最小权限凭证限制影响范围。
我的判断
我认同作者把责任放回工具运行时。模型行为会变化,而去重、预算和权限都应提供可验证的边界。
但这篇提供的是一次事故复盘和工程经验,没有给出引入这些措施前后的故障率,也没有对照实验。20 次、100 次、120 秒和破坏性动作 1 次,都只能作为配置起点。
原文自身也有需要补齐的地方。分类把转移资金列为 destructive,退款示例却标成 write,没有解释依据;代码只实现总调用数和截止时间检查,没有实现独立的破坏性动作预算。
run_id + 参数 也不等于业务操作身份。模型改了参数,或者用户因网络问题开启新一轮请求,都会产生新键。涉及资金时,是否还需要订单或退款业务层面的唯一约束,必须另外设计。
幂等负责防重复,审批和权限负责判断能不能做,三者不能互相替代。
同理,给外部文本加边界标记并不能保证消除提示注入。作者提出的这些措施适合作为多层约束,不能被理解成单项配置就能解决问题。
能拿来做什么
- 给现有 AI 编程工具逐项补上
side_effect,区分读取文件、修改文件、删除资源和对外发送消息,并明确哪些动作需要独立审批。 - 在 Node 工具调度入口集中实现预算检查、逐次计数和幂等键注入,让所有重试都经过同一个入口。
- 检查长输出和超时路径:大结果返回摘要与句柄,启动子进程的工具要验证超时后底层进程是否真的退出。
- 选一个会修改外部状态的工具接入 sandbox endpoint,用相同参数和返回结构验证流程,再检查短期凭证是否只覆盖必需的接口。