跳到主要内容

你的 API 曾假设一次只有一个人类用户。并行智能体打破了这一契约。

· 阅读需 14 分钟
Tian Pan
Software Engineer

我认识的一位后端工程师在一个周二的下午盯着一个从未有过波动的 Datadog 图表:其内部日历服务的单用户 429 计数器。投诉的客户并没有改变他们的行为。他们只是开启了助手功能,现在每当用户说“帮我找下周的时间”时,该功能就会针对同一个日历 API 并行启动八个规划线程。速率限制器(Rate Limiter)——每用户每分钟 60 次请求,这个设置非常合理,是多年前针对一个在物理上无法点击得那么快的 UI 编写的——在每次请求的前三秒内就会触发,并悄无声息地破坏了助手一半的响应。

速率限制不是 Bug。契约才是 Bug。那个后端,就像大多数在 2024 年之前编写的内部服务一样,在每一层都植入了一个悄然执行的假设:一个用户意味着一条活动流,其节奏受限于人类的反应时间,拥有一个 cookie 罐、一个 CSRF 令牌和一套在出现问题时可以重新提示的凭据。Agent 一次性粉碎了所有这五个假设,故障表现为一系列看似无关的事件——429 风暴、“最后写入者胜”(last-write-wins)导致的数据损坏、无法取证的审计日志、挂起无头工作线程的重新认证循环——在模式被命名之前,没有人会将它们联系起来。

我一直与平台团队沟通的一个简短总结是:你拥有的每一个后端都与它的调用者有一个未记录的契约,而那个契约是与人类协商达成的。现在 Agent 出现了,要求重新协商。你可以选择在代码审查中主动进行协商,也可以选择在下一次事故期间被动进行。

Agent 打破的五个假设(以及为什么它们在触发前是隐形的)

“单用户按人类速度操作”并非单一假设。它至少包含五个假设,分层贯穿于技术栈的不同部分,由不同的团队负责,且每一项在孤立来看都是站得住脚的:

  • 速率限制是为稳定的人类节奏而设计的。 真实的人会点击、阅读、输入,然后再点击。每分钟 100 次请求的限制对此绰绰有余。而一个 Agent 扇出规划器可以在 10 秒内发送 500 个请求,然后静默 5 分钟。令牌桶在 5 分钟的时间窗口内能完美填充,但限制器依然会不断触发,因为是节奏不对,而不是总量不对。
  • 幂等性被视为客户端的问题。 当“你”是一个注意到重复扣费并投诉的人类时,“如果你重复提交,那是你的问题”是行得通的。但当“你”是一个规划 Agent,在遇到暂时的 502 错误时通过从头重新运行其工具调用来进行重试,服务器会静默地为每项操作创建两份副本,而 Agent 则会报告成功。Idempotency-Key 请求头自 2021 年以来一直是 IETF 草案,但大多数内部 API 仍将其视为可选。
  • 会话和 CSRF 令牌假设只有一个 cookie 罐。 单页应用(SPA)会话模型依赖于每个浏览器的 cookie 和绑定到该会话的 CSRF 令牌。针对同一个逻辑用户启动 10 个并行的 Agent 工作线程,你就会拥有 10 个 cookie 罐,或者是一个由 10 个并发写入者共享的罐子——这两种模式都会破坏原始认证设计者从未测试过的环节。
  • 审计日志记录的是动作,而不是授权链。 当 U 是一个可以被询问发生了什么的人时,“用户 U 在时间 T 更新了记录 R”就足够了。但当 U 是一个代表人类行事的 OAuth 主体(Principal),而该人类又是代表一个上季度获得授权的服务账户行事时,“用户 U 做的”就是一个由于遗漏导致的谎言,合规性审查最终会发现这一点。
  • 锁定语义采用“最后写入者胜”,因为同时打开两个标签页的人类很罕见。 用户在两个浏览器标签页中打开同一条记录并同时编辑曾是一个值得忽略的边界情况。现在,三个 Agent 在同一秒内向同一条记录写入已成为常态,而你 2019 年做出的“我们只用最后写入者胜”的决定现在正悄无声息地丢失数据。

这些都不罕见。每一项都是资深工程师会独立辩护的决策。问题在于这五项同时成立,而 Agent 的工作负载在启用后的第一天就会同时测试这全部五项。

速率形态失配不是通过调优就能解决的问题

当 429 激增时的第一直觉是提高限制。这是错误的,而且这种错误方式带来的损失超过了它所节省的。

考虑一下速率限制器到底是干什么用的。主要有两个任务:保护后端免受单个租户耗尽容量的影响,以及约束来自泄露凭据的滥用。这两个任务都以相同的单位衡量——每个主体的每分钟请求数——因为对于人类用户来说,每分钟请求数是资源消耗以及“此账户的行为像一个正常账户还是像一个数据抓取脚本”的一个合理代理。

Agent 解耦了这两个单位。现在每分钟的资源消耗是爆发式且高昂的;行为信号现在变得毫无意义,因为每个账户看起来都像脚本。为了适应爆发而提高每分钟的限制,意味着你的滥用启发式检测消失了,而你的容量保护也变成了痴人说梦。

重新设计的思路是将预算分为两个维度。并发预算(Concurrency budget) 限制给定主体在同一时间可以处于处理中的请求数——这是保护后端的关键,因为处理中的请求直接对应于线程池、数据库连接和下游 API 配额。令牌桶(Token bucket) 限制一段时间内的总工作量,但你可以将其设置得很宽松,因为你已经通过并发控制限制了最坏情况下的扇出。一个试图针对并发限制为 8 的服务启动 500 个并行线程的规划器,要么会排队,要么会收到带有重试提示的快速 503 响应,或者(最好是)收到一个带有 Retry-After 响应头的 429 错误,Agent 的执行器知道如何遵循该响应头。图表将不再呈现误报的锯齿状。

第二部分是将每个工具的配额与每个主体的配额分开。工具目录具有截然不同的影响范围——搜索调用消耗几毫秒和一条公共列表端点,而“发送电子邮件”调用则消耗外部 API 费用和送达率声誉。在同一个单用户预算下将两者都视为“每次请求 1 个单位”,正是这种抽象失效让一个有 Bug 的 Agent 在 15 分钟内耗光你的事务性邮件配额。

幂等性现已成为契约,而非特性

我在事后分析中经常看到一种模式:一个智能体(Agent)从后端收到 502 错误,然后它从其规划循环的最顶端开始重试,而不是仅仅重试失败的调用,导致后端最终创建了两个相同的资源。修复方案总是一样的——让端点接受 Idempotency-Key 请求头并存储第一次尝试的结果——而得到的答复也总是一样的:“我们会把它加入待办清单(Backlog)。”

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