跳到主要内容

并非“全员回复”:智能体出站扇出风险

· 阅读需 10 分钟
Tian Pan
Software Engineer

用户要求智能体(agent)“告知 Karen 我们完成了”。智能体调用了 send_email,收件人字段设置为 karen-team@,这是它的联系人查询工具返回的最合理的地址。这条包含三段内部专用项目状态的信息——其中包括一行关于客户续约风险的坦率描述——最终发送到了四十个收件箱。其中一个收件箱恰好属于该客户。事后分析(postmortem)持续了两周。

没有提示词注入。没有模型越狱。工具完全按照规范运行。团队为 send_email 编写的契约是“向收件人发送消息”。而现实世界强制执行的契约是“广播给一个发送者未审计其构成的群体”。这种差距——即工具的命名与其核心实际能力之间的鸿沟——正是大多数出站智能体事故的源头。

电子邮件是显而易见的例子,但同样的风险潜伏在智能体接触的每一个消息工具中。人类为这些渠道建立的三十年肌肉记忆,并未转移到那些正在通过联系人列表进行模式匹配的规划器(planner)中。

工具库中潜藏的危害

每个出站渠道都有一种智能体无法直观理解的“扇出”(fan-out)语义。其签名看起来无害——单个收件人字段、渠道 ID、电话号码——而运行时(runtime)却会静默地将其解析为从一个人到一千人不等。

考虑一下你的工具库实际暴露了什么:

  • 电子邮件收件人字段,接受的分发列表与个人地址无异。karen@karen-team@ 仅差一个 token;规划器对其中任何一个的信心取决于联系人查询工具首先返回哪一个。
  • BCC(密送)默认为空,而用户期望智能体继承他们个人邮件客户端的习惯,即在每次发送时都抄送给自己。智能体没有客户端,审计线索无从谈起。
  • 日历邀请,会自动包含原始组织者邀请函中的一系列转发的可选人员。因此,智能体安排的“与 Sam 跟进”可能会静默地邀请 Sam 的整个团队,加上出现在父级会议中的三位外部顾问。
  • 名称看起来像私聊但实际发布到公共频道的 Slack 频道——如 #alex-and-me#oncall-private#temp-discussion-2024-q3。频道 ID 是不透明的;智能体没有信号表明受众是四十个人而不是两个人。
  • 发往短代码时扇出到群组列表的短信网关。号码看起来就像个号码。路由却是广播。
  • Webhook 工具,其目标 URL 是一个 Zapier 或 n8n 端点,它会扇出给一打没人告诉过智能体的订阅者。你收到的 HTTP 200 是扇出成功的证明,而非没有扇出的证明。

在工具规范中,以上每一个都只是单次调用。但在现实世界中,它们每一个都是扩音器。

为什么规划器会选择“声音最大”的那扇门

规划器正在进行的是模式匹配,而非人类式的受众建模。当它遇到返回多个候选人的联系人查询结果时,它会根据 token 相似度、近期性(recency)和训练数据中的频率组合进行排名——而这些都与“这是个人还是群体”无关。

事实上,分发列表的得分通常更 。它们在企业语料库中被提及得更频繁。它们的 handle 更短、更易记(例如 engineering@eric.henderson.iv@)。它们出现在联系人搜索结果的顶部,因为被搜索的频率更高。规划器完全是在按照它受训的方式行事,结果就是最合理的收件人往往是爆炸半径(blast radius)最大的那一个。

同样的动态在每个渠道中上演。名为 #general 的 Slack 频道排名高于特定的用户 ID。当日历邀请收件人含糊不清时,默认会发给整个会议室。智能体不知道哪个选择具有扇出语义,因为工具规范没有告诉它。

这是**混淆代理人问题(confused deputy problem)**的一个变体,即智能体以超出请求意图的方式使用了其合法权限。智能体有权发送邮件,有权使用收件人地址。升级发生在“发送消息”与“向四十人广播”之间的差距中,没有任何 IAM 策略能捕捉到这一点。

必须落地的约束

解决这个问题需要将收件人解析视为头等大事,而非工具调用的副作用。以下四种实践方案按减慢智能体速度的顺序排列:

两步收件人解析(Two-step recipient resolution)。将 send_email 拆分为 propose_send(提议发送)和 commit_send(提交发送)。智能体调用前者;一个确定性的解析器通过查询你的目录对目标进行分类,返回元数据(个人 vs. 群体、群体规模、受众类型、最近活跃收件人),然后才触发提交阶段。解析器不是 LLM,它是几行读取地址簿的代码,且拒绝隐瞒其发现。

