跳到主要内容

由于注册表缓存存在一小时延迟,你的智能体仍在调用已被撤销的工具

· 阅读需 11 分钟
Tian Pan
Software Engineer

用户打开集成页面,找到上个月安装的 Stripe 连接器,点击“移除”,然后关闭了标签页。他们认为自己刚刚撤销了一项授权。实际上,他们只是修改了数据库中的某行数据,而当前正在与他们对话的 Agent 在接下来的 43 分钟内都不会再次读取该数据。在此期间,Agent 会尝试调用该 Stripe 工具,注册中心的授权层会正确地拒绝请求,Agent 的 harness 框架会将此次拒绝视为暂时的下游波动并重试三次,而用户自己的 Stripe 审计日志将记录来自他们认为刚刚已断开连接的供应商的三次未经授权访问尝试。

用户的投诉几乎原话是:在我移除之后,你们的平台仍在尝试访问我的 Stripe。这正是实际发生的情况,而根本原因比错误报告所涉及的层面更深。工具注册中心是 Agent 获准执行操作的唯一事实来源(source of truth)。但 Agent 并没有读取这个事实来源,它读取的是缓存。

缓存是你无意中做出的承诺

每一个缓存都隐含了对数据陈旧度(staleness)的容忍。当缓存的值是产品名称或功能标志(feature flag)默认值时,一个小时的数据过期通常只是轻微的 UX 瑕疵。但当缓存的值是 Agent 代表用户调用的功能集时,一小时的过期就是你几乎肯定无意中对用户做出的承诺:我们将继续在已被你撤销的授权下运行,长达 60 分钟,而且你无法从你的端执行任何操作来让它更早停止

用户无法从产品表面发现这个承诺。“移除”按钮并没有注明 一小时内生效。确认提示(Toast)也没有说明 活动会话在下一次缓存刷新前将保留之前的授权。用户对“移除”按钮的心理模型是所有移除按钮教会他们的:点击之后,东西就没了。

平台的心理模型是由 harness 代码决定的,而这种代码逻辑源于一年前某个周三下午做出的负载舍弃(load-shedding)决策,那时用户甚至还没听说过这个产品。这两种模型之间的不匹配正是事故发生的根源。

移除是一个事件,而非轮询结果

弥补这一差距的方法不是缩短 TTL。更短的 TTL 只是用一个陈旧窗口换取一个更小的窗口,如果不完全放弃缓存,这个窗口永远无法达到零。真正的模式是最近修订的 Model Context Protocol (MCP) 正在悄然趋向的:将可用工具的变化视为注册中心推送到活动会话的事件,而不是让会话去轮询。

一个声明了 listChanged 能力的服务应该在工具集发生变化的瞬间发出 notifications/tools/list_changed 消息。活动的客户端收到通知后,丢弃其缓存的清单(manifest),并在下一轮操作前重新读取注册中心。这里的语义不是“我们承诺缓存会在一小时内刷新”,而是“我们承诺用户授权的任何更改都会在下一次 Agent 操作中可见”。

思路的转变比传输协议本身更重要。一旦你将注册中心视为事件源而非可轮询值的来源,陈旧度的界限就不再取决于 TTL 选项,而是取决于注册中心与活动会话之间的往返时间(round trip)。陈旧缓存的“爆炸半径”从“长达一小时”缩小到“发送一次通知所需的时间”。这就是凌晨三点发现的 UX 问题与根本不存在的 UX 问题之间的区别。

这种模式并非没有代价。它要求 harness 为每个活动会话维护一个指向注册中心的开放通道,这在运维上比轮询缓存更重,而且它还要求客户端在通知到达时真正执行操作。目前几个主流的 Agent 客户端仍然没有做到这一点。MCP 规范强制要求了服务端行为,但客户端行为仍是多个主要实现版本的问题追踪器(issue tracker)中出现的空白。采用这种模式的团队需要验证双端:在客户端默默忽略通知的情况下,仅在服务端声明该能力,比完全不声明更糟糕,因为它教会了用户去信任一个无人执行的保证。

“已移除”与“无法连接”是两种不同的拒绝

Stripe 事件中的第二个失败模式不是缓存,而是重试。调度框架(harness)调用了工具,注册中心的授权层正确返回了拒绝,但调度框架将该拒绝视为瞬时的下游错误,并重试了三次。

调度框架并无故障。它只是在执行团队为工具调用路径配置的重试策略,这也是每本分布式系统教科书针对网络依赖调用所建议的策略:假设第一次失败是瞬时的,带退避地重试,在少量尝试后放弃。该策略对于它所针对的错误群体是正确的。但对于注册中心返回的特定错误则是错误的,因为那个错误在定义上就是永久性的。

