产品9/18/2026·约 5 分钟

一套 Agent 运行时、两个平台和一只键盘

QuipKey 的跨平台不是复制页面做出来的。我们把准入、连接器、记忆与事件投影收进一套 Rust 运行时,让每个宿主各自对自己的边界负责。

第三个客户端一直在房间里

QuipKey 最初是一只带 agent 面板的 iOS 键盘。后来产品有了主 App、Android 版本,以及一些只有当 agent 能在键盘消失之后继续工作才成立的能力:连接器、记忆、文件、后台任务和生成的卡片。

增加一个平台最容易走的路是复制宿主实现。给 Android 新写一套服务,翻译 iOS 回调,之后再让页面慢慢对齐。这条路能很快做出一个像样的首个 demo,也会同时制造每个重要状态的第二个定义。一个连接器可能在一端被启用、另一端被拒绝;一个工具结果可能被变成两条消息;记忆可能由发起它的对话写一次,又由刚好打开的 App 写一次。两个客户端看起来类似,agent 的行为却不同。

更难的决定是先找出宿主不应该拥有的东西:

运行时拥有 agent 的含义;宿主拥有它的表面与生命周期。

轮次、模型路由、工具分发、连接器准入、记忆、工作区文件和事件流属于一套 Rust 运行时。iOS、Android 和键盘是消费者。它们渲染收到的事实,通过同一份契约把用户意图送回去,并把平台特有的事情留在边缘。

这不是说各个平台的页面必须一模一样。键盘窗口更窄,有不同的焦点模型;Android 有自己的输入法生命周期;iOS 有键盘扩展进程和主 App 进程。这里的一致性是:一次工具调用在这些表面上拥有同一个身份和结果,而不是每个控件都占同样的像素。

能力是一个交集

第一个跨平台 bug 看起来像一个缺失的功能。某个技能在包里存在,也在 UI 里显示为已启用,但设备无法执行它的一项操作。目录回答的是「这个包是否声明了技能」,UI 却把答案当成了「这个宿主能运行技能」。

这是两个问题。一个能力只有在两边都为真时才可用:

available = deployed_package ∩ device_execution

现在这个交集由运行时拥有。包 manifest 描述技能和它声明的操作;宿主提供设备事实:权限、平台支持、凭据以及承载请求的中继。需要连接器的技能不能因为文件装上了就启用;授权过期的连接器也不能因为那一行还在就算 ready。

这对键盘尤其重要,因为键盘不能悄悄借用主 App 的假设。它可能在另一个进程里运行,权限状态不同,寿命也更短。同一个 resolver 为两个宿主回答问题,并给出命名状态:不可用、可用但未连接、已就绪。若只用布尔值,UI 就只能猜该隐藏动作还是解释怎样连接。

包边界也让安装变得诚实。出厂技能随应用包进入;用户创建的技能带着 manifest 和修订号成为包。运行时读取同一张注册表。Android 页面没有第二张会和 iOS 漂移的列表,宿主也没有一条会悄悄复活被运行时拒绝的本地规则。

一份事件模型,多种投影

Agent 产出的不是一段回答字符串。一轮里有用户意图、thinking、工具调用、工具结果、引用、卡片、文件和最终答案。早期的宿主各自投影这段序列:一端把工具调用画成结果卡,另一端把它直接塞进转录,第三端要等整轮结束才显示任何东西。

现在运行时发出一份带稳定标识的事件模型。工具调用和结果共享一个 id;卡片可以关联它生成的产物;引用可以挂在使用它的回答上。宿主可以把 thinking 行折叠,也可以把它放进 disclosure,但不能凭空制造第二个事件,也不能丢掉结果与产生它的操作之间的关系。

这条边界一次修复了多种可见问题。QuipKey 的对话可以把 thinking 和工具调用显示成可读的工序,而不是一堵文字墙;Android 可以用 Compose 画出同一段过程;键盘可以显示紧凑投影,主 App 显示完整转录。工具交接的文件进入工作区,仍是用户可以预览的产物,而不是只存在于某一个宿主聊天记录里的字符串。

宿主仍然拥有布局。它决定 Markdown 表格怎样展开、长回答在哪里滚动、长按在自己的表面上表示什么。它不决定事件是否发生。这条边界让一次 UI 修复保持为 UI 修复,而不会变成只在一个平台上改变 agent 语义。

记忆需要一个写者

记忆是复制宿主最危险的地方。一段对话可以产出值得留下的事实,App 也可以发起一次整理。如果两者都写同一批记忆文件,结果就取决于哪个进程最后完成。

运行时给每次写入分配起因。对话可以整理自己的轮次;定时清理可以重排已有条目;用户可以直接编辑记忆。每种操作都有来源和修订号,因此自动写者只能替换自己读到的那份状态,再也不能静默覆盖人的编辑。

存储形状遵守产品的一个有用约束:记忆是一份用户能查看和修改的文件,而不是只有某个页面知道怎样展示的隐藏行。iOS 与 Android 的页面是这些文件的投影。键盘可以提供只读视图,却不会变成第二个记忆数据库。

工作区也由同一个 owner 负责。工具产生的图片或 HTML 是产物,转录只保存引用。预览、钉住与分享操作的是产物。宿主可以选择宫格或列表,却不能创建一个生命周期会和工作区分叉的第二副本。

键盘是边界的试金石

键盘会在任何隐藏假设上立即露馅,因为它随时可能消失。Agent 运行时也许正在加载模型,输入法却已经收到按键;工具可能在用户切换 App 后才返回;连接器可能需要一个只有主 App 才能呈现的权限弹窗。

所以宿主适配器拥有一小组明确状态:键盘窗口是否准备好、输入表面是否有焦点、运行时是否能接受一轮,以及当键盘无法呈现某个东西时怎样把请求交回主 App。运行时拥有轮次本身。冷启动可以延迟回应,却不会改变工具调用的含义。

正因为分工清楚,Android 键盘可以跟上 iOS 的功能工作,而不需要重新实现 agent。平台工作确实存在——输入法 API、窗口 owner、导航和 Compose 布局都要用原生方式解决——但 agent 的包、能力、记忆和事件契约不随平台分叉。

不复制换来的东西

代价是看得见的。一套共享运行时需要严格契约,宿主适配器不能靠发明一个本地布尔值来绕过缺失状态。有些改动会更慢,因为 owner 必须被移到真正能同时看见问题两端的层。

回报是产品可以增长,而语义不会成倍增加。新连接器只准入一次,新技能只描述一次,新事件类型在各宿主分别投影,但它的身份和生命周期仍然共用。记忆或工作区的 bug 修好后,键盘、iOS 与 Android 消费的是同一个修复。

两个平台仍然是需要分别构建和测试的产品。它们共享的比像素更有价值:无论 QuipKey 在哪里打开,运行时都做出同样的承诺。

关键结论

跨平台设计先分配状态 owner。宿主拥有自己的窗口、焦点与生命周期;运行时拥有 agent 的含义和持久事实。

从包状态和设备状态一起计算能力可用性。一个声明过的技能,只有当前宿主能承载它暴露的每项操作时,才是可执行技能。

使用一份事件模型,让每个宿主做投影。布局和手势不同是健康的;工具调用、引用和产物被分别解释才是漂移。

把键盘当成一等客户端。它短暂的生命周期会尽早暴露隐藏假设,而干净的宿主适配器让同一套 agent 契约服务键盘、手机 App 和另一个平台。

文中提到的产品

继续阅读