ML 研究中最常见的谎言是"上次跑的结果很好"。上次用的什么代码?什么数据版本?什么参数?没人说得清。我用文件系统事务(临时目录 → 原子重命名)实现了每次迭代的不可变快照,让"上次的结果"变成一个可查询的事实而不是一段记忆。

“上次的结果"是个幽灵

做 ML 研究的人都经历过这个场景:两周前跑出了一个 Sharpe Ratio 1.8 的策略,现在想复现,发现代码改过了、数据更新过了、参数记不清了。你知道那个结果存在过,但你无法证明它。

在 AI 编排系统中这个问题更严重。AI 自动迭代 15 轮,每轮生成不同的代码、用不同的参数、得到不同的指标。第 8 轮的结果很好,但第 12 轮覆盖了第 8 轮的代码。你甚至不知道第 8 轮用的是什么代码——因为没有人保存中间状态。

不可复现的研究结果不是研究成果,是轶事。

问题的结构

这个问题的本质不是"忘了保存”。而是系统的信息模型只有"当前状态",没有"历史状态"。

传统的研究流程是这样的:

跑代码 → 看结果 → 改代码 → 再跑 → 覆盖上一次的结果

每次迭代都是原地修改。代码文件只有一份最新版本,结果文件只有一份最新输出。想回到两轮之前的状态?手动 Ctrl+Z 或者翻 Git 历史。

但 Git 是为人类设计的版本管理工具——它假设人类会在有意义的节点手动 commit。AI 不会。AI 在一个循环里连续跑 15 轮,每轮之间的时间间隔可能只有几秒。你不能期望 AI 在每轮结束时停下来写一条有意义的 commit message。

这引出了一个设计需求:版本化应该是系统的自动行为,不是操作者的主动行为。每次状态转换自动产生快照,不依赖任何人记得要保存。

我考虑过的方案

方案 A:Git 自动提交

每轮迭代结束时自动 git add && git commit。用 Git 的版本历史来管理迭代状态。

Git 在什么场景下是对的:人类驱动的开发流程,commit 粒度是"一个有意义的改动",commit message 传达意图。

为什么在 AI 迭代场景下不适用:三个原因。

第一,粒度不匹配。AI 一次任务可能产生 20 个版本,每个版本包含代码、执行结果、指标、元数据。Git 管理的是文件变更,不是"一次完整的迭代快照"。你需要把代码、结果、指标分散在不同文件里,然后靠 commit hash 把它们关联起来。可以做,但别扭。

第二,性能。Git 的 add + commit 在大仓库里不是零成本操作。当一个任务快速迭代时,频繁的 Git 操作会成为瓶颈。而且 Git 的锁机制意味着并发任务会互相阻塞。

第三,查询能力。“给我第 8 轮的指标”——在 Git 里需要 git log 找到对应的 commit,再 git show 提取文件内容。可以做,但不如直接读一个目录那么直观。

方案 B:数据库存储

把每轮的代码、结果、指标写入 PostgreSQL。用关系表管理版本。

数据库在什么场景下是对的:结构化查询需求强、数据量大、需要事务一致性的场景。

为什么没完全采用:代码是文本,执行结果是 JSON,指标是数字,HTML 报告是大文本。把这些全塞进关系数据库,要么用 TEXT 列存大字段(查询效率差),要么拆成多张表(关联复杂度高)。数据库擅长管理结构化的元数据,但不擅长存储异构的工件(artifact)。

另一个考虑:文件系统天然支持"打开文件看内容"的操作。调试时直接 cat code.py 看代码,比在数据库里 SELECT code FROM versions WHERE ... 直觉得多。可调试性在研究系统里是一个被低估的需求。

方案 C:文件系统事务 + 不可变快照(我的选择)

设计哲学一句话:每次迭代是一个不可变的目录,用文件系统事务保证原子性。

原子写入的设计

每次迭代的快照是一个目录,包含固定的文件结构:

{base_dir}/{task_id}/v{version}/
    ├── code.py         # 这轮的完整代码
    ├── result.json     # 完整的执行结果
    ├── metrics.json    # 提取的关键指标
    ├── metadata.json   # 自动生成的元数据(时间戳、任务 ID、版本号)
    └── report.html     # 可选的可视化报告

关键设计:写入过程是原子的。不是一个文件一个文件地写——那样如果写到一半进程崩溃,会留下半成品。而是:

