⚡ Claude Codeが遅いのはモデルのせいだと思ってた|犯人のhookをp95 4821→680msに

bokuwalily
bokuwalily

@bokuwalily ・ 90本

サムネイル

Claude Codeを使い込んでいると、hookという機能がとても便利だと気づきます。ファイルを編集するたびに自動でフォーマットをかけたり、TypeScriptのコンパイルエラーをその場で検知したり、セッションが終わるたびにAPIの使用コストを記録したりと、hookは「Claudeが作業をするたびに自動で走る小さなプログラム」です。一度セットアップしてしまえば、あとは勝手にやってくれる——そのはずなのですが、ある日気づいたのです。「なんか最近、Claudeの応答が遅くない?」と。

調べてみると、原因はhookでした。ファイルを編集するたびにTypeScriptの型チェック(tsc)が全ファイルに対して走っていて、それだけで数秒かかっていたのです。しかし困ったことに、Claude Codeの公式UIにはhookの実行時間を確認する画面がありません。「遅い気がする」という感覚はあっても、「どのhookが何ミリ秒かかっているか」を数字で見る方法が、そのままでは存在しないのです。

そこでLilyが作ったのが、`hook-latency-wrap.sh` と `hook-latency-report.sh` の2本のスクリプトです。前者は任意のhookをラップして実行時間を記録し、後者がそのデータを集計してp95(95パーセンタイル)の遅延が1500ミリ秒を超えているhookに警告を出します。仕組みはbashの組み込み変数ひとつだけ——外部ツールも追加パッケージも必要ありません。この記事では、この計測システムをゼロから再現できるように、コードの読み方から `settings.json` の差し替え方、実際の集計結果の見方まで順番に解説します。

hookの基本と「ブロッキング」問題

Claude Codeのhookとは何か

Claude Codeのhookとは、特定のタイミングで自動的に実行されるシェルコマンドやスクリプトのことです。設定は `settings.json` の `hooks` キーに書き、`PostToolUse`(ツール使用後)や `Stop`(Claudeが応答を止める前)などのタイミングを指定します。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "command": "~/.claude/scripts/hooks/post-edit-typecheck.js"
      }
    ]
  }
}

たとえば上記の設定では、Claudeがファイルを編集する(Editツールを使う)たびに `post-edit-typecheck.js` が実行されます。このスクリプトは編集されたファイルを検知し、TypeScriptプロジェクトなら `tsc --noEmit` を走らせて型エラーを即座にClaudeに伝えます。

レスポンスがブロックされる仕組み

hookが「ブロッキング」と呼ばれる所以は、Claudeの次の行動がhookの完了を待つからです。つまりhookが3秒かかれば、Claudeの応答も3秒遅れます。ひとつの編集作業で複数のファイルを触れば、その分だけhookが何度も実行されます。10ファイル編集 × 3秒 = 30秒の遅延、ということも理論上は起こりえます。

この仕組みを理解すると、hookは「便利だが重さを持つもの」だとわかります。何も考えずに追加し続けると、ある日突然Claudeの応答が体感でわかるほど重くなります。しかしどのhookが原因なのかを知る手段が、公式には用意されていないのです。

なぜ公式の計測手段がないのか

hookの設計思想

Claude Codeのhookは、基本的に「走らせて終わり」という設計になっています。hookが返したstdoutやstderrはClaudeのコンテキストに渡りますが、「何ミリ秒かかったか」という情報はどこにも記録されません。

hookの設定ファイル `settings.json` にはtimeoutを設定するオプションもありますが、それは「時間がかかりすぎたときに打ち切る」ための設定であって、実際の実行時間を計測するためのものではありません。設定できる `timeout` の値が何ミリ秒で切れたかも、ログとして残る場所はありません。

問題が起きて初めて気づくパターン

