技术8/28/2026·约 5 分钟

三态,不是一个布尔值

我们的 agent 只有在用户连上自己的账号后才能触达某个邮箱。一个布尔值描述不了这件事,而那个错误的两态答案,先索要日历权限、再告诉用户我们需要 Gmail。

那个不得不是三个值的布尔值

我们的 agent 能做一些需要第三方账号的事——读一个邮箱、查一个日历、在网盘里找东西。对其中每一件,一个能力只有在用户连上自己的账号之后才是真的。

显而易见的模型是一个标志位。而标志位立刻就是错的,原因只有在界面里才会显形。

一个连接器可能处在三种处境之一,而这三种需要相反的处理:

  • 我们不为这个 app 提供它,或者这个 app 没有内置它。用户对此永远无能为力。
  • 我们提供它,而用户还没连自己的账号。用户大约四十秒就能修好。
  • 已连接,就绪。

压成一个布尔值,你会得到两种失败之一。要么你展示了做不到的东西——这是最坏的结果;要么你藏起了用户本来能用的东西——那只是让人失望。

驱动整个特性的区分就在前两者之间,而第一种状态正确的处理是删掉它,而不是禁用它。

unavailable 的意思是看不见

我们这次部署不提供的能力,不应该出现在列表里、搜索索引里,或者 agent 自己那份「我能做什么」的目录里。不是置灰。不是挂一个「即将推出」。

置灰是一个承诺。一行灰掉的条目说的是「这东西存在,而你暂时拿不到」,这是关于我们路线图的一个我们没核对过、也无法兑现的断言。它还占掉了本该属于可用能力的那个位置,并且让列表在毫无收益的前提下变长。

于是规则是:不可用的能力是从快照里移除,而不是在快照里被标记。 一旦它不在这个界面里,每个消费者就自动是对的——注册表、搜索索引和 agent 自己的工具目录都不可能提供它,因为它们谁都没听说过它。

这就是「删掉而不是禁用」的全部理由,而且它可以外推到任何能力列表:一个被禁用的条目需要在每一个消费者那里被永久抑制,而每一个都是一次忘记的机会。一个缺席的条目则不可能被任何人提供。

not linked 必须在你要之前决定

中间那态是真正的设计工作所在。

notLinked 意味着能力是真的、用户还没连账号。正确的体验是把它显示出来,并附上那个「去连接」的动作。

错误的体验很容易被无意中构建出来,它长这样:

  1. 用户点了一个需要日历的能力。
  2. 我们判断出它没连,于是打开系统权限弹窗。
  3. 他们授予了日历访问。
  4. 然后我们请他们连接 Google 账号。

每一个单步都是对的。这个顺序是错的。系统弹窗说的是它将允许我们读取他们的日历,而它实际做的事是启动了一个把我们自己的账号交给你的流程。一个在第 3 步停下的用户,被索取了我们要的东西,而且自然结论是:这个 app 搞糊涂了——或者在收集它根本用不上的访问权。

于是规则是:在你索要任何东西之前,先判定链接状态。 查清楚已经连上了什么;如果答案是「没连」,你做的第一件事是提供连接,不是提供权限。

还有第二个理由,是关于顺序本身的。权限是少数几件用户只有一次机会、并且能在冷静状态下做决定的事情之一。把它花在一个后来证明并不需要这个请求上,是花得很差。

跨进程边界时,缺席就是一个答案

app 拥有会话和浏览器。键盘扩展两样都没有。

这是一个真实约束,也很容易被解决得很糟。键盘需要知道连上了什么,才知道哪些能力可以用,而它问不了。于是 app 把一份快照写进共享存储,键盘去读它。

这里危险的默认做法,是把缺失或读不到的快照当成「我不知道,那就让用户试试吧」。想想那会造成什么:一个从未连接过任何东西、或者本周从未打开过 app 的用户,得到的是一个会提供账号类能力、然后在使用时失败的键盘。这个特性的第一版正好有这个失效,而它呈现出来的样子是「这个功能有 bug」,而不是一个权限错误。

我们定下来的规则正相反,并且现在在授权跨越进程或缓存边界的任何地方都按它执行:

关于授权的证据缺席,就是没有授权的证据。

一份空快照不意味着「不知道」。它意味着什么都没连过,因为唯一的写者是 app,而 app 每次变更都会写。任何需要连接器的能力都不可用。fail closed。

这条规则的镜像也值得说,因为同一种直觉会在另一个方向上误伤:如果你把 fail closed 用在真正「未知」而非真正「缺席」的东西上——一个还没被查询过的部署、一个还没回答过的服务器——你会把本来能用的能力也压掉。区别在于究竟是已知为空还是尚不知道。一份由拥有真相的组件写出的空文件是一个事实;一份从未被写过的文件是一个问题。

两个界面,一种形状,不同的寿命

我们构建两个相关的东西,值得明确指出它们不是同一个对象。

能力界面是这份 app 版本能做什么。它编译进去,随 app 变化,并且在每一个跑这个版本的设备上都一样。

连接器界面是此刻真正可用的、属于这个账号和这台设备的东西。它在每个会话从服务器的答案重建,无需 app 变化就会变。

同样的形状、同样的字段名、同样的消费者——寿命完全不同。能力界面是二进制的属性;连接器界面是「此刻」的属性。

把它们混在一起的失效是精确且糟糕的:agent 提供了一种这个二进制能表达、而这个账号撑不住的能力。用户拿到一个他没法用完的工具。而这正是整套设计存在的唯一理由要防的东西;当两个界面是寿命分明的两个值、而不是寿命模糊的一个值时,它要容易防得多。

读一份旧快照,而不是拒绝它

同一个区域里还有一个更小的决定。

共享快照带一个格式版本,而上一个版本仍然在设备上——它是此前每一个版本写下的东西。新版本到来时有两个选项:拒绝读旧版本,或者读它、并拒绝从中得出旧版本无法支撑的结论。

我们选择读。

拒绝会看起来永远正确、并且永远是坏的。一份旧快照自己永远不会变成新的;它只在 app 下次写入时才改变,而 app 只有读得了才会写。拒绝旧格式,于是「先更新键盘、后打开 app」的用户会得到一个功能被关掉、并且没有任何报错的状态——直到他碰巧启动了一次主 app。

于是规则是:读它,标出它说不出来的东西,并且不服务而不是猜。旧格式能说出哪些连接器已连;它说不出单个操作要花什么,因为那个字段当时还不存在。那些操作在下次同步之前一律不服务。

这是把 fail closed 用在一个字段上,而不是一个文件上——只缺信息缺到那么窄,一点不多。

小结

把能力可用性建模成一组状态而不是一个标志位,并给每一种各自应有的处理。把「用户能修」和「谁都修不了」分开的那一态,最值得找出来,而它通常需要相反的界面:对其中一种是一个邀请,对另一种是删除。

在你开口要之前先弄清楚自己要的是什么。任何会向操作系统亮牌的请求——权限弹窗、同意面板——之前,都应该先做完所有本可以告诉你「这个请求没必要」的检查。

授权跨越边界时 fail closed,并且精确地说清「空」是什么意思。一份因为它的拥有者会写所以已知为空的存储,是一个事实,意思是不。一份从未被写过的存储是一个问题,而乐观地回答它等于授予了没人确认过的权限。

并且把静态的和动态的分开。构建能表达什么,和账号能撑住什么,形状相同而寿命不同;把它们混在一起,会产出一种它撑不住的 agent 工具。

文中提到的产品

继续阅读