对超过规模阈值的群组收件人执行默认拒绝(Default-deny)。选一个数字——5 个比较合理,10 个比较慷慨——当解析出的收件人列表超过该数字时,要求用户显式确认。阈值应根据敏感度而异:外部收件人达到 1 个即触发,内部收件人为 10 个,公共频道则永不自动触发。智能体可以在确认提示中包含拟定的收件人列表;由人类审核并批准。

出站空跑预览(Outbound dry-run preview)。对于任何涉及 N+ 个人类的消息,在发送前向用户渲染消息和解析后的收件人列表。这不只是一个“加载动画”——它是一个完整的预览,填好了实际的收件人、抄送和密送字段。这种摩擦(friction)正是重点。关于“人在回路(human-in-the-loop)”监管的研究发现,维持 10–15% 升级率(escalation rates)的组织能交付可靠的智能体;低于 1% 意味着智能体被过度信任,超过 25% 则意味着智能体并没有真正节省工作。

扇出预算(Fan-out budgets)。按会话和按用户,对所有出站调用的收件人总数设置预算,并在智能体超过预算时大声拒绝。原本会向一千个收件箱发送垃圾邮件的错误在发送前一百个后就会失效。预算是一个断路器(circuit breaker),而不是许可系统:它不要求智能体知道自己在做什么,只要求它停止做过头的事。

这四项措施加起来只需花费一天的工程时间,且每次出站操作大约增加 20 毫秒的延迟。它们不需要模型变更、微调或新的提示词注入防御。它们只需要你将工具库真正视为一个安全边界(security boundary)。

审计预期意图与实际结果

即使采取了所有四种缓解措施,你仍然会遇到事故。问题在于你是否能察觉到它。

你的智能体(Agent)采取的每一次出站操作都应记录两个接收者计数:预期(智能体的推理逻辑声称要执行的操作)和实际(下游系统接收到的操作)。两者之间的差异就是信号。如果智能体声称“我给一个人发了邮件”,而副作用日志显示了 40 个地址,这就是 Bug;审计能让你在客户打电话投诉之前就发现这一点。

这需要捕获智能体循环的两个环节——模型被要求执行的操作,以及工具针对该意图实际执行的操作。仅凭 LLM 请求日志无法显示邮件是否发送到了分发列表。仅凭工具调用日志也无法显示智能体认为自己在做什么。你需要两者,并通过请求 ID 进行关联,并设置报警,当计数差异超过可配置的阈值时触发。

从业者常遇到的缺口:

  • 记录了工具调用(call)但没有记录解析后的参数(parameters)——导致你看到 send_email 被调用了,但不知道实际发给了谁。
  • 记录了智能体的自然语言推理,但没有将其解析为结构化意图,导致无法与工具调用进行比对(diff)。
  • 将差异报警视为低优先级的仪表盘监控项,而不是紧急事故告警。扇出(Fan-out)事故具有极强的时效性。消息已经进入了接收者的收件箱;撤回前的每一分钟延迟,都意味着更多的已读回执。

我见过的处理得好的团队将差异视为 P2 级别的报警,并设有 5 分钟的 SLA,这与他们处理未经授权的数据泄露的态度一致。这是一个正确的类比:数据正在离开组织,只是通过了一个看起来更友好的渠道。

架构层面的认知

出站消息工具有智能体无法直观理解的扇出语义。人类在三十年里培养了肌肉记忆——点击“回复全部”前的短暂迟疑,对“收件人”栏的二次检查,以及最后才输入收件人以防误发半成品邮件的反射动作。这些都无法传递。智能体没有一个让它犹豫的“回复全部”按钮;它只有一个返回 HTTP 200 的函数调用。

如果一个团队发布的智能体拥有与人类邮件客户端相同的工具能力,却没有人类的肌肉记忆,那么这个团队必然会引发那些本该由肌肉记忆去防止的事故。缓解措施不是编写更好的 Prompt。而是要意识到工具规范(tool spec)是一份契约,而这份契约是不完整的,缺失的条款正是人类在几十年里被这些渠道反复“毒打”后下意识吸收的教训。

在证明其安全性之前,请将你的出站工具库视为一组广播原语(broadcast primitives)。在允许消息发出之前,要求智能体证明接收者是一个个体。审计意图与效果之间的差距,对差异进行报警,并对总覆盖范围设定预算。在发布前就做好这些,而不是在写事后复盘(postmortem)时才反思——因为复盘报告是你把本不该说的事情告诉了包括客户在内的 40 个人之后,才不得不写的文档。

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