替 Agent 新增一個工具,例如「抓取某個平台的公開貼文」,通常不難。它上線後穩穩跑了兩週;某天你把函式的 limit 預設值從 25 改成 20,並替 sort enum 加了一個新值。程式改了、測試全綠、PR 也合併了。
三天後,正式環境開始偶爾報錯。模型用了一個你上週已刪除的 enum 值呼叫工具,執行期驗證拒絕了它,stack trace 還指向 dispatch layer。你盯著 dispatch 程式碼看了半小時,卻找不到任何一行有問題。因為真正的錯不在那裡:你改了函式簽名,卻沒有同步更新模型讀到的工具描述。模型手上仍是舊 schema,自然會依照舊結構產生呼叫,而新舊結構已經對不上。
這就是工具描述失真(tool-description drift)。它是 Agent 工程中最常見、也最難定位的一類 bug;難追的原因很具體:錯誤發生的位置和根因所在的位置不同。錯誤浮現在執行層,根因卻躺在一個沒人會主動打開的 JSON 檔裡。這篇要談的不是「記得保持同步」,而是從結構上移除 bug 的來源:不要再留下第二份會逐漸失真的副本。
工具描述為什麼會失同步
拆開來看,失真的根本原因只有一個:你同時維護了兩個真相來源。
第一個是實際執行的程式碼:函式簽名、參數驗證、預設值、enum 限制。這是硬規則,錯了就會立即明顯失敗。
第二個則是模型讀到的工具描述:name、description 與 parameters JSON schema。它比較軟,寫錯時不會立刻爆炸;模型只是先產生一個錯誤呼叫,最後才在執行層失敗。
只要還得靠人手讓兩者一致,失真就只是遲早的事。你可能改了程式碼裡的參數卻忘記改描述,也可能更新描述卻漏了程式碼;甚至兩邊都改了,語意仍未必一致。這些問題不會在修改當下現形,而是等模型剛好產生一個碰到差異的呼叫才爆發。到了那時,你早已忘記兩週前改過什麼。解法只有一個方向:把兩份副本變成一份。
唯一真相來源:宣告就是介面
觀念要先轉過來:你其實不需要另外維護工具描述 JSON。
函式的 parser 宣告,加上 docstring,本來就已經包含工具描述所需的一切。以下是一個普通的 argparse 宣告:
subreddit = commands.add_parser("subreddit", help="Query a public board's feed")
subreddit.add_argument("subreddit")
subreddit.add_argument(
"--sort",
choices=("hot", "new", "top", "rising", "controversial"),
default="hot",
)
subreddit.add_argument("--limit", type=int, default=25)
help 就是命令的一行說明;choices 是 enum 限制;default 是預設值;type 定義參數型別,而位置參數則是必填欄位。模型呼叫工具需要知道的資訊全在這裡:命令做什麼、接受哪些參數、哪些必填、enum 有哪些值、預設值是什麼。更重要的是,這份宣告同時也是執行期用來解析與驗證的定義,因此它不可能偏離執行邏輯——它本身就是執行邏輯。
所以,別再寫第二份工具描述。正確的做法是視那份文件為不存在:只有程式碼;需要工具描述時,再從程式碼投影產生。資料流只能單向由程式碼流向描述,絕不能反過來。
從宣告自動產生完整能力目錄
一旦接受「宣告即介面」,工具描述就不該手寫,而應由一個衍生器統一產生。
衍生器做的事情很機械化:它走訪每個平台 context、匯入其 parser,並將 argparse action 清單讀成三個不可變結構:Platform、Command 與 Parameter。每個 Parameter 都帶有名稱、型別、是否必填、enum 值、預設值與 help 文字。這是一個完全由程式碼衍生出的介面 read-model。
有了這個 read-model,所有輸出格式都只是下游產物。describe --format json 可以輸出完整的機器可讀介面,供 Agent 選擇工具;render_skill() 則產生人或模型都能讀懂的能力目錄。目錄中的命令數量不是人工填寫的常數,而是即時計算的 sum(len(platform.commands))。目前共有 22 個平台 context、241 個命令,其中沒有一個是手動輸入到目錄裡的。
這帶來一個很舒服的特性。新增平台,只要新增平台 context,目錄便會自動收錄它的所有命令;修改參數,只需編輯 parser 宣告,目錄中對應的 enum 與預設值也會自行更新。你不再遇到「新命令寫好了但忘記註冊」,或「參數改了但目錄還是舊的」這類問題,因為根本不存在「註冊」這個動作。目錄是計算出來的,不是人工維護的。
(這種「衍生而非維護」的直覺,和使用集合運算判斷跨語言遷移實際完成了哪些項目是一樣的,詳見跨語言遷移文章。)
用 CI 在提交時抓出失真
衍生機制解決了「新命令會自動進入目錄」,但還有一個缺口:有人改了 parser 宣告,忘了重新執行衍生器,也沒有提交重新產生的目錄。repo 裡那份檔案又會變舊,失真便從側門溜了回來。
最後一道關卡要放在 CI,核心只需要一條斷言:
docs-check:
$(PYTHON) -c 'from pathlib import Path; from reverse.catalog import render_skill; \
path = Path("skill/SKILL.md"); \
assert path.read_text(encoding="utf-8") == render_skill(), \
"skill/SKILL.md is out of sync with the code; run make docs"'
它會將 repo 中已提交的目錄,與由目前程式碼重新產生的目錄逐位元組比較。只要差一個字元,CI 就會失敗,並直接提示目錄已過期,請執行 make docs。
這一行的價值,在於它改變了失真被發現的時機。以前,失真是執行期的幽靈:兩週後它在正式環境爆炸,stack trace 還指向錯的地方。現在,它是提交時的一個紅色 X。你會在 pull request 被攔下來,錯誤明確告訴你目錄過期,重新產生即可修好。失真從「最難追的 bug」降級成只要一個指令就能消除的編譯錯誤。這就是介面即程式碼的完整閉環:宣告是來源,能力目錄是建置產物,CI 是型別檢查。你不會手寫建置產物,也不會容許它與來源不一致;工具描述同樣應該如此對待。
哪些能力該寫死,哪些交給模型決策
衍生與 CI 能確保介面描述正確,但在此之前還有一個更早的判斷:某項能力應寫成固定程式碼,還是交由模型即時編排?這個分層若判斷錯了,再精確的介面也救不了你。
可以把能力分成三層來看。
底層 primitive 負責讀取一種資料,或執行一個明確動作。它的輸入穩定、輸出結構化,而且能單獨測試。這一層完全是程式碼,不消耗任何推理;241 個命令中絕大多數都屬於這裡。
確定性的 workflow,則是在單一平台內部、順序高度固定的流程,具有共享狀態與清楚的成功條件。例如創意流程 creative-pipeline 會依序執行:尋找機會、接著 Top Ads、再來 creator matching、創意 brief,最後是 generation preflight。步驟順序與相依關係都已固定。這一層也應凍結成程式碼,因為既然順序早已確定,每次都讓模型重新規劃,只會更慢也更不穩定。標記方式只要一行:替命令加上 set_defaults(_command_level="workflow")。程式碼庫中只有這一行,因此目錄可以把 workflows 與 primitives 分成兩個層級呈現。
Agent orchestration 則處理跨平台研究、即時權衡,以及失敗後的重新路由。這才是應交給模型的一層,因為下一步該查什麼取決於上一輪查到了什麼,無法預先寫死。
判斷標準其實很清楚:能力若需要穩定的階段狀態、共享 context 或 generation side effects,就凍結在程式碼裡;若涉及 query expansion、跨平台驗證,或失敗後的重新路由,就交給模型。兩種錯誤都有代價。把研究假設硬編碼到 client 是過度凍結,平台一變又得回頭改程式;把固定順序交給模型每次重新組裝則是凍結不足,省下一次模型決策,卻買來一堆不穩定性。
讓模型根據六種階段狀態決定降級策略
要讓 orchestration 層做出判斷,底層回傳的結果必須讓模型看得懂。模糊的成功/失敗 boolean 不夠;若只給模型 success: false,它能做的只有猜下一步。
因此,workflow 的每個階段都回傳階段狀態,而非 boolean。共有六種:completed、empty、ready、skipped、unavailable 與 blocked。真正有資訊量的,是那些沒有繼續執行的狀態之間的差異:
skipped代表操作人員刻意關閉此步驟,例如將某條收集路徑的上限設為 0。這不是錯誤,模型不應重試。unavailable代表此步驟依賴的某項資源暫時不可用,例如介面報錯或 session 缺失。模型可以跳過它繼續執行,或要求取得新的 session 後再回來處理。blocked則代表前置條件尚未滿足,例如研究證據為空,或 preflight 失敗。模型不該硬推進到下一步,而應回頭補足證據。
以創意流程為例,它會分別判定「platform preflight ready」與「research evidence ready」,最後以 ready = platform_ready and research_ready 整合。任一項失敗時,generation 階段就會以 blocked 回傳,並透過 blockers 清單說明卡在哪裡;當所有商業搜尋結果皆為空時,它根本不會提交 generation job。
為什麼這是為模型設計?讀到 seedance_generation: blocked 加上 blockers: [research_evidence_empty] 的 orchestration 模型,知道該回頭取得證據,而不是重試提交。讀到 organic_discovery: skipped,它知道這是使用者意圖,不是故障,因此應保持不動。讀到標為 unavailable 的步驟,它知道可以繞過該步驟降級處理。只要區分「刻意關閉」、「暫時不可用」與「前置條件未滿足」,模型就能選對降級路徑。若把三者全壓成 false,再強的模型也只會原地打轉。
不同層級,該用不同模型
上述堆疊對模型的要求在每一層都很不同。(四階段逆向工程文章曾在逆向工程情境列出同一張四層表;這裡則把它放到 Agent stack。)依層級分配模型,才能避免浪費能力:
Agent stack 中的工作 | 所需能力 | 選擇 | model id |
|---|---|---|---|
將 241 個命令的 | 長 context,一次讀完整個目錄 | Kimi K3 |
|
Orchestration:讀取階段狀態與 blockers,決定降級、重新路由或繼續執行 | 強推理能力,能依狀態做正確判斷 | Claude Opus 5 |
|
從 docstrings 批次產生模型友善的工具描述文字 | 成本低,可高併發執行數百次呼叫 | Claude Sonnet 5 |
|
工具呼叫錯誤歸因:讀取錯誤與宣告,判斷是失真還是上游變更 | 中等推理能力,可針對特定欄位說明 | GPT-5.6 Sol |
|
最值得細談的是 orchestration 層。閱讀 blocked 與 skipped 後決定下一步,是這個流程中唯一會因切換模型而明顯改變結果的環節;它考驗的正是模型能否依一段狀態資訊做出正確判斷。較弱的模型會把 skipped 當成失敗而重試,或看到 blocked 還是硬送出請求。強推理模型則會讀取 blockers,精準地重新路由。這和指紋辨識文章中反證段落是否真的在反駁自身的差距相同:誰都能提出候選結果,難的是做出判斷。
不必只相信這個差異,直接測試即可:
從自己某個 workflow 取一份真實回傳結果,包含它的
stages與blockers;或自行構造一份blocked且帶有blockers: [research_evidence_empty]的回應。將這份回應、能力目錄(
describejson),以及要求決定下一個動作的指令,分別餵給claude-opus-5與gpt-5.6-sol。只看一件事:模型提出的下一步,是否能正確區分
blocked(回頭補證據)、skipped(使用者意圖,不處理)與unavailable(取得 session 或繞過降級),還是會把skipped當成失敗來重試?正確降級路徑的比例,就是你的選型標準。它決定 Agent 面對真實故障時會原地打轉,還是能自行繞道處理。
真正的阻力在於切換成本
這四個模型來自三家供應商,而 function calling 的切換成本尤其高。OpenAI 的 tools / tool_calls 與 Anthropic 的 tool_use / tool_result 是兩套不同格式。若想在 orchestration 層換上一個判斷力更好的模型,往往得重寫整個工具 dispatch 與錯誤解析路徑。這才是多數人最後把單一模型鎖在 orchestration 層的真正原因,即使那個模型常常誤讀階段狀態也是如此。
AIReiter 把這一層抽掉了。一把 key、一個 OpenAI-compatible 介面,背後支援四個模型;切換時只要改 request body 裡的 model 欄位。
# Orchestration decision: hand the reasoning tier the catalog plus one blocked workflow response, ask for the next action
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<describe json> + <stages/blockers response> + decide the next action"}]
}'
# Generate tool-description text in bulk: change the model field, leave the rest
# "model": "claude-sonnet-5"
# Error attribution:
# "model": "gpt-5.6-sol"
原生 function calling 只需加上一個 tools array,而 OpenAI tool protocol 可原封不動通過這個介面,因此切換模型仍只是一個欄位的修改。若已經使用 OpenAI SDK,只要把 base_url 指向 https://aireiter.com/api/v1,其他都不用改。使用 Anthropic SDK 時,則以同一把 key 呼叫 POST /api/v1/messages。
價格方面,Claude 模型較定價低 30%,GPT 模型半價,Kimi K3 也能使用同一把 key 存取。對這個 stack 而言,折扣正好落在最關鍵的地方。orchestration 層每向前推進一步,就多一次 reasoning-tier 呼叫,因此它是整個 Agent 中呼叫最頻繁、成本也最高的一層,Claude 折扣正好套用在這裡。從 241 份 docstrings 批次產生工具描述,則是高併發 Sonnet 工作,同樣享有折扣。這兩項佔了大部分成本;用於錯誤歸因的 GPT-5.6 呼叫則少得多。
免註冊試用:手動跑幾輪,將同一份
blocked回應餵給兩個模型,親自確認哪一個能正確降級,再把它接進 orchestration 層。
結語
工具描述失真不是靠「記得同步」就能治好的問題。那種做法只是把結構性缺陷壓縮成個人自律問題。真正的解法,是移除雙來源結構:parser 宣告加上 docstring 是唯一來源,能力目錄是從中衍生的建置產物,一條 CI 斷言則扮演型別檢查。失真也就從執行期幽靈,變成提交時的紅色 X。
不過,衍生機制只能保證描述正確,無法保證分層正確。哪些能力要凍結為程式碼、哪些留給模型編排,以及讓模型理解「該重試還是該降級」的六種階段狀態,才是決定 Agent 能否自主運行的兩個關鍵。在這個 stack 裡,模型有兩項具體工作:於 orchestration 層做取捨,以及工具呼叫失敗時歸因根因。能力是否該凍結、該走哪條降級路徑,取決於你設計的階段狀態與寫下的 CI,而不是交給模型決定。
這和以集合為基礎的遷移核對,以及不建立統一 response Model兩篇文章採取的是同一種立場:AI 能壓縮單一步驟所需時間,但最後的判定仍必須受限於你硬編碼的約束。等整個流程順暢運作後,唯一剩下的摩擦就是模型切換;那是基礎設施問題,而統一介面正能解決它。