実際にありがちなのは「気づいたら重くなっていた」というパターンです。hookを追加した直後は問題なくても、プロジェクトが大きくなるにつれてtscの実行時間が伸び、気づいたら毎回5秒待つようになっていた——こういったケースです。しかし何も計測していなければ、問題の原因をhookだと特定するまでに時間がかかります。「Claudeが遅いのかな」「PCの負荷かな」と別の原因を疑いがちです。

Lilyも最初はそうでした。ある時期から応答が遅く感じるようになり、しばらくはモデルの問題だと思っていました。実際に計測を始めて初めて、TypeScriptチェックのhookが平均で2,800ミリ秒かかっていることがわかったのです。

hook-latency-wrap.sh の全体像

「ラッパー」という発想

Lilyが採用したアプローチは「ラッパースクリプト」です。既存のhookを直接触るのではなく、その外側に薄い計測レイヤーを被せます。既存のhookを `original-hook.sh` だとすると、`settings.json` のcommandを次のように書き換えます。

"command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/hooks/original-hook.sh"

`hook-latency-wrap.sh` は引数として渡されたhookをそのまま実行し、実行前後の時刻差からミリ秒単位の経過時間を計算して、`~/.claude/logs/hook-latency.jsonl` に追記します。元のhookの終了コードをそのまま返すので、Claudeから見た動作は何も変わりません。

$EPOCHREALTIMEとは何か

時刻計測の核心は、bashの組み込み変数 `$EPOCHREALTIME` です。これはbash 5以降で使える変数で、`1751234567.891234` のように「秒.マイクロ秒」の形式でUNIXタイムを返します。Pythonを呼んだりdateコマンドを実行したりせず、bashの内部だけで現在時刻をマイクロ秒精度で取得できます。

Pythonを使わない理由は明確です。Pythonの起動コストは環境によって30〜100ミリ秒ほどかかることがあり、計測のために呼び出す処理がノイズになってしまいます。`$EPOCHREALTIME` であれば、変数参照のコストしかかからず、計測値を汚しません。「計測のために計測が遅くなる」という本末転倒を避けるための選択です。

hook-latency-wrap.sh の実装を読む

スクリプトの骨格

スクリプト全体は43行のシンプルな構成です。要点を抜き出すと次のような流れになっています。

HOOK_BIN="${1:-}"
shift || true

LOG_DIR="$HOME/.claude/logs"
mkdir -p "$LOG_DIR"
JSONL="$LOG_DIR/hook-latency.jsonl"

start_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')

"$HOOK_BIN" "$@"
exit_code=$?

end_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')
elapsed_ms=$(( (end_us - start_us) / 1000 ))

hook_name=$(basename "$HOOK_BIN")
ts=$(date -u +%Y-%m-%dT%H:%M:%S)

printf '{"ts":"%s","hook":"%s","elapsed_ms":%d,"exit_code":%d}\n' \
  "$ts" "$hook_name" "$elapsed_ms" "$exit_code" >> "$JSONL"

exit "$exit_code"

`${EPOCHREALTIME//./}` は `1751234567.891234` というドット入りの文字列からドットを除去して `1751234567891234` という純粋な整数にします。bash上では整数演算しかできないため、この変換が必要です。`sed 's/^0*//'` は先頭のゼロを除去してシェルが8進数と誤解するのを防ぐ処理です。

`start_us` と `end_us` の差を1000で割ることで、マイクロ秒からミリ秒に変換します。これが `elapsed_ms` になります。最後に `exit "$exit_code"` で元のhookの終了コードをそのまま返すのがポイントで、元のhookが失敗を示す終了コードを返せば、ラッパーもその値を引き継ぎます。

bash 4以下へのフォールバック

`$EPOCHREALTIME` はbash 5以降でしか使えません。macOSにプリインストールされているbashは長らくバージョン3系(GPLv2ライセンスの都合でAppleが更新していない)でした。このスクリプトでは、`$EPOCHREALTIME` が空だった場合にPythonにフォールバックする処理が書かれています。

[ -z "$start_us" ] && start_us=$(python3 -c 'import time;print(int(time.time()*1000000))')

