今天要做的事:把 W1 做出來,而且是一個可以獨立執行、獨立測試的 workflow。
它不知道 Supervisor 存在,也不知道 W2、W3 存在。
這是我覺得 n8n 做 Multi-Agent 最重要的一個實作決定。
Worker 不要畫在主流程的畫布上。每個 Worker 是一個獨立的 workflow,用 Execute Workflow Trigger 當入口。理由:
新建一個 workflow,命名 W1-order-lookup:
Execute Workflow Trigger ──▶ AI Agent ──▶ Code(後處理)──▶ (return)
│
┌───────────────┼───────────────┬──────────────┐
Chat Model Structured Output Tool×3 (無 Memory)
(小) Parser
在較新版的 n8n 裡,這個 trigger 可以定義輸入欄位的 schema(Input data mode 選 Define using fields below),這件事非常值得做 —— 它讓 Worker 有一份明確的「函式簽名」。
定義這些欄位:
| 欄位名 | 型別 | 說明 |
|---|---|---|
task_id |
String | 工單 ID |
question |
String | 要查什麼 |
facts_known |
Object | 已知事實(order_id, customer_email) |
need |
Array | 主管要哪些欄位 |
attempt |
Number | 第幾次嘗試 |
定義好之後,Supervisor 那邊呼叫時會看到這些欄位的表單,不會傳錯。這比用 JSON 隨便塞可靠太多了。
get_order_by_id節點:Google Sheets Tool
Get Row(s)
haodou-orders / Sheet:orders
order_id
{{ $fromAI("order_id", "訂單編號,格式為 A 加 5 位數字,例如 A10293") }}
用訂單編號查詢單一筆訂單的完整資料。
使用時機:已經知道訂單編號時。
輸入:訂單編號(格式 A + 5 位數字)。
若你手上沒有訂單編號,不要使用此工具,改用 list_orders_by_email。
list_orders_by_email節點:Google Sheets Tool(第二個)
Get Row(s)
email
{{ $fromAI("customer_email", "客戶的 email 地址") }}
用客戶 email 查詢該客戶的所有訂單,會回傳多筆。
使用時機:客戶沒有提供訂單編號,但你知道他的 email。
注意:可能回傳多筆訂單,你必須把每一筆都回報,不可以自己挑一筆。
Tool Description 最後那一句很重要。沒有那句話,模型拿到 3 筆訂單,會挑一筆「看起來最相關的」回報 —— 然後你就永遠不知道還有另外兩筆。
get_customer節點:Google Sheets Tool(第三個)
customers
email
{{ $fromAI("customer_email", "客戶的 email 地址") }}
查詢客戶的會員等級與歷史備註。
使用時機:僅在主管明確要求客戶資料(need 包含 tier 或 notes)時使用。
不要為了「多知道一點」而呼叫此工具。
最後一句是我加的成本控制。不加的話,模型會為了「充分了解客戶」每次都順便查一下,每次多花一次工具呼叫。
完整版:
你是「好豆選物」的訂單查詢專員。你的唯一工作是:
根據主管給你的工單,從訂單系統查出正確的事實並回報。
## 你的職責邊界
- 你只回報事實,不寫給客人看的回信。
- 你不判斷這件事該怎麼處理,那是主管的工作。
- 你不猜測、不推論、不補充資料庫裡沒有的東西。
## 可用工具
- get_order_by_id:已知訂單編號時使用
- list_orders_by_email:只知道 email 時使用,會回傳該客戶所有訂單
- get_customer:僅在工單的 need 包含 tier 或 notes 時使用
## 查詢策略
1. 如果 facts_known 有 order_id → 用 get_order_by_id
2. 如果沒有 order_id 但有 customer_email → 用 list_orders_by_email
3. 如果 list_orders_by_email 回傳多筆,全部回報,不要自己挑
4. 兩者都沒有 → 不要呼叫任何工具,直接回報 insufficient_info
5. 工具呼叫上限 3 次
## 訂單狀態的中文說法(回報時請用中文,並保留原始英文值)
pending_payment = 待付款(尚未收到款項)
packing = 備貨中(已收款,正在出貨準備)
shipped = 已出貨(在物流途中)
delivered = 已送達
returning = 退貨處理中(客戶已申請退貨,正在處理)
cancelled = 已取消
## 回報規則
- facts 陣列中每一筆都必須有 source,格式為 orders!<order_id> 或 customers!<email>
- 沒有 source 的資料不要放進 facts
- 查不到資料時回報 status = not_found,這是正確的行為,不是失敗
- 資訊不足無法查詢時回報 status = insufficient_info,並在 missing 陣列列出缺什麼
- 絕對不要編造訂單編號、物流編號或日期
## 特別注意
returning 的意思是「客戶申請退貨,我們正在處理」,
不是「商品正在退回給客戶」。不要搞反方向。
最後那一段 returning 的說明,是專門修 Day 06 T07 那個把語意搞反的錯。
這裡有一個我想強調的體會:這份 prompt 大約 40 行,比 Day 04 那份 59 行的短,但它只講一件事。單兵 Agent 的 59 行裡,真正跟查單有關的只有 5 行 —— 現在變成 40 行專門講查單。同樣的 token 預算,用在一件事上的密度差了 8 倍。
User Message 欄位:
工單編號:{{ $json.task_id }}
第 {{ $json.attempt }} 次嘗試
要查什麼:{{ $json.question }}
已知事實:{{ JSON.stringify($json.facts_known) }}
主管需要的欄位:{{ $json.need.join(", ") }}
AI Agent 的 Require Specific Output Format 打開,掛一個 Structured Output Parser,schema 填(明天 Day 14 會講這份 schema 的設計細節):
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["ok", "not_found", "insufficient_info", "partial", "error"]
},
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
"facts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": { "type": "string" },
"value": { "type": "string" },
"source": { "type": "string" }
},
"required": ["key", "value", "source"]
}
},
"answer": { "type": "string" },
"missing": { "type": "array", "items": { "type": "string" } },
"notes": { "type": "string" }
},
"required": ["status", "confidence", "facts", "answer", "missing"]
}
這個節點是 W1 可信度的來源,不要跳過。
LLM 就算有 schema 約束,還是會做兩件事:facts 裡放沒有 source 的項目、status 說 ok 但 facts 是空的。用程式抓:
const r = $input.first().json.output ?? $input.first().json;
const taskId = $('Execute Workflow Trigger').first().json.task_id;
const facts = Array.isArray(r.facts) ? r.facts : [];
// 1. 丟掉沒有 source 的 fact
const clean = facts.filter(f => f && f.key && f.value && f.source);
const dropped = facts.length - clean.length;
// 2. status 與 facts 的一致性檢查
let status = r.status ?? 'error';
if (status === 'ok' && clean.length === 0) {
status = 'not_found'; // 說有答案卻交不出證據,一律降級
}
// 3. source 格式檢查
const badSource = clean.filter(
f => !/^(orders|customers)!/.test(f.source)
);
return [{
json: {
task_id: taskId,
worker: 'order_lookup',
status,
confidence: typeof r.confidence === 'number' ? r.confidence : 0,
facts: clean,
answer: r.answer ?? '',
missing: Array.isArray(r.missing) ? r.missing : [],
notes: r.notes ?? '',
_audit: {
dropped_factless_source: dropped,
bad_source_format: badSource.length,
downgraded: status !== r.status
}
}
}];
那個 _audit 物件是我後來加的,它讓我第一次看見 Worker 到底幻覺了多少次。Day 19 的可觀測性會把它寫進 trace log。
第 2 條規則(ok 但沒 facts 就降級成 not_found)幫我抓到最多問題。模型很喜歡說「我查到了」然後 facts 是空陣列。
輸入:
{
"task_id": "t_test_1",
"question": "訂單 A10293 目前狀態與預計到貨時間",
"facts_known": { "order_id": "A10293", "customer_email": "wang@example.com" },
"need": ["status", "carrier", "tracking_no", "eta"],
"attempt": 1
}
輸出:
{
"task_id": "t_test_1",
"worker": "order_lookup",
"status": "ok",
"confidence": 0.96,
"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" }
],
"answer": "訂單 A10293 已於 2026-03-30 出貨,由黑貓宅急便配送,貨號 8891234567,預計 2026-04-01 送達。",
"missing": [],
"notes": ""
}
輸入 facts_known: { "customer_email": "wang@example.com" },不給 order_id。
輸出:
{
"status": "partial",
"confidence": 0.8,
"facts": [
{ "key": "order_A10293_status", "value": "shipped(已出貨)", "source": "orders!A10293" },
{ "key": "order_A10293_eta", "value": "2026-04-01", "source": "orders!A10293" },
{ "key": "order_A10296_status", "value": "pending_payment(待付款)", "source": "orders!A10296" }
],
"answer": "該客戶目前有 2 筆進行中的訂單:A10293 已出貨(預計 4/1 到)、A10296 待付款。無法確定客戶詢問的是哪一筆。",
"missing": ["order_id"],
"notes": "客戶有多筆訂單,建議請客戶確認訂單編號,或在回信中同時說明兩筆"
}
T04 解掉了。 而且注意它自己回報 status: "partial" 和 missing: ["order_id"] —— 這正是 Day 07 說的「Worker 要會說不」。notes 裡的建議是提供資訊,最後怎麼辦由 Supervisor 決定。
輸入 facts_known: {}。
輸出:
{
"status": "insufficient_info",
"confidence": 1.0,
"facts": [],
"answer": "無法查詢:未提供訂單編號或客戶 email。",
"missing": ["order_id", "customer_email"],
"notes": ""
}
而且工具呼叫次數是 0。 對照 Day 06 的 T02 —— 單兵 Agent 在這個情境會編一個訂單編號去查。
差別在哪?不只是 prompt 寫得比較好,而是:status 有一個 insufficient_info 這個合法出口,模型有地方可去。 有出口,就不需要編答案。
1. Google Sheets Tool 查不到時回傳的東西很不一致。
有時是空陣列,有時是一個 {},有時是錯誤訊息字串。我在 prompt 裡加了一句:
若工具回傳空結果、空物件或錯誤訊息,都視為「查無此筆」,回報 not_found。
2. $fromAI() 的描述寫得越具體越好。
只寫 $fromAI("order_id") 時,模型有時會傳 "A10293 "(帶空白)或 "#A10293"(帶井號),查不到。加上格式描述之後就穩了。更保險的做法是在 Sheets Tool 的 Lookup Value 外面包一層:
{{ $fromAI("order_id", "訂單編號,A + 5位數字").trim().replace("#", "").toUpperCase() }}
3. 不要給 W1 Memory。 我試過給它 Simple Memory,結果它在第二次查詢時直接引用上一次的結果(「如同先前查詢,訂單已出貨」),沒有重新呼叫工具。Day 23 會完整討論。
list_orders_by_email + Return All Matches + 「不可以自己挑一筆」。ok 但無 facts 就降級。這是 Worker 可信度的來源。_audit 欄位讓你看得見幻覺次數。insufficient_info 這個合法出口的存在。明天做 W2 知識專員,會用到向量檢索 —— 這是解 T05(幻覺政策)的關鍵,而且「怎麼讓它老實說 not_found」比想像中難。