环境坏了,测试 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只介绍,本机未安装,未运行
HTTP429、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 那一层。重试相乘的道理见「超时了,再发一次安全吗?」那一章。

注意上限没变:如果每一轮流层重试里又先碰上首字节超时,一次调用最坏仍是 (SDK 2+1) × (流层 2+1) = 9 个请求(测试 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 周的版本把它算作流层故障,重发一次就好。

顺带发现:SDK 1.11.0 默认客户端(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):

场景真相朴素版谨慎版谨慎版最后一次的证据
健康PASSPASSPASS200,已加载
产品 BugBUGBUGBUG200,已加载
连接被重置(持续)环境BUGENV_ERRORnet::ERR_CONNECTION_RESET
503(持续)环境BUGENV_ERROR503
API 慢 2 秒(持续)环境BUGENV_ERRORnet::ERR_ABORTED,前端 1 秒超时
连接被重置(只一次)PASSBUGPASS重载后 200
产品 Bug + 重置一次BUGBUGBUG重载后 200

朴素版误报 4 个,谨慎版 0 个,两者都没有漏报。最后一行是反向检查:加了重试,真 Bug 还要能抓到。

取舍:把"持续 5xx"判成环境错误,也可能盖住一个真的 500 Bug。所以 ENV_ERROR 不是丢弃,而是"无法判定":带着网络证据进人工分诊,不计入找到的 Bug,也不计入漏报。

6toxiproxy:在 TCP 层注入(只介绍)

Shopify 的 toxiproxy 是一个 TCP 代理,README 的第一句是 "Toxiproxy is a framework for simulating network conditions."。客户端连代理,代理连真实服务,中间按 "toxic" 动手脚;控制接口是 HTTP,默认端口 8474。本机没有安装,下面只摘 README 里的条目,没有实际运行。

toxic作用(README 原意)属性
latency所有数据加延迟,延迟 = latency ± jitterlatency、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 秒计。

故障
持续
被测 loop
read 超时
发出的请求数
最坏耗时(不计抖动)
结局

▶演示二:独立故障 vs 扎堆故障

每个请求的长期平均故障率都是 p。q 是"上一个坏了、这一个也坏"的概率;q = p 时就是独立故障。精确值用动态规划算,模拟用带种子的伪随机数在浏览器里实时跑 2000 个任务(合成数据)。模拟用的随机数和 Python 实验不是同一串,数字会有小幅差别。

种子
前 12 个模拟任务的请求序列(绿 = 正常,红 = 注入故障,空格分隔不同任务):

✎练习

题 1(放大):响应头一直不来。SDK 的 max_retries = 2,第 3 周的 loop 在流层捕获了 APIConnectionError,流层最多重试 2 次。一次模型调用一共会发多少个请求?
个
SDK 每轮发 1 + 2 = 3 个请求后抛 APITimeoutError;它是 APIConnectionError 的子类,被流层捕获,流层一共跑 1 + 2 = 3 轮:3 × 3 = 9。第 5 周的版本只让"响应头到了之后"的错走流层,结果是 3 个。
题 2(超时):用 SDK 1.11.0 的默认配置做流式调用,流发了几个事件后卡住,连接不断。会发生什么?
默认 Timeout(timeout=600, connect=5.0);read 超时是两次收到数据之间的上限。SDK 的重试只覆盖拿到响应之前,读流时的超时要 agent 自己处理。所以要显式设 read 超时,并把读流时的超时归入流层重试。
题 3(算):每个请求独立地以 30% 的概率出故障,每次模型调用最多尝试 3 次(重试 2 次),一个任务要 2 次调用。任务成功的概率是多少?
%
单次调用失败要连续 3 次都坏:0.33 = 0.027,成功 0.973;两次调用都成功:0.9732 ≈ 0.9467。
题 4(方向):长期平均故障率仍是 30%,但故障扎堆出现(坏了之后下一个请求以 0.8 的概率继续坏),重试紧接着发出。真实的任务成功率和上一题的公式比?
第一次调用三次全坏的概率从 0.33 = 0.027 变成 0.3 × 0.82 = 0.192,任务成功率的精确值是 0.7637。正相关让独立公式偏乐观。如果退避等得比一段故障还久,相关性会变弱。
题 5(BugHunt):测试 Agent 打开被测页面,看到"加载失败",网络记录里 /api/cart 是 net::ERR_CONNECTION_RESET。它应该怎么做?
A 就是把环境故障误报成 Bug(实验里朴素版在 5 个非 Bug 场景里误报了 4 个)。B 会让持续的问题无声消失,也可能盖住真问题。C 区分了暂时故障和持续故障,并把证据留给人。

7面试要点

一句话讲清楚

故障注入是主动把环境弄坏、确定地看反应:我按层列故障清单(TCP、HTTP、SSE 流、被测应用),用假模型服务器、Playwright 的 page.route、toxiproxy 分别注入,断言请求数、等待时间和结局;结局只允许"恢复后完成"或"明确上报环境错误"。随机注入用固定种子,并且先由计划推出预言再核对。在我们自己的 loop 上,这套方法找出了卡住的流会挂 10 分钟、流里的 error 事件让 agent 崩溃,并把首字节超时被两层重试放大到 9 个请求(第 3 周已记下但没修)钉成测试、改掉。

追问准备

  1. 超时怎么分?连接超时、等响应头的超时、两个事件之间的空闲超时、整个任务的总时长,各管一段。SDK 的重试只管拿到响应之前。
  2. 重试为什么会放大?同一个错误在两层都被当成可重试,次数相乘。修法是按"出错时处在哪个阶段"分层,每种错误只在一层重试,并用故障注入测请求总数。注意上限没变:每轮先首字节超时、再流卡住,一次调用最坏仍是 (2+1) × (2+1) = 9 个请求;要压住总量,再加一个整次调用的请求数或时间预算。
  3. 怎么证明测试 Agent 不会把环境故障报成 Bug?拿一个已知没有 Bug 的版本,注入各种环境故障,断言误报数是 0;再拿已知有 Bug 的版本加暂时故障,断言没有漏报。
  4. 随机故障注入怎么复现?固定种子,记录每个请求注入了什么;测试里断言实际结果等于按计划推出的预言。
  5. 独立公式算出来的可靠性能信吗?故障扎堆时偏乐观。实验里同样 30% 的故障率,独立时任务成功 0.9467,扎堆时 0.7637。

常见错误说法

❌ "SDK 自带重试,超时不用管":SDK 的重试只覆盖拿到响应之前;流卡在半路,默认要等 600 秒,而且不会自动重试。
❌ "重试多几层更保险":同一个错误在两层都重试,请求数相乘,首字节超时时 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 源码,交互部分不受影响。