iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
AI 自動化

從單一 Agent 到 Supervisor 架構:用 n8n 實作會自己分工的 Multi-Agent AI 部門系列 第 16 篇

Day 16|AI Agent Tool 節點:主管加一位專員的最小派工雛形

  • 分享至 

  • xImage
  •  

今天要做的事:第一次把兩個 Agent 接起來。只做「Supervisor + W1」,不貪心。
並且解決一個實作上的真實難題:TaskEnvelope 是巢狀 JSON,但工具的參數是平的。

今天的目標

Gmail ──▶ Supervisor ──▶(派工)──▶ W1 查單專員 ──▶ 回報 ──▶ Supervisor ──▶ 輸出

不接 W2、不接 W3、不寄信。只驗證一件事:主管能不能正確地把工單交給專員,並且拿回結果。

Day 15 的教訓還熱著:一次只接一個,接好了再接下一個。

n8n 裡把 Agent 當工具的兩種掛法

掛法 節點 特性
A. AI Agent Tool AI Agent Tool 子節點 在同一張畫布上定義子 Agent,最直觀
B. Call n8n Workflow Tool Call n8n Workflow Tool 子節點 指向一個獨立 workflow,可定義輸入欄位 schema

我選 B。 是因為:

  1. Day 11 已經把 W1 做成獨立 workflow 了,本來就該重用
  2. B 可以定義輸入欄位,這是今天要解的核心問題(見下文)
  3. 獨立 workflow 有自己的 execution log,除錯時不用在一堆巢狀節點裡挖

但 A 也有它的場合 —— 如果子 Agent 很簡單(例如一個只做翻譯的小 Agent),不值得開一個獨立 workflow,用 A 更快。Day 18 我會把兩種都測。

核心難題:巢狀 JSON 怎麼傳?

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 是完全相同的設計哲學:給一個合法的出口,它就不會亂編。

我現在會把這件事當成一條通則:每當你發現模型在編東西,先問「它有沒有一個表達『沒有』的合法方式」。

Supervisor 的第一版(最小可用)

新建 workflow main:

Gmail Trigger ──▶ Set(整理欄位)──▶ AI Agent(Supervisor)──▶ Code(輸出檢查)
                                           │
                        ┌──────────────────┼──────────────────┐
                   Chat Model         Structured Output    Call n8n
                     (大)              Parser         Workflow Tool
                                                          (→ W1)

Supervisor 的 System Prompt(今日最小版,明天會大改)

你是「好豆選物」客服部門的主管。你不直接處理客戶問題,
你的工作是判斷客戶來信的需求,並派工給合適的專員。

## 你的團隊
- order_lookup(查單專員):能從訂單系統查詢訂單狀態、物流、到貨時間、金額

## 你的工作流程
1. 閱讀客戶來信,判斷客戶想知道什麼。
2. 如果需要查詢訂單資料,呼叫 order_lookup 工具派工。
3. 收到專員回報後,整理成結構化的結果輸出。

## 派工規則
- 派工時,question 要寫清楚「要查什麼」,不要寫「怎麼查」。
  ✅ 「查出訂單 A10293 的狀態與預計到貨時間」
  ❌ 「請使用 get_order_by_id 工具,輸入 A10293,然後看 status 欄位」
- order_id 只填客戶明確提到的編號。客戶沒提到就填空字串。
- need 填你需要專員回報的欄位。

## 重要限制
- 你自己沒有查詢訂單的能力。不要假裝你知道訂單狀態。
- 你不寫給客戶看的回信。你的輸出是給系統用的結構化資料。
- 每封信最多派工 2 次。

最後那三條「重要限制」是關鍵,明天會展開講。

Structured Output Parser 的 schema

{
  "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 平行。

今日小結

  • 把 Worker 掛成工具有兩種方式,Call n8n Workflow Tool + 定義輸入欄位是主線做法。
  • 巢狀 TaskEnvelope 的傳遞用「攤平 → 重組」:對外扁平、對內結構化,清理在入口做一次。
  • 不要用單一 payload JSON 字串欄位傳參數,失敗率高且無法觀察。
  • $fromAI() 的描述要寫清楚:來源、格式、空值怎麼填。第三項最重要。
  • 通則:每當模型在編東西,先問「它有沒有一個表達『沒有』的合法方式」。
  • 第一版 Supervisor 立刻暴露三個問題:自己寫回信、自己加事實、該派工時不派工。

明天整篇處理這三個問題 —— Supervisor 的 System Prompt 怎麼寫,才能讓它不瞎指揮、不搶工作。這是整個系列我改最多次的一份 prompt。


上一篇
Day 15|Worker 獨立測試:每人 20 題考試與評估表
系列文
從單一 Agent 到 Supervisor 架構:用 n8n 實作會自己分工的 Multi-Agent AI 部門 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言