agent 出過錯的卡是最好的測試資料,但它們要能跟程式碼放在一起重跑、審查、送進模型 API,內容就得整套重建,能留下的只有它讓 agent 出錯的那個結構。合成化(synthetic data)的驗收條件只有一條,這張卡原本會讓 agent 漏掉哪一個問題,換完之後還是會漏掉同一個。
這 30 天要驗收的對象是一隻需求釐清 agent,它把需求方寫的雜亂 ticket 讀進去,整理成結構化的 ticket,輸出分四個區塊,Goal(這張卡要做什麼)、Scope in(做的範圍)、Scope out(不做的範圍)、AC 標註,並標出有問題的那幾條 AC。有問題指三類,彼此衝突、無法驗證、與描述不符。
而要放進資料集的那幾張卡,最有價值的一定是 agent 出過錯的那幾張。它們上面有客戶名字、有報價、有還沒公開的功能與時程,一張都不能原樣進 repo,更不能送進第三方模型的 API。
最省事的做法是 redaction,把敏感的字遮掉或抽換,但遮完的東西已經不像需求方會寫出來的 ticket。agent 讀到的輸入必須像人寫出來的東西,需求方寫卡不會在中間留一排黑條,遮完之後它讀到的輸入跟線上會遇到的完全不同,這筆 case 測到的行為也就不是線上那個。
redaction 留下的東西還拼得回去。金額塗掉、日期留著,日期塗掉、專案代號留著,湊三張卡就有人認得出這是哪一個客戶的哪一個案子。
真正要換掉的東西大致是這幾類:
合成化換掉的是領域、措辭跟篇幅,不能動的是它讓 agent 出錯的那個結構。那張優惠碼功能的 ticket 是現成的例子,它有 7 條 AC,第 2 條寫「優惠碼一人只能用一次」,第 3 條寫「優惠碼可以無限次使用」,而第 3 條同時違背 description 裡「不要被亂用」那句。人工標完的期望標註集合是 4 個標註,第 3 條同時有兩個標註。
把它換成一張課程上架的卡,領域全換、AC 從 7 條變成 12 條、打架的那兩條分開藏在中間,驗收的方式是把新卡重讀一遍,逐格對回原本那份 goal state。標註的總數要還是 4 個,同一條 AC 要還是同時中「彼此衝突」與「與描述不符」。
留這張卡的理由就在這裡,只要 agent 從逐條檢查變成整體看一遍,掉的會是「與描述不符」那一個,4 個標註變成 3 個,而 Goal、Scope in、Scope out、AC 標註四個區塊看起來一模一樣。這個性質合成完要原封不動留著,合成化把它換掉了,這筆 case 就沒有用了。

第一步不能跳過,先寫下這張卡的問題跟期望的標註集合,合成化之後才有東西可以對;顛倒過來做,就變成看變體卡長什麼樣、再回頭說它測的就是這張卡原本的問題。
最後原卡與變體卡的對照表,留在自己機器上就好,它進了 repo 等於把前面幾步全部還原回去。
需求釐清 agent 的資料集還沒補齊,合成化這一套還沒用在它的資料集上,但另一份實驗已經完整做過一次。這份實驗是叫 agent 照一份寫作規範產出 Trello 卡片,3 個案例整批換到另一個領域,換掉的是帳單金額、第三方服務回的錯誤碼、內部 branch 名與 commit SHA,還有聊天室裡補的那段需求。
換完之後重跑了 24 次,4 個 case × 2 個 configuration × 3 次,把規範拿掉的那一組,逐條算的通過率 64%,4 個 case 一個都沒有全過。合成過的案例還測得出原本要測的那個差別,合成化做錯了就看不到這個差別。
Airbnb 的做法方向反過來,他們每天抽 5% 的線上流量去識別化,跑程式檢查跟 grader,被標出來的輸出交人工複審,每週由 PM 執行,發現新的失敗模式就加一項新的 eval(Eval-driven development)。他們有源源不絕的線上流量,去識別化之後就是資料;這個系列沒有那種流量,做的是把手上 agent 出過錯的那幾張卡合成化,補齊資料集。
合成化只解決了資料能不能寫出來。開頭說的重跑、審查、送進模型 API,靠的是資料集跟 grader、fixture 放在同一個 repo 裡,同事拉下來就能跑,改一格就有 diff。
所以還有一條規則要現在就定下來。一筆 case 帶三樣東西,整份輸入原文、這筆通過的條件、以及第一次真跑時把外部回應原封不動存下來的那份檔案(fixture),三樣都是檔案,都進版本控制。
當程式管的意思是它也有測試。repo 的 dataset/case.test.ts對那張優惠碼的卡檢查三件事,標註是一個集合、判不了的格子要寫明、Scope out 不能用完全相同:
test('ac-conflict-001 pins the four flags as a set', () => {
const c = loadCase('dataset/ac-conflict-001.yaml');
assert.deepEqual(c.goalState.ac_flags, {
mode: 'exact_set',
values: ['3:conflict', '3:mismatch', '4:unverifiable', '7:unverifiable'],
});
// 釘的是集合不是總數:標對 4 個跟標錯 4 個分得出來。
assert.equal(new Set(c.goalState.ac_flags.values).size, 4);
});
test('ac-conflict-001 keeps the ungradable cells written down', () => {
const c = loadCase('dataset/ac-conflict-001.yaml');
for (const field of ['comment_wording', 'tool_calls', 'goal_wording']) {
assert.equal(c.goalState[field]!.mode, 'ignore', `${field} must stay written down`);
}
});
test('scope_out is must_include, not exact_set', () => {
// 「結帳頁效能」對「結帳頁效能問題」不該紅,而降檔是 dataset 的一次變更、留 diff。
const c = loadCase('dataset/ac-conflict-001.yaml');
assert.equal(c.goalState.scope_out!.mode, 'must_include');
});
有人把 AC 標註那一格改鬆,這三條測試會先紅,改的人得在 commit 裡說為什麼。改一格判斷標準也是一個 commit,要寫清楚為什麼改,diff 留著。判斷標準改鬆不會亮紅燈,分數只會變好看,沒有那則 commit 訊息,半年後沒有人查得出是哪一次把那一格放寬的。
合成化也一樣要留紀錄,哪一張卡是哪一次合成化換出來的、換掉了哪幾類識別字、換完誰重讀過,寫在那筆 case 旁邊。這幾行字看起來很囉唆,但下一個接手的人要判斷這筆 case 還算不算數,靠的就是這幾行。
agent 出過錯的卡要能重跑、審查、送進模型 API,內容就得整套重建,留下的只有它讓 agent 出錯的那個結構。redaction 不夠,agent 讀到的輸入要像人寫的,遮過的卡也拼得回去。合成化換掉的是領域、措辭、篇幅與識別字,不能動的是它讓 agent 出錯的那個結構,換完人重讀一遍、逐格對回原本的 goal state,再重跑一次看差別還在不在。
資料集要當程式管,輸入原文、通過的條件、fixture 三樣都進版本控制,改一格判斷標準也是一個 commit,合成化的來歷寫在那筆 case 旁邊。