測試紅了按 re-run,綠了就 merge。在 CI 裡這個動作不需要理由,沒人會停下來想那個按鈕在統計上做了什麼事。

同樣一個動作搬進模型評測,它會動到你最後貼進報告的那個數字,而且只往一個方向動。

UK AISI 的 Inspect 在自己的錯誤處理文件裡掛了一個警告框,講的就是這件事。框架作者跳出來提醒你別太相信自己跑出來的分數,這種事情不常見。順著這條線往原始碼裡摸,旁邊還蹲著另外兩個。

重試那一下,等於多擲了一次骰子

一場 eval 剝到最裡面只有一件事:跑 N 個樣本,每個樣本給模型一個輸入,記下成功或失敗,最後把成功的數量除一除。那個商數就是分數。所以 eval 本質上是在估一個機率。

估機率有條不成文的規矩,每個樣本的取樣次數要一樣。一個樣本擲一次骰子、另一個擲三次,算出來的平均值就不是你以為的那個平均值。

retry_on_error 做的事情,正好踩在這條規矩上。它讓出錯的樣本重跑幾次才算真的失敗。文件裡的範例是這樣下的:

1
2
inspect eval ctf.py --retry-on-error    # retry 1 time
inspect eval ctf.py --retry-on-error=3 # retry up to 3 times

如果錯誤來自 API 掛掉、rate limit、sandbox 抽風,重試完全合理,因為「誰被重試」跟「樣本內容是什麼」無關,骰子多擲的機會是隨機灑的,平均起來不偏。

問題在另一種錯誤。官方文件那個警告框(docs/handling-errors.qmd,標題是 Retries and Distribution Shift)是這樣寫的:

While sample retries enable improved recovery from transient infrastructure errors, they also carry with them some risk of distribution shift. For example, imagine that the error being retried is a bug in one of your agents that is triggered by only certain classes of input. These classes of input could then potentially have a higher chance of success because they will be “re-rolled” more frequently.

你的 agent 有個 bug,只有碰到某一類輸入才會炸。可能是輸入裡夾了奇怪的字元,可能是工具回傳特別長把 context 撐爆。那一類輸入的每個樣本,都因為這個 bug 拿到了額外的擲骰機會。其他一次就跑完的樣本沒有。

而且這個偏移是單向的。重試只會發生在錯掉的樣本上,沒有任何機制會把已經成功的樣本抓回來重跑一次看它會不會失敗。所以重試這件事只能把分數往上推,推不下來。

還有一層更狠的。文件在講容錯門檻那段順手寫了一句:failed samples are not scored。錯掉的樣本預設根本進不了計分。也就是說,重試真正決定的不是「這個樣本考幾分」,是「這個樣本有沒有資格進到計分裡面」。一群本來會集體缺席的輸入,因為多擲了幾次骰子,其中一部分擠進來了,而且擠進來的都是剛好成功的那些。

框架是有給後路的。score_on_error 會讓錯掉的樣本照樣用出錯前的 TaskState 去計分,而且它只在重試全部用完之後才觸發,跟 retry_on_error 疊得起來。代價是你的 scorer 得有辦法吃一個殘缺的 state。文件沒有寫 scorer 自己也在這一步炸掉會怎麼樣,但照這個設計推下去,那個樣本最後大概還是只有 error 沒有 score,跟沒開一樣。

文件給的補救方式很誠實,也很不夠:

Consequently, when enabling retry_on_error you should do some post-hoc analysis to ensure that retried samples don’t have significantly different results than samples which are not retried.

事後自己去分析,比對被重試過的樣本跟沒被重試的樣本結果有沒有顯著差異。

我的立場是,這個選項的名字取得太安全了。retry_on_error 讀起來像 max_retries,像連線層的東西,你在 debug 一個一直被 429 打斷的 eval 時會很自然地加上去,然後忘記它還在。真正該做的是把重試過的樣本在 log 裡單獨標出來、分開算一次分數,讓落差自己跳到你臉上,而不是寄望每個人都記得回去做事後分析。Inspect 其實已經把 error_retries 欄位寫進樣本裡了,材料是齊的,差的是有沒有人替你算。

會讓我改變想法的條件也很具體:如果實務上的重試絕大多數都是真的基礎設施錯誤,agent bug 觸發的那種佔比低到可以忽略,那自動化這個比對就是過度設計,加一個沒人看的欄位而已。我手上沒有這個分佈的數字,所以這只是我的判斷,不是結論。

