補寫於 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 也還是可以要求切。想管住「切換這個動作本身」,需要的是能站在動作發生的那一刻的東西。

這就是 PreModelSwitchPostModelSwitch 切進來的位置。這兩個 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 的預設值在這兩個事件上被調短了:commandhttpmcp_tool 這三種載體的預設 timeout 在 PreModelSwitchPostModelSwitch 上降為 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。文件給的範例值是這幾種寫法:

1
2
3
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_modelto_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 欄位得到的。

參考來源Claude Code Hooks 官方文件CHANGELOG