跳到主要内容

改变答案的重试:针对非确定性 LLM 调用的幂等键

· 阅读需 10 分钟
Tian Pan
Software Engineer

你构建过的每个分布式系统都依赖于一个隐形的假设:超时后的重试是安全的。操作是幂等的,因此如果客户端放弃等待并重新发送,最坏的情况也只是重复工作,并最终收敛到相同的状态。两个 PUT 请求落地同一行。两个 DELETE 请求留下同样的空缺。重试只是伪装成第二次尝试的“无操作”(no-op)。

LLM 调用打破了这一假设,而且是悄无声息地打破。重试并不会重新获取相同的答案 —— 它会采样一个新的答案。当客户端因为响应在传输中丢失而在网络层超时,但提供商实际上已经完成了生成时,重试会产生第二个、不同的答案。现在,对于一个逻辑请求,存在两个不同的输出,而你的技术栈中没有任何部分知道哪一个是权威的。

这并非罕见的极端情况。在模型背后运行超时机制的从业者报告称,即使底层调用最终成功,仍有 5–10% 的请求会触发完整的超时加重试循环。其中的每一次重试都是一次抛硬币,而你的系统从未被设计成去裁定这种结果。

为什么“超时重试”不再安全

重试原语源自一个确定性的世界。无论你调用一次还是五次,GET /users/42 都会返回同一个用户。带有完整正文的 PUT 请求在构建上就是幂等的。整个可靠性堆栈 —— 负载均衡器、重试中间件、断路器 —— 都建立在这样一个前提下:重新发起请求要么不产生新操作,要么会收敛。

采样生成则不具备这种结构。温度(Temperature)高于零意味着输出是从分布中抽取的,而不是查表得出的。即使温度为零,你也不一定能得到保证:跨 GPU 批次的浮点非结合性、取决于批次中其他请求的混合专家(MoE)路由,以及提供商端无声的模型更新,都意味着“相同输入”并不意味着“相同输出”。请求不再是读取。它更像是一次同样需要耗钱的掷骰子。

因此,超时造成了客户端无法从其端解决的三种歧义状态:

  • 请求从未到达模型。重试是安全且必要的。
  • 请求已到达模型,生成仍在运行。重试会开启第二次生成。
  • 请求已完成,响应在返回途中丢失。重试会为一个已经回答过的问题产生一个不同的答案。

单纯的“超时重试”对这三种情况一视同仁。在确定性的世界里,这没问题,因为第二和第三种情况是无害的。但在非确定性的世界里,第二和第三种情况正是资金流失和 Bug 滋生的地方。

失败模式:隐蔽且昂贵

重复生成造成的损害很少表现为崩溃。它表现为一种缓慢累积的错误,没有任何单行日志能解释其原因。

双重计费。 这是最直接的影响。提供商按次生成收费,而不是按逻辑请求收费。如果客户端在响应丢失后重试,用户只需问一次问题,却会被收取两次费用。在 5% 的重试率下,这在小型应用中只是舍入误差,但在大规模应用中则是一项切实的开支 —— 而且它是隐形的,因为这两笔费用在隔离观察时看起来都是合法的。

副作用被触发两次。 这是危险的一点。在智能体循环(agent loop)中,一次生成往往就是“行动”的决定:模型发出工具调用,编排器执行它。如果该生成的传输被重试,模型会被重新采样,再次发出工具调用 —— 可能是一个不同的调用 —— 编排器随后运行它。退款被发放了两次。电子邮件发出了两次。一行数据被插入了两次。智能体框架中到处都是描述此类问题的 Open Issue:重试后工具被调用了两次,并行子智能体下的 MCP 调用被重复了四次。模型层无法承诺幂等性,因此副作用层继承了这种抛硬币的不确定性。

分歧的对话历史。 当重试产生第二个助手回复时,你的对话日志中现在包含了对同一条用户消息的两个答案。无论你持久化哪一个,它都会变成既定事实;另一个则变成了幽灵。如果用户在流式 UI 中看到了第一个(在传输中丢失的)答案,但你存储了第二个,你的转录记录就不再符合用户的现实 —— 而且该转录记录的每个下游消费者(包括你的评估流水线)都在为一个用户从未见过的回答打分。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates