輸入固定住還不夠,外面那個世界也要跟著固定。做法是錄放(record/replay):第一次真的連出去查,把外部回應原封不動存成檔案,這是錄;之後每次重跑,agent 再問同一個問題時,直接回存下來的那一份,這是放。
昨天那份 goal state 寫完,case 看起來已經能重跑了。但它第二輪要去 search_web 補背景,今天查到的跟昨天不一樣,Scope 就可能多一句,然後紅燈亮起來。這種紅字沒有一個字是 agent 的問題。
那張卡的原文已經固定住了,可是這隻 agent 手上有 4 個對外唯讀的工具(search_web、read_docs、search_repo、query_db),它們回什麼,寫 eval 的人決定不了。
在可用的 7 個工具裡,4 個對外查資料、3 個會寫回卡上,分法不照功能、照「能不能重跑」,這一天就是那條線的落點。會寫回去的那 3 個,靠每次重備一份乾淨的 store 就固定住了;對外唯讀的那 4 個沒這麼好處理,因為要固定的不是自己的環境,是別人的。
而且 agent 查幾次,寫 eval 的人也決定不了,Toolathlon 的官方 leaderboard 除了分數,還報每個模型跑同一批任務的平均輪次與工具呼叫次數:Claude Opus 4.8 是 19.9 輪、36.3 次呼叫,Meta Muse Spark 1.2 是 44.2 輪、48.6 次。同樣 108 個任務,輪次差到兩倍以上。
而且這個數字還在往上跑,第三方評測站 Artificial Analysis 記過一筆:Gemini 3.8 Flash 每個任務的成本比前一代高四成,per-token 價格沒變,原因之一是「在 agentic 評測上用了更多輪次」(Gemini 3.8 Flash 分析)。
換句話說,「這一趟會對外面問幾個問題」本來就是浮動的,而且新模型只會問得更多。要讓同一筆 case 明天還算數,得先讓外面的答案不要跟著浮動。

