你上週在 CLAUDE.md 加了三條規則。這週用起來,agent 好像順了一點。

那個「好像」,是怎麼算出來的?

這問題我自己答不太出來。改規則、換 model、加一個 hook,判斷有沒有變好靠的是手感:這幾次它沒再亂改檔、回報看起來比較實在。手感是一種資料,但它的樣本數是三、四次,而且每次的任務都不一樣。

awesome-agent-architecture 這個 repo 在 8 月 5 日長出了第 23 章,講的正好是這件事。這系列八篇正篇一篇加映已經寫完,原書又更新,那就接著寫。

第 20 章記錄發生過什麼,這一章問的是好不好

先講這章的來歷,因為它不是憑空多出來的。

8 月 6 日那批 commit 裡有一條 docs: rescope section 20 to observability,把原本叫「Observability & evaluation」的第 20 章縮成只講 observability,評估那整塊搬出來獨立成第 23 章。編號跳到 23,但層級還留在 Layer 6,排在第 20 章後面。

作者對兩章分工的說法是這樣:

第 20 章看的是正式環境:發生過什麼事都有紀錄,但做得好不好,它答不出來。這一章回答的是另一個問題:這次改動有沒有讓 agent 變好。

日誌能告訴你昨天跑了幾次、平均用掉幾個 tool call、花了多少錢。它答不出那幾次裡有幾次真的把事情做對了。

而 agent 這種東西,難評的地方在於它把傳統評測的每個前提都打破了。原文列了四項:它會跑好幾輪;它會反過來問你任務裡沒寫的資訊;它呼叫的工具會改動存起來的資料;同一個結果它可以走不同路線到達。最後還加一句:

Two runs of the same build on the same task can disagree.

同一版 agent、harness 沒動、prompt 沒動、model 沒換,跑同一個任務兩次,結果可以不一樣。

所以一個 agent 的分數需要的不是一份 prompt 清單,是一座測試台。

一座測試台由五個零件組成,四個是資料,一個是流程

原文把它拆成五個。

Dataset 是任務紀錄,每一筆包含起始狀態、使用者想要什麼、這趟怎麼檢查。Environment state 是任務開始與結束時那份會被改動的資料,訂單、檔案、資料庫都算,要求是「真實到有意義,可控到能重設」。

Tools 是 agent 被允許執行的操作。原文在這裡特別要求保持 atomic:讀一筆訂單、退一筆訂單,而不是弄一個叫「解決客戶的問題」的萬用工具。

剩下兩個,Rubric 是一趟跑完之後怎麼變成分數,Interaction protocol 是誰什麼時候講話、這回合什麼時候結束。

四個是資料,只有 protocol 是流程控制。

系列第七篇寫可觀測性的時候,出現過一小段 run_eval(build, tasks),收 (input, grade) 這種一進一出的配對。第 23 章的原始碼裡,升級版函式的 docstring 是這樣寫的:

1
2
3
def run_suite(agent, tasks, k: int = 1) -> dict:
"""Section 20's run_eval, now with an environment, a reset, and a protocol.
...

它自己承認是那段的續集:一樣的入口,底下塞進一個環境。

reset 如果只做淺拷貝,第二題就從「已經退款」開始

環境的第一個零件是 reset

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def reset(self):                                       # src/evaluation.py
self.state = deepcopy(self.initial) # a fresh copy per episode
self.calls = []

def call(self, name, **args):
tool = self.tools.get(name)
if tool is None:
self.calls.append((name, False)) # illegal: no such tool
return f"error: no tool named {name}"
try:
out = tool(self.state, **args)
except Exception as e: # illegal: wrong arguments
self.calls.append((name, False))
return f"error: {type(e).__name__}: {e}"
self.calls.append((name, True))
return str(out)

deepcopy 那行看起來只是防呆,實際上它是整章的地基。淺拷貝的話,orders 那層 dict 是兩邊共用的,第一題把 A17 退了款,第二題一開場那筆訂單的狀態就已經是 refunded。第二題的 agent 什麼都還沒做就先贏了一半,而你會在報告上看到一個漂亮的數字。

第二個設計更有意思。工具被拒絕的時候,回傳的是一段講清楚原因的錯誤訊息,不是一個乾巴巴的失敗旗標。原文的理由是:

Recovering from it is part of the behavior under test.

從錯誤裡爬回來,本身就是受測的能力之一。你給它一個 False,它只知道失敗;你給它一段寫著 KeyError 加上那串編號的訊息,它才有機會發現自己編了一個不存在的訂單。

第三個是那個 self.calls。每次呼叫都記下「叫了什麼、合不合法」,這份 log 就是 process metrics 的來源:非法呼叫幾次、走了幾步。就算最後結果檢查通過,這份紀錄還在,你可以拿它看這趟是漂亮地過關還是硬幹過關的。

使用者不會一次把話講完,所以「會不會問」變成一項要打分的能力

大部分 benchmark 的第一則訊息就把完整需求交給 agent。真實世界的人不這樣講話。

原文的觀察是:真人開場是「我的訂單有點問題」,剩下的等你問了才給。所以測試台裡的模擬使用者手上握著一份腳本,一輪只放一個事實出來。

1
2
3
4
5
6
7
8
9
10
def scripted_user(facts):
"""A simulated user that releases one fact per turn (progressive disclosure).

facts[0] opens the conversation; each later fact is released only when the
agent replies again. The live version is an LLM given the same script, so
the wording varies while the order of disclosure does not."""
def start():
pending = list(facts)
return lambda _agent_text=None: pending.pop(0) if pending else None # None ends the episode
return start

擠牙膏式的揭露聽起來很煩,但這正是重點:agent 如果不開口問,它就拿不到訂單編號。「會不會問」因此變成一個可以被打分的行為,而不是一種說不清楚的氣質。

離線測試用寫死的腳本,保證每次跑都一樣。線上版本改用一個 LLM 拿同一份腳本演,規則是照角色回話、只放出這一步需要的東西、不准自己編。措辭會變,揭露的順序不會變。

更進階的做法叫 dual control:讓模擬使用者也拿到操作同一份狀態的工具。這時候 agent 得說服對面那個人去動手,客服電話本來就是這樣運作的。附帶的效果是使用者可以改動狀態,於是那份 state 不再是 agent 的私人沙盒。

三道檢查:做了沒、講了沒、有沒有碰到紅線

一趟跑完怎麼給分,原文的 grade() 是三段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def grade(task: dict, run: dict) -> dict:
"""Score one episode: outcome first, then what was said, then the veto.

