跳到主要内容

速率限制层级崩溃:当你的智能体循环产生自我 DoS 时

· 阅读需 14 分钟
Tian Pan
Software Engineer

错误报告显示服务很慢。仪表板显示服务很健康。每分钟 Token 使用量处于层级上限的 62%,稳稳处于绿色安全范围内。然后你打开追踪(traces)查看形态:一个用户请求生成了一个规划步骤,该步骤发出了 11 个并行工具调用,其中 4 个是搜索扇出,每个都触发了子智能体,而这些子智能体又分别并行调用了 3 个工具——那个单一的“请求”现在正同时从 47 个不同的工作线程猛击你自己的 Token 桶。产品的其他 99 名用户被堵在它后面,收到了他们本不该得到的 429 错误。你的智能体正在对自己发起 DoS 攻击,而速率限制器(rate limiter)正在忠实执行你给它的指令。

这就是速率限制层级崩塌。你购买了为 HTTP API 设计的边界防御系统,在那样的系统中,一个请求等于一个工作单元;然后你把它连接到一个请求意味着深度未知且分支因子无界的树形系统前端。单一桶模型不仅无法提供保护,而且它的失败是隐形的,因为你的聚合数据从未突破任何限制。损害发生在尾部、相关的爆发中,以及那些恰好在时间上紧邻重度请求的专注用户身上。

速率限制的初衷是防止行为异常的客户端压垮共享基础设施。这一前提假设客户端大多是简单的循环:轮询任务、webhook 重试、爬虫。解决方案是为每个 API 密钥分配一个 Token 桶,大小根据客户支付的层级而定。一个进程,一个密钥,一个桶。你可以凭直觉进行推演。

智能体系统打破了这一前提。一个面向用户的请求不再仅仅是一个客户端;它是一个规划器(planner),在运行时决定发出多少个并发子请求。并行工具调用——这个因能减半实际延迟而被所有人开启的功能——意味着在第一个响应到达之前,规划器的输出可能会膨胀成数十个进行中的调用。子智能体使情况更加复杂:树的每个分支本身就是一个规划器,拥有自己的扇出。单个“请求”的有效分支因子可以轻松达到两到三个数量级。

面对这种情况,扁平化的每 API 密钥桶不仅没用,反而有害。它无法告诉你哪个用户触发了爆发。它让单个失控的智能体有权耗尽所有配额,使其他租户陷入饥饿。并且它将模型自身的恢复行为——重试、重新规划、回退——变成了一个放大器。

扁平桶混淆的四个层级

如果你的速率限制器只有一层,它就是在执行一个不变式。智能体系统至少有四个不变式,将它们折叠进一个桶中意味着你每执行第四个不变式时,就会违反其中的三个。

**租户层级(Tenant level)**是你那扁平桶实际保护的对象:即针对提供商级上限,客户组织的聚合支出。这是最粗略的信号。它回答了“整个账户是否超过了其层级?”,但对账户内部的分布却只字未提。

**用户层级(User level)**是你产品对租户内单个终端用户做出的公平性保证。如果没有它,一个用户的失控智能体会悄无声息地窃取同组织内其他所有用户的容量——这是经典的“喧闹邻居”模式,由于一个糟糕的提示词(prompt)就能发出 50 个工具调用,现在它具有了 AI 特有的爆炸半径。这是决定你的产品是否让人感觉可靠的层级。

**请求树层级(Request-tree level)**是分配给单个终端用户请求及其所有后代的预算。如果没有它,一个过度规划的请求会耗光用户的每用户配额,接着是他们的租户配额,即使他们的其他工作本可以轻松容纳。这是防止一个糟糕的提示词毁掉用户整个下午的层级。

**工具层级(Tool level)**是针对每个下游的保护:你的向量数据库每秒可以处理 300 个请求,你的内部搜索集群可以处理 50 个,第三方天气 API 可以处理 10 个。这些是与模型 Token 无关的后端硬性限制。如果没有它,一个决定并行调用最慢工具 50 次的规划器,会通过搞垮工具而不是被告知“不行”来发现这一点。

一个成熟的智能体速率限制器会同时执行这四个层级,请求受限于所有适用预算中的最小值。为了节省实现成本而扁平化其中任何一层,只会让故障转移阵地。

