この記事は Zenn に公開した同題の記事(https://zenn.dev/hitokraft/articles/claude-code-cost-is-conversation-length )を、筆者本人が Qiita 向けに再掲したものです。数字は 2026-08-16〜17 の 48 時間の実測で、記事中の script はその環境で実行して確かめています。
結論(3行)
- Claude Code の消費は やった仕事の量ではなく会話の長さ で決まる。48時間・全セッション・12,898ターンを集計したら、77.5% が「過去の会話の読み直し」 で、実際に考えて書いた出力は 9.3% だった(定価換算ベース)。
- 重複作業は 0〜1%。同じコマンドの再実行も、同じファイルの読み直しもほぼ無かった。中身に無駄は無く、無駄なのは長さだけだった。
- だから効くのは「セッションを短く切る」ことだけ。effort を下げる・安いモデルに替える・調査を減らすのは効かない(削りにいく対象が全体の 9.3% しか無い)。
以下、測り方と再現手順をそのまま置く。自分の ~/.claude/projects/ で同じ表が出せる。
何を測ったか
Claude Code は会話を JSONL の transcript として保存している。
~/.claude/projects/<cwd をスラッシュ置換した slug>/<session_id>.jsonl
1行が1メッセージで、assistant の行には API が返した usage がそのまま入っている。推測値を使う必要はなく、実使用量がそこにある。
{"type":"assistant","timestamp":"2026-08-17T02:11:40.912Z",
"message":{"model":"claude-opus-5",
"usage":{"input_tokens":4,"cache_creation_input_tokens":1200,
"cache_read_input_tokens":183000,"output_tokens":320}}}
4つのフィールドの意味と、単価の比率(絶対額は公式の料金表を見てほしい。ここで効くのは比率のほう):
| フィールド | 意味 | 入力単価に対する倍率 |
|---|---|---|
input_tokens |
キャッシュに載っていない新規の入力 | 1.0 |
cache_creation_input_tokens |
今回プロンプトキャッシュに書き込んだ分 | 1.25 |
cache_read_input_tokens |
キャッシュから読み直した分 | 0.1 |
output_tokens |
モデルが生成した分 | モデルにより 5 倍前後 |
集計対象は 2026-08-16〜17 の48時間・全セッション。定価換算はサブスク利用なので実際に請求された金額ではなく、入力と出力を同じ物差しに載せるための換算値として使っている。
結果
| トークン | 定価換算 | 割合 | |
|---|---|---|---|
| 会話の再読(cache read) | 3,940M | $2,128 | 77.5% |
| 記憶への書き込み(cache write) | 53M | $362 | 13.2% |
| 出力(実際に考えて書いた分) | 10M | $256 | 9.3% |
| 合計 | 4,003M | $2,746 | 100% |
先に注意書きを2つ。この「割合」は定価換算ベースで、トークン数ベースなら cache read は 98.4% を占める。単価が 1/10 でも量が桁違いなので、どちらの見方でも結論は変わらない。新規入力(input_tokens)はキャッシュがほぼ効いていて無視できる量だった。
もっと重要なのは集中の仕方だった。
- 長時間セッション4本だけで全体の 87%(7,141ターン・平均コンテキスト 488K・3,487M トークン、トークン数ベース)。
- 残りの60本以上は合計 5,757ターン・平均コンテキスト 90K で 516M しか使っていない。
- つまり 同じ1ターンでも、会話の長さ次第で費用が5倍以上変わる。
- 最大のセッションは 36.4時間・2,441ターン・ピーク 998K・平均 509K・12.4億トークン。これ1本で全体の3割。
なぜそうなるか
理由は単純で、モデルは毎ターン、過去の会話を丸ごと読み直すから。費用は「積まれた中身 × 読み直された回数」になる。
最大セッションの数字がそのまま説明になっている。
平均コンテキスト 509K × 2,441ターン ≒ 12.4億トークン
積まれていた中身自体は約 500K(ツール呼び出し 317K + ツール結果 192K)しかない。**中身は 500K なのに 12.4億使った。**差は全部「読み直した回数」である。
同じことが起動時の固定荷物にも効く。プロジェクト規約ファイル・ツール定義・メモリで 45K あり、これが全ターンに乗る。
45K × 12,898ターン = 5.8億トークン = 全体の 14.5%(トークン数ベース)
「ファイルを1回読んだ」は1回の費用ではない。以後の全ターンに毎回乗る。
プロンプトキャッシュはこれを「安く」するが「読まなくする」わけではない。単価 0.1 倍でも、1,000回読み直せば元の 100 回分になる。キャッシュは長い会話を許可証にはしてくれない。
もう一つ、実測して初めて分かったこと。1M コンテキスト窓の設定だと自動圧縮がほとんど発火しない。 36時間のセッションで自動圧縮は 1回、10.5時間のセッションでは 0回だった。窓が広いぶん、会話が際限なく伸びる。窓を狭めても単価は下がらないが、読み直す総量は下がる。
効くこと / 効かないこと
効くこと
- 1セッションをコンテキスト 200K(目安300ターン)で区切る。 実測の短いセッション群の平均は 90K だった。長時間4本を平均 90K で回した場合の試算では 3,487M → 643M(−71%)。状態を外部(リポジトリ・DB・引き継ぎメモ)に持たせておけば、区切っても失うものは無い。
- 調査・検索・大量のファイル読みは subagent に出す。 ツール結果は会話量の 27〜41% を占めていた。50ファイル読ませて要約だけ返させれば、親の会話には要約しか残らない。実測では最大セッション 2,441ターン中、委任は 13回だけだった。
- セッションの途中でモデルを変えない。 プロンプトキャッシュはモデル単位なので、切り替えるとその時点の全コンテキストがキャッシュ書き込み(入力の 1.25 倍)として積み直される。実測した最大セッションは実際に3回モデルが切り替わっていた。
- 起動時の固定荷物を削る。 規約ファイルとツール定義は毎ターン課金される。ここだけは「1回削れば全ターン効く」数少ない場所。
効かないこと
- effort(推論の深さ)を下げる。 出力は全体の 9.3%。仮に半減させても全体の 4.6% しか減らない。
- 安いモデルに替える。 上と同じ理由。加えて、途中で替えるとキャッシュ無効化で逆に増える。
- 調査・検証を減らす。 重複作業は 0〜1% で、そもそも削る無駄が無い。ここを削ると減るのは費用ではなく品質だけ。
削るべきは仕事ではなく会話長、というのが実測の結論。
再現手順
自分の transcript で同じ表を出す。会話の中身は一切読まず、usage フィールドと model 名だけを合計するスクリプト。読み取り専用で、何も書き込まない。
mkdir -p /tmp/cc-cost && cd /tmp/cc-cost
# 下の breakdown.mjs を保存してから
node breakdown.mjs 2 # 直近2日
node breakdown.mjs 7 # 直近7日
// breakdown.mjs — Claude Code の transcript からトークン内訳を集計する(読み取り専用)
// 使い方: node breakdown.mjs [days]
import { readdirSync, statSync, createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { homedir } from 'node:os';
import { join } from 'node:path';
const DAYS = Number(process.argv[2] ?? 2);
const CUTOFF = Date.now() - DAYS * 86_400_000;
const ROOT = join(homedir(), '.claude', 'projects');
// $/MTok。**公式の料金表で確認して自分の使うモデルに書き換える**(下は 2026-08 時点の値)。
// サブスクでは実際に課金されない。input と output を同じ物差しに載せるためだけに使う。
const PRICE = {
'claude-opus-5': [5, 25],
'claude-sonnet-5': [2, 10],
'claude-haiku-4-5': [1, 5],
};
const priceOf = (m) =>
PRICE[m] ?? PRICE[Object.keys(PRICE).find((k) => (m ?? '').startsWith(k))] ?? null;
function walk(dir, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p, out);
else if (e.name.endsWith('.jsonl') && statSync(p).mtimeMs >= CUTOFF) out.push(p);
}
return out;
}
const T = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 }; // トークン数
const W = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 }; // 定価換算
const sessions = [];
const unpriced = new Map(); // PRICE に無いモデル → トークン数(黙って 0 にしないため)
for (const file of walk(ROOT)) {
const s = { turns: 0, tokens: 0, weight: 0, peak: 0 };
const rl = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
for await (const line of rl) {
if (!line.startsWith('{')) continue;
let o; try { o = JSON.parse(line); } catch { continue; }
const u = o.message?.usage;
if (!u) continue;
if (o.timestamp && Date.parse(o.timestamp) < CUTOFF) continue;
const inp = u.input_tokens ?? 0, cw = u.cache_creation_input_tokens ?? 0;
const cr = u.cache_read_input_tokens ?? 0, out = u.output_tokens ?? 0;
T.input += inp; T.cacheWrite += cw; T.cacheRead += cr; T.output += out;
const p = priceOf(o.message?.model);
if (p) {
// cache write は入力の 1.25倍、cache read は 0.1倍
const w = { input: (inp / 1e6) * p[0], cacheWrite: (cw / 1e6) * p[0] * 1.25,
cacheRead: (cr / 1e6) * p[0] * 0.1, output: (out / 1e6) * p[1] };
for (const k of Object.keys(W)) W[k] += w[k];
s.weight += w.input + w.cacheWrite + w.cacheRead + w.output;
} else {
const m = o.message?.model ?? '(unknown)';
unpriced.set(m, (unpriced.get(m) ?? 0) + inp + cw + cr + out);
}
s.turns++; s.tokens += inp + cw + cr + out;
s.peak = Math.max(s.peak, inp + cw + cr); // そのターンでモデルが読んだ量 = コンテキスト長
}
if (s.turns) sessions.push(s);
}
const tSum = T.input + T.cacheWrite + T.cacheRead + T.output;
const wSum = W.input + W.cacheWrite + W.cacheRead + W.output;
const row = (label, k) =>
` ${label.padEnd(26)} ${(T[k] / 1e6).toFixed(0).padStart(6)}M $${W[k].toFixed(0).padStart(6)} ` +
`${((W[k] / wSum) * 100).toFixed(1).padStart(5)}%`;
console.log(`直近 ${DAYS} 日 / セッション ${sessions.length} 本 / ` +
`${sessions.reduce((a, s) => a + s.turns, 0)} ターン`);
console.log(` ${'内訳'.padEnd(24)} ${'トークン'.padStart(7)} ${'定価換算'.padStart(7)} 割合`);
console.log(row('会話の再読 cache_read', 'cacheRead'));
console.log(row('記憶への書込 cache_creation', 'cacheWrite'));
console.log(row('新規入力 input', 'input'));
console.log(row('出力 output', 'output'));
console.log(` 合計 ${(tSum / 1e6).toFixed(0).padStart(6)}M ` +
`$${wSum.toFixed(0).padStart(6)} 100.0%`);
sessions.sort((a, b) => b.weight - a.weight);
const top = sessions.slice(0, 4);
console.log(`\n上位4セッション = 全体の ` +
`${((top.reduce((a, s) => a + s.weight, 0) / wSum) * 100).toFixed(1)}%`);
for (const s of top) {
console.log(` $${s.weight.toFixed(0).padStart(5)} / ${String(s.turns).padStart(5)}ターン / ` +
`最大コンテキスト ${(s.peak / 1000).toFixed(0)}K`);
}
for (const [m, tok] of unpriced) {
console.log(`\n⚠ PRICE に無いモデル: ${m}(${(tok / 1e6).toFixed(0)}M トークンが換算から抜けている)`);
}
見るべきは合計額ではなく 上位数本への集中度と、最大コンテキスト。ここが 400K・600K に張り付いていたら、そのセッションは仕事ではなく長さで課金されている。
実装した hook — 規約は書いただけでは守られない
「200K で区切る」と決めても守られなかった。実測では、914K で警告を受けたセッションがその後 369 行進んでも区切らず、別のセッションは警告を5回受けながら 472K まで再膨張した。
そこで UserPromptSubmit hook に測定を挿した。中核はこれだけ。
/** transcript の末尾から直近の usage を1つ拾う。それが「今のコンテキスト量」。 */
export function contextFromTranscriptTail(tail: string): number | null {
const lines = tail.split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (!line || !line.includes('"usage"')) continue;
let o; try { o = JSON.parse(line); } catch { continue; } // tail の切れ目は読み飛ばす
const u = o.message?.usage;
if (!u) continue;
return (u.cache_read_input_tokens ?? 0)
+ (u.cache_creation_input_tokens ?? 0)
+ (u.input_tokens ?? 0);
}
return null;
}
設計で効いた点:
-
1ターン分の
usage(output を除く3つの合計)が、そのターンでモデルが読んだ量そのもの。 全部を集計する必要は無く、末尾から最初に見つかった1件でいい。 - 末尾だけ読む。 transcript は 20MB を超える。全読みは毎プロンプト走らせられない。実測で 20MB のファイルでも 55ms。巨大なツール結果で1行が大きいことがあるので、見つからなければ読む範囲を一段広げて再試行する。
- しきい値を3段にした。 200K でセッション自身に通知 / 400K で人間の画面にも警告(AI が無視しても人間には見える)/ 600K で次のプロンプトを実際に止める。
-
止める時も、畳む道だけは必ず通す。 「引き継ぎ」「checkpoint」を含む入力とスラッシュコマンド(
/clearなど)はバイパスする。ここを塞ぐと、止められた側が状態を保存して終わることすらできなくなる。 - 上限以下では何も出力しない。 通知自体がトークンを食うので、超過時に1行だけ。何が起きても入力を壊さず終了する。
400K を超えると人間の画面にも「この会話が◯◯Kまで長くなっています。キリのいいところで /clear してください。作業の状態は保存されているので失われません」と出るようにした。AI 向けの通知だけでは無視される、というのも実測で分かったことのひとつ。
補足(正直に書いておくこと)
- 割合は定価換算ベース。サブスク利用なので、その金額が請求されたわけではない。
- 「重複作業 0〜1%」は、同一コマンドの再実行と同一ファイルの再読を数えた結果であり、意味的な重複まで検出したものではない。
- 200K で区切った場合の「−71%」は**試算(推定)**で、区切ったあとに実測して確かめた数字ではない。
- 上のスクリプトは自分の環境で実行して動作を確認したもの。ただし出る数字はその人の使い方の内訳であり、この記事の 77.5% を再現するものではない(短く区切って運用していれば cache read の比率は下がる。それが正しい姿)。
同じ運用から、ここで書いた「200K で区切る」「上限で止める」を機械的に回すための運用一式(コンテキスト量を測って止める hook・自己申告の「完了」を弾くテスト・停止時に自分で引き継ぐ script の実物)を、note の有料記事(¥9,800)にまとめています: https://thought-media.pages.dev/go/kit?src=qiita