云上的 Agent,和本地有什么不同

6月11日15min

现在的 Agent 框架,几乎都默认一个前提:你在用桌面端。

一个人、一台机器、一个进程。Agent 跟着你开机跑,往本地写文件,API key 随手丢进环境变量,终端一关它就没了。出错了?重试。缺包?pip install 直接装进系统 Python。

状态、密钥、生命周期——全挤在一个信任边界里。

这些「奢侈」,云上一个都没有。

Agent 跑在一个每次都要重启的沙箱里,硬件跟陌生人共享,被它从未谋面的调用者触发——定时任务、一个 HTTP 请求、另一个 Agent。跑的时候,用户大概率在睡觉。沙箱里的代码可能是恶意的。文件系统得扛住一次次部署。密钥不能跟 Agent 放在一起。

桌面端白送你的那些保证——持久化、身份、网络信任、重试——在云上全都得作为显式的系统一层层重新搭出来。

过去几个月我们在做的就是这件事,把这一层拧紧。过程中有两个教训。如果你也交付过桌面端的 Agent,想过搬到云上会有什么变化——这就是答案。

教训一:把变得慢的和变得快的拆开

在桌面端,用户环境和 Agent 运行时是同一个东西,同一个人、同一种节奏在更新。云上不是。

Agent 应用会在平台侧积累状态。一个股票分析师的 Agent 装了 matplotlib,下载了市场数据,写好了图表脚本。那个环境就是 Agent 的肌肉记忆。我们在用户满意的那一刻把它冻成一个沙箱快照,然后就这么冻着,直到用户下次主动编辑它。每次运行都从同一个镜像启动——同样的包、同样的文件、同样的版本。周一的运行跟周五的一模一样,因为底下什么都没动。

这是桌面端框架白送不了你的东西。六个月前的 pip install,今天解析出来的是不同版本。云上的一个快照,永远解析出相同的字节。可复现性是平台欠用户的债,而冻结快照是还这笔债最便宜的方式。

然后耦合问题就来了。

那张冻住用户环境的镜像里,还夹着 runner 代码——就是我们开发的那个小 harness 库,负责每次运行时管理 Agent。用户希望自己的环境纹丝不动。我们希望自己的 runner 一天发版无数次。同一个产物,两个截然相反的需求。

我们的第一版方案很粗暴:启动时检查快照里的 runner 跟刚部署的版本是否一致。不一致?扔掉快照,从干净模板启动。能用,也没人抱怨。伤害只发生在部署后的首次运行。

但无人值守的运行把这层遮羞布撕了。周一早上九点的 cron job,不该因为我们八点五十五发了个版就丢掉自己的环境。我们在悄悄违反的契约是——「你的环境会一直冻着,直到你亲手改它。」

这个解法我们花了比该花的更久才看清。用户环境跟 runner 代码的变更速度完全不一样。用户什么时候编辑 Agent,由用户决定。平台一天发版很多次。把它们当成一个产物,意味着每次部署都得二选一:要么留着过时的 runner,要么毁掉用户明确要求我们保留的冻结环境。

最终落地的模型,借了操作系统处理更新的思路:内核可以变,你的 home 目录不变。你不会为了打个安全补丁去格式化硬盘。

