更好的模型,更糟的工具
原文由 Armin Ronacher 于 發布,訂閱此部落格
過去兩天,一個非常奇怪的 Pi issue 讓我一頭栽進了兔子洞。簡單來說,較新的 Claude 模型有時會在呼叫 Pi 的編輯工具時,於巢狀的 edits[] 陣列中加入額外、憑空捏造的欄位。而且不是 Haiku 或什麼小模型:是 Opus 4.8。編輯內容本身通常是對的,但參數不符合 schema,因為模型會發明不存在的鍵,Pi 因此拒絕這次工具呼叫並要求重試。
光是這樣其實不算太意外,模型本來就偶爾會產生格式錯誤的工具呼叫,尤其是小模型。讓我意外的是,這個問題在較新的 Anthropic 模型上反而越來越嚴重——Opus 4.8 和 Sonnet 5 都會出現,但舊一點的模型都不會。換句話說,這個家族中最頂尖的 SOTA 模型,在這個特定的工具 schema 上反而比它們的舊版兄弟還要差。
如果你好奇 Fable 的情況:我刻意沒有測試它,因為不確定他們在背後跑的分類器會不會默默把我降級成 Opus。
工具呼叫就是文字
如果你沒有花太多時間研究 LLM 工具呼叫的內部機制,最重要的一點是:工具呼叫並沒有什麼魔法,用的只是相當粗糙的帶內信令(in-band signalling)。模型會收到一份對話紀錄、一個系統提示,以及一份可用工具的清單。伺服器把這些材料拼成一個帶有特殊標記 token 的大型提示。由於模型曾在這種格式的範例上接受訓練與強化學習,它在生成過程中的某個時刻,就會吐出一段被 API 或客戶端解讀為「用這些參數呼叫這個工具」的內容。
以檔案編輯工具為例,預期的呼叫內容大概會長這樣:
{
"path": "some/file.py",
"edits": [
{
"oldText": "text to replace",
"newText": "replacement text"
}
]
}接著 harness 會驗證參數、執行編輯,再把結果回饋給模型。如果驗證失敗,模型會看到錯誤訊息,通常會再試一次。
Anthropic 模型的格式化究竟是怎麼做的,外界並不清楚,但有些人已經挖出了「ANTML」標記,而且它們有時也會洩漏到公開的對話中。就我所知,上面那個呼叫從模型端序列化出來大概會長這樣:
<antml:function_calls>
<antml:invoke name="edit">
<antml:parameter name="path">some/file.py</antml:parameter>
<antml:parameter name="edits">
[
{
"oldText": "text to replace",
"newText": "replacement text"
}
]
</antml:parameter>
</antml:invoke>
</antml:function_calls>這裡有兩點值得注意。首先,這東西雖然看起來像 XML,但其實不是 XML,只是他們覺得方便做詞元化與訓練的一種格式。另一點是,基本的頂層字串參數會直接以行內形式出現,而物件陣列則是透過 JSON 序列化來實作。雖然我不完全確定實際上就是這樣運作,但有跡象顯示應該相去不遠。這點稍後會變得很重要。
要讓模型產生這樣的結構,有兩種截然不同的做法:
- 你可以要求模型產生符合 schema 的有效 JSON,然後事後再驗證。
- 你可以限制採樣器,讓無效的 JSON、甚至不符合 schema 的結構根本就採樣不出來。
第二種做法就是大家常說的語法感知(grammar-aware)或受限解碼(constrained decoding)。採樣器會遮蔽掉那些會違反語法的 token。如果模型目前正在一個 JSON 物件內,而 schema 規定只允許 oldText 和 newText,採樣器就能阻止它吐出 "in_file" 或 "type"。語法感知解碼既可以用來限制輸出在語法上是有效的 JSON,也可以用來強制特定的枚舉值或鍵名。
如果沒有任何形式的限制,模型只是在遵循學來的慣例而已。
故障情形
Pi 的編輯工具支援在一次呼叫中進行多組精確的字串替換。這就是為什麼參數裡會有一個 edits 陣列。在失敗的案例中,模型產生的項目會長這樣:
{
"oldText": "...",
"newText": "...",
"requireUnique": true
}或是這樣:
{
"oldText": "...",
"newText": "...",
"oldText2": "",
"newText2": ""
}在反覆試驗中,我看到一整群憑空捏造的尾隨鍵名:type、id、kind、unique、requireUnique、matchCase、in_file、forceMatchCount、children、notes、cost、oldText2、newText2、oldText_2、newText_2,甚至還有出現在編輯物件內部的 event.0.additionalProperties 鍵。
最惱人的是,在我檢查過的無效呼叫中,實際的 oldText 和 newText 內容在位元組層級上完全正確。模型其實已經產生了正確的呼叫,只是在物件結尾又多加了一堆無意義的東西。
這個故障也高度依賴上下文。像「編輯這個檔案」這種全新、單輪的提示,我完全無法重現。但如果是已經讀過檔案、診斷過問題、再組合出多行編輯的代理式(agentic)歷史紀錄,就能重現。更惱人的是,並非所有對話紀錄都會出現這種行為。事實上,我是靠 Petr Baudis 的對話紀錄才終於重現這個問題!在那位使用者的 session 中,接續對話會讓 Opus 4.8 大約有 20% 的機率失敗。把歷史紀錄中的思考區塊(thinking blocks)移除,失敗率會減半。開啟嚴格的工具呼叫(strict tool invocation)在我的測試中則完全消除了這個問題。
為何情況越來越糟
我最強烈的假設是,這不是隨機的退化,而是訓練過程產生的產物。
較舊的 Anthropic 模型在訓練時,雖然也有接觸一些工具(其中部分是有文件的),但那時的訓練還沒有把像 Claude Code 這樣實際出貨給使用者的 harness 當作明確的目標。現代的 Anthropic 模型很可能不同之處在於,它們的後訓練包含了 Claude Code 或一個非常相似的 harness。模型會學到在那個環境中,成功的工具呼叫長什麼樣子,同時也會學到那個環境會容忍哪些錯誤。
Claude Code 自己的工具相對扁平。一般的編輯工具並不是 Pi 那種巢狀的 edits[] 結構,而是更接近 file_path、old_string、new_string,加上一個可選的旗標(replace_all)。觀察 Claude Code 的客戶端很有啟發性:它包含了針對錯誤工具呼叫的重試路徑、參數別名、型別轉換、Unicode 修復,以及對未知鍵的過濾。換句話說,Anthropic 自家的客戶端似乎預期並接受相當程度的寬鬆輸入,並大多在背後默默修復掉。
如果強化學習是在那樣的 harness 中、或是其模擬環境中進行,那麼略微畸形的工具呼叫仍然可以完成任務並獲得獎勵。harness 完全吸收了錯誤,使得模型幾乎不會因為發明別名、多加一個欄位或使用相近的參數名稱而受到梯度上的懲罰。
更糟的是,模型可能會非常強烈地適應 Claude Code 標準的編輯工具形狀。不同的 harness 可能會提供一個語意相同但 schema 不同的工具,這樣的工具會越來越偏離訓練分佈。訓練得越好的模型,反而可能越難配合你,因為它的先驗更強。
這不算太令人意外,但和幾個月前的情況相比已經有所改變。Opus 4.5 推出時,對其他編輯工具的適應力非常好。事實上,我當時還相當確信我們走在正確的道路上——只要指令寫得好,模型就更有可能適應各種工具形狀。
現在我對我們正在走的這條路線有點擔心。替代的工具 schema 可能不只是陌生而已,它們可能會被針對某個特定、寬容的工具生態系所優化的後訓練給隱性地懲罰。而那個生態系是沒有文件的。雖然有一個有文件的 text editor tool,你會發現 Claude Code 其實並沒有遵循那個格式。Claude Code 內部在做什麼(它是一個閉源的 harness)對你是完全不透明的。
寬鬆的 Harness
Claude Code 顯然是閉源的,但我們還是可以看看經過壓縮的程式碼,大致了解它在做什麼。老實說,它對輸入資料非常寬容。
首先,Claude Code 會檢查模型可見文字中是否有洩漏的 <invoke 標記。它在發生這種情況時還會發送一些遙測資料,然後透過自己的狀態機把錯誤推回給模型來重試這類糟糕的呼叫。
它還有明確的 Unicode 跳脫修復,會修正字串值中損壞的 \uXXXX 序列和孤立的代理字元(lone surrogates)。它也為每個工具提供了參數別名。舉例來說,Edit 接受 old_str(大概是來自模型在官方文件的 text editor tool 上訓練的時期)、schema 中較新的 old_string、new_str/new_string,以及作為 file_path 別名的 path,還有更多。
它還會默默過濾掉非預期的鍵,而且也沒有使用 strict 模式。strict 模式的問題在於,Anthropic 會對工具定義施加複雜度限制,導致 API 請求失敗,所以推測這就是 Claude Code 沒有嘗試使用它的原因。
嚴格性
這個問題也會出現在其他 harness 上嗎?Anthropic 的一大問題是,模型完全是封閉的,harness 也是。Codex 模型同樣是封閉的,但至少 harness 不是。我們還有 gpt-oss,至少有點值得參考。這些模型被明確訓練成使用 OpenAI 的 harmony 回應格式,而且有大量文件至少能讓我們了解 OpenAI 的人是怎麼思考這個問題的。
Harmony 把頻道(channels)和工具呼叫的內容類型納為提示格式的一部分。一個函式呼叫看起來會像這樣:
<|start|>assistant<|channel|>commentary to=functions.get_weather
<|constrain|>json<|message|>{"location":"San Francisco"}<|call|>重點是 <|constrain|>json。模型可以在帶內表明這個訊息主體是 JSON,而推論堆疊就可以利用這個邊界,切換到針對工具呼叫主體的 JSON 受限採樣。推測 Anthropic 的模型中也有類似的機制,至少在 strict 模式下我想應該是有的。
harmony 中的標記能幫助採樣器偵測何時需要以特定語法進行採樣,而因為它是對話紀錄的一部分,這件事就變得相當容易。對於託管的 GPT 模型,還有一個選項可以為需要遵循這類規範的自訂工具提供 LARK 語法。
Anthropic 看起來和這個做法不同,雖然也許不完全如此。如果物件陣列真的是以 JSON 來表示,看起來確實如此,那麼模型就必須在工具參數內部撰寫 JSON。很可能有基本的語法受限採樣在作用,而這可能部分解釋了那些多餘的鍵。對於巢狀陣列參數,那個 JSON 在字串字面量中包含了經過跳脫的多行檔案內容,全部包在一個標籤內。那些非預期、憑空捏造的鍵,恰好出現在這個任務中熵最高的點:也就是在關閉一個長達數百個 token、經過跳脫的 newText 字串之後,模型必須決定要接 } 還是 , "..." 的時刻。
Opus 4.8 和 Sonnet 5 似乎對於編輯工具呼叫應該長什麼樣子有著強得多的先驗,而那個先驗看起來就是 Claude Code 的編輯 schema:扁平的 old/new 字串配對,加上可選的 replace_all 旗標。我的猜測是,Opus 已經學到編輯操作可能會有一個額外的可選欄位,但在 Pi 的巢狀 oldText/newText 結構下,它並沒有學過那個欄位的名稱。所以它每次都會重新採樣一個看似合理的名稱,這就是為什麼失敗時會產生數十種隨機鍵名,而不是一個固定的別名。
由於 Anthropic 的 strict 模式似乎可以修復這個問題,我推測在伺服器端,他們會拒絕採樣 JSON schema 結構中不允許的鍵。這也能解釋為什麼在啟用 strict 模式時,他們會對工具定義的複雜度設限。
到目前為止,我測試過的 Codex 模型都沒有出現這類退化。我測試了所有可用的版本,除了 5.6,那個我目前還沒有存取權限。
這對 Harness 意味著什麼
令人不安的教訓是,工具 schema 並非中立,至少在 Anthropic 模型上不是。我們喜歡假裝 schema 是一份抽象的契約,而模型是一個會遵守它的通用推理者,但對某些工具來說,情況可能已經不再如此。
工具 schema 本身就落在分佈的某個位置,有些形狀接近模型在後訓練中看過的,有些則相距甚遠。有些對提供者隱藏的編碼方式來說很容易(例如 ANTML 中的頂層屬性),而有些則要求模型在長串多行字串之後,於巢狀陣列中撰寫大型的跳脫 JSON 物件。模型可能聰明到足以理解 schema,卻仍然在壓力下無法精確地採樣出要求的形狀。
如果這類模型行為持續下去,我不禁思考這對 harness 會有什麼影響。顯然,人們可以在 Anthropic 上開啟 strict 採樣,問題應該就會消失。另一方面,模型會出現這種行為,本身就顯示了強化學習對它們的影響。如果你想獲得最佳的模型效能,對抗那個先驗恐怕是徒勞的。
現實是,Claude Code 並非開源,我們也無法真正知道他們在 RL 環境中做了什麼。除非你的工具與之非常相似,否則不能假設在 Claude Code 上訓練出來的行為能夠乾淨地遷移到你的工具上。越多後訓練發生在某一個主導的 harness 內,其他所有 harness 就越必須繼承它的怪癖。
我過去對嚴格的語法受限工具呼叫比較抱持懷疑,因為受限解碼可能會帶來品質上的取捨。我現在仍然認為整體而言可能是如此,但這個 bug 讓我的先驗大幅改變了。如果最新的模型在解決任務上越來越強,卻在忠實地產生替代工具 schema 上越來越差,那麼 harness 就必須在某個環節提供更強的保證。
如果你想了解更多,或想討論這個問題,歡迎參考 Pi tracker 上的 issue。
隨機一篇部落格
留言
登入後參與討論