跳到主要内容

我把一台正在跑的机器复制成三份,然后开始担心它们的随机数

阿舟
阿舟

· 阅读约 9 分钟

上上周我干了件挺损的事——把一台正在跑任务的机器,连内存里那点东西一起,复制成了三份。

不是磁盘快照那种。三台都还在喘气,你敲命令进去,三个都会回你。DigitalOcean Managed Agents 的沙箱,跑活的是 OpenCode,背后挂 DeepSeek v4 Pro。

画外音:写"三个都会回你"的时候我手指顿了一下。往下看你就知道,它们仨其实是同一个 PID。

活本身不复杂:给实习生建一个虚构咖啡烘焙坊的状态看板,四个指标卡、一张近期批次表格,单文件 HTML,不引任何外部资源。这种活给得越具体它干得越好——你要是甩一句"顺手把页面理一理",它能给你理成亲妈都不认识。这次开得不错,91.6 秒,29,084 个 token。

我得看一眼它做出来没有。沙箱里起个静态服务器:

python3 -m http.server 8080

再用 doctl 把本地端口映射进去:

doctl harness-runtime port-forward <session-id> 18101:8080

这条转发只绑本机,沙箱对公网不暴露。截图是真 Chromium 加 Playwright,从这条隧道加载 microVM 里的页面拍的——不是扔个 URL 让浏览器自己去看那种糊弄。

到这儿都还正常。有意思的是下一步。

我给它做了个检查点,25.23 秒。从这个检查点分出三个副本,15.29 秒。

三个分叉刚起来,我一条指令都还没给,它们就已经是这状态:

PID: 593
GET / → 200
index.html: 6695 bytes

三份,一模一样。

我盯着这三行看了半分钟。冒出来的不是"好快",是一句更怪的:这不是"从快照启动一台新机器",这是把一台正在呼吸的机器整个抬起来搬了过去。

区别大了。磁盘快照保的是工作成果——文件、依赖、配置、构建缓存、git 状态;重新拉起来,JIT 是冷的,socket 是新的,进程从零起。内存检查点保的是当下:服务器还开着,socket 还连着,JIT 还是热的,RAM 里是什么就是什么,每个副本从检查点那一刻接着往下跑。

这俩我一开始是混为一谈的,觉得 fork 不就是快照换了个名字。现在看,差着一条命。

三个分叉随后各领了活:一个改浅色极简北欧风,一个改复古绿色 CRT 终端风,一个保留原设计加个内联 SVG 七日图表。25.6 到 46.7 秒,收工。

有个细节:原服务器进程从头到尾没重启过。你每次请求它都重新从磁盘读文件,改完保存、刷新,新版本就在那儿。

然后我去翻第三个分叉那张图表,看出问题了。标着 27 的柱子实际只到 18 上下,标着 45 那根停在 40 附近。刻度算错了。

但我第一眼没看出来。第一眼觉得,这图挺好看的。

所以你既然把 AI 当实习生用,审查标准就只能比自己写的更严,不能更松。不好看的东西你会多看两眼;好看的、结构干净的、配色舒服的产出,恰恰最容易让你一眼扫过去就点通过。那根错柱子不是数字算错,是比例尺算错。而比例尺错的图表比一张明显崩掉的页面危险——后者你一眼拦下,前者得你拿着计算器对一遍才现形。

截图我没删,留着。

接下来测回滚。我在父会话里把 web 服务器杀了,应用目录整个删掉。隧道瞬间连不上,页面 404。然后调回滚:

doctl harness-runtime checkpoint rollback <ckpt-id>

5.59 秒返回。又过 6.04 秒,状态刷成 READY。

应用目录回来了,index.html 回来了,PID 还是 593,GET / 又返回 200。

这里我卡了一下。被我显式 kill 掉的进程,以同一个 PID 复活了。重启的话 PID 要重新分配,所以这不可能是"重新启动一个进程"——它只能是从检查点保存的内存状态里被还原出来的。

为了确认回来的是不是同一张页面,我把新截图和原图逐像素对了一遍。79.2 万像素,88 个不同。88 个全在右上角那个带 CSS 脉冲动画的状态圆点上。

那一瞬间我是有点爽的。那种"你真把时间倒回去了"的爽。

爽完开始发怵。

评论里有人——Hayrullah Kar——指出一个我确实没想过的问题:既然分叉复制的是内存,那内存里所有随机数生成器的状态也被复制了。带种子的 PRNG,比如 Python 的 random,三个分叉在检查点之后会产生完全相同的序列。

他建议分叉刚起来时跑一条:

head -c 16 /dev/urandom | xxd

三个副本如果吐出相同的字节,就意味着你在不同分叉里生成的 UUID、会话令牌、幂等键,有可能互相碰撞。

