启动 13 次、切换 1 次:我怎样让智能体安全执行发布
发布执行器启动了 13 次,前 12 次停止都不是待发布版本本身的缺陷导致的。五项机制让协调智能体、验证工具和网络造成的问题停在生产切换之前,文中附有实现片段。
迁移后半程要上传约 2,000 个文件、创建映射、部署,然后切换域名。我把这些步骤放进一个由 systemd 托管的长脚本,由 AI 协调智能体驱动。执行器一共启动了 13 次,最后一次才完成切换。
前 12 次停止,没有一次是待发布版本本身的缺陷导致的:五次来自协调智能体,四次来自我们自己的验证工具,两次来自网络,还有一次是时间预算检查按设计拒绝继续。它们都没有损害生产环境。下面拆开说明让失败停在安全位置的五项机制。
12 次停止的原因

| 原因 | 次数 | 例子 |
|---|---|---|
| 协调智能体的状态或修改 | 5 | 遗留上次尝试的构建回执;sed 修改失败后未检查就重启;复制了与生产数据不一致的演练数量;某个脚本缺少 Python 路径 |
| 我们的验证工具 | 4 | 拒绝已知无害的页面;Python 哈希随机化导致台账重建顺序不同;某一步缺少允许列表;拒绝保留查询字符串的 CDN 重定向 |
| 网络 | 2 | 上传到第 767 个文件时,连接 R2 的 TLS 握手超时;部署成功后,Vercel CLI 最后一次获取结果失败 |
| 时间预算检查 | 1 | 剩余窗口不足以完成后续阶段,因此按设计停止 |
| 待发布版本本身的缺陷 | 0 |

要判断能不能让智能体驱动发布,这张表给出了实际执行中的阻力。多数阻力来自智能体和它使用的工具。执行器遇到不满足条件的情况就拒绝继续,才让这些阻力没有变成生产事故。

机制一:批准必须绑定证据哈希
执行器会在四个检查点暂停。每到一处,它先将通过程序检查的证据写进 hold 文件,再等待 go 文件。go 文件必须比 hold 文件更新,而且包含 hold 文件的 SHA-256。旧批准或提前写好的批准,因此无法放行新的证据。
hold() { # hold N title evidence-file
local n=$1 sha
cp "$3" "$S/HOLD-$n.txt"; sha=$(sha256sum "$S/HOLD-$n.txt" | cut -c1-64)
until [[ -f $S/GO-$n && $S/GO-$n -nt $S/HOLD-$n.txt ]] && grep -q "$sha" "$S/GO-$n"; do
[[ -f $S/STOP ]] && fail "STOP at HOLD-$n"; sleep 10
done
}
# reviewer releases it: echo "reviewed $(sha256sum HOLD-3.txt | cut -c1-64) <note>" > GO-3
日志里的每次批准,都能追溯到它具体批准了哪份证据。智能体以这种方式放行了 14 次检查点,我没有手工放行任何一次;无论由谁批准,留下的审计记录相同。
机制二:按完整内容精确授权
工具先输出 JSON 摘要,再询问 Proceed? [y/N]。一个小型伪终端包装器代替人回答。它严格核对整份摘要及其数据类型,而且只对第一次提示作出肯定回答:
def strict_eq(a, b):
if type(a) is not type(b): return False # 0 != False, 1 != True
if isinstance(a, dict): return a.keys() == b.keys() and all(strict_eq(a[k], b[k]) for k in a)
if isinstance(a, list): return len(a) == len(b) and all(map(strict_eq, a, b))
return a == b
# on "Proceed? [y/N]": answer "y" only if this is prompt #1 and strict_eq(last_plan_json, EXPECTED); else "n"
EXPECTED 来自已经封存的证据,包括计划摘要值(digest)、数量、目标和对象键。这套授权机制也要像生产代码一样测试:先输入工具真实输出的确认内容,再改动一个字段,确认包装器回答的是 n。
机制三:每个阶段都能恢复,并重新核实状态
重跑时,执行器不会相信本地的“已完成”标记,而是读取数据存储,重新分类:
- 上传: 预留 ID 由键和哈希生成。重跑会跳过已经确认完成的文件,对上次回复未知的那一个文件,使用同一个预留 ID 重新上传。恢复后实际上传了 1,180 个文件,跳过 767 个,失败数为 0。
- 映射: 区分“已按预期应用”“尚不存在”和“不匹配”。授权执行器只接收尚不存在的映射;只要发现不匹配,立即停止。
- 恢复入口: 即使指定
RESUME_AT=mappings,也要重新执行所有启动检查,之后才允许跳到映射阶段。
机制四:只重试已经理解的失败
for try in 1 2 3 4 5 6; do
run_upload > "$log"; rc=$?
[[ $rc -eq 0 ]] && break
transient=$(grep -cE '^unready [^ ]+: (direct PUT reply unknown|PUT outcome unknown)' "$log")
other=$(grep -cE '^(refused|Traceback|Error)|requires audited reconciliation' "$log")
[[ $rc -eq 2 && $transient -ge 1 && $other -eq 0 ]] || break # anything else: stop
sleep 20
done
[[ $rc -eq 0 ]] || fail "upload failed after $try tries"
如果不区分原因,一律重试,那一周早些时候遇到的两个真实问题就会被掩盖。这里仅重试我们已经证明可以安全重试的那一类失败,其他情况都停止。
机制五:验证器本身也需要测试
域名切换前,检查程序会验证新构建生成的每个 URL,约 80,000 个,并核对全部媒体字节。这也是代码,也会出错。12 次停止中有四次由验证工具造成,代价最大的一次涉及重定向:
Vercel 的 308 重定向会保留请求的查询字符串。
/assets/x.m4a?v=123会转到https://media.example/x.m4a?v=123。我们的检查却要求Location与不含查询字符串的媒体 URL 完全相等,于是在网站正常工作的情况下,一轮报告了 4,758 个失败。

由此补了两条规则:
- 如果旧系统也通不过同一检查,先调查检查器。 直接请求旧部署一次,就看到了相同的重定向行为。
- 离线模拟必须先复现线上失败,通过结果才可信。 我们的模拟桩按检查器期待的方式返回重定向,没有复现 Vercel 的实际行为。修复时必须先让旧检查器报告失败,再证明新检查器通过。候选部署检查的 4,759 个失败,包括上述 4,758 个重定向误报和 1 个回滚检查失败。
Vercel 发布时需要注意的行为(截至 2026 年 10 月)
vercel deploy --prod --skip-domain会创建生产部署,但不会移动自定义域名,也不会改变项目的“当前生产部署”指针。定时任务跟随当前生产部署,不跟随域名别名。 每次部署前后,我们都核实了这点。- 项目默认的
*.vercel.app别名会随着部署移动。 - 在伪终端里执行
npx <cli>,如果本地没有相应软件包,会先出现Ok to proceed?安装提示。无人值守执行可能卡在这里,应该将 CLI 安装到本地并固定版本。审查模型在它卡住上传之前发现了这个问题。
我接受的取舍
严格的启动检查有时会因为并非真实故障的原因拒绝继续,例如某个预期数量误用了演练数据。每次拒绝都要修正再启动,通常花 5–15 分钟。这个代价我可以接受;让执行器靠猜测继续,代价更难控制。后续应该让每个预期值都来自封存证据,避免沿用上一轮的状态。
下一篇:哪些交给智能体,哪些仍由我决定,以及独立审查究竟值在哪里。