跳到主要内容

你的智能体发送的 Webhook 被另一团队的智能体接收了

· 阅读需 11 分钟
Tian Pan
Software Engineer

当来自不同团队的两个 Agent 第一次开始互相交谈时,没有人为此编写功能开关(feature flag)。对话就这样出现在日志中。一个支持 Agent 向共享总线发布了一个名为 ticket.resolved 的事件。一个增长 Agent 正在监听来自完全不同产品线的 ticket.resolved,它捕获了这个事件,在后续邮件中祝贺了(错误的)客户,并提供了一个客户并不拥有的产品折扣。在有人注意到之前,这个循环已经运行了 43 次。

共享总线完全按照共享总线的设计逻辑运行。它通过主题名称将消息路由给任何订阅者。它不知道一个团队的 ticket.resolved 指的是支付支持流程,而另一个团队指的是退货流程。它不知道第二个 Agent 是否有权对第一个 Agent 的事件采取行动。事实上,它根本不知道任何一方是 Agent。总线看到的只是带有字符串标签的字节,并将其送达。Agent 看到了一条符合其 Schema 的消息并采取了行动。

这就是我想要命名的故障模式:意外的跨 Agent 通信(accidental cross-agent communication)。两个从未被设计为进行交谈的系统开始交换消息,因为它们底层的架构没有关于哪个 Agent 拥有哪个主题的概念,没有关于哪个 Agent 被允许消费哪个事件的概念,也没有溯源(provenance)的概念。过去,路由错误的代价是队列阻塞或通知丢失。现在的代价是一个自主运行的循环,直到有人拔掉电源。

为什么总线不知道是谁在说话

大多数事件总线是为服务(services)设计的,而不是为 Agent 设计的,这种区别至关重要。一个订阅主题的服务对载荷(payload)执行特定的操作:写入数据库、触发副作用、发出指标。查看该订阅的审查者可以从代码中清楚地看到消费者将如何处理该消息。静态审查在发布之前就能捕获明显的错误。

而订阅主题的 Agent 会根据其提示词(prompt)和工具(tools)执行其认为应该做的任何事情,这取决于消息本身的内容。消费者的行为不再体现在代码中,而是在消息、系统提示词、工具注册表以及 LLM 对这三者的解读中。审查者无法预先知道 Agent 会如何处理来自陌生发布者的 ticket.resolved,因为答案是发布者控制的字符串的函数。

这瓦解了发布/订阅(pub/sub)设计中一个承重墙级别的假设。主题过去是一种协调机制——一种让无关系统就事件含义达成一致的方式。对于 Agent 来说,它们变成了指令机制——一种让无关系统促使另一个系统采取行动的方式。这种转变在代码层面微乎其微,但在后果层面却是巨大的。

默认情况下,发布/订阅总线不强制执行主题所有权。Kafka 的多租户指南直接承认了这一点:你需要命名空间前缀(finance.transactionsmarketing.events) and ACL 来防止租户互相干扰,即使有了这些措施,配置错误的消费者组也可能跨越边界进行订阅。总线是一个邮政系统,任何知道地址的人都可以读取送达的邮件。

跨话题干扰真正开始的四种方式

在我读过或写过的事故回顾(postmortems)中,第一次跨团队 Agent 接触几乎从未是故意的。它通常以四种方式之一发生,这些模式重复出现的频率足以让我们为其命名。

通用主题名称。 两个团队独立决定他们的领域事件名为 task.completedorder.updateduser.signup。他们谈论的是不同的任务、订单和用户,但他们在同一个频道上说话。只要这两个团队都留在各自的服务网格(service mesh)内,这就是一个潜伏的 Bug。一旦一个 Agent 使用宽泛的过滤器进行订阅,这个 Bug 就会变成一个特性,Agent 开始在错误的领域采取行动。

通配符订阅。 需要广泛情境感知的 Agent 通常使用类似 *.failedsupport.* 的模式进行订阅。通配符很方便:Agent 可以了解它未曾预料到的故障。但它也会捕获来自其他团队正好落在匹配命名空间中的每一个事件。像 agent-event-bus 这样的库将通配符主题匹配作为一项核心功能,正是因为 Agent 需要它。这种便利的代价是,当你从未见过的团队向 inventory.failed 发布内容时,你的对账 Agent 决定对其进行对账。

收购或重组后的主题重用。 拥有某个主题的团队解散了。他们的消费者消失了。六个月后,一个新团队为一个全新的流程选择了相同的主题名称,却没意识到旧的发布者仍在发送消息。新流程中的 Agent 开始接收来自一个没人记得的系统的陈旧事件。旧发布者的所有者已经不在公司了。修复工作变成了“考古”。

未经协调的 Schema 收敛。 两个团队独立得出了恰好匹配的 JSON 结构:都有 { id, status, customer_id, timestamp }。双方都在听起来相似的主题名称下发布。总线将错误的载荷发送给错误的 Agent,而 Agent 接受了它们,因为 Schema 匹配。校验器从未触发。Agent 对着连贯的垃圾数据采取行动。

