搭建云端 agent 基础设施:有什么不同,我们学到了什么

如今大多数 agent 框架都默认跑在桌面上。一个用户、一台机器、一个进程。agent 在笔记本开着的时候运行,写本地文件系统,把 API 密钥放在环境变量里,终端一关就没了。出了问题,用户重试一次。agent 需要装包,pip install 直接装进用户自己的 Python。状态、密钥、生命周期,全都待在同一个可信边界里。

云端 agent 基础设施没有这些便利。

agent 跑在一个每次都全新启动的 sandbox 上,硬件和陌生人共享,触发它的调用方用户从来没见过:一个定时任务、一个 HTTP 请求、另一个 agent。运行发生时用户多半还在睡觉。sandbox 里的代码可能带有恶意。文件系统必须熬过一次次部署。凭证不能和 agent 待在一起。桌面白送给你的每一项保证——持久化、身份、网络信任、重试——都得作为一套显式的系统重新搭出来。

过去几个月,我们在 CREAO 一直在加固这一层。从中得出两条经验。如果你做过桌面 agent,又好奇它搬到云上会变成什么样,下面就是变化所在。

经验一:把变得慢的和变得快的分开

在桌面上,用户的环境和 agent 的运行时是同一个东西,同一个节奏更新,同一个人来更新。在云上,它们不是。

一个 agent 应用会在平台这一侧不断累积状态。一个股票分析 agent 装上 matplotlib,下载行情数据,写出绘图脚本。这个环境就是 agent 的肌肉记忆。用户一旦满意,我们就把它冻结成一个 sandbox 快照,并一直冻着,直到用户再次改动环境。每次运行都从同一个镜像启动。一样的包,一样的文件,一样的版本。周一的运行和周五的表现一致,因为底下什么都没动过。

这正是桌面框架没法白送给你的特性。半年前的一次 pip install,今天解析出来是不同的版本。一个云端快照,永远解析出同样的字节。可复现性是平台欠用户的,而冻结快照是兑现它最省力的方式。

然后,耦合问题出现了。

冻结用户环境的那个镜像,里面同时还装着 runner 代码——我们自己写的一个小 harness 库,每次运行时负责管理 agent。用户希望自己的环境一动不动。我们希望自己的 runner 一天发好几次。同一个产物,两个相反的要求。

我们最初的解法很糙。启动时检查快照里的 runner 是不是和我们刚部署的版本一致。不一致就把快照扔掉,从一个干净模板启动。这招管用,也没人抱怨。损失只落在部署后的第一次运行上。

无人值守的运行让这层掩护失效了。周一早上 9 点的 cron 任务,不该因为我们 8:55 部署了一次就丢掉自己的环境。我们正在悄悄违背的那条契约——「你的环境在你改动之前一直冻结」

真正的解法,我们看明白得太晚了。用户的环境和 runner 代码,变化的速率完全不同。用户想改 agent 才改。我们一天部署平台好几次。把两者当成一个产物,等于每次部署都被逼着二选一:要么留着过时的 runner 代码,要么毁掉用户明确要我们保住的那个冻结环境。

我们最后采用的模型,借鉴了操作系统处理更新的方式。内核会变。你的 home 目录不变。你不会为了装一个安全补丁就把磁盘抹掉。