我没跑这个测试。我只比对过 PID 和 index.html 的字节,内核熵池什么情况我不知道。所以现在的做法是:在有人验证之前,把分叉之后生成的一切都当成"可能重复"来处理。这不是过度谨慎,这就是"复制当下"的代价。

Dhruv Malaviya 说得更透一层:存活进程会持有检查点那一刻内存里的凭据,三个分叉就是三份活跃令牌和会话密钥的副本。内存检查点还会带走那些本来就不该落盘的东西——平时只活在 RAM 里、一重启就没的东西,现在被复制了三份。

前阵子我在同一个平台上做过一次暂停测试,HARNESS_INFERENCE_API_KEY 是当普通环境变量放在沙箱里的。按这个推断,三个分叉应该持有同一个密钥。但我从没在任何分叉里打印验证过——这句只是推断,别当结论用。

这是我这次学到、也最不舒服的一点:你以为你复制的是"一个状态",其实你复制的是"一个身份"。状态复制多少份都行,身份复制出去就麻烦了。内存检查点把这两样打成一个包,你按下"分叉"那会儿,两个一起被复制。

回滚同样有边界。它还原的是 microVM 内部,不还原外部世界。智能体在检查点之后发出的 API 调用、推上去的 commit、发出去的消息,回滚收不回来。代码回去了,外部留下的痕迹不会跟着回去。

这条我认同,但得诚实:我的测试没展示到这一层。整个过程都发生在 microVM 内部,没有任何外部操作。是理论边界,不是实测出来的。想验证得让分叉在检查点之后真去调一次外部 API,我没做。

最后算账。整个实验五个会话——三个分叉加一次全新重跑——余额从 3.26 美元掉到 2.99,27 美分。大头是推理费,不是沙箱时间。

这里我要推翻自己初稿里的一个论点。

我原来想写的是"分叉能省下一分半的搭建时间",听着挺划算。后来发现这说法站不住。我用完全相同的提示词、同一个模型开了个全新会话,得到的是另一个应用:浅色主题而不是深色,多了一条棕色页眉,数据全线不同——库存 1,847 公斤对 2,847,未结订单 8 对 18,批次号 BR-1042 对 #1047。

两次构建的时间也对不上:

run A: 91.6s   in=26656   dark   库存 2847
run B: 31.3s   in=2558    light  库存 1847

同样提示词同样模型,差三倍的时间,差一整个应用。

所以"分叉省时间"是个伪命题,省不了——你没法用分叉复现一次没发生的构建。分叉真正的价值在别处:它精确还原的是当时那一刻。你要回到的不是"一台差不多的新机器",是"那台特定的机器、那个特定的状态"。两码事。

想省时间的人会用错它。想精确回到某一点的人才会用对它。

然后是收尾这一步,我把自己绊了个跟头。

清理的时候我跑了:

pkill -f "harness-runtime port-forward"

这个模式匹配到了正在运行它的那个 shell 自己。它把 shell 杀了。脚本在删除会话那一步之前就中断了。等我回头一看,五个会话全还活着。

……

这个错误我犯了两次 😅。

画外音:一个能把你整台机器精确回滚到六秒前的系统,我居然栽在自己手滑的 pkill 上,多少有点黑色幽默。

补救办法其实很简单,事后看也是常识:脚本跑完之后列一遍会话确认,别假定它跑完了。别假定。

几个限制记一下,免得有人照抄踩坑:这功能还是公共预览,只在一个区域提供;创建检查点要 25 秒左右,不便宜;每个检查点只属于单个会话。

最后这条有坑。分叉继承父会话的机器状态,但不继承父会话的检查点历史。我在分叉上列检查点,结果是空的。第一反应是想把 fork1 回滚到它分叉出来的那个父检查点,然后吃了 404,checkpoint not found。

要在分叉内部也能回滚,得先给分叉自己建一个检查点。这设计我认为合理——分叉就是台新机器,凭啥继承爹的存档——但它不写在脸上,你得撞一次才知道。

所以这东西到底值不值?

值。但值的地方不是省时间,是在你准备干一件可能翻车的事之前,把"现在这个能用的状态"钉在那儿。失败代价从"重建一下午"压到"六秒钟"。

代价是你在按下分叉之前得想清楚另一笔账:那台机器身上带着什么。密钥、令牌、随机数状态,还有那些本来只该活在 RAM 里的东西。机器状态可以随便复制,身份不行。平台不会替你兜这个底,它只管把机器复制得漂漂亮亮。

本期缴税:27 美分,买一个"复制状态的同时也复制了身份"的认识。下次分叉之前,我会先把环境变量数一遍。

阿舟
阿舟

写代码写到一半开始怀疑人生,靠 AI 工具续命,顺手把踩过的坑都记下来。

查看主页 →