1. 在目标目录的同级创建一个临时目录(前缀 .v{n}_tmp_)
2. 把所有文件写入临时目录
3. 所有文件写完后,用 rename() 把临时目录重命名为正式目录
4. 如果任何步骤失败,删除临时目录

rename() 在 POSIX 文件系统上是原子操作——要么成功,要么什么都没发生。这和数据库的事务是同一个思路:要么全部提交,要么全部回滚。不存在"代码保存了但指标丢了"的中间状态。

这个模式来自数据库的 WAL(Write-Ahead Log)设计。WAL 的核心原则是"先写日志再写数据"——如果写数据时崩溃,可以从日志恢复。我的模式是"先写临时目录再重命名"——如果写文件时崩溃,临时目录会被清理,不会污染正式版本。

关键判断点

做出这个设计的关键认识是:版本快照不是"开发者工具",是"系统基础设施"。

很多系统把版本管理当作"方便开发者调试"的附加功能。Git、MLflow、Weights & Biases 都是这个定位——它们是独立的工具,研究者主动使用它们来跟踪实验。

我的设计把版本快照融入了系统的状态转换逻辑。不是"迭代完成后保存一个版本",而是"保存版本是迭代完成的一部分"。如果版本没有被保存,这次迭代在系统层面就没有完成。

这和上一篇文章讨论的"终局思维"是同一个框架的应用。终局思维说:回溯能力不是事后补的,是设计约束。版本化是回溯能力的技术实现——如果每次迭代都有不可变的完整快照,任何"上次的结果"都可以被精确定位和复现。

这个判断也受到了不可变基础设施(Immutable Infrastructure)思想的影响。在容器化部署中,服务器不是被"修改"的,而是被"替换"的。每个版本的服务器镜像是不可变的——出了问题回滚到上一个镜像,而不是在当前镜像上打补丁。同样的逻辑:每个版本的研究快照是不可变的——出了问题回到上一个快照,而不是在当前状态上找差异。

不可变的代价和收益

不可变意味着存储空间会线性增长。每次迭代保存完整的代码和结果,而不是增量 diff。20 轮迭代 × 每轮 1MB = 20MB 一个任务。千次任务 = 20GB。

这是一个有意识的取舍。增量存储(只存 diff)更省空间,但查询时需要从第一版开始逐步重建——复杂度高,出错风险大。完整快照浪费空间,但每个版本自包含——读取任何版本只需要读一个目录,不依赖其他版本的完整性。

磁盘便宜,工程师的调试时间贵。这是一个简单的经济账。

另一个收益:元数据自动注入。每个版本的 metadata.json 自动包含任务 ID、版本号、UTC 时间戳。这些字段不由调用方提供,由存储层自动填充——防止人为(或 AI)错误导致的元数据不一致。

实施效果

版本化之后,“上次的结果"不再是一个需要回忆的问题。查询任务 ID,列出所有版本目录,直接打开对应版本的 metrics.json。从"你记得上次是什么参数吗"变成"查一下 v8 的 metadata”。

更重要的效果是在迭代评估中:系统可以自动比较当前版本和历史最优版本的指标。如果连续 3 轮指标下降,自动终止迭代。这个逻辑依赖于每个版本的指标都被完整保存——如果只有"当前版本"和"上一个版本",你无法判断趋势。

调试也变简单了。以前排查"为什么第 12 轮的结果比第 8 轮差":翻日志、对比代码 diff、猜测可能的原因。现在:打开 v8 和 v12 的目录,diff code.py 看代码差异,diff metrics.json 看指标差异。所有信息就在那里,不需要重建。

这个决策教会我什么

我从这次实践中提炼了一条设计原则:

任何需要回答"之前是什么状态"的系统,都应该把状态快照作为系统行为的一部分,而不是操作者的责任。自动化的不可变快照消除了"忘了保存"这个失败模式。

这条原则的适用范围不限于 ML 研究。配置管理(Terraform 的 state file)、数据库迁移(migration 文件的线性历史)、甚至文档版本控制(每次发布是一个快照而不是一个 diff)都是同一个思路。

核心认知是:可复现性不是一种美德,是一种架构约束。如果你的系统需要回答"之前发生了什么",那么系统必须在架构层面保证"之前的状态被保存了"。靠操作者的自觉是不够的——人会忘,AI 更不会主动记。


系列第六篇。上一篇:让 AI 写的代码跑起来,但别让它跑出去。第四篇:终局思维。第三篇:AI as Operator, Kernel as Law。第二篇:MCP 的问题不在协议层。第一篇:为什么我没有用多智能体架构