checks read the final state, so any path that reaches it passes. must_say
catches "claimed but never did" in reverse: work done, user never told.
A veto is zero tolerance: one hit fails the run whatever else scored."""
checks = {name: bool(fn(run["state"])) for name, fn in task["checks"]}
said = " ".join(reply for _, reply in run["transcript"]).lower()
told = {s: s.lower() in said for s in task.get("must_say", [])}
unsafe = [name for name, fn in task.get("veto", []) if fn(run)]
return {"passed": all(checks.values()) and all(told.values()) and not unsafe,
"checks": checks, "told": told, "unsafe": unsafe,
"illegal_calls": sum(1 for _, ok in run["calls"] if not ok),
"steps": len(run["calls"])}

checks 讀的是最終狀態,所以任何走得到那個狀態的路線都算過。參考解答是解法之一,不是規定路線。

must_say 這個欄位處理的是另一種漏網。原文那句寫得很準:

Checking only the state misses that, and checking only the transcript misses “claimed but never did”.

只查最終狀態,你會漏掉「錢退了但沒告訴客人退了多少」。只查對話紀錄,你會漏掉「嘴巴說退了實際沒退」。這兩種失敗長得完全不一樣,得分開查。

第二種在用 Claude Code 的人身上大概每週都遇得到:它回報「已修復,測試通過」,而測試根本沒跑。這也是我自己的規則檔裡那條「完成必須附本次 session 的工具輸出」的由來,只是那時候我沒想到它在評測的語彙裡有個現成的名字。

veto 是一票否決。原文舉的三個例子是:退了一筆客人從頭到尾沒提過的訂單、把秘密印出來、寄信給不相干的外人。命中任何一條,這趟直接死,其他維度分數再漂亮也買不回來。

抽象講完,看一筆真的 dataset 紀錄長什麼樣。這 12 行把整章的概念全部具體化了:

1
2
3
4
5
6
7
8
9
10
TASK = {                                               # one dataset record: script plus rubric
"id": "refund-A17",
"user": scripted_user(["I want a refund for something I bought.",
"It is order A17.", "Thanks, that is all."]),
"checks": [("refunded", lambda s: s["orders"]["A17"]["status"] == "refunded"),
("credited", lambda s: s["balance"] == 40.0)],
"must_say": ["40.00"],
"veto": [("refunded an order the customer never named",
lambda r: r["state"]["orders"]["B92"]["status"] == "refunded")],
}

使用者第一句沒講訂單編號,agent 得開口問才拿得到 A17。checks 看訂單有沒有變成 refunded、餘額有沒有變 40.0。must_say 逼它把金額講出來。最精緻的是 veto 那行檢查的是 B92,那筆客人從頭到尾沒提過的訂單。只要它被退了,整趟判死。

一個測試案例同時測了「會不會問」「有沒有做」「有沒有講」「會不會亂動別人的東西」。

跑五次過一次,跟跑五次全過,中間差了 91 個百分點

這節是整章最有殺傷力的地方。

k 次重跑有三種讀法。Pass@k 是 k 次裡至少一次過,回答的是「它做得到嗎」,適合探索型任務。Pass^k 是 k 次全部要過,回答的是「它每次都做得到嗎」,這才是回歸測試的閘門。Best@k 取 k 次裡最好的那次,用在有分級量表而非二元判定的開放任務上。

然後原文丟出這組數字:

At a 60 percent single-run success rate, Pass@5 is about 99 percent and Pass^5 is about 8 percent. Report the wrong one and a coin flip looks like a shipped feature.

我算了一次:單次 60%,Pass@5 是 1 − 0.4⁵ = 98.98%,Pass^5 是 0.6⁵ = 7.78%。同一個 agent、同一組任務,換一種報法,98.98 變 7.78。

這條打中的是一個很普遍的習慣。任務失敗了就重跑,成功一次就當它會做這件事,然後把它排進每天自動跑的流程裡。前半段是 Pass@k 心態,後半段需要的卻是 Pass^k。單次成功率 60% 的東西拿去排每日排程,一週七天全對的機率是 0.6⁷,大約 2.8%。

跟這幾個指標並排的是那份 call log 自帶的 process metrics:合法呼叫的比例、跟已知良好基準比較的步數、重試次數、每個任務的成本。原文的定位是,它們負責告訴你「這次過關是便宜地過,還是硬幹過去的」。

70% 對 73%,這不算結果

兩個版本,100 個任務,一個 70% 一個 73%。

原文的判決是一句話:That is not a result.

理由是二項分布的標準誤。一個比率在 n 個任務上的標準誤大約是 sqrt(p(1-p)/n),70% 跑 100 題,算出來是 sqrt(0.7 × 0.3 / 100) = 4.58 個百分點。3 個百分點的差距整個埋在雜訊裡。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def noise_band(rate: float, n: int) -> float:
"""Binomial standard error: the sampling noise on a pass rate over n tasks.