文件寫「超過」,程式碼寫「達到」

fail_on_error 是容錯門檻,用來決定要壞掉幾個樣本才把整場 eval 判死。你可以給它布林值,也可以給數字:小於 1 當成比例,大於 1 當成絕對個數。文件那張表逐字是這樣寫的:

Value Behaviour
fail_on_error=True Fail eval immediately on sample errors (default).
fail_on_error=False Never fail eval on sample errors.
fail_on_error=0.1 Fail if more than 10% of total samples have errors.
fail_on_error=5 Fail eval if more than 5 samples have errors.

兩個 more than。再看實作,src/inspect_ai/_eval/task/error.py 裡的判斷式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def _should_eval_fail(
sample_error_count: int, total_sample_count: int, fail_on_error: bool | float | None
) -> bool:
if fail_on_error is False:
# if fail_on_error is False, we never fail
return False
elif fail_on_error is None or fail_on_error is True:
# if fail_on_error is None or True, we fail if there is any error
return sample_error_count > 0
else:
if fail_on_error < 1:
# if fail_on_error is less than 1, we make a fractional check of errors
return sample_error_count >= fail_on_error * total_sample_count
else:
# if fail_on_error is larger than 1, we check the absolute count of errors
return sample_error_count >= fail_on_error

兩條數值路徑都是 >=。你設 5,第 5 個錯誤就收攤,不是第 6 個。你設 0.1 而總共 100 個樣本,第 10 個錯誤就收攤,不是第 11 個。差一個。

單看這個落差不痛不癢,一場跑幾百個樣本的 eval 少容忍一個錯不會改變什麼。真正會咬人的是它帶進你腦袋的那個模型:你在 Task 裡寫 5,心裡想的是「我可以吃下 5 個錯」,實際上你能吃下的是 4 個。等哪天你要解釋「為什麼這場 eval 明明還在容忍範圍內卻死了」,你會拿著文件那張表推論半天,推不出來。

我另外把 docs/ 底下其他提到 fail_on_error 的四份檔案翻過一遍(scoring-policy.qmdfallbacks.qmdtasks.qmdsample-source.qmd),它們全部只是把讀者指回 handling-errors.qmd,沒有任何一份重新描述過那張表的數字語意。所以那張表就是唯一的數字出處,也就是說,照文件走的人不會有第二個地方可以對答案。

講清楚我的位置:我沒有安裝過 Inspect,也沒有跑過任何一次 eval。上面這些全部出自把官方文件跟原始碼攤開來逐字讀。「文件寫 more than、程式碼寫 >=」這條是比對兩份文本得到的落差,不是跑出來看到的行為。這是本文所有敘述的邊界,往下幾節也一樣。

比例門檻的分母,會在跑到一半的時候長大

回頭看剛剛那行 sample_error_count >= fail_on_error * total_sample_count。比例門檻要能用,右邊那個 total_sample_count 必須是個常數。你有 500 個樣本、設 0.1,門檻就是 50,從頭到尾不動。

除非樣本不是一開始就全部躺在那裡等你跑。

Inspect 支援 SampleSource 驅動的 task,樣本邊跑邊生。這時候 planned total 是一個會長大的數字,而 SampleErrorHandler.__init__ 的 docstring 把這個坑寫得比文件正文還清楚,逐字是:

defer_fractional: Don’t abort mid-run on a fractional threshold — leave it to the end-of-run check. Used by SampleSource-driven tasks, whose planned total grows while they run: an early error would otherwise be measured against a transiently small denominator (e.g. a 1-sample seed erroring trips 1 >= 0.5*1 before the source has produced the rest). Absolute-count and any-error thresholds don’t depend on the denominator and still abort mid-run.

1 >= 0.5*1。一個 seed 樣本,分母當下是 1,你設的容錯比例是 0.5,那個樣本錯了,1 >= 0.5 成立,整場 eval 在真正的樣本都還沒生出來以前就被判死。你寫 0.5 的意思是「一半壞掉我都還能忍」,它讀成的是「第一個就不准壞」。

defer_fractional 就是為了這個存在:比例型門檻不在跑的中途判,留到收工再算。

