跳到主要内容

负载降级是为人类设计的,而 Agent 会放大你正在抵御的风暴

· 阅读需 13 分钟
Tian Pan
Software Engineer

对人类来说,503 意味着一个“稍后再试”的页面和一段咖啡休息时间。对 Agent 来说,503 只是在七次重试中的第一次尝试前那 250 毫秒的挫折,而且规划器(planner)已经开始询问 LLM 是否有其他工具可以绕过这个失效的依赖项。第一种行为为过载的服务提供了恢复空间。第二种行为则是过载服务的噩梦:数以千计的关联重试,每一次都比人类的操作更廉价、更快速,其中一半还会扩散(fan out)到下一个依赖项,因为规划器认为那是一个富有创意的变通方案。

负载脱落(Load shedding)—— 即通过丢弃低优先级任务来维持高优先级路径可用的准则 —— 是在流量发送主体主要是键盘前的人类,或者是具有手动调优重试策略且行为良好的服务的时代设计的。当 Agent 集群出现时,这两个假设都会瞬间崩塌。Agent 重试速度更快,能同时从更多地方发起重试,绕过故障重新规划,并把你返回的 503 视为负载均衡的暗示,而不是你本意中希望达成的协作式背压(back-pressure)信号。

本文将探讨为什么标准的负载脱落策略在面对 Agent 客户端时会失效,上游服务需要什么样的原语才能真正卸载 Agent 流量,以及 Agent 本身在工具层和规划层必须做些什么,才能不再成为别人事故报告中的恶意流量。

为什么现在 503 的意义截然不同

HTTP 错误的语义从未被完整地书面记录。它们是服务器和客户端之间的一种契约,这种契约之所以有效,是因为客户端大多是由拥有共同心智模型的人类编写的:429 意味着减速,503 意味着我们已满,Retry-After 是你应该遵守的提示。浏览器、CDN 和行为良好的 SDK 都将这一契约内化了。重试风暴曾被视为 Bug,而且这些 Bug 足够罕见,以至于运维团队可以为它们命名。

Agent 并不共享这一契约。典型 Agent 的工具层将每一个非 2xx 响应都视为一个可以重试的瞬态波动(transient blip),因为设计这种循环的 LLM 是在 Stack Overflow 的代码片段上训练出来的,在那些片段中,带退避算法(backoff)的重试是通用的好建议。更糟糕的是,当重试最终耗尽时,规划器并不会去喝咖啡休息 —— 它会询问模型该怎么办,模型会建议使用另一个能得到类似答案的工具,于是 Agent 现在开始冲击一个完全不同的依赖项,而这个依赖项从未在关联需求下进行过压力测试。

最终状态是大多数平台团队至少见过一次的情景:Agent 集群的出现看起来与协调一致的 DDoS 攻击毫无区别,只不过操作者的意图是友好的。流量特征是统一的,时序是关联的,请求模式不像人类驱动的会话那样多变,而且背压信号被忽视了。根据你的 WAF 所调优的每一项特征,这些 Agent 就是 攻击。

三大放大器

当涉及到 Agent 时,有三个乘法因子会将微小的上游波动演变成停机故障。

更快的重试。 人类的请求循环内置了冷却时间 —— 人类会阅读错误信息,切换标签页,一分钟后刷新。Agent 的循环则不然。一个简单的工具调用重试策略会在第一次重试后的一秒内开始第二次尝试,四秒内开始第三次,在任何人类尺度的系统察觉到上游异常之前,就已经烧掉了六次重试。指数退避(Exponential backoff)有所帮助,但前提是它确实被接入了;在实践中,许多 Agent 框架提供的重试原语其默认值是根据人类的延迟预算调优的,而不是为了上游的生存。

关联的扇出。 当一万名人类用户遇到 503 时,他们各自根据自己的时间感做出反应。当一万个 Agent 线程遇到 503 时,它们会在 同一 时刻重试,因为它们运行着相同的重试策略和基础延迟。抖动(Jitter)是标准的解决办法,但抖动是你必须记得添加的功能 —— 而大多数工程师复制粘贴的默认重试装饰器中并不包含它。更糟的是,当 Agent 被部署为托管池(例如一个每个节点运行 N 个 Agent 工作线程的后端服务)时,重试风暴会在节点级别聚集,因此单个上游波动会演变成来自单个源 IP 的同步浪潮,这会在错误的层级触发你的速率限制器,使其看起来像是一个异常的客户端,而不是一个集群。