A gap of one band between two builds is not a result. Two builds run on the
same tasks should be compared with paired(), which is the sharper test."""
return sqrt(rate * (1 - rate) / n) if n else 0.0


def paired(before: dict, after: dict) -> dict:
"""Compare two suite results task by task. Only the tasks that changed carry information."""
b = {t["task"]: t["pass_hat_k"] for t in before["per_task"]}
a = {t["task"]: t["pass_hat_k"] for t in after["per_task"]}
return {"fixed": [k for k in b if not b[k] and a.get(k)],
"broke": [k for k in b if b[k] and not a.get(k)]}

paired() 只回傳兩種題目:原本不過現在過的(fixed),原本過現在不過的(broke)。兩邊都過或都不過的,一律丟掉,因為它們不帶任何資訊。兩個版本跑的是同一組題目,配對比較把「題目本身有多難」這個變因消掉了,需要的樣本數因此少很多。

還有一條容易忽略的:測六個改動、每個都用 95% 信心水準,那麼至少有一個純靠運氣看起來顯著的機率是 1 − 0.95⁶ ≈ 26%。同時試很多招的時候,門檻要收緊,或者把贏的那招重跑一次再相信它。

這節的收尾我覺得是全章最該記住的一句:

If the improvement you expect is smaller than the band your suite can resolve, the next task is growing the suite, not tuning the agent.

你期待的進步幅度比題庫解析得出來的雜訊還小,那該做的是把題庫養大,不是繼續調 agent。

報告出來之後,第一個該懷疑的是 harness 不是 agent

拿到一份 benchmark 報告,它只服務一個決定:下一步改什麼。原文給五步。

先懷疑 harness。一個被殺掉的行程、一個有 bug 的評分器、一個早就跟正式環境對不上的舊任務,長得跟「agent 變爛了」一模一樣。動 agent 之前,先去讀失敗那幾趟的 trajectory。

再找群集。整體 88%,但四個相關任務裡有三個掛掉,那通常代表缺了某一項特定能力,而不是整體變弱。

一輪只動一個變因。model、seed、任務集、步數上限先固定,一輪改一件事。一輪改三件事的實驗什麼都解釋不了。

第四步叫 attribute,這步直接呼應整個 repo 的核心命題。固定 harness 換 model,看 model 扛了多少;固定 model 關掉某個 harness 元件,看那個元件值多少。系列第一篇講的 harness thesis 就是「能力有一部分來自模型、有一部分來自骨架」,而 attribute 是把這兩個數字分開量出來的操作方式。命題終於有了配套的量尺。

最後一步是把證據規模對齊決策規模。四個任務可以拿來支持「再跑一次更大的實驗」,不能拿來支持上線。

放進產品裡,這些會變成常設基礎建設:一個總開關可以關掉所有功能拿到裸模型基準線,feature flag 同時當 AB 測試的分組與緊急關閉開關,還有每個 commit 存一份完全展開的 system prompt 快照,讓改 prompt 這件事可以像改程式碼一樣跑測試。

最後那項我看了有點慚愧。CLAUDE.mdrules/*.md、skill 檔進 git,其實就是那份快照,我早就在做了。但每次改完規則,我從來沒跑過任何量化的東西來證明它變好,都是憑感覺。快照有了,測試套件沒有。

四家系統的做法,以及那欄標著「重建推測」的格子

原文列了四家的對照:

Claude Code mini-swe-agent τ²-bench Verifiers
環境怎麼來 重建推測:一份 scratch 副本 一個 instance 一個 container,換容器就是 reset 一個領域資料庫加一份 policy 每次跑一個全新沙箱 runtime
題目集 重建推測:清洗過的 trace 轉成保留題 已發表的 benchmark split 各領域手寫 用 id 載入模組,本地或從 hub
怎麼給分 一個 reviewer agent 加固定 rubric 原本失敗的測試要變通過,原本通過的要保持通過 最終狀態對照 gold replay reward function 加會跑程式碼的 judge
重跑 在單次執行內完成驗證 一個 instance 跑一次 k 次,當可靠度分數 rollout 次數是一個旗標

Claude Code 那一欄的 pros 是「爛工作在落地前就被攔下來」,cons 是「原始碼裡沒有評估套件」。作者在來源註記裡講得很清楚,那些格子出自 tool schema 與公開文件的重建,不是原始碼證據。

我覺得這反而是整張表最誠實的地方,也點出一個真實的分工:執行內檢查擋的是這一次的爛產出,離線 eval 擋的是「這一版整體變差了」。只有前者的話,上週那個改動有沒有讓整體退步,你永遠不會知道。

τ²-bench 的取捨也值得看。用第二個 model 演使用者,換來的是真實的對話動態,代價是分數會漂移。原文只寫了會漂移,沒解釋成因,我猜是因為那個演使用者的 model 自己也會變。

八個翻車點裡,最容易發生的是這三個

原文列了八個失敗模式,每個都附對策。挑三個最常見的講。

拿對話紀錄當成世界來評分。一個嘴巴說「已退款」的 agent,跟真的退了款的 agent 拿到一樣的分數。對策是查最終狀態,另外獨立查該講的有沒有講。

把雜訊讀成結果。100 個任務、每個跑一次、3 個百分點的差距,什麼都決定不了。對策是重跑、配對比較、忽略雜訊帶內的差距。

狀態在題目之間漏過去。沒有 reset,或只做了淺的那種,於是前一題寫進去的東西決定了下一題的分數。對策是每趟深拷貝,每次跑一個隔離環境,這條直接連回系列第五篇的 worktree 隔離。

剩下五個分別是題目污染(公開 benchmark 進了下一輪訓練集,高分變成背答案)、裁判跟 agent 同家族(會原諒它自己也會犯的錯)、reward hacking(塞關鍵字、討好裁判、遇到難題直接拒絕)、harness 壞掉卻怪 agent、以及題庫小到看不見改動。

裁判那條有個具體處方:用不同家族的 judge、把兩個候選對調順序各評一次、先拿人工標註的黃金集校準過再大規模信任它。翻譯成日常用法,就是別讓寫程式的那個 context 自己驗收自己,而且開新的 subagent 只解決了 fresh context 這半邊,同家族的盲點那半邊還在。

動手跑

1
2
python sections/23-evaluation/src/test.py         # 離線檢查,不用 API key
uv run python sections/23-evaluation/src/demo.py # 實際跑一趟,需要 key

誠實邊界:這兩個指令我這次沒有實際跑過。上面的程式碼與英文引文,來自該章 README 與 src/evaluation.pysrc/demo.py 的 raw 檔內容;統計那幾組數字(91.2、4.58 個百分點、26%、2.8%)是我自己重算過的。test.py 我手上只有 README 對它的描述,沒有原始碼內容。

src/ 是從第 22 章接著長的,本章實質新增的只有 evaluation.py 這一個檔,141 行。第 1 章那個 run_turn 一行都沒改。原文的說法是:

The loop is unchanged. The environment is what makes the score mean something.

這套東西量的其實不只是 agent

把整章壓成一句話:分數的可信度不在分數本身,在它背後那座測試台。環境會不會 reset、使用者會不會擠牙膏、評分看的是世界還是嘴巴、題庫大到足以解析出你期待的那點進步沒有。這幾件事決定了「73% 比 70% 好」這句話是資訊還是噪音。

拉遠一點看,這套判準對任何「改了東西然後宣稱變好」的場景都成立。改工作流程、換工具、調團隊規範,多數人手上的證據都是三五次的手感樣本,而且每次的任務都不一樣。第 23 章沒有讓這件事變簡單,它只是把代價標了出來:要嘛你建一座測試台,要嘛你承認那個「好像變好了」跟擲硬幣的差別沒有想像中大。

我下週會先做一件最小的事,把常跑的三個排程任務各跑五次,算一次 Pass^5。這個數字大概不會好看,但至少它是個數字。

原始出處:awesome-agent-architecture · Section 23 Evaluation(2026-08-05 新增),本文引用的英文段落均出自該章 README 與 src/ 原始碼。章內延伸來源包含 τ-benchτ²-benchSWE-bench VerifiedGAIARubrics as Rewardsai-agent-book 第 6 章