Agent难复现Bug的克星:确定性模拟测试
通过单一种子驱动故障/时钟/随机,使偶发Agent Bug变为可精确定位到单步的可重现artifacts。
通过单一种子驱动故障/时钟/随机,使偶发Agent Bug变为可精确定位到单步的可重现artifacts。
确定性模拟测试通过一个种子驱动每一次故障、时钟和随机选择——这样,一个只在生产环境中出现一次的 Agent flaky bug,就变成了可以复现的产物,并且可以收缩到仅一行。
TL;DR:最棘手的 Agent bug 只在特定的故障交织情况下才会出现——工具在副作用之后刚好失败、重试触发、资金被扣两次。Happy-path 测试发现不了它,而一旦进入生产环境你就无法复现。确定性模拟测试(DST)——FoundationDB、TigerBeetle 和 Antithesis 背后的技术——使得故障、时序和随机性成为一个种子的纯函数,这样任何失败都可以精确回放,并且可以收缩到其最小根因。在一个可运行的 Python 演示中,happy path 通过了, seeded fuzzing 捕获到一次双重扣款,精确回放,并将一个包含 4 个故障的调度收缩到唯一重要的那个故障。
心智模型:一台带有录制按钮的飞行模拟器。与其等待暴风雨击中真飞机,不如按需召唤暴风雨——当暴风雨导致飞机坠毁时,你可以逐帧回放那场暴风雨直到理解它,然后剥离出造成损害的那一阵风。
Agent 运行在一个充满敌意的世界里。工具超时、API 返回错误、重试触发、步骤竞态。大多数时候一切正常。但在故障具体落在哪一步的空间里,隐藏着一个 bug——一个不是幂等的重试、一个假设调用成功的状态更新、一个运行两次的补偿逻辑。它只出现一次,在生产环境里,涉及真实资金,然后就消失了:你用同样的输入重新运行,它却正常工作,因为这次时序不同。
传统测试帮不上忙。基于示例的测试只会走 Happy Path。即使是随机测试,如果真的触发了 bug,也无法告诉你是怎么触发的——触发它的随机性已经消失了。
DST 翻转了模型。每个非确定性来源——故障注入、时钟、线程调度、RNG——都通过一个单一种子路由。这带来了三样东西:
可复现性。同样的种子总是产生同样的运行。一次失败是一个永久的产物。
探索性。扫描数千个种子(在模拟时间中快速进行)以探索人类永远不会想到手工编写的故障交织方式。
收缩性。一旦你有一个失败的场景,机械地移除各个部分,直到只留下最小的触发因素——把一个混沌的失败变成一行可复现的代码。
这里"故障调度"就是那些在第一次尝试时就会失败的步骤集合——整个非确定性的来源,被明确化和可种子化。工作流中有一个真正的 bug:confirm 的重试会重新运行 charge,且没有幂等性保护。
for step in PLAN:
if step == "confirm":
for t in range(2):
try:
attempt("confirm", t); break
except Fault:
attempt("charge", 1) # <-- the bug: a second, unguarded charge on retry
else:
for t in range(2): # every other step has a safe, idempotent retry
try:
attempt(step, t); break
except Fault:
continue
因为运行是其调度的纯函数,fuzzing、回放和收缩都是平凡的:
def fuzz(seeds):
for seed in range(seeds):
schedule = random_schedule(random.Random(seed))
if not invariant_holds(run_agent(schedule)): # property: charges ≤ 1
return seed, schedule
def shrink(schedule):
minimal = set(schedule)
for step in list(minimal):
if not invariant_holds(run_agent(minimal - {step})):
minimal -= {step} # drop faults that aren't needed to trigger the failure
return minimal
1. happy-path test (no faults): PASS <- the bug is invisible here
2. fuzzing seeded fault schedules: FAIL on seed 1
schedule = ['analyze', 'confirm', 'notify', 'plan'] -> charged the customer 2x
3. replay same schedule twice: 2x and 2x charges -> identical (reproducible)
4. shrink to the minimal cause: ['confirm']
one fault at 'confirm' is all it takes to double-charge.
Happy Path 通过了——这就是这个 bug 会被发布的原因。Fuzzing 找到了一个包含四个故障的失败调度,其中三个是无关的噪音。回放证明它可以精确复现。收缩剥离了噪音,变成一行可复现的代码:confirm 处的一个单一故障就导致了双重扣款。这是一个开发者可以在几分钟内修复的 bug 报告,而不是一个令人困扰的"在我机器上能工作"。
DST 正在迎来它的时刻。FoundationDB 首创了它;TigerBeetle 的 VOPR"可以任意加速时间——一分钟的模拟等于数天的真实测试";Antithesis 融了一大笔钱,卖的是一个确定性虚拟机,可以回放每条指令以复现失败。几乎每个严肃的数据库公司现在都在使用或评估它。
Agent 是下一个明显的目标,而且可以说更适合:Agent 的 think→act→observe 循环本身就是一系列离散的、可 mock 的步骤,有着良好定义的失败点(工具错误、超时、部分写入)。工程上的转变是让 Agent 的非确定性可注入——时钟、重试和工具故障都在你控制的接缝之后——然后让模拟器制造混乱。你断言的属性("charges ≤ 1"、"没有孤立的预订"、"计划永不退化")成为故障下正确行为的规格。
它是一个最小模型——非确定性只是"哪些步骤先出错",状态空间很小。真实的 DST 在两个方面更难:确定性是一种纪律(每次时钟读取和交织都必须通过种子路由,这就是 Antithesis 为什么要构建虚拟机来强制执行它),而探索是真正的问题(巨大的状态空间意味着 DST 与基于属性的测试和引导式 fuzzing 配对)。从小处着手:让一个 Agent 的故障和时钟可注入,然后跨多个种子断言一个不变量。
python3 demo.py # standard library only
Platforms & write-ups
Antithesis — Deterministic simulation testing: how it works and when to use it — seeds, fault injection, time-travel debugging, and why DST pairs with property-based testing.
TigerBeetle — the VOPR simulator — stubs out clock/network/disk, injects partitions and corruption, and replays any failure from (seed, commit).
FoundationDB — simulation testing — the origin of the approach.
Phil Eaton — What's the big deal about Deterministic Simulation Testing? — an approachable tour of controlling clocks and randomness.