判定今天交給程式了,那個少掉的標註是程式抓出來的,不是人對照 goal state 找出來的,而且程式回的不只一個紅字,還有少了哪一項、多了哪一項。
被驗收的是一隻負責需求釐清的 agent,它讀進需求方寫的雜亂 ticket,整理成結構化的 ticket,輸出分四個區塊,Goal(這張卡要做什麼)、Scope in(做的範圍)、Scope out(不做的範圍)、AC 標註,並標出有問題的那幾條 AC。有問題指三類,彼此衝突、不可驗證、與描述不符。
資料集到這裡寫得出來了,判定卻還是人的工作。一份 goal state 放在左邊,agent 這次的輸出放在右邊,一個欄位一個欄位對照。
那張優惠碼功能的 ticket 有 7 條 AC,期望的標註是 4 個,加上 Goal 與 Scope 那幾個欄位,對完一筆要好幾分鐘。對到第三筆就不會有專注力逐字讀了,眼睛只去找幾個關鍵字在不在。
更麻煩的是對完之後手上沒有東西,留下來的是一句「這次看起來對了」。前面改了一行 prompt、優惠碼那張卡第 3 條 AC 的「與描述不符」就不見了,那一次也是這樣,事後誰也拿不出當時到底哪個欄位對、哪個欄位沒對。
前面把「整理對了」寫成一份可以逐個欄位比對的資料時,每個欄位右邊已經決定好嚴格程度,三檔,完全相同、必須存在、不比。那份東西現在就是 case 檔案裡的 goal_state:
goal_state:
goal: { mode: must_include, keywords: [] }
scope_in: { mode: must_include, keywords: [優惠碼套用, 後台建立, 過期處理] }
scope_out: { mode: must_include, keywords: [結帳頁效能] }
ac_flags:
mode: exact_set
values: [3:conflict, 3:mismatch, 4:unverifiable, 7:unverifiable]
comment_wording: { mode: ignore, note: 措辭不比 }
tool_calls: { mode: ignore, note: 查了幾次、查了誰不比 }
goal_wording: { mode: ignore, note: 判不到 }
ignore 那三個欄位回的不是通過,是不評分,永遠不能貢獻一盞綠燈。一筆 case 如果每個欄位都是 ignore,等於什麼都沒檢查,那也不算通過。
scorer 只允許一種寬容,全形半形視為同一個字(Unicode 的 NFKC 正規化)、連續空白收成一個空白。比這個更寬的鬆緊全部要寫進 dataset 那個欄位,不能寫進判定的程式,不然放寬一筆就等於偷偷放寬了全部。
比對的核心在 repo 的 src/scorer/goal-state.ts,三檔各一段,每個欄位回的是過不過加上少了什麼、多了什麼:
export function compareField(field: string, spec: FieldSpec, actual: string[]): FieldVerdict {
if (spec.mode === 'exact_set') {
const expected = new Set(spec.values.map(normalize));
const got = new Set(actual.map(normalize));
const missing = [...expected].filter((v) => !got.has(v));
const unexpected = [...got].filter((v) => !expected.has(v));
return { field, mode: 'exact_set', pass: missing.length === 0 && unexpected.length === 0, missing, unexpected };
}
if (spec.mode === 'must_include') {
const got = actual.map(normalize).filter((v) => v.length > 0);
if (spec.keywords.length === 0) {
return { field, mode: 'must_include', pass: got.length > 0, missing: [], unexpected: [] };
}
const missing = spec.keywords.map(normalize).filter((kw) => !got.some((v) => v.includes(kw)));
return { field, mode: 'must_include', pass: missing.length === 0, missing, unexpected: [] };
}
return { field, mode: 'ignore', pass: null, missing: [], unexpected: [], note: spec.note };
}
export function scoreCase(caseId: string, goalState: GoalState, actual: Record<string, string[]>): CaseScore {
const fields = Object.entries(goalState).map(([field, spec]) => compareField(field, spec, actual[field] ?? []));
const graded = fields.filter((f) => f.pass !== null);
// 一個欄位都沒判到,不算過。
const pass = graded.length > 0 && graded.every((f) => f.pass === true);
return { caseId, pass, fields };
}
ignore 那一檔回的 pass 是 null,scoreCase 只數有評分的欄位,這就是不評分跟通過在程式裡分開的地方。
前面替 scorer 下定義時寫過,它吃期望值跟實際輸出,回傳過或不過,旁邊附上判斷的依據。手邊那份跑完的 A/B 實驗把這一句做對過一次,每一條 assertion 回傳的不是一個布林值,是 (passed, evidence) 兩樣,過或沒過旁邊一定跟著憑證。
沒有那一段憑證,紅字只說得出這個欄位沒過,說不出差在哪裡,而差在哪裡才是查得下去的起點。所以這支 scorer 每個欄位回三樣,過或沒過、期望裡有而沒出現的(標成 -)、出現了但期望裡沒有的(標成 +)。
模型用 gpt-5.4-mini,整趟 9 秒,紀錄下來 5 步、2 次工具呼叫,兩次都是 update_ticket,最後停在沒有再呼叫工具。它一次都沒有對外查資料就把這張卡整理完了。
跑完 run 目錄留下四個檔案,整理後的 ticket、這一趟每一步的 trajectory、用了哪個模型,還有判定結果。判定結果長這樣:
FAIL ac-conflict-001
✓ goal (must_include)
✗ scope_in (must_include)
- 後台建立
- 過期處理
✓ scope_out (must_include)
✗ ac_flags (exact_set)
- 3:mismatch
- 4:unverifiable
+ 2:mismatch
+ 4:mismatch
+ 5:unverifiable
+ 6:mismatch
· comment_wording (ignore) 措辭不比
· tool_calls (ignore) 查了幾次、查了誰不比
· goal_wording (ignore) 判不到
上面這張是程式輸出的,不是人寫的。產生它的 scorer 在公開 repo(rara7777/pm-agent-eval)的 src/scorer/,讀 case 檔裡的 goal_state,逐個欄位比對之後把結果排成上面那樣,這一趟跑完留下的四個檔案在同一個 repo 的 runs/2026-09-11T01-47-45-125Z-ac-conflict-001。七個欄位裡兩個過、兩個紅、三個不評分,整筆 FAIL。goal_wording 那個不評分,是因為 Goal 這句寫得夠不夠精準,沒有程式判得出來。
- 3:mismatch 是今天最重要的一行,第 3 條 AC 同時中了兩類問題,它跟第 2 條「一人只能用一次」打架,也違背 description 裡「不要被亂用」那句,所以期望的標註是 4 個,不是 3 個。
agent 抓到了衝突就停在那裡,沒有回頭把 description 再讀一次,於是第二個標註沒有出現。前面幾天說「改一行 prompt 最先掉的是與描述不符那一個」時,手上沒有跑出來的東西,那是用推的。現在第一次真跑,掉的就是同一個標註,而且這次不是改了 prompt 才掉,是它本來就沒標到。
- 4:unverifiable 跟 + 4:mismatch 要一起看,第 4 條「結帳流程要順」它認出來有問題,但歸成與描述不符,條號對、類別不對。這也是為什麼 ac_flags 這個欄位比的是集合而不是總數,兩邊都是 4 個,不代表是同樣那 4 個。
剩下三個 + 是它在第 2、5、6 條上各標了一個,期望裡沒有。漏掉一個跟多報一個代價不一樣,這兩種錯後面會分開處理。
scope_in 這個欄位回報少了「後台建立」跟「過期處理」,可是它輸出的 Scope in 裡寫著「後台可新增優惠碼」「活動結束後優惠碼失效」,兩件事一件都沒少。must_include 現在的做法是正規化之後做字面的包含比對,「後台建立」四個字沒有連著出現,就算 missing。
寫 goal state 那天說過,逐字比對抓錯重點,措辭差一個字就判失敗。現在撞上這句話的,是這支自己寫的 scorer。
這個欄位有兩條路,把關鍵字換成真的不會變的字,或者承認 must_include 判不出「意思一樣」,把它標成判不到,兩條路改的都是 dataset,不是 scorer 的程式。
這個欄位能被看出來是 scorer 的問題,靠的就是紅字旁邊那兩行 -。只回一個 FAIL 的話,它會被記成 agent 又錯一筆,接下來就有人去改一個沒壞的 prompt。
這隻 agent 的四步是拆 description、補背景、逐條檢查 AC、寫回去,補背景要靠四個對外查資料的工具,search_web、read_docs、search_repo、query_db。這一趟的 trajectory 裡這四個一次都沒出現,兩次工具呼叫都落在最後那一步,照紀錄看只有第 1 步跟第 4 步真的跑到。它沒去查,那一趟錄下來給重播用的 fixture 也就是空的。
四步是設計時寫下來的,它實際走了哪幾步要跑完才有得看。那份 goal state 裡 tool_calls 這個欄位寫的是 ignore,查了幾次、查了誰不比,紅字裡不會出現工具呼叫。會看到它,是因為 trajectory 把每一步都留在 run 目錄裡。
判定交給程式了,scorer 讀 case 檔裡的 goal_state,每個欄位照三檔嚴格度比,回傳過或不過,旁邊附上少了哪一項、多了哪一項。同一份輸入、同一份剛建好的假 ticket store、同一份 goal state,跑完直接吐出結果,中間沒有人在看。
第一次真跑的結果是四個評分的欄位過了兩個,整筆 FAIL。少掉的 3:mismatch 就是前面只用推的那一個標註,這次被程式抓到了。另一個紅的 scope_in 概念都在,紅的是判斷標準的關鍵字寫得太死。人剩下的工作從逐個欄位比對,變成讀那兩個紅的欄位、判斷它們各自是誰的問題。