这些模式中的每一种都早于 Agent 出现。不同的是后果。以前路由错误的 Webhook 意味着日志文件中的 404 错误。而发送给 Agent 消费者的路由错误事件则意味着代表一个未经授权的系统执行了一次操作——退款、电子邮件、数据库写入、下游工具调用。

授权缺位

更深层的问题在于,消息总线对授权一无所知。当两个服务交换消息时,授权是在动作层强制执行的:即使服务 A 收到了一个本该给服务 B 的事件,服务 A 仍必须调用经过授权的 API 才能对其进行操作,而该 API 可以检查调用者的身份。爆炸半径受限于服务 A 原本被允许执行的操作。

当智能体(agent)收到一个错误路由的事件时,动作层就是该智能体的工具注册表,而该注册表的范围被设定为可以执行许多有用的操作。监听 ticket.resolved 的智能体可以发送电子邮件、发起退款、更新 CRM 记录并调用计费 API —— 在正确的语境下,这些都是它应该做的事情。在接收端,没有任何机制可以区分“我从自己团队的发布者那里收到了这个事件”和“我从陌生人那里收到了这个事件”。无论哪种情况,都会触发相同的工具调用。

最近关于智能体身份标识的研究直接指出了这一差距。OAuth 和 OpenID Connect 是为用户授权给应用程序而设计的,而不是为智能体授权给子智能体或接受同行的工作而设计的。目前还没有广泛部署的协议能让智能体询问“是谁发布了这条消息,他们是否有权要求我做任何事情?”。目前提出的解决方案 —— 智能体身份 URI、针对智能体的 OIDC 扩展、能力令牌(capability tokens)—— 还处于早期阶段。与此同时,总线只验证发布者对总线的身份,而不对下游的任何行为进行授权。

这正是你应该感到不安的地方。传统的安全模型假设如果你锁好了每个服务的门,你就锁好了整栋建筑。智能体是带有凭据的用户,根据听到的内容在房间之间走动。如果房间不记录谁被允许偷听什么,那么这栋建筑就没有安全模型。它只有走廊。

真正的隔离是什么样的

修复方法并非“更仔细的命名”。命名规范是必要的,但并不充分;人们总会违反它们,尤其是在截止日期前。修复方法是让总线本身感知哪个智能体拥有哪个主题(topic),并为每个消费者提供一种在采取行动前验证来源的方法。

一个可行的隔离模式包含四个部分。它们都不是新奇的东西,而这正是重点 —— 这种失效模式之所以持续存在,是因为团队将总线视为中性基座,而不是一个必须做出授权决策的地方。

第一部分是 Broker 权重的前缀与 ACL 规范,而非约定层面的规范。每个主题名称都带有团队前缀(payments.support.ticket.resolved),Broker 强制要求只有所属团队的负责人(principals)才能发布到该前缀,并且只有加入白名单的负责人才能订阅。跨前缀的通配符订阅需要书面签字批准和明确授权。ACL 是记录系统(system of record);README 只是出于礼貌。

第二部分是 信封(envelope)中的签名发布者身份,而不是在主题名称中。每个事件都携带一个关于发布者的可验证声明,并使用消费者可以验证的密钥进行签名。在智能体看到负载之前,消费者的首要检查是“这是否来自我有权操作的发布者?”。如果答案是否定的,消息将在边缘被丢弃,在调用 LLM 之前。这相当于智能体的 webhook 签名验证,既然智能体已成为消费者,那么证明在 webhook 上使用 HMAC 合理性的理由,同样适用于总线内部消息的签名。

第三部分是 智能体端的源域名白名单。智能体的配置声明了它愿意对哪些发布者采取行动。支付支持智能体接受来自 payments.support.* 的事件。它拒绝其他所有内容,包括那些碰巧符合其 schema 的事件。这使得意外的交叉通信变成了一个显式的配置更改,是可审查且可审计的。

第四部分是 动作层面的溯源检查。当智能体决定调用工具 —— 发起退款、发送电子邮件 —— 时,工具调用会携带原始事件的来源。工具网关可以拒绝代表来自智能体授权范围之外的发布者的事件进行操作。这是“双重保险”(belt and suspenders):即使一个错误路由的事件通过了,工具也会拒绝触发。

你会注意到,这与成熟支付系统用于防止资金发生意外变动的架构基本相同。总线是一个执行授权的地方,而不仅仅是一个传递消息的地方。在智能体架构(agent fabric)实现这一点之前,下一个 43 次迭代循环已经在路上了。

需要内化的核心观点

共享事件总线是我们拥有的最有用的设计模式之一。它也是一种默认开放的通信架构,而在过去的十年里,我们在没有更改默认设置的情况下不断向其中添加智能体。你的智能体发送的 webhook 被另一个团队的智能体接收,这并不是意外事件。这是一个按名称路由且不进行授权的基座的自然行为,其消费者行为是它们收到的每条消息内容的函数。

六个月后不会写这篇复盘报告的团队,是那些将总线视为特权表面的团队 —— 就像他们对待数据库、机密存储和生产 API 一样。主题所有权属于 Broker。发布者身份属于信封。源白名单属于智能体。溯源属于工具调用。一旦这四者到位,意外的智能体间通信就不再是一个循环,而变成了日志文件中的一条被拒绝的消息,而这正是它从一开始就该有的样子。

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