我们划了同一条边界。沙箱从用户冻结快照启动,原封不动。然后我们热替换掉 runner,别的都不碰。顺序是这样的:

  1. 把新版 runner 塞进沙箱的临时目录。
  2. node --check 验证语法,确保在碰任何线上文件之前就把错抓到。
  3. 原子替换:解开旧 runner 的不可变标记,把新的拷过去,用 chattr +i 重新锁死,然后把 chattr 二进制本身藏起来,让沙箱内的代码无法反向解锁。
  4. 清掉 V8 的编译缓存(/home/user/.cache/v8-compile-cache/*),确保加载的是新文件,而不是跑旧的字节码。
  5. 以上任何一步失败,直接杀掉沙箱,换一个全新的重来。绝不让任何半升级状态跑进 Agent。

整次替换大约 300 毫秒。运行成功后,如果替换过 runner,我们做一次重新快照,把更新后的代码烤进用户镜像,这样下次运行直接跳过替换步骤。平台部署从不会丢弃用户状态——只是把新 runner 叠进去。用户的包、文件、自定义配置,全部原样带到下一次。

如果这篇文章只带走一件事,就是这个诊断问题:任何你在云平台上持久化的东西,问一句——谁控制这个产物的变更节奏?如果用户和平台都拥有它,你迟早要为耦合买单。按所有权边界把产物拆开,让双方在自己的时钟里更新。

教训二:把密钥挡在执行边界外面

这一课,是云 Agent 基础设施跟其他所有东西的分界线。

桌面端 Agent 以用户身份运行。用的是用户的 key,在用户的机器上,走用户的网络。云上的 Agent 以「无名之辈」的身份运行——共享硬件、裸奔在公网上、执行的是 LLM 从一段可能是恶意的 prompt 里生成的代码。这个安全模型,必须假设沙箱内的代码已经被攻破了,而不是寄希望于它没被攻破。

我们坚持的规则很简单:任何长生命周期的凭证,都不准住在沙箱里面。

当 Agent 需要调一个带认证的服务——Slack、GitHub、用户自己的 API——它手里不拿 token。它向跑在沙箱外的一个 API bridge 发本地 HTTP 请求。bridge 在宿主侧贴上 OAuth token,把请求转发出去。响应回来的时候,token 从未进过沙箱的内存或环境变量。

有意思的地方在于:bridge 怎么知道这个沙箱有权问?两层校验,刻意叠起来的。

第一层,IP 白名单。bridge 只接受来自我们沙箱宿主机所在内网网段的连接。从任何其他地方——开发者的笔记本、泄漏的 URL、公网——发来的请求,在网络层就被丢掉,一行应用代码都跑不到。这把 bridge 钉在了特定的物理基础设施上,对网外的任何人毫无用处。

第二层,每次运行签发一个短生命周期的 JWT。沙箱启动时,平台签一个 scope 精确到本次运行的 token:哪个用户、哪个应用、哪个会话,过期时间刚好覆盖运行窗口,多一秒都不给。沙箱每次调 bridge 都带着它。bridge 验签名、查过期,然后才去解析用户存储的凭证,在服务端贴上。就算沙箱被劫持,攻击者继承的也不过是一个跟运行同生共死的 token,并且只能授权那一个会话范围内的调用。没有主凭证可以偷。

同一个 bridge 还负责往外带计费扣除、日志和指标数据——所以它是唯一一个双向穿越沙箱边界的接口。沙箱内的其他一切,默认视为已被攻破。

如果明天有人搞 prompt injection,骗 Agent 把 process.env 往一个 webhook 里倒,攻击者能拿到的,只是一个短生命周期的 JWT——只能从我们内网用,过期就作废。正是这个属性,让我们敢在共享基础设施上跑不信任的用户代码,还睡得着觉。

底层的模式

可靠、安全的云 Agent 基础设施,不是什么新奇架构。就是几条属性,一条都不让步:

  • 状态住在沙箱里,冻着,直到用户亲手改。
  • 代码可以热替换,跟状态互不牵扯。
  • 凭证留在宿主侧,永远不进 Agent。
  • 一条执行管线服务所有调用者——不管触发者是个人、一个定时器、还是另一段软件。

最后这条,是整个设计的 punchline。一个 executeAgent 函数,同时处理 UI 点击、定时运行、API 调用。计费系统、额度扣除日志、可观测信号——不管是一个人类点了「运行」、一个 cron 触发了、还是一个脚本调了 API,全都一模一样。增加一个新的触发入口,是路由层面的改动,不是架构层面的。Agent 自己不知道、也不在乎是谁拉了扳机。

这就是桌面端框架给不了你的东西,也是云版本值得去建的原因。笔记本上的 Agent 被绑在笔记本上。云上的 Agent 是你整个技术栈都能调用的一个函数。用户只写一次。平台让它扛住部署、在共享硬件上安全运行、接受用户从未预料到的调用者。

Agent 就是一个带自然语言接口的函数。它的实现归用户。它的触发入口、运行时、安全边界归平台。纪律在于把每一层建得能在自己的时钟里独立演化,并且花时间找到系统之间的裂缝——在别人先找到之前。

正是这一点,让下一个入口便宜到可以随手发,也安全到可以随手发。

CC BY-NC-SA 4.02022-PRESENT © Elone Hoo