绕过故障重新规划。 这是人类时代负载脱落机制无法防御的一点。当工具 A 失效时,规划器会询问模型该怎么办。模型会 —— 乐于助人且流利地 —— 建议工具 B 可能可以实现类似的目标。工具 B 有自己的依赖图、自己的速率限制和自己的配额池。现在,你的脱落机制导致负载发生了 转移,而不是消失。用户可见的失败率保持在较低水平(Agent 最终成功了),但服务成本翻了一番,而原本应该吸收溢出流量的依赖项悄然成了下一个失效的目标。在最坏的情况下,规划器发现并行调用工具 A 和工具 B “更稳健” —— 现在每一次重试都变成了两个请求而不是一个。

为什么标准原语不再适用

大多数负载分流(load-shedding)实现将请求分为不同的优先级段。Netflix、AWS 和 SRE 规范都描述了相同的模式:付费客户处于顶层,其次是登录用户,再往下是匿名用户,最底层是已知的爬虫。当系统过载时,你会自下而上地丢弃请求。

当主体(principal)稳定时,这种模式行之有效。但当主体是“代表用户操作的智能体(agent)”时,它就分崩离析了。智能体继承谁的优先级?大概是用户的——但智能体可能是一个用户并未关注的后台任务,30 秒的延迟无伤大雅;也可能是一个用户正盯着的前台助手,1 秒的延迟都不可接受。相同的用户身份涵盖了这两种情况。基于主体的速率限制(Rate-limit-by-principal)无法区分它们。

同样的问题也出现在 Retry-After 中。这个响应头是为单一客户端设计的,其中“等待 30 秒”是一个连贯的指令。但对于智能体,“等待 30 秒”是模糊的:规划器(planner)是应该暂停整个工作流,还是只暂停这条工具路径,或者尝试使用不同的参数调用同一个工具?如今大多数智能体框架根本不会将 Retry-After 传递给规划器——工具层在本地遵守它,然后立即失败进入重新规划步骤,忽略上游的建议并尝试相邻的操作。

此外,旨在保护上游免受单一租户影响的配额(quotas),当一个提示词(prompt)扩展为多个工具调用时会发生统计错误。一个发出一次提示并触发四次工具调用加三次重试的用户,按配额算法计算,相当于七个用户的负载。定价模型、配额模型和用户的心智模型各不相同,这种分歧往往在平台压力最大时演变成计费纠纷。

上游需要构建什么

首先需要改变的是上游服务,因为智能体客户端既没有视野也没有权限靠自己解决这个问题。有三个原语大致是强制性的。

一个 流量类别标签(traffic-class label),用于区分智能体驱动和人类驱动的请求。这不仅仅是一个响应头约定;它必须穿透认证层,以便速率限制器和负载分流器能够识别。一些平台已经开始使用诸如 client-mode: interactive | batch | agent 之类的请求属性,并作用于 API 密钥。分流器在发生故障期间将智能体模式的流量视为较低优先级,即使底层用户是高级会员,其理论依据是智能体模式的任务比盯着加载图标的人类更能容忍“稍后重试”的语义。

协作式分流信号(Cooperative shedding signals),让智能体层(而不只是工具层)能够据此行动。标准的 Retry-After 响应头告知直接客户端进行等待。目前缺失的是一种表示“停止规划新任务”的信号——一种在智能体栈中向上传播的提示,以便规划器暂停其循环,而不仅仅是 HTTP 重试。一些平台已经开始尝试在 429 错误中使用结构化响应体,包含 recommended-action: pause-workflow | switch-region | abandon 字段。虽然现在还处于早期阶段,约定尚未标准化,且大多数客户端都会忽略它,但这正是正确的方向:上游比客户端更了解自身状态,赋予智能体更丰富的背压(back-pressure)词汇库,让它能够选择放弃计划,而不是盲目重试。