HomebrewでインストールしたBash 5を使っていれば `$EPOCHREALTIME` が使えますが、念のためのガードです。自分の環境のbashバージョンを確認するには `bash --version` を実行してください。`GNU bash, version 5.x` と表示されればOKです。

出力されるJSONLのフォーマット

1回のhook実行ごとに1行、次のフォーマットで `hook-latency.jsonl` に追記されます。

{"ts":"2026-07-03T06:12:34","hook":"post-edit-typecheck.js","elapsed_ms":2847,"exit_code":0}

フィールドの意味は次の通りです。

記事の画像

このJSONL形式(1行1JSON)は、後から集計しやすく、ファイルへの追記も安全です。複数プロセスが同時に書き込んでも、1行が壊れるだけで他の行への影響がありません。

settings.json への組み込み方

Before:元の設定

たとえば `post-edit-typecheck.js` を計測したい場合、元の設定はこうなっています。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "command": "~/.claude/scripts/hooks/post-edit-typecheck.js"
      }
    ]
  }
}

After:ラッパーを挟んだ設定

`command` の値を次のように書き換えるだけです。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/hooks/post-edit-typecheck.js"
      }
    ]
  }
}

変更点は `command` の値のみです。`hook-latency-wrap.sh` を先頭に追加して、元のhookパスを引数として渡す形にします。元のhookが引数を受け取る場合は、そのまま後ろに続けて書けます。

複数のhookを同時に計測する

計測したいhookが複数ある場合は、それぞれの `command` に同じように `hook-latency-wrap.sh` を挟みます。すべての計測結果は同じ `hook-latency.jsonl` に追記されるので、どのhookが遅いかを一覧で比較できます。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/hooks/post-edit-typecheck.js"
      }
    ],
    "Stop": [
      {
        "command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/hooks/cost-tracker.js"
      }
    ]
  }
}

設定ファイルはJSONなので、コンマの位置と閉じカッコの数に注意してください。変更後にClaudeを再起動するとhookが反映されます。

hook-latency-report.sh で集計する

実行方法と引数

`hook-latency-report.sh` は引数なしで実行すると過去7日分のデータを集計します。日数を変えたい場合は引数で指定します。

# 過去7日分(デフォルト)
~/.claude/scripts/hook-latency-report.sh

# 過去1日分
~/.claude/scripts/hook-latency-report.sh 1

# 過去30日分
~/.claude/scripts/hook-latency-report.sh 30

出力される集計表

実行すると次のような表が出力されます(数値は例です)。

=== hook latency (last 7d, 1247 records) ===
hook                             n    mean     p95     max  fail
----------------------------------------------------------------------
post-edit-typecheck.js         823  2103ms  4821ms  9340ms     2 ⚠
cost-tracker.js                212   144ms   387ms   912ms     0
self-audit-stop.sh             212    38ms    89ms   203ms     0

右端に `⚠` がついているのが、p95が1500ミリ秒を超えているhookです。このケースでは `post-edit-typecheck.js` が明らかに遅く、p95で4821ミリ秒(約5秒)かかっています。823回も実行されているので、この1週間でTypeScriptチェックだけで合計29分ほど待っていた計算になります。

p95という指標を選んだ理由

平均値(mean)ではなくp95を閾値に使う理由を説明します。

たとえば100回のhook実行のうち、95回は100ミリ秒で終わるが、5回だけ5000ミリ秒かかるケースを考えます。この場合、平均は `(100×95 + 5000×5) / 100 = 345ms` です。平均だけ見ると問題なさそうに見えますが、実際には100回に5回は5秒待たされています。

p95(パーセンタイル95)は「実行時間を小さい順に並べたとき、95番目に来る値」です。上の例ではp95 = 5000msとなり、「たまに非常に遅い」という事実が見えるようになります。hookが「ほとんどは速いが、時々ひどく遅い」という挙動を示すのはよくあることなので(tscが大きなファイルを解析するときなど)、p95は現実の問題を見つけるのに適した指標です。