为什么重试会让情况变得更糟,而不是更好

面对 429 的自然反应是使用指数退避和抖动进行重试。这是无状态 HTTP 客户端的正确定式。对于智能体来说,它是相关故障的放大器,具体原因值得详述。

首先,工作单元不对。指数退避计算的是请求数;而提供商计算的是 Token 数。一个 200 Token 的请求和一个 12,000 Token 的请求都被计为“一次重试”,但后者成功时消耗的预算是前者的 60 倍。你的重试计划测量的是错误的轴线,因此无法为你何时停止碰壁提供有用的保证。

其次,扇出是同步的。一个在规划步骤中发出 11 个并行工具调用的智能体通常会同时收到 11 个 429 错误,因为当这 11 个调用到达时,底层的桶是空的。随后,每个原始的重试库都会以相同的基数退避它们——除非抖动设置得足够大,否则它们会一起恢复并重现爆发。每一次“重试风暴”在结构上都与最初导致 429 的那次完全相同。

第三,重试并非没有代价。智能体循环中的重试会消耗上下文窗口、实际延迟、用户耐心,以及至关重要的一点——重新检查故障的规划器上的 Token。如果考虑到插入错误消息导致的上下文膨胀、累积延迟导致的超时以及规划器被更长的追踪搞糊涂,那么在一个 8 步的智能体循环中重试 3 次的 2% 工具错误率,可能会演变成 15–20% 的端到端故障率。重试预算是一种稀缺资源;请像对待稀缺资源一样对待它。

第四,规划器通常通过重新规划来进行隐式“重试”。如果你的智能体对失败的工具调用的反应是“再想一想,换个方法”,那么你现在就有两种形式的重试在同时发生:HTTP 客户端的重试和模型的重试。两者互不相知。两者都没有共享预算。一个糟糕的提示词可以同时触发两者,从而产生重试库设计初衷本想防止的那种病态。

实际的做法是将重试预算视为与速率限制预算同等地位的一等资源。为每个用户请求分配一个在其树结构中所有工具调用和模型调用共享的有界总重试次数。激进地扣除。当预算耗尽时,直接报错,而不是默默地在自我修复中耗尽用户的配额。

规划器真正可以消耗的协作式背压

这是几乎没人实现的部分。你的规划模型可以看到系统提示词、工具和对话,但它看不到你的限流器。因此,当它发出 11 个并行工具调用而你用 429 响应其中 3 个时,规划器并不理解系统在说“慢一点”。它理解为有 3 个工具坏了。它要么会重试它们——消耗更多预算——或者尝试绕过它们,这通常意味着选择一个不同的工具并再次调用 那个 工具 11 次。

你希望规划器接收的信号不是单个调用上的 429;而是关于它接下来允许进行的工作形式的陈述。将其视为注入下一轮对话的、具备预算意识的系统消息:

  • “此请求的预算中还剩余 4,200 个 token。”
  • “由于搜索工具的后端压力,并行工具调用目前限制为 3 个。”
  • “天气工具处于冷却状态;在接下来的 30 秒内优先使用缓存快照。”

模型(尤其是最近的模型)在面对具体的指导时表现得异常出色。其成本很低:在系统提示词中增加一个额外部分,每轮更新一次。其好处是规划器会停止发出你本来就要拒绝的扇出(fan-out),从而在源头而非边缘消除 429 风暴。

相反的做法——沉默的拒绝——不会教给规划器任何东西。它会重试、重新规划或幻觉出成功。这三种情况都会让仪表盘变得更糟。

一个微妙的陷阱:不要将原始的桶状态(bucket state)丢进提示词中。“你在 A 桶中有 8,441.7 个 token,在 B 桶中有 311 个,补充速率为 1000/s……”这会比不给信息更让模型困惑。将数字翻译成模型可以采取的行动:可以进行多少个并行调用,优先使用哪些工具,是否进行批处理。模型非常擅长遵循以其自身动作空间词汇表陈述的约束。

在并发中依然有效的预算核算

如果限流仅在 LLM 调用返回后才进行检查,那么它就已经失败了。当你得知成本时,预算已经花掉了;你是在记账,而不是在执行。更糟糕的是,如果你的智能体发出并行调用,每个调用都会竞相消耗桶中的资源,而恰好最先检查的那个会胜出——完全忽略了其他正在进行的调用。

