你构建过的每个分布式系统都依赖于一个隐形的假设:超时后的重试是安全的。操作是幂等的,因此如果客户端放弃等待并重新发送,最坏的情况也只是重复工作,并最终收敛到相同的状态。两个 PUT 请求落地同一行。两个 DELETE 请求留下同样的空缺。重试只是伪装成第二次尝试的“无操作”(no-op)。
LLM 调用打破了这一假设,而且是悄无声息地打破。重试并不会重新获取相同的答案 —— 它会采样一个新的答案。当客户端因为响应在传输中丢失而在网络层超时,但提供商实际上已经完成了生成时,重试会产生第二个、不同的答案。现在,对于一个逻辑请求,存在两个不同的输出,而你的技术栈中没有任何部分知道哪一个是权威的。
这并非罕见的极端情况。在模型背后运行超时机制的从业者报告称,即使底层调用最终成功,仍有 5–10% 的请求会触发完整的超时加重试循环。其中的每一次重试都是一次抛硬币,而你的系统从未被设计成去裁定这种结果。
为什么“超时重试”不再安全
重试原语源自一个确定性的世界。无论你调用一次还是五次,GET /users/42 都会返回同一个用户。带有完整正文的 PUT 请求在构建上就是幂等的。整个可靠性堆栈 —— 负载均衡器、重试中间件、断路器 —— 都建立在这样一个前提下:重新发起请求要么不产生新操作,要么会收敛。
采样生成则不具备这种结构。温度(Temperature)高于零意味着输出是从分布中抽取的,而不是查表得出的。即使温度为零,你也不一定能得到保证:跨 GPU 批次的浮点非结合性、取决于批次中其他请求的混合专家(MoE)路由,以及提供商端无声的模型更新,都意味着“相同输入”并不意味着“相同输出”。请求不再是读取。它更像是一次同样需要耗钱的掷骰子。
因此,超时造成了客户端无法从其端解决的三种歧义状态:
- 请求从未到达模型。重试是安全且必要的。
- 请求已到达模型,生成仍在运行。重试会开启第二次生成。
- 请求已完成,响应在返回途中丢失。重试会为一个已经回答过的问题产生一个不同的答案。
单纯的“超时重试”对这三种情况一视同仁。在确定性的世界里,这没问题,因为第二和第三种情况是无害的。但在非确定性的世界里,第二和第三种情况正是资金流失和 Bug 滋生的地方。
失败模式:隐蔽且昂贵
重复生成造成的损害很少表现为崩溃。它表现为一种缓慢累积的错误,没有任何单行日志能解释其原因。
双重计费。 这是最直接的影响。提供商按次生成收费,而不是按逻辑请求收费。如果客户端在响应丢失后重试,用户只需问一次问题,却会被收取两次费用。在 5% 的重试率下,这在小型应用中只是舍入误差,但在大规模应用中则是一项切实的开支 —— 而且它是隐形的,因为这两笔费用在隔离观察时看起来都是合法的。
副作用被触发两次。 这是危险的一点。在智能体循环(agent loop)中,一次生成往往就是“行动”的决定:模型发出工具调用,编排器执行它。如果该生成的传输被重试,模型会被重新采样,再次发出工具调用 —— 可能是一个不同的调用 —— 编排器随后运行它。退款被发放了两次。电子邮件发出了两次。一行数据被插入了两次。智能体框架中到处都是描述此类问题的 Open Issue:重试后工具被调用了两次,并行子智能体下的 MCP 调用被重复了四次。模型层无法承诺幂等性,因此副作用层继承了这种抛硬币的不确定性。
分歧的对话历史。 当重试产生第二个助手回复时,你的对话日志中现在包含了对同一条用户消息的两个答案。无论你持久化哪一个,它都会变成既定事实;另一个则变成了幽灵。如果用户在流式 UI 中看到了第一个(在传输中丢失的)答案,但你存储了第二个,你的转录记录就不再符合用户的现实 —— 而且该转录记录的每个下游消费者(包括你的评估流水线)都在为一个用户从未见过的回答打分。
评估流水线评分了错误的副本。 如果存在两个输出,而你的追踪摄取抓取了最后到达的那一个,你的评估(eval)就是在对一个从未交付的人造产物进行分级。仪表盘在变动,原因却无从寻找,你花了一个 Sprint 的时间去追踪一个从未有人读过的响应所产生的退化。
缓存污染。 以提示词为键的响应缓存可能会存储重试产生的答案 —— 即用户从未见过的那个。下一个拥有相同提示词的用户收到的生成结果,仅仅是因为三个请求前的网络波动而存在的。
针对非确定性后端优化的幂等键 (Idempotency keys)
解决方案与支付系统使用了十年的原始方案相同 —— 幂等键 (idempotency key) —— 但其语义必须进行调整,因为被去重的内容不再是确定性的。
形式如下:客户端 为每个逻辑请求生成一个唯一的键并附带上。位于应用程序和模型提供商之间的网关根据存储检查该键。第一次看到该键时,它运行生成逻辑,将结果与该键对应存储,并将其返回。之后每一个带有相同键的请求都返回 存储的 结果 —— 它不会重新采样。现在,逻辑请求精确地映射到一个答案,无论传输层在下面重试了多少次。
对于 LLM 调用,有两个细节比支付场景更重要。
键属于逻辑请求,而不是传输尝试。 如果你的重试中间件在每次尝试时都生成一个新的键,那么你什么也没构建 —— 每次重试仍然是一个新的生成过程。键必须在所有重试逻辑的上游生成一次,并向下传递到每一个可能重新发送的层级。这是最常见的实现错误:键虽然存在,但在错误的地方被重新生成。
参数不匹配必须报错,而不是默默重新运行。 如果一个带有已知键的请求到达,但 Prompt 或参数不同,网关应该拒绝它,而不是提供陈旧的答案或默默生成一个新的答案。已经提供幂等性 Header 的供应商已经这么做了 —— 当键被重复使用但参数不匹配时,OpenAI 的 API 会返回带有 HTTP 409 的 idempotency_conflict。在自己的网关中复制这一契约:键是一个承诺,即该请求是 同一个 请求,而不匹配则意味着这是一个值得大声指出的 Bug。
一个实际的难点:生成可能需要 30 秒以上,因此在第一次尝试仍在进行中时,重试可能会到达。存储需要三种状态,而不是两种 —— 进行中 (in progress)、已完成 (completed)、已失败 (failed) —— 这样并发重试就会阻塞在进行中的结果上,而不是启动并行的生成。只有“已见/未见”状态的幂等存储在生成缓慢的情况下仍然会导致双倍计费。
教会重试层识别“从未送达”与“可能已完成”的区别
幂等键让重试变得 安全。它们并不能让重试变得 聪明。重试层仍然需要做出决定,并且应该根据请求到达的程度来决定。
如果连接在发送任何字节之前就失败了 —— DNS 失败、连接被拒绝、TLS 握手错误 —— 则可以证明请求从未到达模型。重新采样是正确的,而且几乎不需要键。如果失败发生在请求完全传输 之后 —— 读取超时、响应中途连接断开、已经转发调用的网关返回 5xx —— 请求 可能 已经完成,重试必须走幂等路径。将此作为重试中间件中的分类体系进行编码:传输前失败可以自由重试,传输后失败只能通过键进行重试。
然后为副作用划定界限。幂等键对 生成 进行去重;它不会自动对 生成产生的工具调用 (tool call) 进行去重。将工具执行视为其自身的幂等单元:将工作流 ID、工具名称和参数哈希成第二个键,保留执行调用的分类账,运行每个处理器一次,并在任何重试中重放存储的结果。生成层和动作层各需要自己的“精确一次 (exactly-once)”边界 —— 一个保护你的账单,另一个保护你的用户。
最后,修复计费对账。将成本归于逻辑请求(由幂等键标识),而不是传输尝试。如果你的成本仪表板计算供应商调用次数,那么网关吸收的去重重试仍会显示为支出,除非你根据键进行对账。否则,你解决了正确性问题,却留下了会计问题。
值得铭记的启示
“超时重试”是从一个重新发送请求要么无害要么收敛的世界中继承下来的原始做法。将其原封不动地放入具有非确定性后端的系统中,会将每一次超时变成一次抛硬币 —— 既费钱、又可能触发副作用、还会悄悄分叉你对话历史的第二次掷骰子。
这种规程并不陌生。它是幂等键,是让支付 API 变得安全的同一套工具,外加非确定性所迫使的两个调整:为 逻辑 请求生成键,使其在每次重试中幸存下来;并将“精确一次”边界拆分为两个 —— 一个围绕生成,一个围绕它触发的副作用。审计你的技术栈,查看键是在哪里生成的。如果它产生于重试循环内部而不是外部,你就不具备幂等性。你只是在进行一个会改变答案的重试,而你还没发现罢了。
会员专享
余下内容仅对会员开放。
会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
- —完整文章,包含未公开存档的部分
- —可落地的工作框架,附带权衡与决策依据
- —新文章抢先看,先于公开发布
随时取消 · 一次订阅,畅读全部