跳到主要内容

你的 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)。”

那个待办事项应该被列为 P1 优先级,因为幂等性的缺失不再仅仅是一个潜在风险。对于人类用户来说,重复提交只是偶尔发生且用户能察觉到的事情。但对于智能体用户,出错重试是市面上每一个智能体框架的默认行为。Stripe 在十年前就为支付业务搞清楚了这一点,因为搞错的代价是真金白银;而那些涉及任何外部状态——发送通知、创建日历事件、修改记录——的后端,也即将以自己的代价换取这个教训。

实现难度小得惊人。在每个变更类(Mutating)端点上接受 Idempotency-Key 请求头。将请求体与该 Key 一起进行哈希处理。如果遇到重复的 Key 且请求体哈希匹配,则返回原始响应。如果 Key 重复但请求体哈希不同,则返回 422 状态码——这属于客户端错误(Bug)。将 Key 保留 24 小时。这就是全部规范,对于你希望智能体调用的任何服务,这现在都是不可逾越的底线。

更难的部分是在网关层将其设为强制性。如果智能体“忘记”发送幂等 Key,在触发重试的那一刻就会悄无声息地损坏数据。将来自智能体主体的变更调用中缺失 Key 的情况视为 400 错误——明确契约,拒绝请求,迫使 Bug 在开发阶段就暴露出来。

“代表(On behalf of)”必须具有实际意义

名为 user_id 的审计日志列从来不是为了回答“究竟是谁授权了这次操作”这个问题。它过去是为了回答“这是谁的会话”。当会话属于人类且人类为自己授权时,这两个问题有相同的答案。

对于智能体,这两个问题的答案发生了不可逆转的分歧。RFC 8693 (OAuth 2.0 令牌交换) 和“代表”流程(On-behalf-of flow)已经为此建模:令牌携带一个代表授权人类的 sub 声明,一个代表正在行动的智能体的 act 声明,并且 act 声明可以链式传递——智能体的智能体代表用户,每一层都记录它在请求中添加的内容。标准并不新鲜,新鲜的是围绕执行和记录这些标准的工具。

你的后端需要做出以下改变:

  • 审计日志 schema 增加三个一等字段。 principal(最终授权操作的人员或服务)、actor(直接调用者——通常是智能体身份)以及 delegation_chainact 声明的 JSON 路径,以便取证审计员可以回溯权限流转过程)。一列已经不够用了。如果你继续将智能体身份塞进 user_id,你将失去在六个月后辨别一条记录是被本人删除、被其助手删除、还是被该助手调用的第三方智能体删除的能力。
  • 请求头必须透传,否则链路会在第一跳断裂。 边缘网关下游的内部服务需要接收并转发 principal/actor 请求头,而不是将其剥离。这正是那种在事故发生并变成每个人的麻烦之前,没有任何团队愿意牵头负责的跨团队协作工程。
  • 授权决策基于执行者(Actor),而非仅仅基于委托人(Principal)。 一个人可能有权删除记录。但代表他们行动的智能体可能没有权限——或者根据政策需要单独的同意。if user_can_delete(record) 这种检查形式已经不再适用。它需要变成 if actor_can_delete(record, on_behalf_of=principal),让策略引擎能够根据执行者身份和委派范围进行推理。

我听到的对此的推托是“我们以后再加,审计日志现在挺好的”。其实并不好。当监管机构或事故响应团队需要重建是谁授权了某次写入操作时,“审计日志显示是用户 U”而没有区分执行者,这就是一个你无法回溯修补的漏洞,因为数据从未被捕获。趁现在表规模还小,赶紧增加这些列。

“智能体友好”陷阱

当这份变更清单出现在平台团队的路线图上时,人们很容易被诱惑,将其全部归类为“智能体友好 v1”之类的标签,并作为一项功能发布。这种定性是错误的,其方式会导致比什么都不做还要糟糕的结果。

“智能体友好”听起来像是你在完成一些可选工作后贴在文档上的标签——类似于“支持 GraphQL”或“webhooks v2”。它暗示了存在一个供智能体使用的并行 API 表面,人类可以忽略它,并且需要一个漫长的迁移过程,其中一些端点支持智能体而另一些不支持。在实践中,智能体可以触达的每个端点(也就是说,你的身份验证边界后的每个端点)现在无论你喜欢与否,都是智能体契约的一部分。你面临的选择不是“我们是否支持智能体”——因为它们已经在调用了——而是“我们的契约是书面化并强制执行的,还是正在生产环境中通过一次次事故被逐渐摸索出来的”。

真正有效的思路是将契约视为对你授权层所面对的每一个 API 的重新谈判。一组小型且强制性的变更——并发预算、幂等 Key、审计日志中的委托人/执行者拆分、用于委派调用的 OAuth on-behalf-of、针对每个工具的配额——应用于每一项服务,并由网关统一执行新契约。可选功能可以稍后推出。强制性功能必须先行,因为“唯独这项服务忽略幂等性”的失败模式是隐蔽的数据损坏,而不是优雅降级。

我看到那些做得出色的团队,会为非人类调用者制定明确的 AAA 规范(认证、授权、审计),由平台团队负责,并每季度审查每个后端的合规性。而那些做不好的团队,仍在忙于根据上周的报警调整速率限制。

周一该做什么

如果你的后端是在 2024 年之前设计的,而你现在正开启智能体(agentic)功能,那么这种“重新谈判”势在必行。关键在于你是掌握主动权,还是被动应对。以下是回报最快的三项具体行动:

  • 针对“突发流量 vs 稳态节奏”的不匹配,审计你的限流器(rate limiters)。 监测请求的时间分布,而不仅仅是速率。找出那些方差较高的主体(principals)——这些通常就是智能体用户——在提高每分钟限额之前,先增加并发上限。
  • 挑选出影响面(blast radius)最大的十个接口,并在网关层强制要求幂等键(idempotency keys)。 比如发送邮件、扣款、写入外部系统、创建持久化记录。对于智能体主体,如果没有携带幂等键,直接返回 400 错误。无论你是否准备好,重试机制都已经箭在弦上。
  • 在审计日志模式(schema)中添加 actorprincipal 列并开始填充数据,即使策略引擎目前还没用到它们。 后期回填几乎不可能,而实时捕获的成本很低。当智能体第一次执行了未经人类授权的操作时,你会庆幸拥有这些数据。

智能体的工作负载不会等待你的路线图。你曾经与人类用户达成的“契约”,正随着本季度的每一次事故回顾(incident reviews)被逐行重写。最终胜出的团队,将是那些在凌晨 3 点开始争论之前,就已经制定好新契约的人。

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