基于意图的速率限制(Rate-limit-by-intent),而不仅仅是基于主体。智能体群会针对每个用户提示发出数百个相关的请求;真正起作用的限制器应该是询问“这个集群是否集体尝试了过多的操作?”。在实践中,这意味着为请求标记一个在工具调用之间持久存在的工作流 ID(workflow ID),在工作流级别进行速率限制,并暴露一个智能体在开始新步骤前可以检查的配额。令牌桶(token-bucket)原语本身没有问题;改变的是桶里计数的内容。

智能体必须做什么

即使具备了所有这些上游原语,智能体层也承担着一半的责任。有几项准则可以将行为良好的智能体与导致负载放大的智能体区分开来。

工具层必须将 429 和 503 视为协作信号,而非瞬时错误。 这听起来显而易见;但在实践中,绝大部分生产环境的智能体代码将所有非 2xx 响应都视为可以盲目重试的对象。一个 ReAct 风格的循环会将大部分重试预算浪费在当前窗口内永远无法成功的错误上。解决方法是对失败进行分类:延迟后可重试(带有 Retry-After 的 429)、抖动后可重试(没有特定提示的 503)、不可重试(非速率限制的 4xx)以及针对该工具的致命错误(工具已下线,放弃它,本次运行不再推荐使用)。每一类都需要不同的策略,并且必须在框架层面强制执行——而不是留给模型在每一轮对话中自行重新发明。

规划器必须知道何时放弃,而不仅仅是何时重试。 这是更难的准则。一个总是绕过失败进行重新路由的规划器,是一个为了追求用户可能不再想要的目标而消耗无限成本的规划器。工具层面的熔断器(circuit breaker)会有所帮助:当一个工具触发熔断时,规划器会将其视为本次运行中不可用的工具,而不是被要求去“创造性地绕过”一个已知的故障依赖。针对每个工具的熔断器还能防止一个性能下降的服务耗尽其他工具所需的重试预算。

成本预算必须包括浪费的重试,而不仅仅是成功的工作。 大多数智能体成本看板统计令牌(token)数,有时统计工具调用次数,但几乎从不统计为失败尝试所做的工作。这个审计漏洞正是重试风暴(retry storms)潜伏的地方。将每次重试视为一项有预算的操作,并使该预算对规划器可见,能让系统在服务成本激增之前有理由放弃——并给运营人员提供一个清晰的信号,表明上游出了问题,而不是将其掩埋在不明原因的令牌激增中。

抖动(Jitter)是默认选项,而非可选项。 智能体拥有的每条重试路径都应在工具层应用完整的抖动,理想情况下,抖动窗口应以工具 ID 为键,这样重试相同工具的两个智能体就会错开,而重试不同工具的两个智能体也不会意外碰撞。这是清单上成本最低的修复方法,也是最常被忽视的方法。

缺失之物的轮廓

更深层次的问题在于,我们现有的负载脱落原语(load-shedding primitives)是为请求与主体(principal)之间的一对一关系而设计的。Agent 打破了这一现状。一个单一的用户提示词会演化成一棵工具调用树;一个单一的工具调用可能变成跨区域的扇出;一次单一的失败则可能触发重新规划,从而导致负载翻倍。适配这个世界的新原语——工作流作用域的配额、传递给规划器的协作式脱落信号、跨跳持久化的流量类别标签、意图感知限流器——尚未标准化,在大多数网关中也未实现,且大多数 Agent 框架也并未一致地遵循。

架构上的结论是令人不安的:按主体限流(rate-limit-by-principal)是人类时代的原语。按意图限流(rate-limit-by-intent)才是 Agent 时代的原语,但目前还没有人完整地构建出来。在这种机制出现之前,这项工作只能由 Agent 运营者和平台团队手动协商:为你的流量打上标签、遵循 Header 头部信息、在重试中加入抖动(jitter)、尽早放弃无效路径,并且不要再让 LLM 把 503 错误当成一个创意写作的提示词。上游服务最终会演进出适配新世界的原语。而能在这一转型期生存下来的 Agent,将是那些在等待过程中没有被列入 WAF 黑名单的佼佼者。

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