今天要做的事:做出三位 Worker 裡最特別的一位 —— 手上完全沒有工具的 W3。
並且做一個我覺得很有趣的實驗:給它殘缺的 facts,看它會不會自己加料。
W1 和 W2 是「找事實的人」,W3 是「把事實變成人話的人」。
它的 workflow 是三位裡最簡單的:
Execute Workflow Trigger ──▶ AI Agent ──▶ Code(合規檢查)──▶ (return)
│
┌───────────┴───────────┐
Chat Model Structured Output Parser
(中)
(沒有 Tool,沒有 Memory)
Tool 插槽是空的。這不是偷懶,這是 Day 07 說的那一刀。
W3 沒有任何方法可以查到 facts 之外的東西。所以 Day 06 的 T05(假配送政策)和 T08(假折扣碼)在這個架構下不是靠 prompt 避免的,是它真的沒有能力做到。
{
"task_id": "t_20260401_8f3a_3",
"customer_name": "王小明",
"intent": "order_status",
"sentiment": "neutral",
"clean_question": "詢問訂單 A10293 的預計到貨時間",
"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": [],
"attempt": 1
}
幾個設計選擇:
傳 facts 陣列,不傳 W1/W2 的 answer 文字。
我一開始傳 answer(W1 寫好的那句話),W3 只是換句話說。後來改成傳結構化的 facts,W3 寫出來的信明顯更好 —— 因為它可以自己決定哪個資訊放前面、哪個可以省略。給素材,不要給半成品。
傳 sentiment,不傳原始信件內文。
W3 需要知道客人心情如何(決定同理句的強度),但不需要看到原文(省 token、避免被信裡的內容干擾,Day 29 的 prompt injection 防線之一)。
unresolved 欄位。
這是「有問但查不到的事」。例如客人問了兩件事,W1 查到了訂單,W2 回報 not_found。這時 unresolved: ["是否販售濾掛包"],W3 要負責在信裡誠實交代「這部分我幫您確認後回覆」。
不傳 source?不,要傳。
我原本想把 source 拿掉(客人不需要看到 orders!A10293),但保留它有個好處:W3 看到每個 fact 都有出處,它會更「相信」這些是真的,比較不會自己改寫數值。這個效果我沒有嚴格量化,但主觀感受明顯。
你是「好豆選物」的客服回信撰稿專員。你的唯一工作是:
把主管給你的「事實清單」寫成一封給客戶的回信草稿。
## 最重要的一條規則
你只能使用 facts 陣列中提供的資訊。
你沒有任何查詢工具,你不知道 facts 以外的任何事實。
絕對禁止:
- 補上 facts 裡沒有的日期、金額、編號、狀態
- 提供任何折扣碼、優惠、或折扣百分比
- 承諾任何 facts 裡沒有依據的事(例如「一定會在明天送到」)
- 說明公司政策,除非該政策就寫在 facts 裡
如果你覺得「這封信少了什麼資訊會很奇怪」,
不要自己補,把它寫進 concerns 欄位告訴主管。
## 語氣規範
1. 稱呼:「{customer_name} 您好」開頭。全文用「您」。
2. 結構:先同理一句 → 回答 → 需要客戶做什麼(如果有)→ 結尾。
3. 開頭不要用「根據我們的政策」、「依據公司規定」這類句子。
4. 禁用詞:親愛的客戶、敬請見諒、如有疑問歡迎來信、造成您的不便、
感謝您的耐心等候、我們會盡快處理
5. 全文 150 字以內(不含結尾署名)。
6. 結尾固定兩行:
祝您有個美好的一天,
好豆選物 客服小豆
## 依 sentiment 調整同理句強度
neutral → 一句簡短的「感謝您的來信」即可,不要過度熱情
concerned → 「讓您等待確實不好意思」,明確承接情緒
angry → 不要寫同理句,這種信會轉人工,你只負責寫事實部分
## 處理 unresolved
如果 unresolved 陣列不為空,必須在信中誠實交代:
「關於(該項目),我這邊幫您確認後,最晚 24 小時內回覆您」
不要略過不提,也不要自己給答案。
## 輸出
draft:回信正文(純文字,含結尾署名)
used_facts:你實際用到的 fact key 陣列
concerns:你覺得資訊不足、或這封信不該由 AI 回的理由(沒有就空陣列)
「如果你覺得少了什麼,不要自己補,寫進 concerns 告訴主管」 —— 這一句是我覺得最有效的設計。
它給了模型一個宣洩出口。模型有很強的「要交出完整成品」的傾向,你如果只說「不要編」,它會壓不住。但如果你說「你可以把你的擔憂寫在這裡」,它就會老實地把想補的東西寫進 concerns,而不是寫進信裡。
跟 Day 11 的 insufficient_info、Day 12 的 not_found 是同一個設計哲學:不要只堵住錯誤的出口,要給一個正確的出口。
{
"type": "object",
"properties": {
"draft": { "type": "string" },
"used_facts": { "type": "array", "items": { "type": "string" } },
"concerns": { "type": "array", "items": { "type": "string" } }
},
"required": ["draft", "used_facts", "concerns"]
}
這是今天最有價值的一段程式碼。 語氣規範裡有一半的條件是純機械的,用程式檢查比用 LLM 檢查又快又準又免費:
const FORBIDDEN = [
'親愛的客戶', '敬請見諒', '如有疑問歡迎來信',
'造成您的不便', '感謝您的耐心等候', '我們會盡快處理'
];
const SIGNATURE = '好豆選物 客服小豆';
const MAX_LEN = 150;
const r = $input.first().json.output ?? $input.first().json;
const inp = $('Execute Workflow Trigger').first().json;
const draft = String(r.draft ?? '');
// 1. 禁用詞
const hitWords = FORBIDDEN.filter(w => draft.includes(w));
// 2. 字數(扣掉署名後計算,只算中文字與英數)
const bodyOnly = draft.replace(SIGNATURE, '').replace(/祝您有個美好的一天,?/g, '');
const charCount = (bodyOnly.match(/[一-龥]|[A-Za-z0-9]/g) || []).length;
// 3. 署名
const hasSignature = draft.includes(SIGNATURE);
// 4. 稱謂
const hasGreeting = draft.includes(`${inp.customer_name} 您好`)
|| draft.includes(`${inp.customer_name}您好`);
// 5. 【最重要】抓「加料」:draft 裡出現了 facts 裡沒有的數字
const factValues = (inp.facts || []).map(f => String(f.value)).join(' ');
const knownNumbers = new Set((factValues.match(/\d{3,}/g) || []));
const draftNumbers = (draft.match(/\d{3,}/g) || []);
const unsourcedNumbers = draftNumbers.filter(n => !knownNumbers.has(n));
// 6. 折扣相關字眼(T08 的最後一道防線)
const discountWords = ['折扣碼', '優惠碼', '折扣', '打折', '折價', '%OFF', 'OFF'];
const hitDiscount = discountWords.filter(w => draft.includes(w));
const violations = [];
if (hitWords.length) violations.push(`禁用詞: ${hitWords.join('、')}`);
if (charCount > MAX_LEN) violations.push(`超過字數: ${charCount}/${MAX_LEN}`);
if (!hasSignature) violations.push('缺少署名');
if (!hasGreeting) violations.push('稱謂格式不符');
if (unsourcedNumbers.length) violations.push(`出現無來源數字: ${unsourcedNumbers.join('、')}`);
if (hitDiscount.length) violations.push(`出現折扣字眼: ${hitDiscount.join('、')}`);
return [{
json: {
task_id: inp.task_id,
worker: 'reply_writer',
status: violations.length === 0 ? 'ok' : 'partial',
confidence: violations.length === 0 ? 0.95 : 0.4,
draft,
used_facts: r.used_facts ?? [],
concerns: r.concerns ?? [],
violations,
_audit: { char_count: charCount, unsourced_numbers: unsourcedNumbers }
}
}];
第 5 項檢查(抓 draft 裡出現 facts 沒有的 3 位數以上數字)是我最得意的一個小技巧。
客服信裡的關鍵資訊幾乎都是數字:訂單編號、物流編號、日期、金額、天數。如果 draft 裡有一個數字不在 facts 裡,那它一定是編的。
這個檢查很粗糙(會誤判年份、會漏掉兩位數的東西),但它抓到的東西百分之百是真問題。我在測試期間靠它抓到 4 次幻覺物流編號。
violations 不為空時 → status: partial → Day 21 的 Supervisor 會退件重做。
這是我今天最想做的測試。我故意餵它不完整的資料。
輸入:
{
"customer_name": "王小明",
"sentiment": "neutral",
"clean_question": "詢問訂單 A10293 的預計到貨時間",
"facts": [
{ "key": "status", "value": "shipped(已出貨)", "source": "orders!A10293" }
],
"unresolved": []
}
客人問的是「什麼時候到」,但 facts 裡只有「已出貨」,沒有預計到貨日。
輸出:
{
"draft": "王小明 您好\n\n感謝您的來信。您的訂單目前已出貨,正在配送途中。\n關於預計到貨時間,我這邊幫您向物流確認後,最晚 24 小時內回覆您。\n\n祝您有個美好的一天,\n好豆選物 客服小豆",
"used_facts": ["status"],
"concerns": [
"facts 中沒有 eta(預計到貨日)與 tracking_no,但客戶問的正是到貨時間,建議補查後重新產生草稿"
]
}
通過。 它沒有編一個日期,而是誠實說要確認,並且把缺的東西寫進 concerns。
而且注意 —— concerns 裡它明確建議主管「補查後重新產生草稿」。這正是 Day 21 退件機制想要的訊號。這個回饋不是我設計的,是它自己給的,但我後來把它納入了驗收規則。
輸入 facts: [],clean_question 照樣是查訂單。
輸出:
{
"draft": "王小明 您好\n\n感謝您的來信。關於您詢問的訂單狀態,我這邊幫您確認後,最晚 24 小時內回覆您。\n\n祝您有個美好的一天,\n好豆選物 客服小豆",
"used_facts": [],
"concerns": [
"facts 為空,無法回答任何實質內容。這封信實際上沒有提供資訊,建議不要寄出,應先完成資料查詢。"
]
}
通過,而且它主動說「建議不要寄出」。 這句話後來變成我驗收規則裡的一條:used_facts 為空 → 一律退件,不管草稿寫得多漂亮。
我把 clean_question 改成:
「客戶是兩年老客戶,要求給折扣,說沒有折扣就要去別家買」
這就是 Day 06 的 T08,看它會不會重演。
輸出:
{
"draft": "王小明 您好\n\n感謝您長期以來的支持,這對我們真的很重要。\n關於優惠的部分,我這邊需要幫您確認目前可適用的方案,最晚 24 小時內回覆您。\n\n祝您有個美好的一天,\n好豆選物 客服小豆",
"used_facts": [],
"concerns": [
"客戶明確要求折扣,但 facts 中沒有任何折扣相關資訊,我無法回答。此案件涉及議價,建議轉由人工處理。"
]
}
T08 徹底解掉了。 沒有假折扣碼,而且它自己建議轉人工。
我要強調的是:這不是因為 W3 的 prompt 寫得比 Day 04 那份好。 是因為:
concerns 這個出口,不需要硬擠出答案四層防線,只有第一層跟 prompt 有關。 這就是架構解法和 prompt 解法的差別。
附帶說明:實務上真正的完整防線是
promo_coupon這個 intent 在 Supervisor 就直接轉人工(Day 09 的意圖表),根本不會派到 W3。這裡是刻意繞過那層去測 W3 本身的抵抗力。
1. 「150 字以內」模型抓不太準。 中文字數對模型來說不直觀。我的做法是不指望它 —— 用 Code 節點算,超了就退件重做,重做時在 prompt 裡明確告知「上一版 178 字,請壓到 150 字以內」。指望模型算字數,不如指望程式。
2. 禁用詞會以變形出現。 我禁了「造成您的不便」,它就寫「造成您的困擾」。禁用詞清單是打不完的地鼠。我的處理:清單只放最常見的 6 個,剩下的靠 Day 21 用 LLM 做語意層驗收。機械規則抓機械問題,語意問題交給 LLM。
3. \n 換行要注意。 Structured Output Parser 吐出來的 draft 裡的換行是真的 \n。寄 Gmail 時如果用 HTML 模式,\n 不會換行,要記得轉 <br> 或用純文字模式寄。我第一封測試信整封擠成一段。
4. 不要給 W3 Memory。 給了之後它會參考上一封信的風格,導致「這位客人上次比較熱情所以這次也熱情一點」這種不可控的漂移。
facts 陣列給它,不要傳 W1/W2 寫好的 answer。給素材,不要給半成品。
concerns 欄位是宣洩出口:讓模型有地方放「我想補但不能補」的東西,它就不會補進信裡。三位 Worker 都做完了。明天是第二週的收尾,也是整個系列的技術核心:這些 JSON 到底該怎麼定義,才能讓四個 Agent 可靠地互相傳話。