我的 OpenClaw 昨晚为什么把额度跑没了

昨天晚上,我的 OpenClaw 跑了一晚上把额度跑没了,一觉醒来才发现,他是卡在了一个很蠢、但也很典型的死循环里。

表面上看,是一条 2026-04-05 18:00 触发的定时任务一路跑到了深夜,把额度刷空了。可我把日志、会话记录和代码链路都翻了一遍之后,结论完全不一样:真正出问题的,不是“采集面经”这件事本身,而是它做完之后那一步“把结果通知回来”的动作。

说白了,就是正事早就做完了,收尾的时候卡死了。

事情是怎么开始的

这条 cron 的任务很明确:每天晚上 6 点,去找一篇面经,整理成文档,再归档到飞书知识库里。

从配置上看,它是一个标准的隔离任务:

  • 18:00 触发
  • 独立会话执行
  • 超时时间 15 分钟
  • 执行完以后,用 announce 模式把结果回投回来

所以理论上,这条任务最多跑到 18:15,就应该结束。

实际记录也确实是这样。cron 的运行日志里显示,这次任务在 2026-04-05 18:15 左右就已经报了超时。也就是说,如果只看调度层,它根本没有跑到凌晨。

这一步很重要,因为它直接推翻了最开始那个最直觉的判断:不是“主任务太慢”,而是“主任务结束后,后处理逻辑出问题了”。

真正的死循环,不在抓取,而在通知

我后来在会话日志里看到了一个非常醒目的重复文本:

1
A background task "draft-ali-java-interview-doc" just completed successfully.

这句话的意思很直接:后台子任务已经成功完成了,而且它完成的事情就是把文稿整理好。

换句话说,真正费时的那部分工作,其实已经做完了。

后面系统想做的事情很简单,就是把这份“后台任务做完了”的消息再发回去,交给主会话自然地总结一下,最多说个一句两句,或者干脆回 NO_REPLY

听起来很轻,对吧?

问题偏偏就出在这里。

看起来像一个小错误,实际上是完整的故障链

这次故障不是某一行代码单点写错,而是一条链路上的几个小问题串起来了。

第一个问题,是这个 cron 的隔离会话虽然记住了自己是在 Feishu 上跑的,但没有记住“应该发给谁”。

这在日志里能直接看到。它的会话状态里只有:

1
2
3
{
"channel": "feishu"
}

但没有 to,也没有完整的 accountId

这件事对 Feishu 来说是致命的。因为 Feishu 不是只知道“走 Feishu 渠道”就能发消息,它还必须知道目标是谁,比如:

  • user:openId
  • chat:chatId

少了这个目标,系统根本不知道该把消息送到哪里。

所以后面的 announce 一执行,就开始报错:

1
Delivering to Feishu requires target <chatId|user:openId|chat:chatId>

这不是模糊错误,也不是推测,而是系统底层很明确地告诉你:渠道对了,目标没了。

最危险的地方,是系统把“永远不会成功的错误”当成了“过会儿再试试”

如果这次错误只是偶发超时,其实没那么可怕。

很多系统都会有重试机制。网络抖一下、网关慢一下、接口超时一下,重试几次很正常。

但这次不是这种错误。

这次的问题是:参数本身就不完整。没有 to,就永远发不出去。你再重试 10 次、100 次、1000 次,结果都一样。

偏偏 OpenClaw 当时的 announce queue 没有区分这两类错误。它的逻辑大概是:

  1. 发一次
  2. 失败了
  3. 保留队列
  4. 等一会儿
  5. 再发一次

如果这是临时错误,这套逻辑是合理的。

如果这是永久错误,这套逻辑就是一个故障放大器。

这也是这次事故最值得记住的一点:很多死循环根本不长在 while (true) 里,而是长在“队列失败后无限重试”里。

为什么它会真的把额度跑没

一开始我也有个误判。我以为既然只是“通知发不出去”,那损失顶多就是 Feishu API 报错,不应该消耗那么多模型额度。

后来我才发现,不是这样。

announce 这一步不是一条静态转发消息。它实际做的是:把“后台任务已经成功”“整理结果是什么”“请你自然地总结一下,最多 1 到 2 句话”这一整段内容,再当成一条新的 user message 注入到会话里。

也就是说,每次重试,系统都不是单纯重发一条通知,而是在重新拉起一次 agent,让模型再看一遍上下文,再判断一次要不要输出。

