别再 sleep 了:Agent 方法论框架 superpowers 怎么处理测试不稳定

2026-07-29

本文基于 superpowers 仓库 commit 44c9b2d(2026-07-27)梳理,该项目仍在持续迭代,具体行为以仓库 https://github.com/obra/superpowers 最新代码与文档为准。

Agent 写的测试时红时绿,绝大多数时候不是它不会写断言,而是没人教过它「等」有两种写法——等一段时间,和等一个条件。 这两种写法在你本机上表现一模一样,在 CI 上、在并行跑的时候、在机器负载高的时候,差别就是通过率的差别。superpowers 这个 MIT 许可的开源仓库(作者 Jesse Vincent)在 skills/systematic-debugging/ 目录下正面处理了这件事,而且不是写一句「不要用 sleep」了事,是给了判断树、给了可以照抄的实现、给了另一类不稳定的定位脚本。

站内已经有两篇挨着的文章,但管的不是同一段:AI 写的测试用例为什么大多没有鉴别力 讲的是用例本身有没有能力抓到 bug,Agent 失败重试怎么设计 讲的是运行期遇到失败该不该重试、怎么重试——这两篇都是通用方法论。本篇讲的是一个具体的开源项目把「不稳定」这件事拆成了哪两类、每一类给了什么可直接落地的东西,以及这些东西的边界在哪。

一、固定延时的问题不是「时间不够」,是把「多久」当成了「是否」

打开 skills/systematic-debugging/condition-based-waiting.md,第一句写的是:不稳定测试常常用一个随手写的延时去猜时序,于是造出了竞态——在快机器上过,在负载下或 CI 里挂。这句话点的是问题的性质,不是问题的规模。

它给的核心原则一句话:等你真正在意的那个条件,而不是猜它要花多久。

这句话值得多想一层。你写 setTimeout(r, 50) 的时候,脑子里想的其实是「等异步操作做完」,但代码里写下的是「等 50 毫秒」。这是两个不同的命题。50 毫秒这个数是你在本机跑了几次觉得够用凑出来的,它编码的是你那台机器在那个时刻的负载,不是被测系统的语义。等条件的版本长这样,文档里的对照片段是:

// ❌ BEFORE: Guessing at timing
await new Promise(r => setTimeout(r, 50));
const result = getResult();
expect(result).toBeDefined();

// ✅ AFTER: Waiting for condition
await waitFor(() => getResult() !== undefined);
const result = getResult();
expect(result).toBeDefined();

差别不在代码量,在于失败的时候你能读到什么。上面那版失败时告诉你「断言挂了,result 是 undefined」,你不知道是没做完还是做错了;下面那版失败时告诉你「等某某条件超时」,问题域立刻缩小一半。

文档还列了一张场景到写法的对照表,覆盖等事件、等状态、等数量、等文件、等复合条件五种情形。写法都是同一个形状:把「什么算好了」写成一个返回真值的函数。等数量那条是 waitFor(() => items.length >= 5),等文件那条是 waitFor(() => fs.existsSync(path))——你会发现这些条件全都要求被测系统对外暴露一个可以随时查询的面。这一点后面讲边界时还要回来说。

文档同时明确了什么时候不该这么改:在测真正的时序行为时(防抖、节流的间隔本身就是被测对象),固定延时才是对的写法。它甚至给了一个允许保留固定延时的完整范式:

// Tool ticks every 100ms - need 2 ticks to verify partial output
await waitForEvent(manager, 'TOOL_STARTED'); // First: wait for condition
await new Promise(r => setTimeout(r, 200));   // Then: wait for timed behavior
// 200ms = 2 ticks at 100ms intervals - documented and justified

配套三条要求写得很硬:先等触发条件,延时长度必须基于已知时序而不是猜,并且要写注释说明为什么。这三条合在一起才是「允许」,缺一条就退回到猜。这种写法比一刀切的「禁止 sleep」有用得多——因为一刀切的规则 Agent 一定会在某个场景下违反,而违反之后它就没有依据了。

二、实现长什么样:一个通用轮询,三个领域辅助函数

condition-based-waiting.md 里给了通用轮询函数的完整实现:

async function waitFor<T>(
  condition: () => T | undefined | null | false,
  description: string,
  timeoutMs = 5000
): Promise<T> {
  const startTime = Date.now();

  while (true) {
    const result = condition();
    if (result) return result;

    if (Date.now() - startTime > timeoutMs) {
      throw new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`);
    }

    await new Promise(r => setTimeout(r, 10)); // Poll every 10ms
  }
}

三个细节值得单独拎出来。第二个参数是 description,一个纯粹为了错误信息存在的参数——它不参与任何逻辑,只在超时时拼进异常。这是把「失败时可读」写进了签名里,Agent 照着这个签名生成代码时就没法省掉它。轮询间隔是 10 毫秒,文档在常见错误那节点名了:轮询到 1 毫秒是在烧 CPU。超时上界是签名里就带默认值的第三个参数,调用方不传也一定有上界,同一节里另一条常见错误正是「没有超时」——条件永远不满足时死循环。第三条常见错误是把状态缓存在循环外,导致每轮读到的都是同一份陈旧数据,修法是把取值放进循环里调。

同目录下的 condition-based-waiting-example.ts 是这套东西在真实项目里长出来的样子,文件头注明来自一次测试基础设施改造。它导出三个函数:waitForEventwaitForEventCountwaitForEventMatch,分别对应等某类事件出现、等某类事件够数、等满足自定义谓词的事件。三个函数的骨架完全一致——定义一个 check,命中就 resolve,超时就 reject,否则 setTimeout(check, 10) 再来一轮。

真正值得学的是它的错误信息。等数量那个函数超时时抛的是:

new Error(
  `Timeout waiting for ${count} ${eventType} events after ${timeoutMs}ms (got ${matchingEvents.length})`
)

末尾那个 (got ...) 是分水岭。等到 2 个事件却只来了 0 个,和只来了 1 个,是完全不同的两种故障:前者多半是根本没触发,后者多半是真的慢了或者中途断了。没有这个括号,你两种情况看到的报错一模一样,只能回去加日志。

文件末尾附了一段改造前后的对照注释,改造前是发消息之后延时 300 毫秒「希望工具已经启动」,再 abort,再延时 50 毫秒「希望结果已经回来」,然后断言结果有两条;改造后两处延时分别换成了等 2 个工具调用事件、等 2 个工具结果事件。这个对照有意思的地方在于:改造前的两个数字都是在赌,而且赌的是两件不同的事,中间任何一环慢一点,后面的断言就毫无意义地挂掉——你还会以为是 abort 的逻辑有问题。这类误导性失败的排查成本,往往比不稳定本身更贵,跟 Agent 可观察日志怎么设计 里说的那种「报错指向错方向」是同一类损失。

三、另一类不稳定:跑完测试,仓库里多出了不该有的东西

同一个目录下的 find-polluter.sh 处理的是完全不同的一类问题。你跑完测试套件,发现工作区里多了一个 .git 目录,或者多了一个数据库文件,或者某个配置被改了。单独跑任何一个测试文件都正常,合起来跑就出事。你知道有一个测试在污染环境,但不知道是哪一个。

脚本的用法就一行:

./find-polluter.sh '.git' 'src/**/*.test.ts'

第一个参数是要监视的路径,第二个是测试文件的匹配模式。它做的事很直白:用 find 把匹配的测试文件列出来,然后逐个跑,每跑完一个就检查那个路径是不是出现了。核心循环是这样:

  # Run the test
  npm test "$TEST_FILE" > /dev/null 2>&1 || true

  # Check if pollution appeared
  if [ -e "$POLLUTION_CHECK" ]; then
    echo ""
    echo "🎯 FOUND POLLUTER!"
    echo "   Test: $TEST_FILE"

命中之后它会打印是哪个文件、ls -la 出污染物的详情,再给两条后续命令(单独跑这个测试、看这个测试的代码),然后以非零码退出。一个都没命中就打印全部干净并正常退出。

脚本里有两处细节说明它是被真实用出来的。一处是 find 的模式处理:-path 里的 **/ 匹配不了「零层目录」,src/**/*.test.ts 会漏掉直接放在 src/ 下面的测试文件,脚本因此把 **/ 删掉又拼了一个模式,两个一起 -o 再去重。另一处是每轮开头会先检查污染物是否已经存在,存在就打印警告并跳过——这个分支后面讲避坑时要重点说。

这个脚本和 root-cause-tracing.md 是配套的。那份文档讲的是从报错点沿调用链往上追到原始触发点,它在「不知道哪个测试造成的污染」这一节直接把 find-polluter.sh 指了出来。追踪加定位,一个管纵向一个管横向。想让污染压根发生不了,还有第三份 defense-in-depth.md,讲的是找到根因后在数据流经的每一层都加校验——这跟 Agent 工作区隔离 是同一个思路的两种做法,一个从内部加防线,一个从外部划边界。

四、这几块在仓库里的位置

skills/ 目录下一共 14 个技能子目录,systematic-debugging 是其中一个。它内部不是单文件,是主文档加若干技法文件的组织方式:

组成部分它负责什么对应仓库位置你什么时候会碰到它
技能主文档四阶段流程与那条「没做根因调查就不许提修法」的铁律skills/systematic-debugging/SKILL.md遇到任何 bug、测试失败、异常行为的第一时间
条件式等待技法判断该等条件还是该留延时,以及留延时的三条前置要求skills/systematic-debugging/condition-based-waiting.md测试里出现 setTimeoutsleep
等待工具完整实现三个按事件查询的辅助函数与带上下文的超时错误信息skills/systematic-debugging/condition-based-waiting-example.ts要照着落一套自己项目的等待工具
污染源定位脚本逐个跑测试文件,命中第一个让指定路径出现的那个skills/systematic-debugging/find-polluter.sh跑完套件工作区多出不该有的文件
反向追踪技法从报错点往上追调用链,找到原始触发点再修skills/systematic-debugging/root-cause-tracing.md报错出现在调用栈很深的地方
多层校验技法在数据流经的每一层都加校验,让同类 bug 结构上不可能skills/systematic-debugging/defense-in-depth.md根因已经找到,要防止它换个入口复发
技能创建记录这个技能当初怎么被抽取、怎么被加固、怎么被压力测试skills/systematic-debugging/CREATION-LOG.md你想照着这个套路写自己团队的技能

主文档里那条铁律写成了独立一行:NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST。它还写了一条阶段四的分支——如果连续三次修复都没解决,停下来质疑架构,而不是继续尝试第四次。这条对 Agent 尤其对症,因为 Agent 最擅长的失败模式就是不停换一个写法再试一次。

五、边界与代价

先说条件式等待放弃了什么。

它要求状态可查询。 三个辅助函数都建立在「有一个对象能随时把当前事件列表交出来」这个前提上。如果被测系统只通过回调通知、状态藏在子进程里、或者根本没有查询接口,这套写法就落不下来——你得先给系统开一个查询面,那是改被测代码,不只是改测试。

超时这个数字并没有消失。 它只是从「延时」变成了「上界」。默认 5000 这个值仍然是拍出来的,只不过拍小了会红、拍大了只是慢,危害比拍延时小一个量级。但你不能说这套做法「消灭了魔法数字」。

它会掩盖性能回归。 以前固定等 50 毫秒,系统慢到 60 毫秒测试就红了,你被迫去看为什么慢;改成轮询等条件之后,系统慢到 3 秒依然是绿的。稳定性和性能敏感度在这里是有取舍的,如果你的套件同时承担性能守门的职责,这一层得单独补,可以参考 回归测试怎么组织 里关于分层的部分。

条件短暂为真又回退时会误判。 轮询只看采样瞬间,中间态刚好被采到就算过了。

再说定位脚本的边界,这一块限制更硬。

它只认文件系统路径。 判断用的是 shell 的 -e,也就是「这个路径存在吗」。数据库里多出来的行、内存单例被改脏、环境变量被改、全局 mock 没还原——这些它一概不管。

它只能找「单独跑就能复现」的污染。 循环是一个文件一个文件独立跑的,如果污染需要 A 先跑过 B 才产生,这种顺序依赖型的问题它跑一整轮也找不到,最后会告诉你全部干净。

测试命令是写死的 npm test 非 Node 项目要自己改。而且那行后面跟着 || true,测试自身失败会被吞掉,输出也全部丢进了 /dev/null——它只关心污染有没有出现,不关心测试红绿。

它是线性扫描,不是二分。 脚本头部的注释把自己写成了 bisection,但实际循环是从第一个文件顺次跑到命中为止。对定位来说够用,只是别按二分的耗时去预期:最坏情况是把整个套件按文件粒度串行跑一遍,比正常跑一次慢得多。

最后说这整套流程本身的代价,这一条比上面所有条都值得先想清楚。走完整的根因流程会让开发变慢,会让 Agent 在对话里输出大量调查过程——读起来啰嗦,token 也贵。对于改一行文案、加一个日志、调一个常量这类改动,这套流程是彻底的过度设计,硬套只会让人开始绕过它,而一旦开始绕,规则就失效了。合理的用法是把触发条件划清楚:出现不稳定、出现同一处反复修不好、出现说不清原因的失败,才启动它。

六、上手与避坑清单

跑定位脚本前必须先把污染物清干净。 脚本每轮开头会检查目标路径是否已经存在,存在就打印警告并跳过这个文件。如果你上一轮跑完没清理就直接再跑一次,它会把所有文件全部跳过,然后在末尾打印「没找到污染源,全部干净」并以 0 退出——一个彻头彻尾的假绿。踩这个坑很容易,因为脚本命中后是直接退出的,污染物就留在原地。避法:每次跑之前先删掉那个路径,或者至少先确认它不存在。

别在带空格的路径上跑它。 遍历用的是未加引号的 for TEST_FILE in $TEST_FILES,路径里有空格会被拆成两段。避法:确认路径干净,或者把遍历改成按行读。

不是 npm 项目就先改命令再跑。 脚本硬编码了 npm test,pytest 或 go test 的项目跑起来每一轮都是命令不存在,但被 || true 吞掉,你看不到任何异常,只会得到「全部干净」。避法:把那一行改成你的测试命令,并且先手动跑一次确认它真的能执行单个文件。

别让条件函数返回可能为假值的东西。 通用轮询用的是 if (result) return result,判的是真值。条件返回数量 0、空字符串、或者布尔 false 时都会被当成「还没好」,一路等到超时。避法:条件写成明确的比较表达式,或者返回非空对象。

取值一定放在循环里面。 把状态在循环外读一次然后循环里判断,是文档明确列出的常见错误——数据是死的,条件永远不会变。这个错误 Agent 特别容易犯,因为它倾向于把「重复的读取」当成可以提取的公共表达式。避法:条件函数里每次都重新调 getter。

超时值按最慢的环境定,不按本机手感定。 很多人把超时理解成「大概要等这么久」,于是照着本机耗时填一个略大的数,结果在 CI 上并行跑的时候大面积假红——这和当初填延时犯的是同一个错误,只是换了个参数名。避法:超时是保护性上界,宁可大,真慢了会有别的手段发现。

测防抖节流别硬改成条件等待。 文档明说了这种情况该保留固定延时。硬改的后果是测试永远绿,因为你等的那个条件在防抖窗口结束前后都成立,等于把被测行为测没了。避法:保留延时,但按文档三条要求写全——先等触发条件、数字基于已知时序、注释写清为什么。

别让 Agent 直接照抄示例文件。 那个 .ts 文件依赖具体项目内部的类型和一个具体的事件管理器接口,整段抄进你的仓库编译都过不去。避法:抄结构不抄类型——轮询间隔、超时上界、错误信息带实际值、三个函数的分工方式,这些才是可迁移的部分,查询接口换成你自己的。

收尾

这两份材料的价值不在于技法本身有多新——等条件不等时间,写测试的人多半都听过。价值在于它把「什么时候允许破例」也写成了可检查的条件,而且配了能直接跑的东西。Agent 需要的正是这种形状的规则:只说「不要用 sleep」,它遇到测防抖的场景就会自己发明一套理由;说清楚破例要满足哪三条,它才有可对照的依据。

先做三件事验证自己项目的现状:在测试目录里搜一遍 setTimeoutsleep,看有多少条附了说明为什么;挑一个最近红过的不稳定用例读它的失败信息,看能不能只凭报错判断出是没触发还是慢了;跑完整套测试之后 git status 一下看工作区干不干净。第三件事结果不干净就去读 find-polluter.sh,前两件不理想就读 condition-based-waiting.md 和旁边那个示例文件,整个流程的骨架入口在 skills/systematic-debugging/SKILL.md

本文属于 superpowers 方法论专题(共 30 篇,含三篇与其它开源 Agent 项目的对照)。想看把资产铺满的另一种取向,见 ECC 开源 Agent 套件专题

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。