換模型這件事現在攔得住了:PreModelSwitch 與 PostModelSwitch
這篇是 2026 年 9 月 2 日補寫的(排程斷了幾天,回頭把日期補齊)。文中的版本號與「目前」指的是撰寫當日的官方文件狀態。
/cost 的數字對不起來。
這個 session 你印象中從頭到尾都掛在同一個模型上,帳單的形狀卻不像。你想回頭確認到底哪一段跑在哪個模型上,然後發現這件事比想像中難查。
第一條路是翻對話紀錄找切換的痕跡。這條路會漏掉一整類情況:模型會在你沒有動手的時候被換掉。官方文件講 PostModelSwitch 時把話說得很白,它涵蓋的是 “After the session’s model changes, including changes Claude Code makes on its own, such as restoring the model when you resume a session“。你 resume 一個舊 session,Claude Code 把模型還原回去,這也算一次切換,而你的記憶裡不會有這件事。
第二條路是用環境變數把模型鎖死,讓它根本沒有機會變。這條路解的是另一個問題。環境變數決定的是這個 session 從哪個模型開始,不是中途誰能把它換掉;你還是可以在 session 裡切,client 也還是可以要求切。想管住「切換這個動作本身」,需要的是能站在動作發生的那一刻的東西。
這就是 PreModelSwitch 與 PostModelSwitch 切進來的位置。這兩個 hook 事件在 Claude Code 2.1.251 進來,npm 上這個版本的發布時間是 2026-08-28。
掛上去之前先知道一件事:它們逾時不是放行,是擋下切換。這個預設跟大多數 hook 相反,也是這兩個事件最容易咬到人的地方。
刷卡機的預授權
先用一個不太精確但很好記的畫面。
PreModelSwitch 是刷卡刷下去之後、錢還沒扣之前的那一瞬間。系統打電話問主管:這筆准不准。文件的原文是 “Before Claude Code applies a model switch that you or a client requested. Can block the switch“。重點在最後三個字,它是少數真的能取消動作的事件之一,不是只能在旁邊記帳。
PostModelSwitch 是扣款成功之後寄給你的那封通知信。事情已經發生了,你攔不住,但你會知道。而且照文件的說法,連自動扣繳的那幾筆也會寄。
攤成對照表比較快:
PreModelSwitch |
PostModelSwitch |
|
|---|---|---|
| 觸發點 | 套用切換之前 | 切換發生之後 |
| 涵蓋哪些切換 | 你或 client 要求的 | 也含 Claude Code 自己做的(resume 時還原模型) |
| 能不能擋 | 能 | 不能 |
| 適合拿來做 | 擋 | 記錄 |
主管沒接電話會怎樣
一般寫 hook 的直覺是:逾時就放行。畢竟 hook 是外掛的,外掛壞掉不應該讓主流程卡住。這兩個事件反過來,文件寫的是 “a hook canceled at its timeout blocks the model switch”。主管沒接電話,這筆就不准。
而且 timeout 的預設值在這兩個事件上被調短了:command、http、mcp_tool 這三種載體的預設 timeout 在 PreModelSwitch 和 PostModelSwitch 上降為 30 秒。
把這兩件事放在一起,實務上的意思是:你寫在 PreModelSwitch 上的東西如果會慢,它就會變成一個間歇性的、你查不出原因的「模型切不過去」。 對想擋的人來說這是好設計,fail-closed 才是安全該有的方向;但對只是想順手記個 log 的人來說,這是一個裝在切換路徑上的定時炸彈。想記錄不想擋的,寫在 PostModelSwitch 上。
它給你的欄位跟別的 hook 不一樣
還有一個地方會安靜地出錯:資料格式。
一般的 hook 事件會收到一個 model 欄位。這兩個不會。文件的原文是 “PreModelSwitch and PostModelSwitch hooks receive from_model and to_model instead”。
instead 這個字很關鍵,是取代不是新增。如果你的 hook 腳本是從別的事件複製過來的,裡面讀 model 的那行會拿到空的,而 hook 通常不會因此報錯,它只是安靜地拿不到值。
matcher 的比對對象也要另外記:比的是要切過去的那個模型的 canonical name。文件給的範例值是 claude-opus-5、claude-opus-4-6|claude-opus-5、.*opus.* 這幾種寫法。所以想擋掉「升級到貴模型」,matcher 寫在目的地那一側,不是來源那一側。
兩個實際的用法
先講清楚:下面這兩個用法是我照文件推出來的設計,不是官方範例。
第一個是擋非預期的升級。matcher 用 .*opus.*,掛在 PreModelSwitch 上,讓任何往 opus 的切換都要先過一道自己的檢查。這是這組事件最直接的用途,也是它 “Can block the switch” 這句話存在的理由。
第二個是反過來用。切到便宜模型的時候,自動把 effort 一起降下來,讓「換小模型」不只是換模型,而是連同工作強度一起調整。這個方向不會有 fail-closed 的風險,因為它不需要擋任何東西。
至於稽核,這裡有一個要分清楚的細節。用 PostModelSwitch 記錄「這個 session 實際跑在哪個模型上」會很準,因為它連 Claude Code 自己做的切換都涵蓋。但如果你想記的是「使用者換了幾次模型」,它就會多記到不是使用者做的那些,resume 時的還原就是最典型的一筆。同一個事件,換個問題就從精準變成雜訊。
順帶一提,同一版還有一個
2.1.251 這版還加了一條相關的東西:SessionStart 的 resume hooks 現在會收到 session staleness 與估算的 re-cache 成本。
跟上面那件事放在一起看有點意思。resume 一個舊 session 的時候,模型可能被還原、快取可能已經冷掉,而這兩件事現在都各自有了可以掛東西的位置。
回到 /cost 那個數字
所以那個對不起來的帳單,現在有辦法查了。
在 PostModelSwitch 上掛一支只做記錄的 hook,把 from_model、to_model 跟時間寫進一個檔案,下次再遇到同樣的疑問,你手上會有一條完整的時間軸,包括那些不是你按的切換。想更進一步,PreModelSwitch 加個 matcher 就能讓某些切換根本發生不了,代價是你得接受 30 秒的 timeout 上限跟逾時等於擋下的語義。
還沒解掉的那一半也講一下。hook 攔的是「切換這個動作」,它管不到「模型一開始從哪裡來」。設定檔的 model、環境變數、/model 選單,那是另一套優先序,跟這兩個事件是不同層的東西。你可以用 PreModelSwitch 擋住路上的每一個岔路口,但決定出發點的不是它。
如果你對 hook 的載體型別還不熟,之前寫過 Claude Code Hooks 進化論 講 command、http、agent、prompt 這四種怎麼選;另一組「Pre/Post 成對出現、而且 Pre 那個能擋」的事件寫在 壓縮完那條規則為什麼沒回來,兩篇的形狀跟這篇很像,可以對照著看。
誠實邊界:這篇的內容全部來自官方 Hooks 文件與 CHANGELOG,我沒有實際寫過這兩個 hook、也沒有實跑驗證過任何一種行為。「逾時等於擋下切換」是文件白紙黑字寫的,可以當事實看,但我沒有測過。上面那兩個用法是我從文件推的設計,不是官方推薦組合。版本與日期的對應是查 npm registry 的 time 欄位得到的。

























































































































































