这就解释了为什么前半段日志里有大量正常的 assistant 响应,后半段才开始变成大量 403

顺序不是:

  1. 先额度没了
  2. 然后开始循环

而更像是:

  1. 先进入循环
  2. 循环里不断重复触发模型
  3. 跑了一段时间之后,额度或上游状态开始扛不住
  4. 最后出现密集的 403

所以这次真正烧额度的,不是原本那条“采集面经”的主任务,而是它之后那个失败了还不收手的通知链路。

这类问题为什么特别容易误判

因为从用户视角看,只有“一条任务”。

你会觉得:昨天 18:00 的任务怎么一直跑到凌晨。

但从系统内部看,它其实分成两段:

第一段是主任务,负责抓取、整理、写文档。这个阶段在 18:15 左右就已经超时结束了。

第二段是后处理,负责把后台结果 announce 回来。真正一路卡到深夜的,就是这一段。

这也是我以后排障时会特别小心的一件事:当一个定时任务“看起来跑了很久”,不能默认就是主逻辑跑得慢。很可能是主逻辑早就结束了,卡住的是回写、通知、补偿、汇总、收尾这种后处理动作。

很多线上事故都死在“最后一步”。

我是怎么修的

这次我做了三个修复,思路都很直接。

第一个修复,是在 cron 运行时,一旦系统已经解析出了正确的 channel / to / accountId,就立刻把这几个字段写回 cron 会话状态里。

之前的问题是,运行时局部变量里其实知道该发给谁,但这份信息没落到会话状态里。于是本轮知道,下一轮 announce 又不知道了。

这次我直接把它补齐,让后续链路读到的不是一个残缺的 session。

第二个修复,是在 announce 发送前加了一个很早的检查:如果渠道是 Feishu,但目标 to 为空,就直接判定为不可重试错误。

这相当于把“这种错误永远不会靠重试变好”这件事,明确写进了系统里。

第三个修复,是给 announce queue 加熔断。现在如果它碰到的是这种明确的不可重试错误,就直接丢掉队列,不再继续无限重试。

这一步是最关键的保险。

因为状态同步问题以后未必永远不会再出,但即使以后别的地方又出了类似参数缺失的问题,也不应该再让它把整个系统拖进一个无限重试的深坑里。

我学到的几件事

这次问题让我重新确认了三件事。

第一,异步系统里最危险的 bug,往往不是逻辑没写完,而是状态没同步好。运行时明明知道正确答案,后续链路却拿不到,这种问题特别容易拖出长尾故障。

第二,重试从来都不是默认正确。重试只适合临时错误,不适合结构性错误。只要这个错误是“缺参数”“缺目标”“配置不完整”这类问题,那每一次重试都只是让事故更大。

第三,后处理流程和主流程一样重要。很多人给主任务加超时、加监控、加告警,但对 announce、补偿、通知、收尾这些流程掉以轻心。可现实是,很多真正拖垮系统的,反而是这些“看起来不重要的最后一步”。

如果以后我再看见类似现象,我会先查什么

我现在的排查顺序会很固定。

先看 cron 运行记录,确认主任务到底有没有真的跑很久。

然后直接去看对应 session transcript,查有没有同一条内部提示被重复注入。

接着看会话状态里的 deliveryContext,尤其是 Feishu 这种对 to 非常敏感的渠道。

最后再看系统日志里,队列到底是在报超时、报 5xx,还是已经明确在报参数缺失。

这套顺序的好处是,你不会再被“表面上像主任务很慢”这种现象带偏。

最后

昨天这次故障,说复杂也不复杂,说简单也不简单。

简单在于,真正的直接错误只有一句话:Feishu 缺少投递目标。

复杂在于,这句话后面连着会话状态、异步 announce、重试队列、模型调用和资源消耗,最后一起把问题放大了。

我现在反而觉得,这种事故很值得记下来。因为它不是某种特别偏门的奇葩问题,恰恰相反,它非常典型。

只要你在做:

  • cron
  • 队列
  • 异步通知
  • 回写流程
  • 补偿机制

你早晚都会遇到一类问题:主逻辑没出错,收尾逻辑把你拖死了。

这次只是刚好轮到我而已。

我唯一真心希望的是,下次再碰到类似问题时,我能在它开始重试第三次之前,就把它掐掉,而不是等它把额度刷空以后,才来复盘。