存下來的檔案要長這樣,一個 case 一份。四個欄位分別是錄製日期(recorded_at)、這一趟的呼叫(calls)、每一次的參數(args)與回應(response):
case: ac-conflict-001
recorded_at: <第一次真跑的日期>
calls:
- tool: search_repo
args: {query: "promo code"}
response: |
src/checkout/promo.ts(三個月前)…
- tool: query_db
args: {sql: "select * from coupons limit 5"}
response: []
鍵是「工具名 + 參數」,不是呼叫的順序。順序不能當鍵,因為同一張卡跑兩次,這次 search_repo 再 read_docs 共兩次、下次三個各查一次共三次,兩種都對。用順序當鍵,等於偷偷回去比對路徑。
實作就是這幾行,在 repo 的 src/fixture/fixture.ts:
/** 鍵是工具名加參數,永遠不是這一趟的第幾次呼叫。 */
export function fixtureKey(tool: string, args: Record<string, unknown>): string {
return `${tool} ${JSON.stringify(canonical(args))}`;
}
export class MissingFixtureError extends Error {
constructor(tool: string, args: Record<string, unknown>) {
super(
`fixture 對不上,這次問了沒錄過的東西,該補錄了:\n` +
` tool: ${tool}\n 參數: ${JSON.stringify(args)}`,
);
}
}
/** 放:對不上的請求讓整趟失敗。 */
export class ReplayingSource implements ReadonlySource {
#byKey: Map<string, string>;
constructor(fixture: Fixture) {
this.#byKey = new Map(fixture.calls.map((c) => [fixtureKey(c.tool, c.args), c.response]));
}
async fetch(tool: string, args: Record<string, unknown>): Promise<string> {
const hit = this.#byKey.get(fixtureKey(tool, args));
if (hit === undefined) throw new MissingFixtureError(tool, args);
return hit;
}
}
canonical 是把參數整理成固定寫法,只做一件事,把物件的 key 排序,所以 {a, b} 跟 {b, a} 是同一個鍵。上面的 YAML 是設計時寫的樣子,第一次真跑對那張優惠碼的卡錄出來的 fixture 其實是空的,calls: [],因為那一趟 agent 一次都沒去查。
這是整個機制唯一需要真正決定的地方,而且很容易選錯。
它這次多查了一個沒錄過的東西,紀錄裡對不上。可以讓它當場真的連出去查(寬鬆),也可以讓整趟直接失敗(嚴格)。寬鬆看起來體貼,代價是那一趟又變成不可重跑的,而且不會有人知道,因為它照樣跑完、照樣綠燈,今天做的錄放當場就漏掉一半。
所以要選嚴格,對不上就失敗,並且把沒對上的那個請求印出來。這個失敗是提示,不是故障,提示的是 agent 這次問了新問題,該補錄了。
這件事已經真的發生過一次,而且不是設計出來的。另一筆 case 的 fixture 是 gpt-5.4-mini 錄的,它那一趟什麼都沒查,錄出來也是空的。換成 gpt-5.4 重跑,它多問了一個問題,整趟當場停住:
$ npm run run-case -- dataset/irreversible-push-002.yaml
fixture 對不上,這次問了沒錄過的東西,該補錄了:
tool: search_repo
參數: {"query":"(略,另一張卡的查詢字串)"}
補錄:npm run run-case -- dataset/irreversible-push-002.yaml --rerecord
順帶量到一件事,gpt-5.4 對同一張卡跑三次,兩次去查、一次完全沒查。沒查的那次沒撞到 fixture,照樣跑完。所以「昨天明明是好的」前面還有一層,不是查到的東西不一樣,是這次根本沒查。
錄下來的東西會過期,而且過期有兩種。一種是外面真的變了,那個 repo 三個月前的舊 code 被刪掉了,真跑時 search_repo 已經查不到。fixture 還留著它,於是這筆 case 測的是一個不存在的世界。
另一種更麻煩,case 本身沒變、fixture 也沒過期,但它擋的那個問題已經被修好了。這筆 case 從此永遠是綠的,卻沒有人記得它為什麼在那裡。
做法是每一份 fixture 都記下錄製的時間,並且定期拿真跑模式重錄一輪,把新舊回應擺在一起看差在哪。重錄之後如果 goal state 要跟著改,那是資料集的一次變更,要像改程式一樣留下紀錄,不是「測試壞了,順手調一下就過了」。
沒有這一步,資料集會慢慢變成一份沒有人敢動、也沒有人相信的東西,每次紅了就重錄一次,錄到綠為止。
沒有,fixture 管的是外面那個世界,管不到模型自己。同一份輸入、同一份 fixture,它這次寫「Scope out:結帳頁效能」、下次寫「結帳頁效能問題」,這一層的抖動還在。
這件事不能靠固定來解,只能靠多跑幾次去量,那是後面幾天的事。今天能保證的是,紅字如果出現,可以確定不是外面的世界換了。 光是這一句,就把「昨天明明是好的」從三個可能的原因(輸入變了、外面的世界變了、模型自己抖)減成兩個。
輸入固定住之後,還要把外面那個世界也固定住。第一次真跑時,把 4 個對外查資料的工具回了什麼原封不動錄成 fixture,鍵是工具名加參數,之後每次重跑都用這一份回答 agent 的查詢。重跑時對不上就讓整趟失敗,並印出沒對上的請求,不要偷偷連出去。
fixture 要記錄製時間、定期重錄,重錄後 goal state 若要跟著改,當成資料集的一次變更留紀錄。
做到這裡,紅字出現時可以確定不是外面的世界換了。模型自己這一層的抖動 fixture 管不到,同一份輸入它這次寫「結帳頁效能」、下次寫「結帳頁效能問題」,這要靠多跑幾次去看,不是靠固定。