先週、無人で回している定期タスクの週次消費が思ったより伸びていたので、内訳を確かめようとしました。ところが手元のログには「どの枠が、いつ、どのモデルに切り替わったか」がどこにも残っていませんでした。残っていたのは各タスクの成否と成果物だけです。
個人開発で4つのサイトを自動運用していると、枠ごとにモデルを変えるのは自然な設計になります。整形や置換のような定型作業は軽いモデル、記事の書き直しのような判断の要る作業は重いモデル、という具合です。ただ、その振り分けが「そのつもり」で終わっていて、実際にどう動いたかを一度も記録していませんでした。
Claude Code v2.1.251 で PreModelSwitch と PostModelSwitch という2つのフックイベントが追加されています。これを使って、記録する仕組みと、約束していない切り替えを止める仕組みを別々に作ります。分けて作るところに理由があるので、そこから書きます。
週次の消費は、切り替えの記録がないと事後に説明できません
Claude Code の週次上限はモデル横断の共有プールです。ステータスラインの JSON を見ると rate_limits.seven_day のほかに seven_day_opus・seven_day_sonnet という内枠があり、ひとつのプールの中にモデル別の枠が同居している構造が読み取れます。
つまり、ある枠が想定より重いモデルで走った分だけ、別の枠の余地が削られます。そして 9月14日からは、この週次上限がプロモーション前の水準に対して +25% で恒久化されます。現在の +50% と比べれば水準は下がるので、配分を測り直しておく価値があります。
測り直すには記録が要ります。事後に /usage で総量を見ても、「どの作業がどのモデルを食ったか」までは分解できません。切り替えが起きた瞬間にしか取れない情報があるので、その瞬間に書き出す仕組みを先に用意します。
2つのフックが受け取るもの
PreModelSwitch は切り替えの直前、PostModelSwitch はセッションのモデルが変わった後に発火します。他のフックが tool_name などを受け取るのに対して、この2つは切り替えそのものを説明するフィールドを受け取ります。
フィールド 型 意味
from_modelstring 切り替え前のモデル
to_modelstring 切り替え先のモデル
requested_modelstring | null 明示的に要求されたモデル。自動的な切り替えでは null になります
source"command" | "picker" | "sdk" 切り替えの経路。/model コマンド、対話的なピッカー、SDK 経由の3種です
context_tokensnumber その時点のコンテキストのトークン数
prompt_cache_warmboolean プロンプトキャッシュが温まっているか
cache_ttl"5m" | "1h" キャッシュの有効期間
estimated_cache_write_usdnumber キャッシュ書き込みの推定費用(USD)
pricing"configured" | "catalog" | "default" 費用の推定に使った価格表の出所
これに加えて、他のフックと共通の session_id・transcript_path・cwd が入ります。PostModelSwitch は同じフィールドを受け取り、source の取り得る値が2つ増えます。
source があるおかげで、人が画面の前でモデルを選んだ切り替えと、無人実行の中で起きた切り替えを区別できます。この区別が、あとで歯止めを作るときの分かれ目になります。
記録は PostModelSwitch へ、判断だけを PreModelSwitch へ
ここが最初に決めておくべき設計です。私は最初、記録も判断も PreModelSwitch にまとめて書こうとしました。切り替えの直前なら前後の状態が両方見えるので、都合が良さそうに思えたからです。
これは避けたほうがよい書き方でした。PreModelSwitch は、フックがタイムアウトで打ち切られたときに、モデルの切り替え自体をブロックします。多くのフックイベントはタイムアウトしても素通りしますが、このイベントは止まる側に倒れます。
つまり、記録処理が重くて時間内に終わらなかっただけで、切り替えが起きなくなります。ログを取りたかっただけなのに、モデルの振り分けが機能しなくなるという、原因の見えにくい壊れ方をします。無人実行では、これが起きても誰も画面を見ていません。
だから役割を分けます。
イベント 役割 置いてよい処理
PreModelSwitch止めるかどうかの判断だけ ローカルファイルの読み取り、文字列比較。ネットワークアクセスや重い集計は置かない
PostModelSwitch記録だけ ファイル追記、外部への送信。ここが遅くても切り替えは既に終わっています
止める側が速く、記録する側が自由、という非対称な構成になります。フックの出力量そのものにも気を配る必要があり、成功時に何も出さない設計については検証スクリプトの出力を、そのまま hook から返してはいけません で書いています。
歯止めを作る
無人実行のときだけ、許可していないモデルへの切り替えを止めます。対話中は人が画面を見ているので、止める対象から外します。
まず許可一覧を1ファイル置きます。プロジェクトの .claude/model-allowlist.txt に、無人実行で使ってよいモデル ID を1行ずつ書きます。
claude-haiku-4-5-20251001
claude-sonnet-5
フック本体です。標準入力から JSON を受け取り、条件に合わないときだけ終了コード 2 で終わります。多くのフックイベントで、終了コード 2 がコードだけで処理をブロックできる唯一の値です。
#!/usr/bin/env bash
# .claude/hooks/model-switch-guard.sh
# PreModelSwitch: 判断だけを行います。重い処理はここに書きません。
set -uo pipefail
INPUT = $( cat )
ALLOW_FILE = "${ CLAUDE_PROJECT_DIR :- $PWD }/.claude/model-allowlist.txt"
# 無人実行(source=sdk)以外は素通りさせます。
# 対話中の切り替えは人が意図して行ったものです。
[[ " $INPUT " == * '"source":"sdk"' * || " $INPUT " == * '"source": "sdk"' * ]] || exit 0
# to_model を取り出します。パラメータ展開だけで済ませ、外部プロセスを起こしません。
TO = "${ INPUT #* \" to_model \" : }"
TO = "${ TO #* \" }"
TO = "${ TO %% \" * }"
# 一覧が無い環境では止めません(設定漏れで作業を止めないため)。
[ -f " $ALLOW_FILE " ] || exit 0
grep -qxF " $TO " " $ALLOW_FILE " && exit 0
echo "無人実行で許可されていないモデルへの切り替えです: ${ TO }" >&2
echo "許可一覧: ${ ALLOW_FILE }" >&2
exit 2
サンプル入力を流して挙動を確かめます。3通りの入力に対する結果です。
$ echo '{"to_model":"claude-opus-5","source":"sdk"}' | ./model-switch-guard.sh
無人実行で許可されていないモデルへの切り替えです: claude-opus-5
許可一覧: /tmp/hooktest/.claude/model-allowlist.txt
exit = 2
$ echo '{"to_model":"claude-sonnet-5","source":"sdk"}' | ./model-switch-guard.sh
exit = 0
$ echo '{"to_model":"claude-opus-5","source":"picker"}' | ./model-switch-guard.sh
exit = 0
3番目が通ることが大事です。ピッカーから選んだ切り替えまで止めてしまうと、手元で試したいときに邪魔になります。
JSON の取り出しに Python を使わなかった理由
最初は python3 に JSON を渡して to_model と source を取り出していました。読みやすさでは明らかにそちらが上です。実際に手元の環境で20回ずつ回して測ったところ、こうなりました。
実装 1回あたりの所要時間
Python でパースする版 29 ms
パラメータ展開だけの版 7 ms
差の大半はインタプリタの起動時間です。29 ms でもタイムアウトには遠く、この数字だけを見れば読みやすいほうを選ぶのが自然です。私が速い側にしたのは、PreModelSwitch が「遅い=止まる」という性質を持っているからでした。余裕のある側に倒しておくと、後から重い判定を足したくなったときに使える幅が残ります。
なお、これは私の環境での値です。手元の数字は各自で測ってください。判断の材料になるのは絶対値ではなく、Python 起動が固定費として乗るという構造のほうです。
記録を作る
PostModelSwitch 側は、受け取った内容を JSONL に1行ずつ足すだけにします。ここは遅くても切り替えに影響しないので、読みやすさを優先します。
#!/usr/bin/env bash
# .claude/hooks/model-switch-log.sh
# PostModelSwitch: 記録だけを行います。
set -uo pipefail
LOG_DIR = "${ CLAUDE_PROJECT_DIR :- $PWD }/.claude/logs"
mkdir -p " $LOG_DIR "
export LOG = "${ LOG_DIR }/model-switch-$( TZ = Asia/Tokyo date +%Y-%m).jsonl"
python3 -c '
import json, sys, os, datetime
d = json.load(sys.stdin)
row = {
"at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"session": d.get("session_id"),
"from": d.get("from_model"),
"to": d.get("to_model"),
"requested": d.get("requested_model"),
"source": d.get("source"),
"context_tokens": d.get("context_tokens"),
"cache_warm": d.get("prompt_cache_warm"),
"cache_ttl": d.get("cache_ttl"),
"cache_write_usd": d.get("estimated_cache_write_usd"),
"pricing": d.get("pricing"),
}
with open(os.environ["LOG"], "a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
' 2>> "${ LOG_DIR }/model-switch-error.log"
# 記録に失敗しても、この時点では切り替えは終わっています。
# 終了コードで騒がず、エラーはログに落とすだけにします。
exit 0
最後の exit 0 を明示しているのには理由があります。PostModelSwitch で終了コード 2 を返すと、その内容が「フック名 hook error」としてトランスクリプトに描画されます。記録の失敗を切り替えの失敗のように見せる必要はありません。
追記された行はこうなります。
{ "at" : "2026-09-02T03:08:32+00:00" , "session" : "s1" , "from" : "claude-haiku-4-5-20251001" , "to" : "claude-sonnet-5" , "requested" : "claude-sonnet-5" , "source" : "sdk" , "context_tokens" : 48210 , "cache_warm" : true , "cache_ttl" : "5m" , "cache_write_usd" : 0.9042 , "pricing" : "catalog" }
月ごとにファイルを分けているのは、集計の単位を月に合わせたかったからです。週次上限を見るなら週ごとに切っても構いません。
settings.json に登録する
プロジェクトの .claude/settings.json に2つのイベントを登録します。
{
"hooks" : {
"PreModelSwitch" : [
{
"hooks" : [
{
"type" : "command" ,
"command" : "$CLAUDE_PROJECT_DIR/.claude/hooks/model-switch-guard.sh" ,
"timeout" : 5
}
]
}
],
"PostModelSwitch" : [
{
"hooks" : [
{
"type" : "command" ,
"command" : "$CLAUDE_PROJECT_DIR/.claude/hooks/model-switch-log.sh" ,
"timeout" : 15
}
]
}
]
}
}
matcher を省略すると、すべての切り替えで発火します。特定のモデルにだけ反応させたい場合、PreModelSwitch では to_model から導かれる正規化された名前に対して matcher が評価されます。最初は matcher なしで全件を記録し、何が起きているかを見てから絞るほうが順序としては安全です。
タイムアウトを 5 秒と 15 秒に分けているのは、前節の役割分担をそのまま設定に写したものです。判断側は短く、記録側は長くします。
登録後、フックが読み込まれているかは /hooks で確認できます。反応しないときの切り分けはClaude Code Hooks の本番デバッグ術 — ログが足りない時、どこから切り分けるか にまとめています。フック全体の設計判断はClaude Code Hooks — 中核8フックの設計判断と、33イベントに増えた今の歩き方 を参照してください。
集計して、切り替えの値段を見る
JSONL が溜まったら集計します。見たいのは回数だけではありません。経路別の内訳と、往復している組み合わせです。
#!/usr/bin/env python3
"""記録した切り替えを、経路と往復で集計します。"""
import json, sys, collections
rows = []
for path in sys.argv[ 1 :]:
with open (path, encoding = "utf-8" ) as f:
rows += [json.loads(l) for l in f if l.strip()]
by_source = collections.Counter()
cost_by_source = collections.defaultdict( float )
pairs = collections.Counter()
for r in rows:
s = r.get( "source" ) or "unknown"
by_source[s] += 1
cost_by_source[s] += r.get( "cache_write_usd" ) or 0.0
pairs[(r.get( "from" ), r.get( "to" ))] += 1
total = sum (cost_by_source.values())
print ( f "切り替え { len (rows) } 回 / 推定キャッシュ書き込み合計 $ { total :.4f } " )
for s, n in by_source.most_common():
print ( f " source= { s :<8 } { n :>3 } 回 $ { cost_by_source[s] :.4f } " )
# A→B と B→A が両方ある組は、設計を疑う対象です。
seen = set ()
for (a, b), n in pairs.items():
if (b, a) in pairs and (b, a) not in seen:
seen.add((a, b))
print ( f " 往復: { a } <-> { b } { n } + { pairs[(b,a)] } 回" )
サンプル3件を入れて動かすと、このように出ます。
切り替え 3 回 / 推定キャッシュ書き込み合計 $1.2981
source=sdk 2回 $1.1473
source=command 1回 $0.1508
往復: claude-haiku-4-5-20251001 <-> claude-sonnet-5 2+1回
往復を別立てで出しているのには意図があります。同じセッションの中で軽いモデルと重いモデルを行き来している場合、それは工程の切り方が細かすぎるか、あるいは切り替え条件が競合しているかのどちらかである可能性が高いためです。回数だけを眺めていても、この形は目に入ってきません。
切り替えに付いてくる、キャッシュ書き込みの費用
estimated_cache_write_usd がフックの入力に含まれていることには、意味があります。
モデルを切り替えると、プロンプトキャッシュのプレフィックスが変わります。切り替え前に温まっていたキャッシュは使えなくなり、次のターンはキャッシュ書き込みから始まります。prompt_cache_warm が true の状態から切り替えるほど、捨てているものが大きいことになります。
ここで見落としやすいのが、9月1日に入った価格改定との関係です。Claude Fable 5.1 で下がったのはキャッシュ読み取りの単価だけで、$1.00 相当から 75% 引き下げられて $0.25 per MTok になりました。入力 $10 / 出力 $50 per MTok は据え置きで、キャッシュ書き込みの側も下がっていません。
項目 変化 切り替えの多い運用への影響
入力・出力の単価 据え置き 変化なし
キャッシュ読み取り 75% 引き下げ キャッシュが温まったまま長く使う運用ほど恩恵が大きい
キャッシュ書き込み 据え置き 切り替えのたびに発生するため、恩恵が相殺されていく
Anthropic は2026年8月の実使用4週間分から、典型的なワークロードでおよそ25%減、コンテキストとツールの重いワークロードで最大およそ45%減という指数を出しています。ただしこれは、キャッシュ読み取りが総コストに占める比率が高い前提での数字です。切り替えが多く、そのたびにキャッシュを捨てている運用は、この比率が低い側に位置します。
だから見出しの「25%安くなる」を自分に当てはめる前に、まず自分が何回切り替えているかを見る必要がありました。私の場合、無人実行の枠を細かく分けていたことが、そのまま切り替え回数として効いていました。TTL を使い分ける設計そのものについては別に整理する価値がありますが、少なくとも「切り替えの回数は費用の話でもある」という視点は、記録を取り始めるまで持てていませんでした。
まとめ
導入の順序は次の4段階を勧めます。
.claude/hooks/model-switch-log.sh を置き、PostModelSwitch にだけ登録します。この時点では何も止まりません
1週間走らせ、switch-report.py で経路別の内訳と往復を見ます
記録に出てきたモデルのうち、無人実行で使ってよいものだけを .claude/model-allowlist.txt に書きます
model-switch-guard.sh を PreModelSwitch に登録します。タイムアウトは短く設定します
まず 1 と 2 だけを実行してみてください。歯止めは後からで構いません。何を止めるべきかは、記録を見てからでないと決められないからです。
私はこの順序を好みます。個人開発の自動運用では、先に方針を決めて縛るより、実際に起きたことを見てから線を引くほうが外しにくいと感じているためです。私自身、許可一覧に何を書くかは記録を2週間見てから決め直すつもりでいます。