环境坏了,测试 Agent 会把它报成 Bug 吗?
故障注入:自己动手把环境弄坏,确定地看 Agent 怎么反应。可以接受的结局只有两种:恢复后正常完成,或者明确报"环境错误"。崩溃、挂起、把失败报成成功、把环境故障报成 Bug,都是缺陷。
第 3 周用假模型服务器测过断流、缺 message_stop、429、畸形 JSON。这一讲补上时间类故障(卡住、首字节慢)和流里的 error 事件,用固定种子做随机注入,再用 Playwright 把被测应用的网络弄坏。
1测试 Agent 有两种错,后一种更隐蔽
BugHunt-Bench 里的测试 Agent 要在被测 Web 应用里找 Bug、交报告。它的结论有两种错法:被测应用真有 Bug,它没找到(漏报);被测环境出了问题(网络抖、后端 503、模型 API 卡住),它把这当成产品 Bug 报上来(误报)。后一种在日常跑分时很难发现:环境故障本来就少,而且每次不一样。
"不好复现就把日志发给供应商"是被动做法。故障注入是主动做法:把故障写进剧本,想注入几次就几次,然后断言 Agent 的反应。
| 结局 | 算不算对 |
|---|---|
重试后恢复,任务 done | 对(故障是暂时的) |
重试用完,明确上报 env_error,附上证据 | 对(故障一直在) |
| 抛出没分类的异常,整个进程崩掉 | 错 |
| 一直等,挂住不动 | 错 |
把半截输出当成 done | 错 |
| 把环境故障报成产品 Bug | 错(BugHunt 里最贵的一种) |
2故障清单:按层排,每层用不同的工具注入
| 层 | 故障 | 注入工具 | 本讲 |
|---|---|---|---|
| TCP / 网络 | 延迟、连接被重置、只传一部分数据就断、限速 | toxiproxy | 只介绍,本机未安装,未运行 |
| HTTP | 429、529、503、400;响应头迟迟不来 | 假模型服务器(HTTPError、headers_delay_s) | 429 第 3 周已测;本讲测 529、400、首字节慢 |
| SSE 流 | 中途断开、缺 message_stop、畸形 JSON、流里的 error 事件、卡住不动 | 假模型服务器(cut_after、RawEvent、stall_after) | 前三种第 3 周已测;本讲测后两种 |
| 被测应用 | API 连接被重置、503、响应太慢、只坏一次 | Playwright page.route | 本讲实际运行 |
代码在本地 week05_长任务可靠性/code/fault_injection/。假模型服务器从第 3 周复制过来(原文件不改),只加了 stall_after(发完 n 个事件后卡住,不发数据也不断开)和 headers_delay_s(先等一会儿再发响应头)。
3第 3 周没测到(或没修)的三种故障
① 卡住的流:默认要等 10 分钟
流发了 3 个事件就不动了,连接也不断。SDK 1.11.0 的默认超时是 httpx2.Timeout(timeout=10 * 60, connect=5.0)(anthropic/_constants.py:9),read 超时 600 秒。read 超时管的是"两次收到数据之间最多等多久",所以一个卡住的流要等满 600 秒才抛 httpx2.ReadTimeout。测试里用线程跑,2 秒后断言它还挂着。
Codex 对应的是 stream_idle_timeout,默认 300,000 毫秒(第 3 周讲过)。实现是给"等下一个事件"包一层超时(codex-api/src/sse/responses.rs:535-542):
loop {
let start = Instant::now();
let response = tokio::select! {
biased;
_ = tx_event.closed() => return,
response = timeout(idle_timeout, stream.next()) => response,
};
每等一个事件都重新计时,超时就报 idle timeout waiting for SSE,走流层重试。我们的修法是给 SDK 客户端显式设 read 超时。
② 首字节慢:一次调用变成 9 个请求
响应头迟迟不来,客户端 read 超时。这时还没拿到响应,SDK 把 httpx2.TimeoutException 包成 APITimeoutError,按 max_retries=2 自己重试。第 3 周的 loop 在流层捕获了 anthropic.APIConnectionError,而 APITimeoutError 正是它的子类,于是流层又重试 2 次:3 × 3 = 9 个请求,测试里请求头 x-stainless-retry-count 是 0,1,2 重复三遍。read 超时取默认的 600 秒时,单层重试最坏要 3 × 600 秒,两层相乘是 9 × 600 秒,超过 90 分钟。这个乘法第 3 周已经实测到 9 个连接并记了下来,但没有消掉;这一讲用 x-stainless-retry-count 把它钉成测试,并改掉。
Codex 的 guardian 连接池里有一句注释说的就是这个阶段:The SSE idle timeout starts after headers arrive. Bound that wait too.(ext/guardian-v2/src/async_scorer/sampler/connection_pool.rs:479-482)——流的空闲超时从响应头到达才开始算,等响应头这段也要单独兜住。
修法:只有"响应头已经到了、读流时出的错"才归流层重试,拿到响应之前的错交给 SDK 那一层。重试相乘的道理见「超时了,再发一次安全吗?」那一章。
test_mixed_faults_still_reach_nine_requests 实测为 9)。修法消除的是"同一个错误被两层各重试一遍";要把总量也压住,就再加一个整次调用的请求数或时间预算。「超时了,再发一次安全吗?」那一章建议,自己包一层重试时把 SDK 的 max_retries 调成 0。这里保留两层,是因为两层管的错误不相交:SDK 只管拿到响应之前,流层只管读流时;代价就是上面这个混合故障的上限。
③ 流里的 error 事件:第 3 周的 loop 直接崩
官方文档说,流式响应在返回 200 之后也可能在事件流里发错误,例如高峰期的 overloaded_error(非流式时对应 HTTP 529):
event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}
SDK 1.11.0 读到 error 事件时用当前响应的状态码构造异常(anthropic/_streaming.py:132-145),状态码是 200,所以抛的是笼统的 APIStatusError,不是 OverloadedError。它不在第 3 周 loop 的重试列表里,整个 agent 崩掉。第 5 周的版本把它算作流层故障,重发一次就好。
anthropic.Anthropic)收到 HTTP 529 抛的是 OverloadedError,它直接继承 APIStatusError,不是 InternalServerError 的子类(anthropic/_exceptions.py:171、anthropic/_client.py:579-580);其他 5xx(含 503、504)才是 InternalServerError。所以只写 except anthropic.InternalServerError 会漏掉 529。ServiceUnavailableError(503)、DeadlineExceededError(504)只在 Bedrock / Vertex 客户端里使用。④ 重试用完以后:env_error,不是 done,也不是崩溃
529 一直不好、首字节一直超时、流一直坏:第 5 周的 run_agent 返回 env_error: 异常名,messages 里没有半截的 assistant 消息。反过来,400 是请求写错了,是 harness 自己的 bug,不能被当成环境问题吞掉,照样抛出。
4随机注入:固定种子,结果要对得上预言
逐个故障测完之后,还要看"故障随机出现"时整体可靠性有多少。两条规矩:随机数用固定种子,失败了能原样复现;先由计划推出预言(这个任务该成功还是该 env_error、该发几个请求),再真的跑一遍核对。
设每个请求出故障的概率是 \(p\),每次模型调用最多尝试 \(r+1\) 次,一个任务要 \(n\) 次调用。如果故障相互独立:
$$P(\text{任务成功}) = \bigl(1 - p^{\,r+1}\bigr)^{n}$$\(p = 0.3,\ r = 2,\ n = 2\) 时是 \(0.973^2 \approx 0.9467\)。但真实故障往往扎堆:服务过载、网络抖动都会持续一段时间。用两状态马尔可夫链建模:上一个请求坏了,这一个以概率 \(q\) 继续坏;上一个好,这一个以概率 \(p_0 = p(1-q)/(1-p)\) 变坏。这样长期平均故障率仍是 \(p\),只是坏的请求挨在一起。\(q = 0.8\) 时第一次调用三次全坏的概率是 \(p q^2 = 0.192\),独立时只有 \(p^3 = 0.027\)。
正相关让重试一起失败,所以实际成功率低于独立公式:精确值 0.7637。下面是本地真实运行结果(合成数据,每种模式 400 个任务,每个任务都真的打到假服务器上):
独立假设下的公式 (1 - p^3)^2 = 0.9467
[independent] 精确值 0.9467
实测成功 386/400 = 0.9650,Wilson 95% [0.9421, 0.9790]
请求 1097 个,其中注入故障 322 个(0.294)
实际与预言不一致:0
[bursty] 精确值 0.7637
实测成功 310/400 = 0.7750,Wilson 95% [0.7316, 0.8132]
请求 989 个,其中注入故障 352 个(0.356)
实际与预言不一致:0
两个区间都盖住了各自的精确值。扎堆模式下实际发出的请求里故障占 35.6%,高于设定的 30%:坏了才会重试,重试的请求正好落在坏的时段里。这个结论依赖"重试紧接着发生":如果退避等待比一段故障持续得还久,重试会落到好的时段,相关性就弱了。用独立公式估可靠性,和「跑了 300 次,就是 300 个样本吗?」那一章说的是同一件事:正相关时,乐观估计会偏高。
5BugHunt:用 Playwright 把被测应用的网络弄坏
被测应用是一个合成的购物车页面:前端从 /api/cart 取数据,自己算合计(正确是 487.00);?v=buggy 版本注入了一个产品 Bug,合计忘了乘数量(显示 288.00)。故障用 page.route 注入,被测应用一行不改:
await page.route("**/api/cart", lambda route: route.abort("connectionreset")) # 连接被重置
await page.route("**/api/cart", lambda route: route.fulfill(status=503, body="...")) # 后端 503
await page.route("**/api/cart", _slow) # 先等 2 秒再放行
await page.route("**/api/cart", lambda route: route.abort("connectionreset"), times=1) # 只坏第一次
两个规则写死的"测试 Agent"替身(不调模型,让实验确定):朴素版看到页面不对就报 Bug;谨慎版先看网络证据,接口请求失败或 5xx 时重新加载一次,仍失败就报 ENV_ERROR(无法判定),接口 200 且合计和接口数据算出来的不一致才报 Bug。本地真实运行(Playwright 1.63.0,Chromium):
| 场景 | 真相 | 朴素版 | 谨慎版 | 谨慎版最后一次的证据 |
|---|---|---|---|---|
| 健康 | PASS | PASS | PASS | 200,已加载 |
| 产品 Bug | BUG | BUG | BUG | 200,已加载 |
| 连接被重置(持续) | 环境 | BUG | ENV_ERROR | net::ERR_CONNECTION_RESET |
| 503(持续) | 环境 | BUG | ENV_ERROR | 503 |
| API 慢 2 秒(持续) | 环境 | BUG | ENV_ERROR | net::ERR_ABORTED,前端 1 秒超时 |
| 连接被重置(只一次) | PASS | BUG | PASS | 重载后 200 |
| 产品 Bug + 重置一次 | BUG | BUG | BUG | 重载后 200 |
朴素版误报 4 个,谨慎版 0 个,两者都没有漏报。最后一行是反向检查:加了重试,真 Bug 还要能抓到。
6toxiproxy:在 TCP 层注入(只介绍)
Shopify 的 toxiproxy 是一个 TCP 代理,README 的第一句是 "Toxiproxy is a framework for simulating network conditions."。客户端连代理,代理连真实服务,中间按 "toxic" 动手脚;控制接口是 HTTP,默认端口 8474。本机没有安装,下面只摘 README 里的条目,没有实际运行。
| toxic | 作用(README 原意) | 属性 |
|---|---|---|
latency | 所有数据加延迟,延迟 = latency ± jitter | latency、jitter(毫秒) |
timeout | 数据全部拦下,timeout 后关连接;为 0 时连接不关,一直丢数据 | timeout(毫秒) |
reset_peer | 模拟 TCP RESET(Connection reset by peer) | timeout(毫秒) |
limit_data | 传够这么多字节就关连接 | bytes |
bandwidth | 限速 | rate(KB/s) |
slicer | 把数据切成小块,可在块之间加延迟 | average_size、size_variation、delay(微秒) |
每个 toxic 还有 stream(upstream 是客户端到服务端,downstream 是反方向,默认 downstream)和 toxicity(作用到一条连接的概率,默认 1.0)。"整个服务挂掉"不是 toxic,而是把代理的 enabled 设为 false。README 里的 CLI 例子:
toxiproxy-cli create -l localhost:26379 -u localhost:6379 shopify_test_redis_master
toxiproxy-cli toxic add -t latency -a latency=1000 shopify_test_redis_master
什么时候用它:被测对象不是 HTTP(Redis、数据库、消息队列),或者没法改客户端的 base_url。timeout toxic 取 0 就是 TCP 层的"卡住的流",limit_data 就是 TCP 层的"中途断开"。
▶演示一:故障注入实验台
选一个故障、选持续方式、选被测的 loop,看它发了几个请求、最坏等多久、最后是什么结局。请求行为按本地实测(SDK 1.11.0)的规则推演,不发真实请求。时间是"不计抖动"的上界:SDK 退避 min(0.5·2n, 8) 秒,流层退避 0.2·2n 秒;卡住和首字节慢各耗一个 read 超时,其余故障按 0 秒计。
▶演示二:独立故障 vs 扎堆故障
每个请求的长期平均故障率都是 p。q 是"上一个坏了、这一个也坏"的概率;q = p 时就是独立故障。精确值用动态规划算,模拟用带种子的伪随机数在浏览器里实时跑 2000 个任务(合成数据)。模拟用的随机数和 Python 实验不是同一串,数字会有小幅差别。
✎练习
APIConnectionError,流层最多重试 2 次。一次模型调用一共会发多少个请求?APITimeoutError;它是 APIConnectionError 的子类,被流层捕获,流层一共跑 1 + 2 = 3 轮:3 × 3 = 9。第 5 周的版本只让"响应头到了之后"的错走流层,结果是 3 个。Timeout(timeout=600, connect=5.0);read 超时是两次收到数据之间的上限。SDK 的重试只覆盖拿到响应之前,读流时的超时要 agent 自己处理。所以要显式设 read 超时,并把读流时的超时归入流层重试。/api/cart 是 net::ERR_CONNECTION_RESET。它应该怎么做?7面试要点
一句话讲清楚
故障注入是主动把环境弄坏、确定地看反应:我按层列故障清单(TCP、HTTP、SSE 流、被测应用),用假模型服务器、Playwright 的 page.route、toxiproxy 分别注入,断言请求数、等待时间和结局;结局只允许"恢复后完成"或"明确上报环境错误"。随机注入用固定种子,并且先由计划推出预言再核对。在我们自己的 loop 上,这套方法找出了卡住的流会挂 10 分钟、流里的 error 事件让 agent 崩溃,并把首字节超时被两层重试放大到 9 个请求(第 3 周已记下但没修)钉成测试、改掉。
追问准备
- 超时怎么分?连接超时、等响应头的超时、两个事件之间的空闲超时、整个任务的总时长,各管一段。SDK 的重试只管拿到响应之前。
- 重试为什么会放大?同一个错误在两层都被当成可重试,次数相乘。修法是按"出错时处在哪个阶段"分层,每种错误只在一层重试,并用故障注入测请求总数。注意上限没变:每轮先首字节超时、再流卡住,一次调用最坏仍是 (2+1) × (2+1) = 9 个请求;要压住总量,再加一个整次调用的请求数或时间预算。
- 怎么证明测试 Agent 不会把环境故障报成 Bug?拿一个已知没有 Bug 的版本,注入各种环境故障,断言误报数是 0;再拿已知有 Bug 的版本加暂时故障,断言没有漏报。
- 随机故障注入怎么复现?固定种子,记录每个请求注入了什么;测试里断言实际结果等于按计划推出的预言。
- 独立公式算出来的可靠性能信吗?故障扎堆时偏乐观。实验里同样 30% 的故障率,独立时任务成功 0.9467,扎堆时 0.7637。
常见错误说法
❌ "重试多几层更保险":同一个错误在两层都重试,请求数相乘,首字节超时时 3 × 3 = 9。
❌ "
except anthropic.InternalServerError 就能兜住服务端错误":SDK 1.11.0 默认客户端收到 529 抛的是 OverloadedError,不是它的子类;流里的 error 事件抛的是 APIStatusError。❌ "页面报错就是 Bug":先看网络证据,接口失败或 5xx 是环境问题,要重试、要标"无法判定"。
❌ "平均故障率 30%,重试 2 次就有 97.3% 的调用成功":只在故障独立时成立,扎堆时低得多。
代码:本地 week05_长任务可靠性/code/fault_injection/,.venv/bin/python -m pytest -v 跑 19 个测试,experiment_random_faults.py 跑随机注入实验,bughunt_web/bughunt.py 跑 Playwright 矩阵。公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。