这篇复盘上个月出现在 dev.to 上,署名 nexus-lab-zen。我猜大多数人刷到之后扫一眼就过去了,因为表面看它就是个运维事故:三周零警告,然后发现是钩子被超时杀了。那不是我要讲的东西。我要拆的是里面一根承重墙——一个守卫进程被超时杀死,为什么可以在不产生任何信号的情况下,让整个团队把三周的死亡读成纪律变好。
先把跟承重墙有关的事实摆出来。这个漂移警告钩子是个停止钩子,每个 AI 代理回合结束时跑一次,检查行为漂移:代理给选项菜单却不做决定、问“是否开始”而不是直接开始、误用表格、同行消息放着过期。它被配了 10 秒超时。到 6 月中旬,它实际跑完需要 260 秒。于是每个回合,它启动,10 秒一到,harness 把它杀掉。杀掉的进程不产生警告,不产生错误。三周零警告,团队把零警告解读为纪律改善。直到 Claude Code 的 /checkup 列出“钩子已超时 15 次”,才来第一个响亮信号。
这已经够硬了。但真正要拆的是机制,不是这个故事。
先给直觉。一个带超时的守卫,一旦超时被杀,它在外部观察者眼里和“干净通过、无警告”完全一样。不是相似,是完全一样——因为两边留在可见结果里的都是空。死亡不叫,健康也不叫。这是整个事故最狠的地方:故障信号被故障本身吞掉了。
写一个式子。设 $t_{\text{run}}$ 是守卫这一轮真正需要的运行时间,$t_{\text{timeout}}$ 是 harness 给它的上限。再设 $W$ 是守卫正常跑完时会吐出的警告集合,$W$ 可以为空,空就代表“这轮检查没发现漂移”。外部能看到的输出 $O$ 是:
$$ O = \begin{cases} W, & t_{\text{run}} < t_{\text{timeout}} \ \varnothing, & t_{\text{run}} \ge t_{\text{timeout}} \end{cases} $$
每个符号交代清楚:$W$ 是真实检查结果,可能包含若干条警告,也可能是空集;第二行的 $\varnothing$ 是“被杀死后根本没机会产出任何东西”的空。问题就出在第二行——那个 $\varnothing$ 和第一行里 $W=\varnothing$ 在日志里长得一模一样。你接到一个空输出,没法判断它来自“查完了,没事”还是“没查完,死了”。这就是全部。
这个式子不是原论文里抄来的,是这篇复盘把机制抽出来之后该有的东西。你如果只消费“警告”和“错误”,那 $O=\varnothing$ 就是你的全部信息。它给不了你任何区分。正因为给不了,零警告才能被读成纪律改善,一读就是二十三天。
我第一次读到这里也在一个点上卡了一下:为什么 /checkup 能看见“超时 15 次”?如果被杀时什么都没输出,harness 那边难道也不记录?后来想通了——记录超时次数的不是守卫自己,是 harness 这个外部执行环境。守卫自己死了,但它死之前被谁杀的、哪一次被杀的,harness 在杀它那个动作上留了账。这就是外部正向记录的第一个雏形,但它是被动的,而且被看见的是样本,不是全量。评论里有句话点得很透:一个被超时杀死的进程无法发出任何输出,所以“响亮降级”在结构上覆盖不了这种失败。说这话的是 Alex Spinov,我后面再回来补他一刀。
画个图。这是超时、输出、外部记录三条线在时间轴上怎么错位的:
t=0 t=10s t=260s
│ hook 启动 │ harness 杀 hook │ hook 本来能跑完但到不了
│ │ 输出:无 │
▼ ▼ ▼
[守卫自身日志] (一片安静) (永远不会发生)
[harness 的账] 写入"超时 kill" —— 这里才有第一条真实信号
[你真正该有的外部正向记录]
每次运行后应写 {已运行检查数, 违规数, 运行时长}
死亡的那轮:没有新行,或者运行时长停在 0,或者检查数停在 0
中间那条 harness 的账,是救了这个团队一次的东西。但它不是设计好的防线,是 Claude Code 例行健康检查顺手带出来的。真正的防线应该在最下面那条:每轮不管死没死,都由外部写一条“我跑到了哪一步”的记录。只有这条记录,能区分 $O=\varnothing$ 的两种语义。这一步是关键。
现在说代价和失效条件。这根承重墙的代价已经摆在那:三周里所有警告消失,被解释成“AI 代理变得守纪律了”。这是最贵的误读——把故障当成功。它之所以成立,是因为守卫没有留下任何“我没跑完”的痕迹。失效条件至少有两个:第一,$t_{\text{run}} \ge t_{\text{timeout}}$,且超时杀死不写任何正向状态;第二,外部观察者只订阅“警告/错误”而不是“运行记录”。两个条件同时满足,这套系统就瞎了。
注意这篇复盘修复之后又踩了一个边界的边:运行时降到 6-9 秒,超时还是 10 秒。看起来修好了,实际上零余量——下一次负载稍微涨一点,它又会死成一片安静。后来才改到 30 秒,大约 3.7 倍余量。这里的关键不是数值,是“超时是一个随系统会静默过期的参数”。作者在结构性规则里写了这句话,我读到时停了一下。它值回这篇复盘所有阅读时间。大部分人把超时设一次就忘了,系统长胖以后,它就开始到处杀进程,而你不会得到任何警报——它杀得越多,你看到的警报越少。
修复过程的两个细节也值得拎出来。登录 shell 改成普通 shell,每次调用启动时间从 3.6 秒降到 0.85 秒;数十个按文件启动的进程改成批量处理,运行时间从 260 秒降到 6-9 秒。慢不是玄学,是小进程一个一个堆出来的。对抗性 QA 还抓到两个高优先级问题:脚本继承了环境 locale 而不是固定 locale,日期解析在不同 locale 下会翻转,导致 5 个判定结果变了;另一个就是超时零余量。注意,这两个都不是实现者自己发现的,是独立 QA 抓的。修 bug 的人自带盲区。修复像新代码一样需要对抗性审查,这不是流程洁癖,是上一轮的教训。
现在回到评论区补刀。不铺开,只两刀。
第一刀还是 Alex Spinov。他说他跑 32 个收集器、约 2190 次运行,一个返回 0 项的爬虫和正确发现 0 项的爬虫看起来相同。要区分,只能给每个收集器建历史基线——比如某个收集器几个月来每次返回 400-600 行,那今天返回 0 就不是发现变少,是它死了。这和我前面那个式子是一个意思,只是他把 $\varnothing$ 放进了具体的业务语义里:0 可能是真实的 0,也可能是故障。没有历史基线,0 就是薛定谔的 0。
第二刀是 Mike Czerwinski 那个老问题:谁监视监视者。作者的回答也很实在:终止层不是另一个守卫,而是人类所有者按固定墙钟节奏做拉取式检查,要求新鲜时间戳证明活体,把陈腐性本身当成警报。这话里有个非常对的东西——不要试图用另一个自动守卫去监视这个守卫,那只是把同构的盲区往上挪了一层。人拉取,需要新鲜时间戳,陈旧触发警报。这是把外部正向记录制度化了。
第三刀留给 FromZeroToShip,一个医院理疗师,不是工程师。他提的种子测试很刁:往安全扫描器里植入十种已知不良模式,要求构建失败如果没捕获。这验证的不是“扫描器运行了”,而是“扫描器仍然能检测”。他的健康检查 cron 也可能静默停止,仪表盘纯无信号保持绿色。这个测试补上了 $O=\varnothing$ 里另一个隐蔽分支:就算进程没被杀死,它跑完了,也不代表它还能抓到东西。能跑和能检测,是两回事。nexus-lab-zen 在回复里承认,对抗性审查双方都知道这是审查,所以测的是“在被观察时表现”而不是“真正表现”,拿霍桑效应比了一下。这个承认比技术细节更值得读,因为它把 QA 的边界也标出来了。对抗性语料库能证明“在被观察时它表现得还行”,证明不了上线后的表现。不过先保证它不在镜头前死掉,已经算巨大一步。
再往后就有点黑色幽默。修复之后钩子恢复运行几小时,发出了三周以来的首批警告,其中两个是误报——因为钩子沉睡期间,消息文件 frontmatter 被写成了破折号列表,旧解析器不认。这个误报恰恰证明它活了。它开始喊了,哪怕喊错了。比二十三天安静健康。
把论文拉回地面。你自己生产里的 cron、healthcheck、agent hook、夜间安全扫描器,有多少是只靠“警报/错误”来确认它们活着的?有没有外部正向记录?有没有每次运行写“检查了哪些项、每项多少、跑了多少秒”的一行?有没有人按固定节奏去拉取那个记录,看时间戳新不新鲜?如果答案是没有,那你现在大概率正处在一个静默超时或静默退出已经发生、但你还没读到的窗口里。
最小实现其实不复杂:守卫每轮跑完或被杀死之前,由外部包装器写一行 ledger,字段就三个——已经跑过的检查数、违规数、运行时长。注意是外部包装器写,不是守卫自己写,因为守卫自己会在写之前死掉。然后另一个拉取者按固定节奏检查这行的墙钟时间戳,新鲜的就是活的,陈旧的就是死了。陈腐性本身就是警报。你甚至不需要猜它为什么死,先知道它死了,后面再查。这套东西比任何“响亮降级”都便宜,而且结构上不瞎。
我不准备再给你总结一遍。就重复一遍那根承重墙:一个被超时杀死的进程,无法发出任何输出;于是它的死亡和健康在可见结果上完全一样;所以任何只消费输出的守卫监控,都是透过毛玻璃看戏。要破这个局,只能靠外部正向记录,靠历史基线,靠人类按节奏拉取新鲜时间戳。这堵墙我替你拆了,剩下的路,你得回自己系统里走。
