0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の 40 億トークンを分解したら、77.5% が「会話の再読」で重複作業は 0% だった

0
Posted at

この記事は 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. 1セッションをコンテキスト 200K(目安300ターン)で区切る。 実測の短いセッション群の平均は 90K だった。長時間4本を平均 90K で回した場合の試算では 3,487M → 643M(−71%)。状態を外部(リポジトリ・DB・引き継ぎメモ)に持たせておけば、区切っても失うものは無い。
  2. 調査・検索・大量のファイル読みは subagent に出す。 ツール結果は会話量の 27〜41% を占めていた。50ファイル読ませて要約だけ返させれば、親の会話には要約しか残らない。実測では最大セッション 2,441ターン中、委任は 13回だけだった。
  3. セッションの途中でモデルを変えない。 プロンプトキャッシュはモデル単位なので、切り替えるとその時点の全コンテキストがキャッシュ書き込み(入力の 1.25 倍)として積み直される。実測した最大セッションは実際に3回モデルが切り替わっていた。
  4. 起動時の固定荷物を削る。 規約ファイルとツール定義は毎ターン課金される。ここだけは「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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?