後半句才是重點。絕對個數(設 5)跟任意錯誤(設 True)這兩種門檻不依賴分母,所以照樣中途 abort,該停還是會停,只有比例被延後。一個門檻能不能在中途拿來用,取決於它的計算需不需要一個還沒定案的數字。

同一件事在 docs/sample-source.qmd 有散文版:

A fractional fail_on_error threshold (e.g. 0.5) is evaluated at the end of the run rather than mid-run for SampleSource tasks: the planned total grows while the task runs, so a mid-run check would measure early errors against a transiently small denominator.

監控設「錯誤率超過 5% 就 call 值班」,然後每天流量剛起來那幾分鐘、或半夜只有兩三個請求的時候,一個健康檢查失敗就把人挖起來。SRE 的標準解法是加一個最小樣本數,樣本不夠就不判。這裡是同一個病、不一樣的解法:不是等分母夠大,是等分母定案。

保險絲只認得一種形狀的插頭

第三個機制跟分數的關係比較迂迴,踩到了也更難發現。

eval 失敗重跑的時候,Inspect 會盡量重用上一輪已經跑完的樣本,時間跟 API 錢都省得下來。要重用就得把新舊兩輪的樣本一一對上,所以每個樣本需要一個穩定的識別碼。文件給兩條路:自己在 dataset 裡放 id 欄位,或者靠 Inspect 自動編號。

自動編號是流水號。流水號碰到洗牌全部錯位,第 7 號這一輪是 A 題、下一輪是 B 題,重用就是張冠李戴。

框架知道這件事,放了一道保險。docs/_sample-preservation.md 的原文:

You can rely on Inspect’s assignment of an auto-incrementing id for samples, however this will not work correctly if your dataset is shuffled. Inspect will log a warning and not re-use samples if it detects that the dataset.shuffle() method was called, however if you are shuffling by some other means this automatic safeguard won’t be applied.

破口在最後那一句。這道保險偵測的是「你有沒有呼叫 dataset.shuffle() 這個方法」,不是「你的資料順序有沒有被動過」。前者好偵測,後者才是真正要防的事。

你自己 random.shuffle 樣本清單、在讀檔那一層就把行打亂、從資料來源拉的時候順手帶個 shuffle 參數,全部繞過去。繞過去的表現也不是報錯,是安靜:沒有警告、沒有 log、重用照常發生,只是對到的樣本是錯的。分數當然也就不是那個分數了。

文件的建議很單純:洗牌對你的評測重要,就自己放 id

會自動重試的測量系統,都有同一個病

三個擺在一起看,它們共用一個形狀:為了讓測量更穩而加上去的補償動作,回頭改掉了被測量的東西。重試改掉每個樣本的取樣次數,比例門檻改掉「多少算太多」的基準,保險絲改掉哪些樣本會被拿去重用。單獨看每一個都是合理的工程決定,而且每一個都有文件寫。

這個形狀不是評測框架的專利。

CI 的 flaky test retry 就是。設成 retry 三次才算真的紅,那些「只在某種時序下才會壞」的測試就拿到了額外的擲骰機會,通過率被抬上去,而它們正好是整份測試套件裡最該被盯著的那幾個。你沒有讓程式碼變好,你只是讓量測工具對特定一類缺陷變寬容了。

壓測也一樣。把 timeout 的請求重送一次再算成功率,你量到的是「這個系統加上一層重送」的成功率。上線那天,前面沒有那一層。

順手把基準記一下。本文引的原始碼跟文件都是 2026-09-20 從 inspect_ai 的 main 分支拉下來讀的,MIT 授權。PyPI 上的 inspect-ai 最新版是 0.3.266,2026-09-19 上傳,需要 Python 3.10 以上。這個 repo 沒有開過任何一個 GitHub release,想對版本只能看 PyPI。文件站在 inspect.aisi.org.uk,而這幾個檔案都還在動,你讀到這篇的時候措辭跟行號很可能已經不一樣了。

要自己檢查倒是不用讀完整份原始碼。任何一份會自動重試的腳本,評測的、壓測的、CI 的都算,該看的都是同一件事:被重試的那些案例,彼此有沒有共同的特徵。有共同特徵,數字就已經歪了,歪多少要另外算;看起來沒有,多半只代表還沒有人去比對過。

Inspect 的文件至少把這個要求寫出來了:請自己做事後分析。