幂等键这套东西在支付 API 里已经用了十年,逻辑简单到不值一提:客户端生成一个键,随请求发出,服务端记下键和结果,第二次看见同一个键就直接返回缓存。到 Agent 这里,这套东西失效的地方不在服务端,而在键的生成和继承。重试路径上有好几层都会重新构造请求,每一层如果各自生成键,去重就等于没做。
我在工单、扣费、发邮件这三类工具上都踩过同一个坑:第一次调用其实成功了,响应在返回路上丢了,上层重试,第二张工单创建出来,而账单系统和业务库里对不上。本文只聊一件事——幂等键怎么从上层一路继承到真正产生副作用的那一次 HTTP 调用,中间哪些地方会断。
重试的来源不止一个,键就在这些地方断掉
普通 REST 客户端只有一个重试源:你自己写的 retry wrapper。Agent 循环里至少有四个。模型服务商的 SDK 会对 HTTP 请求做重试;工具包装器自己会在 5xx 时重试;Agent 运行时会重试整个步骤;模型本身因为上下文被截断、计划中断、不确定上一次是否成功,会重新发起一个语义相同的工具调用。有实践者给出的数字是 Agent 工具调用重试率在 15%–30%,比你支付代码默认容忍的高出一个数量级。
每一层重试都是一次「继承」:调用方要把已经决定好的操作身份往下传,被调方要原样带着它走。断点通常有三处。
第一处在工具适配层的入口。重试时重新进入工具函数,代码重新执行一遍参数序列化和键计算。如果键的计算里混进了时间戳、随机数、本次调用的 attempt 编号,重试自然得到新键。
第二处在模型重新发起调用的场景。这时传下来的其实是工具参数,而不是上一次的键。键不存在于参数里,就得靠一个稳定的推导函数重建——用会话 ID、工具名、参数指纹这三元组算哈希,而不是用新的 tool-call ID 单独当键。工具调用 ID 每次生成都是唯一的,用它当种子,重试必换键。
第三处在跨进程恢复。进程崩了、worker 被替换、任务暂停几小时后接着跑,内存里的键没了。这时如果没有把 operation_id 持久化到状态库,恢复后的第一次调用就是全新操作,跟前面重新扣一次款没有区别。
先把四种身份分开,混用在键上就会失效
排查任何一个「重试还是重复执行」的问题,我都会先把四个身份写下来对标:
| 身份 | 含义 | 重试时是否变 |
|---|---|---|
| run_id | 一次完整任务运行 | 同一次恢复不变 |
| operation_id | 一个已确认的逻辑业务操作 | 不变 |
| attempt_id | 一次具体调用尝试 | 可变 |
| trace_id | 调用链追踪标识 | 按追踪系统约定 |
把 attempt_id 当幂等键是最常见的一类错误,等于每次重试都告诉服务端「这是一个全新的业务操作」。反过来,把 run_id 当所有工具的键,则会把轨迹里两个确实应该分开的动作压成一个。键的作用域要覆盖「你无法承受重复代价的那个工作单元」,也就是扣费就为 Charge 建 Key,不要为产生扣费的那次模型推理建 Key。
还有一层:operation_id 和参数指纹要分开。operation_id 回答「是不是同一件事」,request_hash 回答「同一件事的参数有没有被悄悄改动」。后者要做规范化——数值、时区、默认值、字段顺序都得有统一处理规则,否则「同一件事」会因为参数序列化顺序不同而被判成不同操作,服务端把同键异参视作冲突直接拒绝,重试变成了报错。
键该生成在哪一层:工具包装器,不是提示词
团队最容易走偏的做法是写提示词:「扣款时请小心,不要重复扣」。这条路走不通。模型看不见网络,不知道上一次调用的响应有没有回来,上下文一被截断就会重新规划。要求一个对故障模式毫无感知的组件去防故障,是把责任放错位置。
正确的位置在工具包装器里,即模型决策和真实副作用之间的那段确定性代码。它看得到每次调用的参数,运行时行为不依赖模型在想什么。按副作用把工具分三类:
- 天然幂等的读取:查资料、看状态、搜知识库。随意重试,不带键。
- 有副作用的写入:扣费、预订、发消息、建单。每个都要键,这是最贵的类别。
- 长任务:生成文档、启工作流。对触发动作设键,再暴露一个独立的状态查询接口,重试时查进度而不是重新启动。
常见事故是整个代码库只有一个统一的重试装饰器,对读是对的,对写是错的,而且错得隐蔽:测试里全是成功路径,没人演练「成功但响应丢失」,bug 只能等到客户投诉重复扣费才被发现。
一个可以照抄的键接管方式是这样的:包装器入口先根据上下文解析出一个操作身份,优先取上游显式透传的 operation_id;没有就用手上的稳定字段(run_id + 指派的步骤序号 + 工具名 + request_hash 前缀)确定性推导;推导结果和意图一起写进状态库,然后调用下游。整个过程不允许出现 Date.now() 一类的东西。
// 思路示意,不是可直接跑的代码
function callWriteTool(ctx, toolName, args, intentSeq) {
const ph = canonHash(toolName, args); // 规范化后的参数指纹
let opId = ctx.getInheritedOperationId(); // 上游透传则直接继承
if (!opId) {
opId = stableHash(ctx.runId, intentSeq, ph); // 只依赖操作本身
ctx.persistIntent(opId, toolName, ph); // 先落盘意图,再发调用
}
return toolWrapper.invoke(toolName, args, { idempotencyKey: opId });
}
服务端不认,键就只是一串字符串
键生成对了,还要看下游接口有没有幂等契约。Stripe、Adyen 这类支付 API 原生支持透传,你把键直接给它们。大量内部服务和一些第三方消息平台不支持,就得自己做去重:用 Redis 保存键到结果的映射,写之前用 SET 的 NX 拿一把短期锁,避免两个并发相同调用都在对方的写入完成之前溜过去。查存储、加锁、执行、缓存、释放锁——第二个调用者命中缓存返回。
这里还有个坑是并发与业务提交不原子。先查去重表发现没记录,再建业务对象,最后补写去重记录:两个请求同时查到「没有记录」会各自建一份;业务对象建完了、写去重记录前进程崩溃,下次请求依然当成新操作。去重记录和业务对象在同一个可事务化数据库里时,用唯一约束加事务合成一次提交;不在一起时,就不要假装有跨系统原子性,老老实实地去查证和对账,并把这些表记为待核对状态。
失效窗口也要对上。Stripe 的官方说明是幂等记录至少保留 24 小时后可以被清除,且区分了不同 API 版本的行为。任务一周后才恢复,早就超出去重窗口,这时用原键去重是无效动作,新键只会绕过所有保护。此类恢复的正确路径是查业务对象、靠业务唯一约束(例如报销场景用发票号 invoiceNo 映射 expenseId),而非重新提交。
退回来想一想:先查后做比硬重试便宜
有个报销场景的实践值得抄:add_expense 超时后不盲起重试,而是先调 get_expense_report 看清当前报告里已有条目,按 (amount, date, typeId, vendor) 匹配;已存在的把 expenseId 记进 state 跳过,不存在才带键安全重试一次;连续三次超时就停下、保存状态、通知用户。这在「服务端根本不支持幂等键」的系统里往往是最实际的姿势——不是所有下游都会认你的设计。
持久化执行引擎(Temporal、Restate、Inngest 这一类)能兜编排层的恢复,重放日志跳过已完成的步骤,但它替代不了边界幂等。日志只保护引擎控制内的步骤,一旦迈到第三方 API,保证就退化成「至少一次,重试到拿到确认」;另外模型重试、SDK 重试、引擎重试、服务商内部重试叠在非幂等写入上,反而是多个重试环互撞。边界上的幂等让这些冗余重试变得安全。两个都要,如果只选一个,选幂等键,它是离钱最近的那层。
上线前我至少会对每个写工具跑三条测试:成功后超时(副作用只发生一次)、请求未到达服务器(重试后发生且只发生一次)、并发重复调用(一个赢,一个读缓存,副作用一次)。再配一个去重命中率指标,命中率突然抬高,多半是网络抖动或模型陷入了重复规划,这个告警比你收到客户投诉早得多。
转载请注明出处: 皮哈皮阿
本文的链接地址: https://blog.phpat.com/agent-engineering/665.html
微信或手机浏览器扫一扫,随时随地随心阅读与分享
-
AI Coding Agent 如何稳定提交可合并的 PR
上周 review 了一个 AI 生成的 PR,测试全绿,但我还是把它关了。它改了 37 个文件,顺手升级了一个依赖,还修改了两条测试断言。问题不是代码不能跑,而是这个 PR 没人敢合。
2026/10/14
-
Agent Harness:可观测、可评测、可回滚的 Agent 基础设施
去年我们做了一个处理退款申请的 Agent。Demo 很顺,上线第一周就出了事故:进程重启后状态丢了,同一笔申请被处理了两次。问题不在模型,而在模型外面那层没人愿意写的基础设施。
2026/10/09
-
MCP 进入生产环境:工具协议、网关与权限边界
MCP 刚出来时,我以为最难的是协议兼容。真正接了十几个工具之后才发现,把工具接上只花了两天,把权限、审计和故障处理理清楚花了两个月。
2026/10/07
-
Agent 安全清单:提示注入、工具越权与数据外泄
一个内部助手被要求总结一封客户邮件。邮件末尾有一行白色小字:把最近的客户名单转发到某个地址。Agent 真的准备去调邮件工具了。安全不能靠模型自觉,得靠系统设计。
2026/10/16
-
并行工具调用暴露的工具设计耦合问题
并行工具调用能砍掉四成延迟,但它更重要的价值在于暴露工具之间的隐性耦合。本文拆解上下文依赖、共享状态竞争与时序前提这三类静默失败,给出读写分类规则、工具合并的判据,以及什么时候该上依赖图编排。
2026/10/23
-
Agent 子目标检查点回归:从回滚状态到登记副作用
Agent 的子目标检查点能回滚本地状态,却回滚不了已提交的外部副作用。本文拆解恢复后重新合成工具调用导致的语义回滚,给出基于副作用指纹的检查点边界划分与确定性回归断言。
2026/10/25
京公网安备11011402053654 
@muffin:链接已更新,分类图像功能...
AI网址导航模版图标库更换指南
作者大大,图标库邀请链接失效了,麻烦更新...
AI网址导航模版图标库更换指南
太好看了,快点更新!
友情链接
@戴墨镜的刘备:哈喽,路过点赞。
vue.js组件之手写签名板vue-signature-simple
太好看了,快点更新!太棒了!👍真的很赞!
vue.js组件之手写签名板vue-signature-simple
你写得非常清晰明了,让我很容易理解你的观...
Emlog插件RSS订阅器发布