今天要做的事:第一次把兩個 Agent 接起來。只做「Supervisor + W1」,不貪心。
並且解決一個實作上的真實難題:TaskEnvelope 是巢狀 JSON,但工具的參數是平的。
Gmail ──▶ Supervisor ──▶(派工)──▶ W1 查單專員 ──▶ 回報 ──▶ Supervisor ──▶ 輸出
不接 W2、不接 W3、不寄信。只驗證一件事:主管能不能正確地把工單交給專員,並且拿回結果。
Day 15 的教訓還熱著:一次只接一個,接好了再接下一個。
| 掛法 | 節點 | 特性 |
|---|---|---|
| A. AI Agent Tool | AI Agent Tool 子節點 |
在同一張畫布上定義子 Agent,最直觀 |
| B. Call n8n Workflow Tool | Call n8n Workflow Tool 子節點 |
指向一個獨立 workflow,可定義輸入欄位 schema |
我選 B。 是因為:
但 A 也有它的場合 —— 如果子 Agent 很簡單(例如一個只做翻譯的小 Agent),不值得開一個獨立 workflow,用 A 更快。Day 18 我會把兩種都測。
Day 14 定的 TaskEnvelope 長這樣:
{
"task_id": "t_001",
"worker": "order_lookup",
"question": "查出 A10293 的狀態與到貨時間",
"facts_known": { "order_id": "A10293", "customer_email": "wang@example.com" },
"need": ["status", "eta"],
"attempt": 1
}
問題來了:工具呼叫的參數是扁平的。LLM 的 function calling 填的是一個個欄位,不是一整包巢狀物件。
我試了三種做法:
payload 字串欄位(❌ 失敗)讓模型把整份 TaskEnvelope 序列化成一個字串塞進去:
{{ $fromAI("payload", "完整的 TaskEnvelope JSON 字串") }}
失敗率很高。 模型產生的 JSON 字串常常有問題:引號沒跳脫、中文被轉成 unicode escape、多一個尾逗號。而且你完全看不到它傳了什麼,除錯地獄。
在 W1 的 Execute Workflow Trigger 裡定義扁平的輸入欄位:
| 欄位名 | 型別 |
|---|---|
task_id |
String |
question |
String |
order_id |
String |
customer_email |
String |
need_csv |
String |
attempt |
Number |
retry_reason |
String |
然後在 Call n8n Workflow Tool 裡,每個欄位用 $fromAI() 各自填:
task_id → {{ $fromAI("task_id", "工單編號,格式 t_ 加時間戳") }}
question → {{ $fromAI("question", "要這位專員查什麼,用一句中文描述") }}
order_id → {{ $fromAI("order_id", "客戶提到的訂單編號;若信中沒有提到,填空字串") }}
customer_email → {{ $fromAI("customer_email", "客戶的 email 地址") }}
need_csv → {{ $fromAI("need_csv", "需要回報的欄位,逗號分隔,例如 status,eta") }}
attempt → {{ $fromAI("attempt", "第幾次嘗試,第一次填 1") }}
巢狀的 facts_known 被攤平成 order_id 和 customer_email 兩個欄位,need 陣列被攤平成 need_csv 字串。
然後在 W1 的第一個節點把它重組回 TaskEnvelope:
const i = $input.first().json;
return [{ json: {
task_id: i.task_id || `t_${Date.now()}`,
worker: 'order_lookup',
question: i.question || '',
facts_known: {
...(i.order_id ? { order_id: String(i.order_id).trim().replace(/[##]/g, '').toUpperCase() } : {}),
...(i.customer_email ? { customer_email: String(i.customer_email).trim() } : {})
},
need: String(i.need_csv || '').split(',').map(s => s.trim()).filter(Boolean),
attempt: Number(i.attempt) || 1,
retry_reason: i.retry_reason || ''
}}];
這個「攤平 → 重組」的模式,是我整個系列最常用的實作技巧。 對外(工具介面)扁平,對內(Worker 邏輯)結構化。
順帶一提,Day 11 講的 order_id 正規化(去井號、轉大寫)我移到了這裡 —— 清理應該在入口做一次,不要散在各處。
只給工具一個 instruction 欄位,讓 Worker 自己從句子裡解析。
好處:最簡單,Supervisor 幾乎不用思考。
壞處:Worker 要多做一次資訊抽取,錯誤率上升,而且錯誤發生在 Worker 這一層,你會誤以為是 Worker 不行。
Day 18 我會拿數據比這三種。
$fromAI 的描述決定了派工品質我調 order_id 那個欄位的描述,調了很久。
第一版:
{{ $fromAI("order_id", "訂單編號") }}
結果:客人沒給訂單編號時,模型編一個。這就是 Day 06 的 T02 在 Supervisor 層重演。
第二版:
{{ $fromAI("order_id", "訂單編號,若無則留空") }}
結果:好一點,但還是有 30% 會編。
第三版(現在用的):
{{ $fromAI("order_id", "客戶在信件中明確提到的訂單編號,格式為 A 加 5 位數字。若客戶沒有提到訂單編號,必須填入空字串,不可以推測或編造。") }}
結果:幾乎不編了。
三個有效的措辭原則:
| 原則 | 例子 |
|---|---|
| 寫清楚來源 | 「客戶在信件中明確提到的」而不是「訂單編號」 |
| 寫清楚格式 | 「A 加 5 位數字」 |
| 寫清楚空值怎麼填 | 「必須填入空字串」—— 不寫這句,模型不知道可以留空 |
第三點最重要。 模型的預設行為是「每個欄位都要填東西」。你必須明確告訴它「空字串是一個合法的值」。
這跟 Day 11 的 insufficient_info、Day 12 的 not_found、Day 13 的 concerns 是完全相同的設計哲學:給一個合法的出口,它就不會亂編。
我現在會把這件事當成一條通則:每當你發現模型在編東西,先問「它有沒有一個表達『沒有』的合法方式」。
新建 workflow main:
Gmail Trigger ──▶ Set(整理欄位)──▶ AI Agent(Supervisor)──▶ Code(輸出檢查)
│
┌──────────────────┼──────────────────┐
Chat Model Structured Output Call n8n
(大) Parser Workflow Tool
(→ W1)
你是「好豆選物」客服部門的主管。你不直接處理客戶問題,
你的工作是判斷客戶來信的需求,並派工給合適的專員。
## 你的團隊
- order_lookup(查單專員):能從訂單系統查詢訂單狀態、物流、到貨時間、金額
## 你的工作流程
1. 閱讀客戶來信,判斷客戶想知道什麼。
2. 如果需要查詢訂單資料,呼叫 order_lookup 工具派工。
3. 收到專員回報後,整理成結構化的結果輸出。
## 派工規則
- 派工時,question 要寫清楚「要查什麼」,不要寫「怎麼查」。
✅ 「查出訂單 A10293 的狀態與預計到貨時間」
❌ 「請使用 get_order_by_id 工具,輸入 A10293,然後看 status 欄位」
- order_id 只填客戶明確提到的編號。客戶沒提到就填空字串。
- need 填你需要專員回報的欄位。
## 重要限制
- 你自己沒有查詢訂單的能力。不要假裝你知道訂單狀態。
- 你不寫給客戶看的回信。你的輸出是給系統用的結構化資料。
- 每封信最多派工 2 次。
最後那三條「重要限制」是關鍵,明天會展開講。
{
"type": "object",
"properties": {
"case_id": { "type": "string" },
"intent": { "type": "string", "enum": ["order_status","product_qa","return_refund","complaint","promo_coupon","other"] },
"intent_confidence": { "type": "number" },
"needs_human": { "type": "boolean" },
"clean_question": { "type": "string" },
"dispatched": { "type": "array", "items": { "type": "string" } },
"collected_facts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {"type":"string"}, "value": {"type":"string"}, "source": {"type":"string"}
}
}
},
"unresolved": { "type": "array", "items": { "type": "string" } },
"summary": { "type": "string" }
},
"required": ["intent", "needs_human", "clean_question"]
}
寄一封測試信:
主旨:訂單什麼時候到
內文:你好,我的訂單 A10293 想問一下什麼時候會到?謝謝
Supervisor 的輸出:
{
"case_id": "c_20260410_a41f",
"intent": "order_status",
"intent_confidence": 0.96,
"needs_human": false,
"clean_question": "詢問訂單 A10293 的預計到貨時間",
"dispatched": ["order_lookup"],
"collected_facts": [
{ "key": "status", "value": "shipped(已出貨)", "source": "orders!A10293" },
{ "key": "carrier", "value": "黑貓宅急便", "source": "orders!A10293" },
{ "key": "tracking_no", "value": "8891234567", "source": "orders!A10293" },
{ "key": "eta", "value": "2026-04-01", "source": "orders!A10293" }
],
"unresolved": [],
"summary": "訂單 A10293 已出貨,黑貓宅急便,預計 4/1 送達。"
}
第一次派工成功。 而且去看 W1 的 execution log,可以看到它收到的是乾淨的 TaskEnvelope,這代表「攤平 → 重組」的流程正常。
我跑了 15 封測試信,發現三個問題(這三個明天整篇都在處理):
問題 1:它自己寫起回信了。 大約三成的信,summary 欄位不是給系統看的摘要,而是一封完整的客服回信:「王先生您好,感謝您的來信……」
問題 2:它在 collected_facts 裡加料。 有兩封信,facts 裡出現了 estimated_arrival: "1-3 個工作日",source 寫 "general_policy"。W1 從來沒有回報這個東西 —— 是 Supervisor 自己加的。
問題 3:客人沒給訂單編號時,它不派工也不說明。 直接輸出 dispatched: [] 和一個含糊的 summary。它既沒有派工去用 email 查,也沒有回報「需要跟客人要編號」。
三個問題的共同點:Supervisor 越界了。 它做了本來不該它做的事(寫信、提供事實),或是該做的事沒做(派工)。
這就是為什麼 Day 09 說 Supervisor 只有四個職責,而且要明確寫出它不做什麼。今天的 prompt 雖然有寫「重要限制」,但顯然力道不夠。
1. Call n8n Workflow Tool 的 Tool Description 要寫「什麼時候用」和「什麼時候不用」。 我一開始只寫「查詢訂單資料」,Supervisor 在客人問咖啡豆保存方式時也去呼叫它。
2. 子 workflow 一定要設 Execute Workflow Trigger 的輸入欄位。 不設的話 n8n 會傳整包 item 進去,你在子流程裡收到的東西形狀完全不可控。
3. 子 workflow 的錯誤會往上冒。 W1 裡如果 Google Sheets 掛了,整個 Supervisor 的執行會失敗。Day 24 會處理(把 Call n8n Workflow Tool 的 On Error 設成 Continue)。
4. 派工是同步阻塞的。 Supervisor 呼叫 W1 時會等它跑完。三位 Worker 都接上之後,延遲會是相加的。Day 26 會討論怎麼讓 W1 和 W2 平行。
Call n8n Workflow Tool + 定義輸入欄位是主線做法。payload JSON 字串欄位傳參數,失敗率高且無法觀察。$fromAI() 的描述要寫清楚:來源、格式、空值怎麼填。第三項最重要。明天整篇處理這三個問題 —— Supervisor 的 System Prompt 怎麼寫,才能讓它不瞎指揮、不搶工作。這是整個系列我改最多次的一份 prompt。