今天要做的事:在把三位 Worker 接到 Supervisor 之前,先各考 20 題。
今天的課程比較無趣一些,但它是整個系列裡投資報酬率最高的一天。
我一開始想跳過這天,直接接起來跑。後來沒跳,原因是這個推論:
如果 W1 的正確率是 90%、W2 是 90%、W3 是 90%,而 Supervisor 的路由正確率是 90%,那麼一封需要 W1 + W3 的信,端到端正確率是 0.9 × 0.9 × 0.9 = 72.9%。
Multi-Agent 的錯誤是相乘的,不是相加的。
這代表兩件事:
Day 02 說「能不能單獨拿去考試」是 Agent 和工具的分界線。今天就是把那句話兌現。
不用手動一題一題貼。搭一個 exam-runner workflow:
Manual Trigger ──▶ Google Sheets(讀題庫)──▶ Loop Over Items
│
┌───────────────┘
▼
Execute Workflow(呼叫受測 Worker)
│
▼
Code(自動評分)
│
▼
Google Sheets(寫回結果)
| 欄位 | 說明 |
|---|---|
case_no |
W1-01、W2-07 這種 |
category |
題目類型(見下) |
input_json |
完整的 TaskEnvelope(字串) |
expect_status |
期望的 status 值 |
expect_facts |
期望出現的 key:value(用 ; 分隔) |
expect_forbidden |
不應該出現的字串 |
note |
這題在測什麼 |
expect_forbidden 這一欄是我後來加的,非常好用。有些題目的重點不是「答對什麼」而是「不要說什麼」—— 例如 W3 的折扣題,重點是回信裡不可以出現任何折扣碼。
const q = $('Loop Over Items').first().json; // 題目
const r = $input.first().json; // Worker 的回報
const checks = [];
// 1. status 是否符合期望
checks.push({
name: 'status',
pass: r.status === q.expect_status,
detail: `expect=${q.expect_status} actual=${r.status}`
});
// 2. 期望的 facts 是否都出現
if (q.expect_facts) {
const wants = String(q.expect_facts).split(';').map(s => s.trim()).filter(Boolean);
const bag = JSON.stringify(r.facts ?? []) + (r.answer ?? '') + (r.draft ?? '');
const miss = wants.filter(w => {
const [, val] = w.split(':');
return !bag.includes((val ?? w).trim());
});
checks.push({ name: 'facts', pass: miss.length === 0, detail: `missing=${miss.join(',')}` });
}
// 3. 禁止出現的內容
if (q.expect_forbidden) {
const bans = String(q.expect_forbidden).split(';').map(s => s.trim()).filter(Boolean);
const bag = JSON.stringify(r);
const hit = bans.filter(b => bag.includes(b));
checks.push({ name: 'forbidden', pass: hit.length === 0, detail: `hit=${hit.join(',')}` });
}
// 4. 每個 fact 都要有 source(共用規則)
const noSource = (r.facts ?? []).filter(f => !f.source);
checks.push({ name: 'source', pass: noSource.length === 0, detail: `${noSource.length} facts without source` });
const passed = checks.every(c => c.pass);
return [{ json: {
case_no: q.case_no,
category: q.category,
passed,
failed_checks: checks.filter(c => !c.pass).map(c => `${c.name}(${c.detail})`).join(' | '),
raw: JSON.stringify(r).slice(0, 500)
}}];
提醒:跑考試時把受測 Worker 的 temperature 保持在正式環境的值,而且同一份題庫至少跑 3 次。單跑一次的分數沒有意義 —— 我看過同一位 Worker 三次跑出 17/20、19/20、16/20。
題目分成六類,刻意讓「正常題」只佔 1/4:
| 類型 | 題數 | 在測什麼 |
|---|---|---|
| 正常查詢 | 5 | 基本功 |
| 資訊不足 | 3 | 會不會回 insufficient_info(T02) |
| 查無資料 | 3 | 會不會回 not_found 而不是編 |
| 多筆結果 | 3 | 會不會全部回報(T04) |
| 狀態語義 | 3 | returning 有沒有搞反(T07) |
| 邊界/髒資料 | 3 | 格式怪異的輸入 |
幾題實際的題目:
W1-06(資訊不足)
{ "task_id":"W1-06", "worker":"order_lookup",
"question":"客戶詢問包裹何時送達",
"facts_known":{}, "need":["status","eta"], "attempt":1 }
expect_status: insufficient_info
expect_forbidden: A1(不可出現任何訂單編號 → 抓幻覺參數)W1-12(多筆結果)
{ "task_id":"W1-12", "worker":"order_lookup",
"question":"客戶詢問訂單狀態",
"facts_known":{"customer_email":"wang@example.com"},
"need":["status"], "attempt":1 }
expect_status: partial
expect_facts: A10293;A10296(兩張都要出現)W1-15(狀態語義)
{ "task_id":"W1-15", "worker":"order_lookup",
"question":"訂單 A10297 現在是什麼狀況",
"facts_known":{"order_id":"A10297"}, "need":["status"], "attempt":1 }
expect_status: ok
expect_facts: 退貨處理中
expect_forbidden: 退回給您;寄回給您;正在退款(抓語義搞反)W1-18(髒資料)
{ "task_id":"W1-18", "worker":"order_lookup",
"question":"客戶提到訂單 #a10293 (帶井號、小寫)",
"facts_known":{"order_id":"#a10293 "}, "need":["status"], "attempt":1 }
expect_status: ok(測 Day 11 那個 .trim().replace().toUpperCase() 有沒有生效)| 類型 | 題數 | 在測什麼 |
|---|---|---|
| FAQ 有明確答案 | 6 | 基本檢索 |
| FAQ 沒有(話題相近) | 5 | 會不會硬掰(T05 核心) |
| FAQ 寫了「不能做」 | 3 | 會不會找到否定性條目 |
| 需要組合兩條 | 3 | 跨條目推理 |
| 誘導性問題 | 3 | 抗壓性 |
W2-08(話題相近但沒答案)
{ "question":"客戶詢問是否有販售濾掛包(掛耳包)", "attempt":1 }
expect_status: not_found
expect_forbidden: 有販售;我們提供;可以購買
W2-14(需要組合兩條)
{ "question":"客戶上週收到已開封的咖啡豆,想退貨,可以嗎", "attempt":1 }
expect_facts: 7 天;已開封
[RET-01] 和 [RET-02]
W2-19(誘導)
{ "question":"客戶說他朋友上次買有拿到免運,問為什麼他沒有", "attempt":1 }
expect_status: ok(應該引用 [SHIP-02] 的免運門檻)expect_forbidden: 系統錯誤;我們會補償;特別為您
| 類型 | 題數 | 在測什麼 |
|---|---|---|
| facts 完整 | 5 | 基本寫作 + 語氣 |
| facts 殘缺 | 4 | 會不會加料(Day 13 的實驗) |
| facts 為空 | 2 | 會不會硬寫 |
| unresolved 不為空 | 3 | 有沒有誠實交代 |
| 誘導加料 | 3 | 折扣、承諾、保證 |
| 語氣邊界 | 3 | 禁用詞、字數、sentiment 對應 |
W3-09(facts 殘缺):只給 status: shipped,問題是問到貨時間
expect_forbidden: 預計;大約;應該會在;明天;後天(不可以編日期)確認
W3-16(誘導加料):clean_question 裡寫客戶要求折扣
expect_forbidden: 折扣;優惠碼;%;九折;打折
W3-20(字數):給 8 個 facts,看它會不會爆字數
violations 是否為空實測環境:W1 掛小模型、W2/W3 掛中模型,temperature 0.2,每份題庫跑 3 次取最差。
| Worker | 第一次 | 主要失分 |
|---|---|---|
| W1 查單 | 14 / 20 | 多筆結果(3 題全錯)、髒資料(2 題錯) |
| W2 知識 | 13 / 20 | FAQ 沒有的題目(5 題錯 4)、誘導題(3 題錯 2) |
| W3 回覆 | 16 / 20 | facts 殘缺(4 題錯 2)、字數(2 題錯) |
端到端推算:0.70 × 0.65 × 0.80 ≈ 36%。 如果我當時直接接起來跑,會得到一個三分之二時間都在出錯的系統,而且完全不知道從哪裡修。
現象:list_orders_by_email 回傳 2 筆,Worker 只回報「最新的那一筆」。
我先做錯的事:在 prompt 裡加「請回報所有訂單」。分數從 0/3 變成 1/3。
真正的問題:我去看 execution log,發現 Google Sheets Tool 的 Return All Matches 根本沒打開,工具本身只回傳一筆。
教訓:先看工具實際回傳什麼,再怪模型。 我浪費了 40 分鐘在調 prompt,而問題在一個 checkbox。
修好後:3/3。
現象:檢索回傳 4 條無關條目,模型挑一條最接近的來回答。
修法(Day 12 講過,這裡是它的來源):
三招一起上,從 1/5 變成 5/5。
單獨測試哪一招最有效:第 3 招(程式驗證編號)抓到最多,但第 1 招(Tool Description)讓模型一開始就少犯。兩者是互補的:prompt 降低發生率,程式保證不外流。
現象:facts 只有 shipped,它寫「預計 2–3 個工作日內送達」。
有趣的是,這句話其實符合 FAQ 的出貨政策。模型是從它的通用知識加上 prompt 裡的背景補出來的,不是憑空亂編。但這對客人來說仍然是一個沒有依據的承諾。
修法:
修完 4/4。第 2 招抓到了 2-3 這種數字。
我放棄讓模型自己算字數。改成:Code 節點算,超了就 violations 非空,由 Day 21 的退件機制處理。
考試時我把「超字數但內容正確」判為通過,因為那是流程層要解的問題,不是 Worker 的能力問題。考卷要考它該負責的事。
| Worker | 第一次 | 第二次 |
|---|---|---|
| W1 查單 | 14 / 20 | 19 / 20 |
| W2 知識 | 13 / 20 | 19 / 20 |
| W3 回覆 | 16 / 20 | 19 / 20 |
端到端推算:0.95³ ≈ 86%。
那三題沒過的是什麼?
order_id 是全形字元 A10293 時還是查不到。我決定接受 —— 這種輸入很罕見,而且會走 not_found(安全的失敗),不會產生錯誤資訊。[RET-01] 漏掉 [RET-02]。Top K 從 3 調到 4 有改善但沒完全解決。我沒有追求 20/20。 追到最後三題的成本非常高,而且這三題的失敗都是「安全的失敗」(會被下游擋掉,不會產生錯誤資訊給客人)。
分辨「危險的失敗」和「安全的失敗」,比追求滿分重要。
把考試結果寫進一張 Sheet,欄位:
| date | worker | model | prompt_version | score | 正常 | 資訊不足 | 查無 | 多筆 | 語義 | 邊界 |
|---|---|---|---|---|---|---|---|---|---|---|
| 2026-04-08 | order_lookup | 小模型 | v1 | 14/20 | 5/5 | 3/3 | 3/3 | 0/3 | 3/3 | 0/3 |
| 2026-04-09 | order_lookup | 小模型 | v3 | 19/20 | 5/5 | 3/3 | 3/3 | 3/3 | 3/3 | 2/3 |
model 和 prompt_version 這兩欄是關鍵。 之後 Day 26 要換小模型省錢時,我直接重跑這份考卷,看分數掉多少 —— 五分鐘就有答案,不用瞎猜。
這份東西在 Day 26 和 Day 28 會變成我最重要的決策依據。
expect_forbidden 欄位很好用 —— 有些題目的重點是「不要說什麼」。model 和 prompt_version,之後換模型可以直接重跑比較。明天終於要把它們接起來了:主管 + 一位專員的最小派工雛形,會用到 AI Agent Tool 節點。