2026 年 11 月 · 5 分钟阅读
一次超时,两次退款:幂等键该放在哪

关键定义
幂等键 调用方为一次业务意图生成的稳定标识,重试时复用同一个值,服务端据此返回第一次的结果,而不是把动作再做一遍。
至少一次投递 分布式系统的默认投递语义,一次调用可能被送达多次。副作用安全要靠幂等来保证,不能靠网络不重复。
多轮请求 MCP 2026-07-28 引入的模式,服务器需要更多输入时返回 input_required,客户端带着答案重发原始请求,不再反向调用客户端。
周六凌晨两点,一个客服智能体决定给客户退一笔 99 美元的订单。它调用退款工具,工具请求到了支付网关,网关开始处理,第七秒连接断了。客户端拿到的只有一个超时,没有结果,也没有错误体。框架按标准做法重试了一次。客户收到了两笔退款。
这条链路上每一环都合乎规范。HTTP 客户端遇到超时重试是对的,网关处理得慢是正常的,模型也没有判断失误。问题出在更早的地方:整个协议层面,它没有一个地方可以问,上一次调用到底落地了没有。
这件事今年变得普遍,是因为协议本身改了一次。2026 年 7 月 28 日的 MCP 规范把会话去掉了。去掉会话之后,跨调用的记忆只能落在请求体里,而一次重试的请求体,和第一次调用长得一模一样。
那份规范改掉了什么
变更目录的前两条就是关键。第一条,从 Streamable HTTP 传输层移除协议级会话和 Mcp-Session-Id 头。第二条,移除 initialize 握手,每个请求自己在 _meta 里带协议版本和客户端能力。需要跨调用保存状态的服务器,改用服务器自己签发的句柄,把句柄当普通工具参数传下去,状态存在工具自己的存储里。
这个方向是对的。旧模型把会话钉在处理 initialize 的那个进程上,于是只剩三个选择:负载均衡做粘性会话、所有实例共享一个会话存储、或者部署一个没人承认是单实例的单实例。三个都是运营税,而且都要到扩容那天才暴露。规范把这个税取消了,代价是另一件事随之失去位置。
整份变更记录讲的是身份、发现、订阅、多轮交互和任务扩展,没有一条讲重试语义、幂等键或者请求 ID 的去重。这一点不该被读成疏漏。协议负责把一次调用送到,本来就不负责判断这是第几次。旧的会话也没提供过这种判断,会话只保证同一个客户端落在同一个进程上,不保证副作用只发生一次。
重发原始请求变成了常规动作
如果重试只发生在错误路径上,问题还能靠一层封装兜住。这次规范把重发写进了正常路径。多轮请求取代了服务器反向发起的 sampling 和 elicitation:服务器需要更多输入时,返回 resultType 为 input_required 的结果,客户端带着 inputResponses 重发原始请求。
要说清楚,这个场景本身是安全的。第一轮服务器没有执行,它只是要求补充输入,重发是设计的用法。但它确立了一条惯例:重发原始请求是合法的客户端行为,服务端不能假设一次业务意图只会到达一次。同一条规范还要求每个请求带上 Mcp-Method 和 Mcp-Name 头,代理不解 JSON 就知道这是一次 tools/call 以及调的是哪个工具。这两个头解决了路由和限流,解决不了去重,因为它们描述的是调用的类型,不是这一次意图。
模型层的重试为什么必然重复
多数智能体框架的重试发生在模型层。工具调用超时,错误作为一条消息回到上下文,模型看到失败,决定再调一次。这一层能看到的事实只有一条:没有拿到结果。它看不到工具是否已经把副作用做完了。对工具来说,第二次到达的请求参数一样、没有键,就是一次新的调用。
分布式系统里这件事有现成的名字,至少一次投递。二十年来的答案也早就定了:要么让写操作本身幂等,要么让调用方带一个稳定的幂等键。区别在于,传统服务的调用方是代码,代码可以保证同一个业务意图用同一个键;智能体的调用方是模型,每次重新生成参数,键也跟着变。要求模型自己生成幂等键,等于让不确定的一方去保证确定性,结构上做不到。
按千分之五的超时率算一遍
把概率乘出来看规模。假设一个客服系统每天一万次写调用,连接中断或超时的比例按千分之五算,一天有 50 次调用拿不到回执。这 50 次里,工具已经执行完、只是回执丢了的比例按一半算,就是 25 次重复副作用。退款平均 120 美元,一天 3000 美元,一个月 9 万美元。这三个参数是我为展示量级设的,换成你自己的网关监控数据可以重算。
去重的成本在另一头。一天一万次写调用,一个月三十万个键,每条记录存键、响应和一个过期时间,这个量级一张普通表就够。所以差距不在技术,也不在钱,在归属:超时是基础设施团队的症状,重复退款是财务团队的症状,中间那一段没有任何一个团队的看板上有指标。
还有一个更隐蔽的放大器。重试往往不是一次,而是三次五次,因为框架的退避策略默认多试几轮。超时率是千分之五,重复执行的概率不是千分之二点五,而是随重试次数放大到接近超时率本身。按三次重试算,重复副作用的次数会接近前述算例的两倍。
重复发生了,怎么查出来
重复副作用发生之后,多数团队查不出来,因为两个动作在系统里长得几乎一样:一次正常退款,一次重试退款,隔几秒,金额相同,操作人都是同一个服务账号。要查出来只能按业务主键分组:订单号加动作类型,五分钟窗口里出现两次的就是候选,再人工确认一次。
这一步会暴露一个更麻烦的事实:幂等要的是业务主键,而业务主键经常不在工具参数里。模型调退款工具时订单号在参数里,可以用参数哈希做键;调建单工具时,业务主键要等工单建出来才有,参数里根本没有。前一类工具能靠参数做键,后一类不行,只能由上游的工作流 ID 兜住。
于是工具设计上多了一道选择题:让每个写工具都接受一个外部传入的业务标识,还是让执行层统一用一个工作流 ID。前者的键更精确,后者的覆盖更全。多数团队最后两个都做,因为只做一个总有一类工具漏在外面。
也有人会说,加一条数据库唯一约束不就完了。对一部分工具确实够,前提是重复执行会撞到同一个唯一键上。麻烦在撞键之后的代价:第二次调用拿到的是一个约束错误,模型看到的是一个新错误,它很可能换个参数再试,于是一次重复退款变成了一张重复的补偿单。约束防住了副作用,没有防住流程。
键能放三个地方,各有各的坏法
第一个位置是客户端,每次调用生成一个新的 UUID。这是最常见也最没用的一种:重试时生成的值和上一次不同,服务端看到的是两笔独立请求,去重逻辑完全不起作用。判断标准只有一条,这个键在重试前后是不是同一个值。
第二个位置是工具处理器,键由持久工作流状态派生,比如轮次 ID 加工具名加参数哈希。重试时工作流状态没变,键就没变,服务端可以直接返回第一次的结果。这是唯一稳定的位置,前提是键在模型之外生成,模型只负责提议调用,不参与决定这次调用算不算新的一次。
第三个位置在工具下面的执行层,也就是网关或者代理。好处是覆盖所有工具,配一次全线生效;代价是网关要能读到请求体才能算出稳定的键,而读请求体意味着网关要为每个工具解析参数结构。前面提到的那两个头部能帮路由,帮不了这一步。
还有一个容易忽略的参数:键的有效期要覆盖重试窗口,不是覆盖业务窗口。Stripe 的做法是默认 24 小时,理由是重试基本发生在分钟级,24 小时足够宽。有效期设成 5 分钟,夜间批量重放就会绕过它,而夜间重放恰好是很多批处理智能体的主要运行时段。
写操作分三级,策略跟着级别走
更根本的做法是把重试策略写进工具契约,而不是留在一个所有人默认它存在的地方。工具按副作用分三级,策略跟着级别走。
只读工具,重试自由,不需要键。这一级占工具库的大多数,也是大多数团队唯一认真测过的一级。
可回滚的写,需要键加补偿。键保证不重复,补偿保证重复了也能退回去。两个都要有,因为键的存储本身也会丢,丢的那一次没有补偿就是一筆查不回来的错账。
不可逆的写,需要确认门和硬停。转账、删除、对外发消息属于这一级。硬停的意思是重试次数用尽后直接失败并把上下文交给一个人,不再让模型自己想办法。多数框架在这里的行为是让模型换个参数再试,而换参数恰恰是把一次错误变成两个不同错误的方式。
现在的工具库里,这三级的元数据通常一条都没有。工具描述写给模型看,讲什么时候用、参数是什么,不讲重复执行会发生什么。这个空缺在演示期看不出来,因为演示期的调用是单次的、有人盯着的,超时也不会重试三轮。
我们的判断
重试安全是工具契约的属性,不是模型能力的属性。协议这次去掉会话做对了,去掉之后,跨调用的记忆只剩两个落脚点:工具自己的存储,或者调用方的持久状态。这两处都必须由代码来写,因为只有代码能保证同一个业务意图在多次尝试里拿到同一个标识,而模型的每次生成都是一次新的采样。
往下推两条可以验的预测。第一,工具清单里会出现一个必需字段,声明重试策略和键的来源,缺这个字段的工具在接入评审时被打回。第二,平台方会把这个字段做成产品能力,用配置而不是代码来保证,因为每个客户都会撞上同一个问题,而客户自己写对的比例不会高。
给正在接入的一方五个问题。工具库里哪些调用有副作用,这份清单谁维护,多久核一次?键从哪儿来,重试前后是不是同一个值?键存多久,存不下的时候降级成什么行为?不可逆的写有几道确认,确认人是谁?重复执行真的发生之后,谁在看那条对账记录?五个问题里第三个答不出来,这个工具就先别进生产,因为它的失败模式不会被任何监控发现。
数据口径与来源
移除协议级会话与 Mcp-Session-Id、移除 initialize 握手、跨调用状态改用服务器签发句柄、多轮请求以 input_required 返回并由客户端重发原始请求、Mcp-Method 与 Mcp-Name 头:MCP 2026-07-28 规范变更记录,一手。modelcontextprotocol.io/specification/2026-07-28/changelog
去状态化之后幂等键三个可放置位置的工程分析、超时导致重复退款的具体失败链:Swytchcode 工程博客,二手,其变更解读本身引自第三方摘要,上线前应核对规范原文。swytchcode.com/content/mcp-stateless-idempotency
幂等键 24 小时默认有效期:Stripe API 文档,一手。docs.stripe.com/api/idempotent_requests
至少一次投递与带抖动的重试退避:Amazon Builders Library,一手工程文献。aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter
本文算例参数:每天一万次写调用、千分之五超时率、五成已执行、单次退款 120 美元、三次重试,均为我为展示量级设定的参数,不是任何厂商的实测值或行业均值。乘除可用这五个参数重算。
常见问题
MCP 去状态化之后,跨调用的状态放在哪里+
服务器自己签发一个句柄,把句柄当普通工具参数传下去,状态存在工具自己的存储里。协议不再承载会话,任何实例都能服务任何一次调用。
幂等键应该由模型生成吗+
不应该。模型每次重新生成参数,键会跟着变,重试就被当成新请求。键必须从持久工作流状态派生,在模型之外生成。
重试策略怎么按工具分级+
只读工具自由重试,不需要键;可回滚的写需要键加补偿动作;不可逆的写需要确认门,重试次数用尽后硬停并交给人。
幂等键存多久合适+
覆盖重试窗口即可,Stripe 的默认值是 24 小时。设得过短会让夜间批量重放绕过去重,那恰好是很多批处理智能体的主要运行时段。
限流能不能替代幂等+
不能。限流减少调用次数,改变不了一次调用已经执行但回执丢失的事实。两者解决的是不同层面的问题。
拿你自己的执行日志跑一遍
交一份脱敏的近 30 天 agent 执行日志,48 小时内拿到八行表:八项技术项每项一行,每格带分子、分母和判据,缺口按是否要改 schema 排序。前三单 ¥8,000。
相关文章
真实的智能体流量里,多智能体几乎不存在
Datadog 一千多家客户遥测:59% 的智能体请求只调用一个服务,三次以上的仅 18%。复杂度搬进了进程内部,不在服务之间。
验收的人不能和干活的待在同一个进程里
58 条拿满分的轨迹,48 条至少违反过一次流程。验收点若和被验收的智能体住在同一个故障域,它看不见那次失败。
智能体交接要带走的是判据,不是任务描述
A2A 把一次交接定义成 Task:有编号、有上下文、有产物、有终态,却没有“上一次为什么被关掉”。五天里同一条链路建了五张卡。我们把交接载荷压成三个必填字段。
Agent 的门禁由平台合同裁定,不是法律
Amazon 用服务条款封禁 Meta 的购物 agent,弹窗只引 Conditions of Use。第九巡回已判 CFAA 不适用——agent 能否访问第三方系统,由对方合同说了算,买方应把它列为接入验收项。