跳到主要内容

推理服务提供商拒绝发送的背压信号

· 阅读需 10 分钟
Tian Pan
Software Engineer

你的重试逻辑在遇到 429 时会退避。当延迟上升时,你的队列深度告警会触发。在这两个信号之间,存在一个供应商负载区间,此时正确的做法是“减速 20%”——而供应商唯一会告诉你的是那个姗姗来迟的二进制限流信号。对于智能体集群协作来说,最有用的信号恰恰是没有任何推理 API 真正公开的那个。

429 是墓碑,而不是警告。当你收到它时,供应商已经认定你的流量过载,你已经浪费了一次请求的 Token 计数,而且——如果你与其他消费者共享租户——他们可能也收到了。有趣的故障模式不是 429 本身;而是它发生前的几秒钟,那时全世界的客户端都在“一切正常”和“你被切断”之间盲目飞行。

200 与 429 之间的死区

将这个问题构造成一个控制循环。你的客户端在决定是否发送下一个请求时有两个输入:响应状态码和响应延迟。两者单独来看都不够充分。

响应状态码是一个阶跃函数。在变成 429 之前,它一直是 200,中间没有任何过渡。不存在“你已达到 87% 容量”的状态码。速率限制请求头确实有帮助——Anthropic 在每次响应中返回 anthropic-ratelimit-tokens-remaining,OpenAI 返回 x-ratelimit-remaining-tokens——但这些描述的是你的令牌桶,而不是供应商的底层容量。你的令牌桶可能只用了 40%,而上游 GPU 集群可能已经达到 95% 的负载,并即将开始在所有客户中卸载负载。请求头告诉你的是你的合约情况;它们不会告诉你底层的实时状态。

响应延迟是另一个维度,它的噪声很大,足以击穿大多数简单的阈值设置。LLM 调用的延迟是 Prompt 长度、输出长度、模型选择、Prompt 缓存状态以及供应商负载的函数。前四个是你请求的属性。第五个才是你真正想要的信号,但它被掩埋在其他因素之下。一个天真的“当 p95 延迟上升时减速”规则,会在用户粘贴长文档时对你进行限流,而不是在供应商真正面临压力时。

因此,你最终得到的是一个由两个不可靠信号驱动的控制循环:一个触发太晚的二进制状态码,以及一个变动主要源于受控因素的连续指标。中间的死区是每个团队都会编写不同启发式算法的地方,而每个启发式算法都错得各有千秋,直到月底账单寄来。

相关性超调是多租户系统的顽疾

该问题的单客户端版本令人懊恼。而多租户版本则是结构性的。

如果推理 API 的每个消费者都在运行自己的客户端控制器,且无法看到其他人的流量,那么当供应商负载上升时,每个客户端都会独立地按原速率继续发送请求,直到各自触碰 429。429 报错会像波阵面一样袭来。每个客户端都退避。供应商负载骤降。客户端的重试定时器大致同时到期(尤其是如果他们都使用相同库的默认设置)。他们全部恢复发送。负载再次飙升。供应商再次限流。恢复过程在震荡而非收敛。

这是经典的惊群效应,但带有两个 LLM 特有的变数。首先,工作单元比普通的 HTTP 调用要昂贵得多——一次重试请求意味着数十秒的 GPU 时间,而不是毫秒级。其次,消费者不仅仅是在重试用户操作;它们是智能体循环,会根据返回的内容发出更多调用,因此一次成功的重试是一个 N 步爆发的开始,而不是结束。

结果就是,“我是一个守规矩的客户端”实际上并不能作为防御手段。即使你拥有完美的带抖动的指数退避逻辑,你仍然会助长相关性超调,因为其他所有人也都有这种逻辑,而同步源于共享信号(429 波阵面),而非客户端之间的任何协调。问题不在于糟糕的客户端行为,而在于缺乏一个共享的上游信号,让客户端能在波阵面形成之前进行调整。

TCP 做对了什么,而 LLM API 做错了什么

网络类比非常直观。TCP 在 20 世纪 80 年代就解决了这个问题。

TCP 并不只是不断发送数据包直到接收方抱怨。它运行着一个拥塞窗口(cwnd),在一切正常时加性增长,在检测到丢包时乘性缩小。这种设计的巧妙之处在于,发送方通过可观察的信号(丢包、ack 定时、ECN 位)推断拥塞情况,并在网络崩溃前进行调整。该协议公开了足够的信息,让发送方能够做出阶梯式决策——减速一半,而不是减速到零——而且跨多个独立发送方的 AIMD 算法在无需协调的情况下,就能产生大致公平、大致收敛的带宽分配。