スクリプト内では `p95 > 1500` のhookに `⚠` マークをつけることで、「1.5秒を超えることが頻繁にある遅延hook」を一目でわかるようにしています。この閾値は `hook-latency-report.sh` の中で `flag = " ⚠" if p95 > 1500 else ""` という1行で定義されており、必要に応じて書き換えることができます。

遅いhookを特定してから何をするか

post-edit-typecheck.js が遅かった原因

`post-edit-typecheck.js` が遅い原因はtscの特性にあります。このhookは編集されたファイルを検知し、そのファイルが属するTypeScriptプロジェクト(`tsconfig.json` がある最も近い親ディレクトリ)に対して `tsc --noEmit --pretty false` を実行します。

TypeScriptプロジェクトが大きくなるにつれて、`tsc` は多くのファイルを解析する必要があり、実行時間が伸びていきます。小さなプロジェクトなら200ミリ秒で終わりますが、200ファイル以上のプロジェクトになると3〜5秒かかることも珍しくありません。

このhookのコードを見ると、`tsc` の実行にはタイムアウトが30秒(`timeout: 30000`)と設定されています。つまり最悪30秒ブロックする可能性があります。計測して初めて「これは問題だ」と判断できます。hookのコードを読んだだけでは「速そう・遅そう」という感覚的な判断しかできず、数字で見て初めて問題の大きさがわかります。

対策:削除・絞り込み・軽量化の3択

計測によって「問題あり」と判定したhookへの対処方法は、大きく3つです。

①削除する:最も根本的な解決策です。TypeScriptの型チェックはClaude Codeが作業を止めたあとに手動でやる、またはCIに任せるという方針にすれば、hookは不要になります。

②トリガー条件を絞る:`matcher` を `"Edit"` だけにして `"Write"` を除くとか、特定のファイルパターン(`.test.ts` は除外など)だけにするとか、hookが実行される回数を減らす方向で調整します。LilyのケースではTypeScriptチェックのhookを対象ファイルの絞り込みで対処し、p95を4821ミリ秒から680ミリ秒まで下げることができました。

③hookの実装を軽くする:`tsc --noEmit` の代わりに差分ファイルのみを対象にした軽量チェックに切り替える、などのアプローチです。`post-edit-typecheck.js` はすでに編集ファイルに関連するエラーだけを抽出するフィルタを持っていますが、tscが全ファイルを解析するコストはそのままです。フィルタで出力を絞っても実行時間は変わりません。

cost-tracker.js の事例

対照的に、`cost-tracker.js` はp95が387ミリ秒と比較的高速です。このhookはセッション終了時(Stopイベント)に1回だけ実行されるので、実行頻度が低く許容範囲内です。

仕組みとしては、Claude Codeが渡してくる `transcript_path` というJSONLファイルを読み込み、すべてのassistantターンのトークン使用量を集計してコストを推定します。出力先は `~/.claude/metrics/costs.jsonl` で、`estimated_cost_usd` フィールドに推定費用がドル単位で記録されます。実行頻度が低く処理が軽い(ファイル読み込みと計算だけ)ため、計測してみると `mean: 144ms` と問題ないレベルに収まっています。

このhookはStopイベントで動くため、もし `hook-latency-wrap.sh` でラップする場合は、stdinを転送しない点に注意が必要です(詳細は次の節で説明します)。

「失敗しているhookはないか」も確認する

集計表の `fail` 列は、`exit_code` が0以外だった回数を示します。hookが失敗してもClaudeの動作は止まりませんが、意図した処理がスキップされています。`fail` 列が0でないhookは何らかのエラーが起きているので、対象hookの標準エラー出力を確認することをおすすめします。

使い回すときの注意点

bash 5の確認方法

`hook-latency-wrap.sh` が `$EPOCHREALTIME` を使うにはbash 5が必要です。自分の環境を確認するには次を実行してください。

bash --version

