三位專員都做完了,但它們現在各說各話。
Day 11 結尾留了一個問題:W1 跑完 20 張工單,27 筆事實用了 16 種不同的 key。同樣是訂單狀態,有時候叫 status,有時候叫「訂單狀態」,有時候叫「訂單 A10293 狀態」。今天把這件事解掉,並且實測解掉要付多少代價。
下週主管要把 W1 和 W2 的事實收在一起、交給 W3 寫信,中間會經過一個 Code 節點。那個節點要做的事情很單純:挑出這次回信需要的欄位。
const status = facts.find(f => f.key === 'status');
這一行在我的實驗資料上,十次有六次會拿到 undefined,因為那 14 筆跟訂單狀態有關的事實裡,只有 5 筆的 key 是 status。
退一步說,就算不用程式取值,整包丟給 W3 讓它自己讀也行——但那樣 W3 就得去理解「訂單 A10294 預計送達日期」跟「eta」是同一件事,等於把不確定性往下游推。到了 Day 21 要用程式驗收回信有沒有漏答,這筆帳還是要還。
我把專員之間的介面寫成兩份固定的格式,在流程裡的位置如下圖。

TaskEnvelope 是主管交給專員的工單:
{
"task_id": "c_EV13_w1",
"worker": "order_lookup",
"question": "查出訂單 A10294 目前的狀態與預計到貨時間",
"facts_known": { "order_id": "A10294" },
"need": ["status", "eta"],
"attempt": 1,
"retry_reason": ""
}
WorkerResult 是專員交回來的回報,三位專員共用同一份:
{
"task_id": "c_EV13_w1",
"worker": "order_lookup",
"status": "ok",
"confidence": 1,
"facts": [
{ "key": "status", "subject": "A10294", "value": "備貨中(packing)", "source": "orders!A10294" }
],
"answer": "訂單 A10294 目前備貨中,預計 2026-09-17 到貨。",
"missing": [],
"notes": ""
}
三位專員共用一份回報格式,主管才只需要一套驗收邏輯。W3 的輸出多了 draft 和 concerns,但 status 和 facts 的語義完全一樣。
原本的 schema 只規定 facts 是一個陣列,每一筆有 key、value、source 三個字串欄位。key 可以填什麼,完全沒規定。
改法是把 key 從自由字串換成 enum:
{
"key": {
"type": "string",
"enum": ["status", "carrier", "tracking_no", "eta", "ordered_at",
"shipped_at", "delivered_at", "amount", "items",
"order_id", "tier", "customer_note"]
},
"subject": { "type": "string" },
"value": { "type": "string" },
"source": { "type": "string" }
}
光改 schema 不夠,prompt 也要同步,不然模型會在兩邊打架。我在 W1 的 prompt 裡加了一段,把 12 個 key 和各自的意思列出來,最後補一句:清單以外的東西一律不要回報,不要自己造 key,也不要把訂單編號寫進 key 裡。
key 一鎖,多筆訂單立刻出事。
客人沒給編號、名下有兩張訂單,W1 要回報兩個狀態。原本它用「訂單 A10293 狀態」「訂單 A10296 狀態」來區分,現在兩筆的 key 都只能是 status,誰是誰就分不出來了。
所以 facts 多了一個 subject 欄位,寫這筆事實屬於哪一張訂單或哪一位客人。key 照樣用清單裡的值,可以重複:
[
{ "key": "status", "subject": "A10293", "value": "已出貨 (shipped)", "source": "orders!A10293" },
{ "key": "status", "subject": "A10296", "value": "待付款 (pending_payment)", "source": "orders!A10296" }
]
下游要拿「這張單的狀態」,寫法變成 facts.find(f => f.key === 'status' && f.subject === orderId),兩個條件都是確定的值。
把 W1 的 schema 和 prompt 換成新版,其他什麼都不動,同一份 20 題的考卷再跑一次。
| 舊版(key 自由填) | 新版(key 鎖 enum) | |
|---|---|---|
| 分數 | 20/20 | 20/20 |
| 不同 key 數 | 16 | 7 |
| facts 總數 | 27 | 30 |
| tokens 中位數 | 3030 | 3584 |
| 時間中位數 | 2.0 秒 | 1.7 秒 |
新版用到的七個 key 分別是 status、eta、tracking_no、carrier、order_id、delivered_at、tier,全部在清單裡,一個自創的都沒有。subject 用到 14 種值,都是實際的訂單編號或客人 email。
代價是 token 多了 554,大約 18%。多出來的是 prompt 裡那份 key 清單,每次呼叫都要重送。這筆錢我覺得划算,因為它換來的是下游每一個節點都不用再處理命名問題。
時間反而變快,不過這個數字我不會拿來說嘴,雲端模型的延遲本來就跳來跳去,Day 11 甚至出現過一題卡 200 秒。
分數一樣是 20/20,但我逐題看輸出的時候,發現兩題的回報怪怪的:
{ "key": "status", "subject": "A99999", "value": "not_found", "source": "orders!A99999" }
A99999 這張訂單不存在,這題的正確行為就是回報查無資料。status 欄位它確實填了 not_found,判斷沒錯。但它同時在 facts 裡塞了一筆事實,value 寫 not_found,source 指向一張不存在的訂單。
另一題的編號是 A1029(少一碼),症狀一模一樣。
這是我自己造成的。舊版 key 可以自由填的時候,模型沒有事實可報就交空陣列;現在它被要求用清單裡的 key,而清單裡沒有「查無」這個概念,於是它把 not_found 當成狀態值塞進 status,順手編了一個 source。
更麻煩的是守門員攔不住。W1 的守門員檢查的是「source 格式對不對」,orders!A99999 格式完全正確。
修法是加一條規則:
// 查無資料或資訊不足時,不該有任何事實
let voided = 0;
if (status === 'not_found' || status === 'insufficient_info') {
voided = clean.length;
clean.length = 0;
}
這條規則不需要知道 A99999 存不存在,只需要知道「說查不到就不該交出東西」。重跑之後,守門員作廢了 2 筆事實,not_found 的題目全部交空陣列。
這件事給我的提醒是:改了契約就要重看一次輸出,不能只看分數。這兩題的分數從頭到尾都是 pass,因為考卷判的是「有沒有正確回報 not_found」,而它確實報了。要不是我逐題翻過去,這個洞會一路帶到下週,變成主管以為查到了東西。
Structured Output Parser 的輸出會多一層 output。 Agent 接了 Output Parser 之後,下一個節點拿到的是 { output: { ... } },不是直接的物件。我的 Code 節點一律寫成這樣:
const r = $input.first().json.output ?? $input.first().json;
不接 Output Parser 的話,模型會自己加圍籬。 我試過不用 parser,純靠 prompt 要求回 JSON,拿到的字串前後會包著三個反引號和 json 字樣。要自己剝掉,而且不保證每次都包。接 parser 之後就沒這個問題了。
System prompt 裡有大括號不會出錯。 很多舊教學會提醒 prompt 裡的 {} 會被 LangChain 當成變數插值而報錯,所以要寫成 {{}}。我在 n8n 2.38 實測,prompt 裡直接放 JSON 範例和 {customer_name} 都正常,沒有任何錯誤。這個限制在這個版本已經不存在,不用再做那個轉義。
我把兩份 schema 當成程式碼管理,放在 lab/harness/builders.mjs 裡匯出,所有 workflow 的 Output Parser 都從這裡讀。這樣改一次就全部跟著改,不會發生 W1 改了、W2 忘了改的情況。
考卷的判準也引用同一份定義。schema 加了 subject 之後,評分程式不需要改,因為它比對的是 value 和 source 的內容,不是 key 的名字——這其實也是 Day 15 說的「不比對整句」那條原則的延伸。
契約有兩份:主管派工用 TaskEnvelope,專員回報用 WorkerResult。status 和 key 都用 enum 鎖死,多筆資料用 subject 區分而不是把編號寫進 key。實測同一份考卷,key 的種類從 16 種收斂到 7 種,分數不變,token 多 18%。但鎖死 key 會逼出新的行為:模型在沒有事實可報的時候,改成把 not_found 塞進 value 並編一個 source,這要靠守門員再補一條規則擋下來。
明天主管正式登場。先做最小的形式:一位主管加一位專員,用 AI Agent Tool 把 W1 掛上去,看它會不會派工。