注册中心授权层与调度框架之间的契约没有区分“该工具对该用户已不存在”和“工具后端暂时无法连接”。两者在调度框架看来都表现为“工具调用失败”。由于缺乏区分方式,调度框架应用了它唯一的策略,即基于“失败可恢复”假设的重试策略。

对用户刚刚撤销的工具进行每一次重试,都是对用户授权决定的第二次违背。第一次调用可以被原谅为用户点击与缓存刷新之间的竞争。但重试不能。到第三次重试时,系统是在针对已经收到的拒绝采取行动,这在性质上比针对尚未刷新的缓存采取行动要恶劣得多。

修复方案不在重试逻辑中,而是在拒绝的词汇表(vocabulary)中。注册中心需要返回足够的信息让调度框架对拒绝进行分类,而调度框架需要根据分类进行分支处理:

  • 已移除已撤销不再授权:不重试,向用户展示结果,并将尝试的调用记录为审计事件。
  • 服务无法连接超时内部错误:在预算范围内带退避地重试,如果失败则告知用户工具暂时不可用。

从调度框架的角度看,这两个分支看起来很像,但它们对应着本质不同的底层状况。一个是用户创建并期望在下一次操作中被遵守的状态。另一个是用户无法控制且可能自行恢复的情况。将它们视为同一种错误,正是产生审计日志特征的原因,而这最初触发了客户自己的安全警报。

审计界面必须对那些延迟生效的授权用户可见

Stripe 事件中一个微妙的方面是用户是如何发现它的。他们不是通过智能体的 UI 发现的,而是在他们自己的 Stripe 审计日志中看到的。此前,他们自己的安全工具发出了警报,提示有来自他们认为已经断开连接的供应商的一系列未经授权的访问尝试。

这种序列在两方面都很糟糕。这意味着平台欠用户一个通知——“我们尝试调用了一个你已撤销的工具,这是我们尝试操作的审计追踪”,但平台没有提供。这也意味着客户关于平台行为的第一事实来源是一个外部系统,这将一场常规的技术支持沟通转变为一起安全事件,客户现在有义务在自己组织内部进行上报升级。

在此类事件之后,平台欠用户的审计界面并非可选项。它是让用户在事后验证其执行的撤销最终是否得到遵守,以及是否发生了进一步未经授权操作的唯一凭证。它需要从产品的用户侧可见,而不是埋在只有平台工程师才能读取的服务器日志中。它需要列出在之前的缓存授权下进行的每一次尝试调用、注册中心的决定以及缓存最终同步的时间。

如果一个平台将此界面作为撤销流程的一等公民(first-class part)来构建,那么当出现问题时,它与用户的关系就会不同。用户可以自行验证系统行为,支持工单变成信息告知性质而非敌对性质,平台自身的工程团队也能获得关于缓存与注册中心偏差产生可见事件的高保真反馈。

架构层面的反思

工具注册中心不是一个数据存储,它是一个控制平面界面。它持有的数值不是关于世界的、读取成本昂贵且因此可以合理缓存的事实。它们是用户关于系统被允许代表他们做些什么的最新决定。将这些决定视为最终一致性(eventually consistent)的代价,是由用户以其审计日志和信任为货币支付的。

一个根据注册中心服务负载来选择 TTL 的团队优化错了目标。用户关心的目标是:他们在授权界面上的最后一次操作能体现在系统下一次采取的行为中。平台可靠性团队关心的目标是:注册中心服务不会被来自数百万活动会话的每回合读取所压垮。如果你停止在同一个维度上表达这两个目标,它们就不冲突。你通过将撤销视为推送到活动会话的事件来处理用户的目标。你通过调整注册中心规模以处理事件广播和针对少数真正需要新鲜数据的会话的按需读取,来处理可靠性团队的目标。

陷阱在于认为你可以用一个 TTL 旋钮同时解决这两个目标。你做不到。TTL 旋钮在单一轴线上权衡用户利益与平台利益,而唯一诚实的答案是:没有任何一个旋钮设置能同时满足两者的利益。一个意识到框架问题的团队会停止调整旋钮,并开始重新设计界面,从而让这个问题根本不需要被提及。

这种认知的长期版本是:智能体工具界面中任何用户可以撤销的内容,都需要被视为控制平面状态,而不是一段可缓存的数据。工具安装、作用域授权、权限标志,甚至是功能权益:每一项都是用户做出的关于系统可以做什么的决定,每一项都带有与 Stripe 工具相同的风险。一个平台如果只在一个界面上做对了,而在接下来的十个界面上做错了,那它还没有吸取教训;它只是修补了上一次事件提到的案例。

下次你团队中有人提议在任何限制智能体权限的界面上设置 TTL 时,要问的问题不是 TTL 应该是多少。要问的问题是 这个 TTL 向用户承诺了他们决策的多少新鲜度,以及 当下次用户移除工具却看到你的智能体不断调用它时,我们是否准备好捍卫这个承诺

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