`GNU bash, version 5.` から始まる出力が出ればOKです。Macで `version 3.2.57` のように古いバージョンが表示された場合は、Homebrewで新しいbashをインストールするか、スクリプト内のPythonフォールバック(`python3 -c 'import time;print(int(time.time()*1000000))'`)が自動的に使われます。どちらでも動作します。

stdinを使うhookをラップするとき

`hook-latency-wrap.sh` の現在の実装は、自分のstdinをそのままhookに転送しません。Claude Codeの一部のhookはstdinにJSONデータを受け取る設計になっています。`cost-tracker.js` がその例で、`transcript_path` や `session_id` などをstdinで受け取ります。このようなhookをそのままラップすると、stdinが届かずhookが正常に動作しない可能性があります。

stdinを転送するには、スクリプト内の `"$HOOK_BIN" "$@"` の行を次のように書き換える必要があります。

"$HOOK_BIN" "$@" < /dev/stdin

ただしこれは別の問題(stdinの使い切りタイミングの制御)を引き起こすことがあるため、stdinを使うhookをラップする前に必ず動作確認をしてください。現時点では、stdinを使う `cost-tracker.js` は計測対象から外し、PostToolUseのhookだけをラップするのが最も安全です。

ログが肥大化したとき

`~/.claude/logs/hook-latency.jsonl` には1回のhook実行ごとに1行追記されます。1日に100回hookが実行されれば、7日で700行です。これ自体は軽量ですが、何ヶ月も放置すると数万行になる可能性があります。

定期的に古いデータを削除したい場合は、次のコマンドで直近30日分だけを残すことができます。

python3 -c "
import json, datetime, pathlib
log = pathlib.Path.home() / '.claude/logs/hook-latency.jsonl'
cutoff = datetime.datetime.now() - datetime.timedelta(days=30)
lines = [l for l in log.read_text().splitlines() if l.strip()]
kept = [l for l in lines if datetime.datetime.fromisoformat(json.loads(l)['ts']) >= cutoff]
log.write_text('\n'.join(kept) + '\n')
print(f'kept {len(kept)} / {len(lines)} records')
"

または、launchdなどで月次の定期削除ジョブを組んでおくとメンテナンスが楽になります。

hookのパスにスペースが含まれる場合

`settings.json` のcommandに空白を含むパスを指定するには、クォートを正しく書く必要があります。

"command": "~/.claude/scripts/hook-latency-wrap.sh \"/path/with spaces/hook.sh\""

ただし、hookのファイルパスには最初からスペースを入れないほうが無難です。パスのスペースはシェルのクォート処理で予期しない動作を引き起こすことがあります。

まとめ:計測して初めてわかること

hookは「一度設定したら忘れる」ものになりがちです。しかし設定した直後と数ヶ月後では、プロジェクト規模もhookの実行回数も変わっています。感覚的な重さに気づいてから原因を探るより、最初から計測の仕組みを入れておくほうが、問題の特定がはるかに速くなります。

`hook-latency-wrap.sh` と `hook-latency-report.sh` の2本を導入することで、「どのhookがどれだけ遅いか」が数字で見えるようになります。p95が1500ミリ秒を超えているhookがあれば、削除・絞り込み・軽量化の3択で対処できます。集計表の `⚠` マークがゼロになるまで調整を繰り返すことで、hookを持ちながら応答速度を保つ環境が作れます。

仕組みはシンプルです。`$EPOCHREALTIME` でマイクロ秒を取得し、差を計算してJSONL1行に書く。それをPythonで集計してp95を出す。外部ツールも複雑なセットアップも必要ありません。hookを使っているなら、このラッパーを一度試してみてください。「なんか最近重い」を感覚で終わらせず、数字で判断できるようになると、hook運用の質が変わります。

📕 この記事の内容を、実装まで全部まとめた有料記事があります 手順・コード・テンプレをそのまま渡しています AIで「寝てても回る仕組み」をゼロから30日で組む全手順