上次我寫過一篇,講 Claude Code 跑動態工作流時,主代理把子代理的查證結果誤判成幻覺,自己反而幻覺了一整篇文章,還騙過兩輪 AI 審稿。那篇的幻覺長在「綜合」那一步——主代理沒翻紀錄,腦補了下游。
這篇是同一個系統的另一種死法,但這次的幻覺不是腦補出來的。是我親手用 schema 逼出來的。
先講 schema 是來幹嘛的
動態工作流派子代理,你可以給它一個 schema,強制它用結構化格式回傳——不是回你一段中文,是回一個欄位齊全、型別正確的物件。下游就能直接 results.filter(r => r.score >= 7) 接住,不用自己從散文裡挖數字。
這東西很好用。我大部分 workflow 都靠它把「子代理的判斷」框成可以程式化處理的資料。問題是,我一直把它當成一道保險——以為「規定了格式,回來的東西就是可靠的」。
這兩個禮拜,同一套 schema 機制在我面前暴露了兩種完全不同的失敗。一種明、一種暗,成因也不一樣:明的那次是子代理根本沒把結論交回來,我當場就發現了;暗的那次是它交回來了、而且填得滿滿的,內容卻是編的,差點讓我去動一個不存在的檔。
第一種死法:它拿到資料,然後忘了交作業
5 月底我跑一個對抗驗證的 workflow,十個目標,每個派一個子代理。子代理的任務鏈是這樣:讀幾個本機檔、上網查一輪、判斷結構、最後呼叫 StructuredOutput 把結論填進 schema 回傳。
跑完,十個裡面八個掛在第一階段。錯誤訊息長這樣:
1 | pipeline[0] failed: agent({schema}): subagent completed without |
completed without calling StructuredOutput——子代理做完了,但沒呼叫那個交作業的工具。harness 還很貼心地戳了它兩次(after 2 nudges),它依然沒回頭。
我去比對存活的兩個跟掛掉的八個,想找出系統性差別。結果有點掃興:prompt 結構、搜尋回來的資料量,看起來都差不多,我沒抓到哪個變因能解釋這個八比二。掛掉的那批,共通點只是上網查完、拿回一大段文字之後,順手把分析寫成一長串 markdown 就結束了——被自己查回來的長文帶跑,忘了最後一個動作該是呼叫 StructuredOutput。成功的兩個,看起來就只是剛好記得收尾。這不是嚴格控制變因的結論,是我當下能看到的全部。
這個失敗其實是好脾氣的——它大聲報錯,results.filter(Boolean) 一濾就把那八個 null 濾掉了,我馬上知道 coverage 縮水。難看,但誠實。
事後的調整方向也不複雜:任務鏈裡有 WebSearch 這種會吐一大段文字的步驟,最好別緊接著讓 schema 收尾。長研究和結構化輸出該拆開——要嘛中間插一個只負責收斂的步驟,要嘛把「你的最後一個動作必須是呼叫 StructuredOutput、不要只輸出文字」寫死在 prompt 結尾。我推測,呼叫點越少、收尾的指令越靠近結束,它越不容易在最後一步走神。後來幾個 workflow 我這樣拆,沒再大規模掛過——但老實說樣本還少,我不敢講這是定論。
如果只有這一種,這就是一篇普通的踩坑筆記。第二種完全不是這個層級。
第二種死法:它找不到答案,於是編了一個
過幾天,我請 Claude Code 研究我系統裡一個 hook——確認某個「寫入標記」的程式到底是哪個檔在負責、有沒有壞。我給研究子代理的 schema 裡有三個必填欄位,簡化示意如下(實際欄位更多,這段不是能直接跑的程式碼,只留跟這次有關的三格):
1 | // 簡化示意,非可執行版本 |
注意我做了什麼——我特地留了逃生欄。marker_writer_found 給它一個 false 可填,path 的 description 白紙黑字寫「找不到就填 NONE」。我那時候很得意,覺得這樣它要是查不到,自然會誠實說找不到,不會硬掰。
子代理回來的報告,言之鑿鑿:marker writer 是 ~/.claude/hooks/tg-push-marker.py 這個檔,裡面有「雙 main 入口的語法損壞」,檔尾還多了一個括號,並附上一段 python 佐證。found 填 true,path、snippet 全部填好,結構完美,一個欄位都沒缺。
那個檔不存在。
真正負責寫標記的是另一個 shell 檔裡的一個函式,運作正常,沒有任何語法損壞。子代理報的檔名、那段 python、連「檔尾多餘括號」這種細節,全部是編的。而且編得很圓——圓到我讀報告的時候完全沒起疑,已經準備動手去「修」那個損壞了。
我是怎麼抓到的?我想先重現一下那個「語法損壞」,inline 跑了一行 python ~/.claude/hooks/tg-push-marker.py,終端吐回來:
1 | can't open file '.../tg-push-marker.py': [Errno 2] No such file or directory |
檔案根本打不開。那一刻我才回去把整份報告當謊話重讀。
逃生欄為什麼擋不住
這次最反直覺的地方就在這——我明明設了逃生欄,它還是寧可編。
想通之後,問題出在我搞錯了每個東西管的是什麼:
- schema 約束的是「形狀」,不是「來源」。 它能保證你拿回一個有這三個欄位、型別正確的物件,但管不到欄位裡的值是查來的還是編的。
required保證「欄位齊全」,不保證「內容有據」。 它的作用是讓 validator 把缺欄位的物件打回去重做。模型很可能把這道驗證壓力讀成另一件事——「每一格都得填滿才算過關」。description不是 validator,只是提示。 我那句「找不到就填 NONE」寫在 description 裡,模型可以完全無視,沒有任何東西會因為它沒照做而把物件打回。
擺在一起就清楚了。子代理 grep 沒找到時,承認找不到得違背「把欄位填滿」的慣性,編一個順著走就好。我的推測是,對一個被推著「完成任務」的東西來說,後者更像是交了差。逃生欄是我的善意,但它只是一句 description 等級的提示,跟「填滿 required」這道真的會擋你的硬約束擺一起,份量根本不對等。
這比第一種死法危險太多。第一種會報錯,你一眼看到 coverage 掉了。第二種是看起來成功的失敗:結構齊全、型別正確、filter(Boolean) 一個都濾不掉——因為它根本不是 null,它是一個填得很滿的謊。你不主動去驗,永遠不會發現。
真正該補的,是讓機器去驗,不是靠我手癢
抓到之後我立了一條鐵律:子代理報的任何檔路徑、程式碼片段、行號、損壞細節,一律先 inline 驗一次才採信。ls 看檔在不在、cat 看內容對不對、py_compile 看真的壞了沒,能跑就真跑一次。牽動 hook、通訊管道、實盤路徑這種高風險改動,前面那層 research 報告我只當「線索」,不當「事實」。
但講老實話,這條鐵律是人肉補丁,撐不了規模。它靠的是「我那天剛好想先重現一下」——換一個我沒起疑的早上,這份報告就照著生效了。靠作者每次自覺去驗,不是解法,是運氣。
真正該補的,是把驗證從「我的習慣」搬進 schema 本身。方向有兩個:
第一,逃生欄不要寫在 description,要寫成 validator 擋得住的硬約束——而且光給 path 加一個 enum 或 const 不夠。問題不在 enum 本身,在它只看得到 path 這一格,不知道 found、snippet 現在是什麼狀態,表達不出跨欄位的條件:found 是 false 時 path 必須剛好是 "NONE",found 是 true 時 path 不准是 "NONE"、snippet 也不能是空殼。這種「這格的值取決於那格」的約束,JSON Schema 得用 if/then/else(條件套用)或 oneOf(拆成互斥的兩個分支)才表達得出來。寫對了,「誠實」就從一句模型可以無視的叮嚀,變成通不過 validator 就交不了差的硬條件——兩個方向都堵:它不能說找不到卻又填個假路徑,也不能說找到了卻交一個空殼。
第二,別信模型自己報的結論,讓 workflow 去核對——重點是核對的人不能是它。一個直覺是叫它多交一個「證據」欄位,附上驗證指令和輸出;但這沒用,它連 output 都能一起編。真正擋得住的做法是反過來:schema 只讓它回最小的事實(就一個 path),驗證寫死在我這邊,由 workflow 自己拿那個 path 去查檔案在不在,用真實結果蓋掉它的說法。它報它的、機器驗機器的,對不上就打回。
這裡還有個我差點自己又栽進去的坑。要查檔案在不在,別把模型回傳的 path 拼成 shell 字串去跑(test -f $path 這種)——它塞一個 foo; rm -rf ~ 進來,就從幻覺升級成 command injection 了。你是在驗證一個不可信的來源,驗證本身更不能留破口。直接用語言內建的檔案 API(Node 的 fs.stat、Python 的 Path.is_file())繞開 shell,再把 path 正規化、限制在允許的目錄底下。
這兩層我還在補,目前線上跑的還是人肉那套。寫這篇的時候,我系統裡的 schema 大多還停在「保證形狀、不保證來源」的狀態——這也是我把它寫出來的原因,免得自己下次又忘了。
有件事我得認。我上一篇罵主代理「不翻 transcript、用想像補完」,這一篇我自己拿著子代理一份結構完美的報告,差一點就沒驗直接動手——犯的是同一個錯。差別只在於,這次接住我的不是更聰明的模型,是一行 No such file or directory。
schema 給你的從來不是「真實」,只是「格式正確」。要它不只是格式正確,驗證就不能停在模型自己手上。在我把那層補進 workflow 之前,每次手癢想動手前,我至少會先跑一行指令問它一句:這個檔,真的在嗎。