我们划了同样的边界。sandbox 从用户的冻结快照启动,原样不动。然后我们只热替换 runner。步骤如下:

  1. 在 sandbox 内的一个临时目录里准备好新 runner。
  2. 用 node --check 校验它,这样任何语法错误都会在我们动到线上任何东西之前被抓出来。
  3. 原子地把它换进去:解开旧 runner 上的 immutable 标志,把新的拷过去,用 chattr +i 重新上锁,然后把 chattr 这个可执行文件本身藏起来,让 sandbox 里的代码没法反向解锁。
  4. 清掉 V8 的编译缓存(/home/user/.cache/v8-compile-cache/*),这样真正加载的是新文件,而不是跑过时的字节码。
  5. 任何一步失败,就杀掉这个 sandbox,换一个全新的重试。绝不让一个升级到一半的状态去跑 agent。

整个替换大约花 300 毫秒。只有在 runner 代码被替换过时,我们才会在一次成功运行之后重新打快照,把更新后的代码固化进用户的镜像里,这样下次运行就完全跳过替换。平台部署从不丢弃用户的状态;它们把新 runner 叠进状态里。用户的包、文件和自定义内容原样延续下去。

如果这条经验你只记一件事,那就是这个用来诊断的问题。对于你在云平台里持久化的任何东西,都问一句:谁掌控这个产物的变化节奏?如果用户和平台都拥有它,你迟早要为这种耦合买单。沿着归属边界把产物拆开,让每一侧按自己的时钟更新。

经验二:把密钥挡在执行边界之外

这条经验,是云端 agent 基础设施区别于其他一切的地方。

桌面 agent 以用户的身份运行。它用用户的密钥,在用户的机器上,对着用户的网络。云端 agent 不以任何人的身份运行,在共享硬件上,对着开放的互联网,执行的是 LLM 根据一段可能带有恶意的 prompt 写出来的代码。安全模型必须假定 sandbox 里的代码已经被攻陷,而不是心存侥幸。

我们守的规则很简单。绝不让任何长期有效的凭证待在 sandbox 里。

当 agent 需要调用一个需要鉴权的服务——Slack、GitHub、用户自己的 API——它并不持有 token。它向一个跑在 sandbox 外面的 API bridge 发一个本地 HTTP 请求。bridge 在宿主机这一侧附上 OAuth token,再把调用转发出去。响应回来时,token 自始至终没有进过 sandbox 的内存或环境。

有意思的地方在于,bridge 怎么知道这个 sandbox 有权来问。两道检查,刻意分层。

第一道,IP 白名单。bridge 只接受来自我们 sandbox 宿主机所在内网网段的连接。来自其他任何地方的调用——开发者的笔记本、一个泄露的 URL、公网——都会在任何应用代码运行之前就在网络层被丢弃。这把 bridge 钉死在一处物理基础设施上,让它对外面的任何人都毫无用处。

第二道,每次运行单独签发的短期 JWT。sandbox 启动时,平台会签发一个限定到这一次具体运行的 token:哪个用户、哪个应用、哪个会话,有效期只覆盖这次运行的窗口,多一点都没有。sandbox 每次调用 bridge 都要出示它。bridge 验证签名、检查有效期,之后才会解析出用户存好的凭证,在服务端这一侧附上。如果一个 sandbox 被劫持,攻击者继承到的是一个随运行结束就失效、且只授权这一个会话内调用的 token。没有任何主凭证可偷。

同一个 bridge 也把计费扣减、日志和指标带出去,所以它是唯一一个在两个方向上都跨越 sandbox 边界的接口。sandbox 里的其他一切,默认都被当成已经被攻陷的。

假如明天某个 prompt injection 说服一个 agent 把 process.env 倒给一个 webhook,攻击者拿到的是一个只在我们内网里好使、且随运行结束就过期的短期 JWT。正是这个特性,让我们能在共享基础设施上跑不受信任的用户代码,还睡得着觉。

底层的那个模式

可靠、安全的云端 agent 基础设施,不是什么新奇的系统。它就是几条始终守住、绝无例外的特性:

  • 状态住在 sandbox 里,在用户改动它之前一直冻结。
  • 代码可热替换,与状态相互独立。
  • 凭证住在宿主机这一侧,绝不进 agent 内部。
  • 一条执行流水线服务所有调用方,无论触发它的是人、调度器,还是另一段软件。

最后这条特性,是整个设计的点睛之笔。一个 executeAgent 函数处理 UI 点击、定时运行和 API 调用。计费系统、积分扣减日志、可观测性信号——无论是人点了「运行」、cron 触发,还是脚本调了 API,全都一模一样。加一个新的触发入口是改路由,不是改架构。agent 本身不知道、也不关心是谁扣下了扳机。

这正是桌面框架给不了你的,也正是云端版本值得做的原因。笔记本上的 agent,被绑死在那台笔记本上。云上的 agent,是你技术栈里其他部分可以调用的一个函数。用户写一次。平台让它熬过部署、在共享硬件上安全运行、并接受用户从没预料到的调用方。

agent 是一个带自然语言接口的函数。它的实现属于用户。它的触发入口、它的运行时、它的安全边界,属于平台。真正的功夫在于:把这些层搭得让每一层都按自己的时钟演进,并且舍得花时间,在别人之前先找到系统之间的裂缝。

正是这一点,让下一个入口既省力又安全地发布出去。