那个听起来很合理的理论
我们用 Rust 写了一套 agent 运行时,因为同一套运行时要同时服务一个 iOS app、一个 Android app 和一个键盘扩展;也因为真正有意思的部分——流式、工具分发、上下文管理——恰恰是有一门语言提供了真正的所有权原语才划算的那部分。
这部分是对的。然后我们又用 Swift 写了一套。
当时的理由,是大家通常会给的那个理由:Rust 内核是引擎,Swift 框架是 app 对话的原生层。薄适配器很便宜,平台惯用法值得拥有,而且有一份原生实现意味着我们不会被 FFI 边界卡住。这是个听起来合理的立场,而它其实是一段「事情是怎么开始的」的描述。
它后来变成了别的东西。两个 loop 意味着两份重试策略——其中一个会把 429 当致命,另一个不会。意味着两份上下文压缩启发式,于是同一段对话会因为恰好由哪个进程处理了那一轮而被总结成不同的样子。意味着两个把工具结果变成消息的地方,于是在一处修好的 bug 在另一处依然活着,而我们不知道用户到底撞上了哪一处。这些都不戏剧性。它们全都是永久的,而且安静地累积。
重复从来不是免费的。那是一笔我们选择不去命名的税。
信号是它逼着我们建了什么
重复不是在你创建它的那一刻显形的,而是在你试图动手改它的那一刻显形的。
我们需要从 Swift 测试里验证 Rust 运行时的一种行为。因为 Swift 那侧无法通过生产路径够到它,我们用 Swift 手写了一个 HTTP 流 bridge——一个基于 URLSession 的垫片——然后又手写了一个 stub URLProtocol 去拦截它,好让测试能确定性地驱动那条流。那是一块真正的基础设施:请求构造、分块解码、取消、错误映射。它存在的唯一目的,是让第二份拷贝变得可测,而它重新实现了 Rust 侧一直做得没错的东西。
这就是那笔税的形状。它从不会以「重复」的账单形式到来。它到来时是一连串各自看起来都该做的小补丁,合起来等于在维护一套你早就完成了的系统的第二份实现。
删掉是更容易的那个方向
收敛本身是整件事里最不有趣的部分,而数字是「为什么」最诚实的总结:
214 files changed, 5 insertions(+), 22,207 deletions(-)
两万两千行被移除,五行被加入。什么都没新写,因为「第一份实现已经能用」在实践中的意思就是这个。消失的那些文件讲述了一个故事:那些东西从没打算被复制。一段 560 行的 agent loop。一个 377 行的生命周期控制器,管理的状态转换在 Rust 运行时里早已建模。一个 234 行的模型调用服务。然后是一整摞上下文栈:一个带自己 token 估算器和中间件的压缩服务、一个配了三种可互换存储的记忆管理器、两个会话服务。其中每一件都是某个人不得不做两次的决定,而两个不一样的决定就是一个还没发生的 bug。
接缝必须窄到能被强制
第二份拷贝删掉,收敛并没有结束。它在宿主仍然拥有三件不该拥有的东西时才结束:它自己发 HTTP 请求、自己解码 agent 的事件流、自己渲染工具调用。那三件每一件都是小的、有平台理由的防御性工作,合起来却是一份换了更好伪装的第二实现——因为它没有 loop,所以看上去不像。
这部分第二天也被收了回去。一万六千多行搬出了宿主,而它留下的是一个投影:宿主负责渲染状态,而那份状态有且只有一个定义,由那个同时产出它所渲染的事件的运行时给出。
于是有一条值得保留的规则:
一条线、一个 loop、任何宿主侧的重放逻辑都没有。
如果一个宿主能从同一条事件流重建出不一样的会话,接缝就已经漏了。信号永远是同一个:某个宿主学会了关于协议的事情。我们的学会了,所以清理的后半段是必要的。
它花了什么,又换来了什么
有两件事值得直说。
接缝是真的,而且不免费。 过去是原生 Swift 的一切——loop、状态机、记忆存储——现在都住在外部函数接口后面。那是真实的成本:跨语言时编译器不再帮你检查类型,错误得被重新构造,调试器不再给力。收敛并非自动正确。当被收敛的东西是一项策略时它才正确,因为两份策略意味着同一个问题有两个答案,而没人能枚举哪个问题会先来。
Rust 是真的成了跨端内核。 在这之前,Rust 只是若干实现之一。在那之后,agent 运行时、输入引擎、以及两个平台的渲染管线都住在同一个地方,原生层被削减到它们本来就该是的样子:真正需要原生的那部分。一个代码库的重心是一点一点移动的,这就是那一步。
小结
重复的实现是一笔税,而这笔税从不会被逐项列出来。它以一连串各自合理的小补丁形式出现——一个垫片、一个适配器、一份必须大致跟上的第二套策略——并且永远以你无法枚举的漂移来支付。
盯住重复逼着你在较弱那一侧建东西的那一刻。一个测试支架、一座桥、一个纯粹为了让第二份拷贝够得着的兼容垫片:那就是这笔税被逐项开出的时刻,也是第二份拷贝没有挣回自己成本的最便宜的信号。
删除比新增容易,而 diff 会告诉你第一份实现是不是真的已经完成。两万两千行删除、五行新增不是一次重构,它是一张收据。
并且在删完之后检查接缝。第二份拷贝很少一次全消失;它会作为宿主侧协议知识重新长出来——app 仍然自己发请求、自己解事件、自己渲染工具调用的那个地方。当宿主除了「展示运行时已经决定好的东西」之外对协议一无所知时,收敛才算完成。