iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
AI Engineering

30 天 AI eval 實戰:一隻 PM agent,改完之後怎麼確定它還是對的系列 第 15

Day 15|對錯還是人眼在比,怎麼讓程式自己判?第一次由程式抓到少掉的標註

  • 分享至 

  • xImage
  •  

判定今天交給程式了,那個少掉的標註是程式抓出來的,不是人對照 goal state 找出來的,而且程式回的不只一個紅字,還有少了哪一項、多了哪一項。

被驗收的是一隻負責需求釐清的 agent,它讀進需求方寫的雜亂 ticket,整理成結構化的 ticket,輸出分四個區塊,Goal(這張卡要做什麼)、Scope in(做的範圍)、Scope out(不做的範圍)、AC 標註,並標出有問題的那幾條 AC。有問題指三類,彼此衝突、不可驗證、與描述不符。

資料集到這裡寫得出來了,判定卻還是人的工作。一份 goal state 放在左邊,agent 這次的輸出放在右邊,一個欄位一個欄位對照。

人眼逐個欄位對,撐得住幾筆?

那張優惠碼功能的 ticket 有 7 條 AC,期望的標註是 4 個,加上 Goal 與 Scope 那幾個欄位,對完一筆要好幾分鐘。對到第三筆就不會有專注力逐字讀了,眼睛只去找幾個關鍵字在不在。

更麻煩的是對完之後手上沒有東西,留下來的是一句「這次看起來對了」。前面改了一行 prompt、優惠碼那張卡第 3 條 AC 的「與描述不符」就不見了,那一次也是這樣,事後誰也拿不出當時到底哪個欄位對、哪個欄位沒對。

那支 scorer 要判哪幾個欄位,判到多嚴?

前面把「整理對了」寫成一份可以逐個欄位比對的資料時,每個欄位右邊已經決定好嚴格程度,三檔,完全相同、必須存在、不比。那份東西現在就是 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 那一檔回的 passnullscoreCase 只數有評分的欄位,這就是不評分跟通過在程式裡分開的地方。

判定旁邊要附什麼?

前面替 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 條上各標了一個,期望裡沒有。漏掉一個跟多報一個代價不一樣,這兩種錯後面會分開處理。

紅的那個欄位不一定是 agent 的問題

scope_in 這個欄位回報少了「後台建立」跟「過期處理」,可是它輸出的 Scope in 裡寫著「後台可新增優惠碼」「活動結束後優惠碼失效」,兩件事一件都沒少。must_include 現在的做法是正規化之後做字面的包含比對,「後台建立」四個字沒有連著出現,就算 missing。

寫 goal state 那天說過,逐字比對抓錯重點,措辭差一個字就判失敗。現在撞上這句話的,是這支自己寫的 scorer。

這個欄位有兩條路,把關鍵字換成真的不會變的字,或者承認 must_include 判不出「意思一樣」,把它標成判不到,兩條路改的都是 dataset,不是 scorer 的程式。

這個欄位能被看出來是 scorer 的問題,靠的就是紅字旁邊那兩行 -。只回一個 FAIL 的話,它會被記成 agent 又錯一筆,接下來就有人去改一個沒壞的 prompt。

四步裡的補背景,這一趟整步沒跑

這隻 agent 的四步是拆 description、補背景、逐條檢查 AC、寫回去,補背景要靠四個對外查資料的工具,search_webread_docssearch_repoquery_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 概念都在,紅的是判斷標準的關鍵字寫得太死。人剩下的工作從逐個欄位比對,變成讀那兩個紅的欄位、判斷它們各自是誰的問題。


上一篇
Day 14|agent 出過錯的那幾張卡上面都是客戶資料,怎麼變成可以重跑的資料集?
下一篇
Day 16|四個欄位過了兩個,這筆 case 算 50% 還是 0?改完 prompt 該看哪個數字
系列文
30 天 AI eval 實戰:一隻 PM agent,改完之後怎麼確定它還是對的18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言