准则是预提交核算(pre-commit accounting):在发送任何调用之前,针对所有适用的桶(租户、用户、请求树、工具)预留 最坏情况 的 token 成本。如果预留失败,调用就不会发出。当调用返回时,将预留与实际成本进行核销——退回未使用的部分,或扣除超出的部分。将预留视为一种锁,而不是一个建议性的数字。

对于并行扇出,在发送任何分支之前,预留 所有 分支最坏情况成本的总和。这是难倒平庸实现的一步:它们按调用预留,结果第一个分支成功了,第二个也成功了,而第六个在进行到一半时突破了桶的限制——此时你已经承诺了五个无法完成的调用。如果你负担不起整个扇出,就拒绝该扇出并告知规划器缩小其计划。

最坏情况评估很棘手,因为在调用之前很难预测输出 token。一种可行的近似方案是:使用模型声明的 max_tokens 作为上限,并在完成后进行核销。你会过度预留,这意味着你有时会拒绝本可以容纳的请求。这就是权衡;这是为了防止失控的补全耗尽桶中资源,导致其他人无米下锅而付出的代价。

保持层级架构诚实的可观测性

你不能仅用一个“429 计数”指标来运行四级限流架构。层级架构的全部意义在于区分 哪一层 拒绝了请求,因为每一层对故障排查都意味着不同的含义。

  • 租户级的拒绝意味着客户超出了他们的方案。这是销售或自动扩容层面的对话。
  • 健康租户内的用户级拒绝意味着某个用户非常活跃;这是产品或 UX 层面的对话。
  • 请求树级的拒绝意味着某个提示词扇出过于激进;这是提示词工程或规划器上限层面的对话。
  • 工具级的拒绝意味着特定的后端是瓶颈;这是基础设施层面的对话。

发出标有 拒绝层 以及租户、用户、请求 ID 和工具的指标和追踪。聚合仪表盘在这里毫无意义;只有当你能够在一个维度上进行过滤并查看其内部分布时,有趣的模式才会显现。如果 92% 的拒绝来自单个层级,那就是需要投入的地方。如果它们均匀分布在所有四个层级中,说明你的层级之间校准失衡。

然后部署一个金丝雀(canary):一个低流量的合成智能体(synthetic agent),以固定间隔运行常见工作流,并标记为独特的合成用户。如果它在实际总流量稳定时开始收到 429,说明扁平仪表盘之下的层级发生了偏移——通常是因为一个新用户的提示词风格正在以意想不到的方式扇出,挤占了你的金丝雀所在的租户下的用户级桶。

这种失效模式在爆发之前极其隐蔽

这个问题之所以难以早期察觉,是因为没有任何单一信号看起来异常。租户的每分钟 Token 数(TPM)维持在配额的 60%–70%。单一 429 报错率处于 “SLA 范围内”。Agent 的成功率很高,因为失败被重试机制静默吸收了。真正退化的是仪表盘无法显示的指标:少数用户经历了极不稳定的延迟,甚至偶尔会出现完全停滞,因为他们的调用在共享令牌桶的竞争中,输给了其他用户的低效 Prompt。

当问题反映在聚合指标上时,已经进入了平台期 —— 你的 Agent 平台在超过特定并发量后便无法继续扩展,且无人能准确解释原因。并非架构失效了,而是架构从未真正建立起来。

修复方案必须是结构性的,而非缝缝补补:为每一层设置独立令牌桶;为每个请求树(request-tree)预留预算;建立重试预算以限制单个 Prompt 可能造成的破坏;引入协作信号,让规划器在派发那些最终会被拒绝的任务之前就能做出调整。一旦你的产品开始为真实用户处理真实的 Agent 任务,这些措施便不再是可选项。因为它们所防范的病态问题并非极端情况,而是任何将四维复杂度压缩至单一维度的系统的必然表现。

Agent 系统中的速率限制并非针对滥用的惩戒手段,而是流量控制的基础构架,防止某个用户过于激进的规划步骤拖垮整个平台。请务必据此构建系统,否则这种 “扁平化” 的弊端迟早会找上你。

References:Let's stay in touch and Follow me for more thoughts and updates