LLM API 的等效机制并不存在。没有请求头会说“你正在调用的集群目前容量已达 92%,请放缓发送”。没有类似于 ECN 的机制,让上游可以在成功的响应中标记一个“顺便说一下,你也是造成问题的一员”的标志。也没有类似于慢启动阶段的机制,让新客户端逐渐探测容量,而不是在第一秒就达到其配置的最大并发量。最接近的东西——速率限制请求头——描述的是你的合约权利,而不是底层的健康状况,因此对于解决协作问题毫无用处。

这是一个协议设计缺陷,而不是客户端的 Bug。你可以编写世界上最聪明的自适应并发控制器,但你仍然只能通过充满噪声的代理指标来推断上游负载,因为供应商认为其自身负载不关你的事。将此视为客户端问题的团队将继续针对延迟波动编写更复杂的卡尔曼滤波器。而将此视为协议问题的团队最终会要求提供那个缺失的请求头。

从底层填补差距的模式

你不能坐等供应商提供你想要的请求头。在此期间,有三种模式可以提供帮助,大致按其缓解痛苦的程度和增加的复杂度排序。

从延迟方差(而不仅仅是延迟本身)中推断负载。 单一的 p95 延迟数值由于干扰太大而无法作为行动依据。滚动窗口内 p95 的 变化(针对请求形态进行归一化处理后)是一个更好的信号 —— 当针对固定提示词和输出长度配置的生成延迟方差上升时,这意味着上游排队情况已经泄露到了你的视野中。监视延迟二阶导数(而非其绝对值)的控制器可以在 429 报错浪潮到来前几秒主动进行限流。Vector 的自适应请求并发和 Uber 的 Cinnamon 自动调节器都针对通用 HTTP 流量实现了类似的功能;LLM 特有的改动在于,你需要按输出长度进行分桶,以免长文本生成被误判为拥堵。

将请求头视为领先指标,即使它们只涉及你个人的配额桶。 一个确实奏效的常见规则是:当 x-ratelimit-remaining-tokens 降至限制额度的 20% 以下时,预先将发送速率减半。这并不能解决底层基座的负载问题,但它消除了导致 429 的两条路径之一(即 自身造成的问题),因此当 429 出现时,你便知道这是供应商端的问题,并能采取不同的应对措施。将这两种情况一视同仁会导致最糟糕的行为。

在供应商动手之前,在熔断层级进行自我限流。 设定一个低于合同最大值的并发上限,并将其视为软限制。当延迟方差超过阈值时,进一步降低该上限。这是一种主动留出的余量 —— 你在状况良好时为未使用到的容量付费 —— 这也是在状况糟糕时避免参与关联性过载(correlated overshoot)所付出的代价。采用这种方法的团队,其账单通常比那些始终以 100% 合同容量运行并在限流风暴来袭时硬扛的团队更平稳。

对于在多个消费端应用前运行内部 AI 网关的平台团队来说,还有第四种模式:自己进行协调。网关能看到所有流量。它可以跨消费者分配容量,在发现方差上升时限制低优先级流量,并向供应商呈现一个单一且表现良好的客户端形象。这并非对协议缺陷的修复,而是将权宜之计集中化,这样下游各团队就无需重复造轮子。它奏效是因为你控制了网关边界的两侧。这在跨组织的情况下是行不通的。

关于协议设计的论证

从长远来看,答案在于供应商应将负载压力信号作为一类请求头(first-class header)公开。类似于在每个响应中返回 x-provider-load-class: green | yellow | red,或数字形式的 x-provider-utilization: 0.0–1.0。它不需要精确,不需要 SLA 保障,它只需要是一个非二进制、频繁更新的信号,让表现良好的客户端能在波峰形成前主动降速。

供应商会提出的反对意见是,公开该信号会教唆客户端去 利用 它 —— 即紧贴着边界线推进,然后再撤退。这种反对意见误解了这种不对称性。客户端已经在紧贴边界推进了,因为那条线是他们拥有的唯一信号。给他们一个分级的信号并不会让他们推得更凶;它能让守规矩的客户端更早退让,这意味着 429 报错会更晚出现,限流风暴恢复得更快,供应商的尾部延迟也会随之改善。这正是 TCP 设计者在 20 世纪 80 年代必须赢得的争论,他们获胜的原因也如出一辙:允许许多独立个体进行协调的共享信号,总比没有信号好,即使有少数个体可能会滥用它。

在这种请求头发布之前,每个基于推理 API 构建系统的团队都在含蓄地赌自己的自适应控制器比别人的更聪明。这场赌博是赢不了的。将背压(backpressure)视为客户端 Bug 的团队,将针对缺失的信号继续构建日益复杂的机制。而将其视为协议设计缺陷的团队,则会游说供应商,同时构建网关,并不再将波峰过载归咎于自己的代码。

你想要的背压信号是你的供应商尚未决定发送的信号。围绕它的缺失进行构建;同时要求它的存在。

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