速率限制与重试:构建有韧性的 LLM 调用
托管 LLM 会以寻常的方式失败——限制、超时、瞬时错误。一点点重试纪律,就能把一个脆弱的集成变成一个可靠的。
几乎每一个 LLM 集成的第一个版本,都在开发者的笔记本上跑得好好的,却在生产中第一个繁忙的下午就垮掉。原因很少在于模型本身。而在于一个托管 API 是一个共享的网络服务,而共享的网络服务会以寻常、可预测的方式失败:它们施加限制,它们超时,它们偶尔返回一个无非意味着"过一会儿再试"的错误。一个脆弱的集成和一个可靠的集成之间的区别不在于聪明。它在于一点点被一致地施加的重试纪律。本指南讲清楚会出什么错,以及如何冷静地处理每一种情形。
你的调用为什么会失败(以及哪些失败是正常的)
先把失败分成两堆,因为它们要求相反的应对。
瞬时失败是暂时的,从任何持久的意义上说都不是你的错。服务被短暂地过载了,你撞上了一个速率限制,一个请求超时了,一个连接断开了。其决定性特征是:*同一个请求,只要你再发一次,就可能成功。*这些正是重试存在的理由。
永久失败不会因为重复而好转。一个格式错误的请求、一个无效的密钥、一段违反政策的提示词、一个对模型而言过大的输入——再发一次只是浪费时间和配额,更糟的是,还会把你更深地拖进一个速率限制里。其决定性特征是:这个请求是错的,而不是不走运。
有韧性的 LLM 代码中最重要的一个习惯,就是把这两者分辨开来、并区别应对。对一个永久失败进行重试是一个 bug。对一个瞬时失败硬性地失败也是一个 bug。大多数脆弱的集成,到处都在犯这两个错误中的一个。
理解速率限制
速率限制是最常见的瞬时失败,而它们不是惩罚。它们是一个共享服务保护自己及其他用户、不被任何单个客户端压垮的方式。提供方通常同时沿着几条轴线给用量设上限——你在一个时间窗里发出多少请求,以及你在那个窗里推送了多少总工作量(常以 token 来衡量)。你可以保持在一条上限之下,却仍然撞上另一条。
实际后果是:你不能仅凭数请求数来推断你的吞吐量。少数几个非常大的请求,就可能耗尽一个 token 预算,而你离请求上限还远着呢。当一个速率限制被触发时,服务会明确地告诉你,往往还附带一个关于该等多久的提示。正确的应对不是更狠地猛敲。而是慢下来,过会儿再回来。
两个习惯能在大多数速率限制的痛苦开始之前就防住它。第一,读响应头和错误体——提供方在那里暴露你当前的用量和限制,而那个信息正是智能地退避的输入。第二,抹平你自己的流量:如果你有一阵突发的工作,把它铺开,而不是一股脑全部打出去,这样你就能逐步逼近限制,而不是一头撞上去。
用正确的方式重试
当你确实要重试时,如何重试关系极大。那个朴素的做法——立刻重试,一遍又一遍——是有积极害处的。如果服务被过载了,一股即时重试的洪流会让它更糟,而你就成了你正试图熬过去的那个问题的一部分。有纪律的做法有三种成分。
**指数退避。**在第一次重试之前稍等一会儿,然后在随后每一次之前把等待时间大致翻倍。第一次打嗝得到一个快速的第二次机会;一个持续的问题则得到越来越多的喘息空间。这单单一个模式就化解了绝大多数的瞬时失败。
**抖动。**给每次等待加上一个小小的随机量。没有它,许多在同一瞬间失败的客户端,会全都在同一瞬间重试,制造出一场同步的踩踏,把服务再次压垮。抖动把重试铺散开来。这是一个微小的改动,在规模上却有着超乎比例的效果,而省掉它是一个经典的错误。
**一个重试上限。**给尝试的次数和你愿意花的总时间设上限。永远重试下去,会把一次短暂的中断变成一个挂起的请求,占着资源、并让等待的人沮丧。到了上限之后,干净地放弃,并浮现出一个真实的失败。
合起来:在一个瞬时错误上,用指数退避加抖动来等待,重试至一个固定的上限,而如果提供了一个关于该等多久的提示,就让它优先于你自己算出的延迟。
超时,以及重试的局限
每一次调用都需要一个超时,而选择它是一个货真价实的权衡。太短,你就放弃了那些本会成功的请求,把缓慢但没问题的响应变成了失败。太长,一个卡住的请求就挂起你的系统、占着一个用户。根据你实际预期的响应长度来挑一个超时,并记住长的生成确实需要更久——一个为单行答案调好的超时,会错误地杀死一个要求长答案的请求。
超时与重试以一种会咬人的方式相互作用。一个请求可能在你这边超时,而服务器仍然在为它工作。盲目地重试,你就可能把同一份昂贵的工作跑两遍。对于只读的生成,那不过是浪费。而对于任何造成一个效果的调用——发送一条消息、写入一条记录、触发一个工具——重复执行就是一个真实的 bug。防御办法是幂等性:把有副作用的操作设计成做两次也安全,往往是通过附上一个唯一的键,让服务器能据以识别并去重一个重复。
当重试用尽时优雅地失败
韧性不只关乎恢复。它也关乎在恢复无望时把失败做好,因为有时服务确实宕了,再多的退避也无济于事。一次优雅的失败有几个性质。
- **它是有界的。**用户或调用方系统在合理的时间内得到一个清晰的答复,而不是无限期地挂起。
- **它是降级、而非崩溃。**在产品允许之处,回退到某种有用的东西——一个缓存的结果、一条更简单的非模型路径、一句诚实的"此功能暂时不可用"——而不是一个空白的错误。
- **它是可见的。**带着足够的上下文记录这次失败,好让日后能理解它,并浮现出一个你可以监控的信号,这样一个上升的失败率在你的用户把它升级之前,就先抵达了你。
一个成熟的集成的标志,不在于它从不失败。而在于当它失败时,下游没有任何东西感到意外。
在它伤人之前看见它
你无法调你看不见的东西。至少要追踪:你按类型划分的失败率、重试触发的频率和它们最终成功的频率、你的延迟(包括花在退避上的时间),以及你离你的速率限制还有多近。最后那一项是预警系统:用量朝着上限爬升,就是你的信号,去铺散流量、优化提示词,或请求更高的限制——是在撞墙之前,而不是之后。大多数速率限制事件,对任何一个在看的人来说,都能提前几小时作为一个趋势被看见。
总结
有韧性的 LLM 调用,归结为一套简短、枯燥的纪律。把瞬时失败和永久失败分开,并对各自正确地应对。用指数退避、抖动和一个坚定的上限来重试瞬时失败——绝不要在一个紧凑的即时循环里。通过抹平你的流量、并读懂服务告诉你的东西来尊重速率限制。审慎地设置超时,并让有副作用的调用幂等,好让一次重试永远不会执行两遍。当重试用尽时,以一种有界、可见、降级的方式失败,并盯住你的限制好让你在撞墙之前行动。这一切都不光鲜,而这一切,正是一个能熬过繁忙下午的集成、与一个熬不过的集成之间的区别。
