返回博客

2026.10.06

断路器跳了 80 次,看板一直显示「运行中」:我写的自愈脚本,到底在救谁?

四个 AI worker 一夜启动 1652 次,每次活 60 秒,看板上始终是「运行中」。我当时的诊断是失败计数卡在 10 到 12、够不到 20,断路器没触发。两天后翻事件表才发现:断路器跳了 80 次,每次都被我自己的续命脚本在一分半内复位。这篇讲怎么从一个计数器快照反推出它被复位过几次、为什么这种死循环会和定时任务锁相、以及一个静默退出的续命脚本为什么和一个健康的续命脚本长得一模一样。

AI Agent可靠性方法论监控

四个 AI worker,一夜之间一共启动了 1652 次。每次都活了 60 秒左右,报同一句错,退出。

整整 7 个半小时,看板上这四张卡一直显示「运行中」。

第二天早上我查清楚原因,写了一条结论进自己的操作手册:失败计数一直卡在 10 到 12,够不到 20 的上限,所以断路器没触发。

这条结论是错的。今天翻原始事件表,断路器其实跳了 80 次,每张卡 20 次。每一次都在一分半钟左右被重新合上,动手的是我自己写的「续命脚本」。

想直接拿判据的看第 2 章,那是一个减法;想知道静默退出为什么比崩溃更难发现,看第 4 章。

1. 那天夜里发生了什么

背景一句话:我在用几个 AI agent 组队优化一个推理引擎,每个 agent 是看板上的一张卡,有个调度器每分钟扫一遍看板,看到「就绪」的卡就起一个 worker 进程去干活。

发卡的时候,我给四张卡都挂了一个 skill(可以理解成 agent 的操作手册)。问题是这个 skill 只装在我自己的配置里,worker 们用的是另外几套配置,找不到它。于是每个 worker 一启动就报 Unknown skill,退出。

调度器的设计是对的:连续失败到一定次数就别再重试了,把卡片设成「阻塞」,等人来看。这就是断路器,跟家里电闸一个意思,短路跳闸,省得一直烧。这几张卡的上限是 20 次。

按这个设计,20 分钟后四张卡就该全部跳闸,总共浪费 80 次启动,然后等我早上来修。

实际浪费了 1652 次。

2. 答案就在第一份监控报告里:110 = 5 × 20 + 10

凌晨 3 点 37 分,我挂着的只读监控交了第一份报告。原话里有这么两个数:

  • 看板上失败计数:10
  • 事件记录里每张卡的崩溃次数:110

我当时(还有那份报告)都把它读成「计数器涨不上去」。现在回头看,这两个数放在一起就已经是证据了。上限是 20,一张卡怎么可能崩了 110 次还没跳闸?

除非计数器被人清零过。清零几次,一个减法就出来了:

被复位次数 = (累计崩溃次数 − 当前计数) ÷ 断路器上限
         = (110 − 10) ÷ 20
         = 5

去事件表核对,3 点 37 分之前每张卡确实有 5 次跳闸:01:51、02:17、02:40、03:02、03:24。一次不差。

这里说白了就是一个计数器快照骗人的问题。计数器在 0 到 19 之间来回转圈,你什么时候去看,它就给你看一个圈里的随机位置。看到 10 不说明它卡在 10,说明你恰好在半圈的时候来看了。

那是谁在清零?续命脚本。我给这类长跑任务都配了一个每十来分钟跑一次的小脚本,因为 worker 经常在截止前自己停工(自作主张宣布完成、或者把自己挂起等评审),脚本的逻辑很朴素:截止前看到「阻塞」的卡,就把失败计数清零、解除阻塞,拉回来接着干。

它根本不问这张卡为什么阻塞。断路器跳闸也是「阻塞」,它照样救。

事件表里,80 次跳闸,后面紧跟着 80 次解除阻塞,间隔最短 54 秒,最长 347 秒,平均 97 秒。

我写了一个专门防止 worker 偷懒的东西,它把专门防止空转的东西给废了。两个机制单独看都对,放在一起是个永动机。

3. 这个死循环被定时任务锁住了节拍

还有个有意思的细节。跳闸到下一次跳闸,间隔在 1267 到 1567 秒之间,平均 1333 秒。这个数是能提前算出来的:

一次烧满所需时间 = 断路器上限 × 调度间隔 = 20 × 60.3 秒 ≈ 1206 秒
续命脚本间隔     = 660 秒(实测 86 次运行,655 到 664 秒)
循环周期 ≈ ⌈1206 ÷ 660⌉ × 660 = 2 × 660 = 1320 秒
实测 1333 秒,差 1%

烧满 20 次要 20 分钟出头,续命脚本每 11 分钟来一次。卡片被救起来之后,脚本下一次来的时候它还在烧,再下一次来的时候它刚好跳完闸,于是又被救起来。四张卡的解除阻塞时间全是 01:57:38、02:19:38、02:41:42、03:03:42 这种,整整齐齐 22 分钟一跳。

这个周期还决定了你在看板上「抽查」到它的概率:

抽查时看到「阻塞」的概率 ≈ 平均复位延迟 ÷ 循环周期 = 97 ÷ 1333 ≈ 7%

另一个独立的数能对上:这 7.6 小时里,每张卡有 90.7% 的时间都有一个 worker 进程活着(413 次启动 × 平均 60 秒 ÷ 27446 秒)。剩下 9% 多一点是阻塞时间加上进程之间的空档。

也就是说,你随便什么时候瞄一眼看板,九成以上看到的都是「运行中」,计数器显示一个 0 到 19 之间的数。看起来一切正常,只是有点慢。

要是没有续命脚本,这一夜的损失是 4 × 20 = 80 次启动。有它,是 1652 次,多了 20 倍。

4. 反过来,续命脚本自己死了,也是一片安静

隔了一天,同样的续命脚本给我上了另一课。

第十三轮开跑前,我把上一轮的脚本复制了一份,只替换了轮次编号。截止时间那一行是写死的数字,忘了改,还是上一轮的,那天凌晨 4 点 58 分就已经过期了。脚本开头就是「过了截止就退出」,于是它每次启动都安安静静地 exit(0)。

这个脚本的约定是「有动作才输出,空输出等于一切正常」。所以它每隔十来分钟跑一次、每次空输出,看上去就是一切正常。

结果七个 worker 里有六个在 10:48 到 12:25 之间陆续停工,没有一个被拉起来。最后一次评测在 12:47。一个 8 小时的轮次,我估计实际干活只有两个半小时左右,最后只涨了 0.16%。

这个失败模式可以用一个很土的比值说清楚:

空输出的信息量 = P(空输出 | 脚本健康且无事可做) ÷ P(空输出 | 脚本已经死了)
              = 1 ÷ 1
              = 1

比值是 1,意思是看到空输出,你对「脚本还活着」的信心一点都不该变。我把「沉默」设计成了「健康」的信号,可「死掉」发出的信号也是沉默。

第 2 章是自愈脚本救过了头,第 4 章是自愈脚本自己死了。两种情况在看板上的样子完全一样:卡片该是什么状态就是什么状态,没有任何东西报警。

5. 看到别人说「我们有自动重试 + 断路器,很稳」,问两个问题

这两次之后,我看任何「失败了会自动恢复」的系统,都会先问两句:

第一,断路器跳闸之后,谁会把它合上? 如果答案是另一个自动化(健康检查、续命脚本、编排器的重启策略),就要接着问那个自动化区不区分「临时故障」和「确定性故障」。同一句报错重复了 20 次,第 21 次大概率还是它。不区分的话,断路器只是在给死循环计时。

第二,你说的「稳」是看哪个数? 如果是某个时刻的状态快照(「全部运行中」「失败计数都在阈值以下」),那它说明不了什么。要看的是累计事件:一段时间里跳闸了几次、被复位了几次。用第 2 章那个减法,(累计失败 − 当前计数) ÷ 上限,只要结果大于 0,就有东西在背后清零。

我那天凌晨手里两个数都有,就是没做这个减法。

6. 这篇里哪些地方不够硬

  • 当时出事的续命脚本是第一版,同一天下午我把它升级成了第二版,原文件被覆盖了。「它看到阻塞就清零计数」这个判断,依据是事件表里 80 次跳闸后都紧跟着解除阻塞、时间点全卡在脚本运行时刻,加上前一轮的同类脚本里确实是这个逻辑。证据很强,但我没有那天那一版的原始字节。
  • 第 1 章那句「当时的结论是错的」,那条错误结论在我的操作手册里躺了两天。它的防御建议(卡片别挂 skill、发卡后 3 分钟确认 worker 活着)还是对的,只是机制解释错了。这次已经顺手改掉。
  • 第 3 章的周期公式只在「烧满时间」和「脚本间隔」差不多量级时才会锁成整数倍。烧满时间如果比脚本间隔小很多,就会变成每次脚本运行都救一次,周期就是脚本间隔本身。我只有这一次的数据。
  • 第 4 章「实际只干了两个半小时」是按 worker 停工时间和最后一次评测时间估的,没有逐个 worker 精确累计。
  • 全文就一个事件、四张卡,都是同一个原因。所以 7%、90.7% 这些数,说的是这一夜,不是普遍规律。

7. 给自愈脚本加这几条

  1. 解除阻塞前先看最近几次失败的报错。同一条报错连续出现、每次进程都活不过 1 分钟,就别救了,改成报警。
  2. 区分「worker 自己停的」和「断路器跳的」。后者默认不救。
  3. 监控别读失败计数器的当前值,改成数事件:一段时间里跳闸几次、解除阻塞几次。
  4. 加一条自检:累计失败次数 > 断路器上限,而卡片状态不是阻塞 → 有东西在复位,立刻报出来。
  5. 自愈脚本每次运行都打一行心跳,哪怕是「无事可做」。监控检查最后一次心跳的时间,别把空输出当健康。
  6. 截止时间这类参数从本轮的配置文件读,不从上一轮的脚本里复制。脚本起好之后,手动跑一遍,把它读到的截止时间打印出来对一眼。
  7. 发卡后 3 分钟内,确认每个 worker 至少有一个进程活过了 2 分钟。在那之前,看板上的「运行中」三个字都不算数。