1
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?

/compact のコストを実ログ705回分で測った — 対話なら回収できる、バッチなら避けるべき

1
Posted at

長いセッションで /compact が走ったあと、使用量が跳ねた気がする。

体感としてはよく聞く話ですが、実際に測った記録を見たことがなかったので測りました。
Claude Code は全セッションのトークン内訳をローカルに保存しているので、推測せずに数えられます。

結論から書きます。

  • 直後のターンは実際に跳ねる。 通常ターンの約26倍を書き直している(実測・中央値)
  • ただし十数ターンで元が取れる。 圧縮前は毎ターン16万トークンを読んでいたので、以降が安くなる(簡易計算で10.1ターン、厳密に累積して12ターン)
  • 回収できるかはセッションが続くかどうかで決まる。 セッション最後の圧縮に限ると、3回に1回は分岐点に届かずに終わっています
  • 本当に失っているのはトークンより時間。 要約1回に中央値157秒。705回で合計30.7時間
  • ただしこれは対話の話。バッチでは結論が逆になる。 回収する相手がいないうえ、失敗が無音になる

前半は対話セッションの収支、後半は claude -p / SDK でバッチとして回す場合の話です。
同じ仕組みなのに、モードによって勧める対処が逆になります。

以下の数字はすべて私の手元1台の実測値です。あなたの数字ではありません。
最後に測定スクリプトを2本置いたので、自分の環境で回してください。

データはローカルにある

Claude Code はセッションごとの記録を ~/.claude/projects/<project>/<session>.jsonl に書いています。
アシスタントの各応答に、API が返した usage がそのまま入っています。

{
  "cache_read_input_tokens": 164698,
  "cache_creation_input_tokens": 2335,
  "input_tokens": 2,
  "output_tokens": 676
}

意味は公式ドキュメントにあります。

フィールド 意味
cache_creation_input_tokens このターンでキャッシュに書いたトークン
cache_read_input_tokens このターンでキャッシュから読んだトークン

そして compaction が起きると、compactMetadata を持つレコードが挿入されます。

{
  "trigger": "auto",
  "preTokens": 181261,
  "postTokens": 7359,
  "cumulativeDroppedTokens": 173902,
  "durationMs": 122511
}

圧縮前後のサイズと、要約にかかった実時間まで記録されています。

今回集計したのは 2,932 セッションファイル、アシスタントターン 47,217 件、
そのうち compaction を含む 148 セッション・705 回です。
サブエージェント(isSidechain)は親と別コンテキストなので除いています。

数字は 2026-08-18 時点のスナップショットです。私自身が Claude Code を使い続けているので、同じスクリプトを翌日回せば件数は増えます(この記事を書いている最中にも1回増えました)。

初出時から数値を全面的に訂正しています。 ログの assistant レコードは「1レコード = 1 content ブロック」で書かれていて、1回の API 応答が複数レコードに分割されるとき、分割された全レコードが同じ usage を持ちます(実測 46,834/46,838 = 100%)。レコード単位で素朴に合計すると二重計上になり、ターン数は約1.94倍、トークン量は1.9〜2.9倍に膨らみます。初出時の集計はこれを踏まえていませんでした。本文・表・スクリプトはすべて message.id で重複排除した値に差し替えてあります。

結果1: 直後のターンは通常の26倍書き込む

まず「跳ねた気がする」の正体から。

cache_write(中央値)
compaction の直前ターン 2,330 tok
compaction の直後ターン 43,844 tok
compaction を含まないセッションの通常ターン 1,712 tok

直後のターンは、通常ターンの約26倍を書き直しています。 体感は正しい。

「通常ターン」の取り方で倍率は動きます。セッション冒頭5ターン(起動時の書き込みが大きい)を除くと通常ターンは 1,143 tok まで下がり、38倍になります。ここでは冒頭を除かない保守的なほうを採りました。

キャッシュ書き込みは読み込みより高くつきます。公式の課金倍率はこうです。

  • 5-minute cache write tokens are 1.25 times the base input tokens price
  • 1-hour cache write tokens are 2 times the base input tokens price
  • Cache read tokens are 0.1 times the base input tokens price

Claude のサブスクリプションでは1時間 TTL が自動的に要求されるので、書き込みは2倍です。
読み込みの 0.1倍 と比べると、同じトークン数でも 20倍の差があります。

約44,000トークンを書き直すのは、キャッシュから読むのに比べて相応に高い。ここまでは体感どおりです。

結果2: 圧縮前は毎ターン16万トークンを読んでいた

ただし、圧縮前の状態も見る必要があります。

compaction 直前ターンの cache_read : 164,690 tok(中央値)

圧縮する直前は、毎ターン16万トークンを読み直していました。
読み込みは0.1倍とはいえ、これが毎回乗ります。

圧縮後はこうなります。

圧縮直後から cache_read(中央値)
1ターン目 28,177 tok
2ターン目 71,695 tok
3ターン目 78,084 tok
5ターン目 83,135 tok
10ターン目 92,172 tok

いったん28,000まで落ちて、2ターン目でもう7万まで戻ります。
ファイルを読み、コマンドを叩けば、その分が積まれるので当然です。

初出時の表は 1ターン目・2ターン目とも 28,173 でした。これは同じ応答が2レコードに分割されていただけで、2ターン目を数えたことになっていませんでした。実際には2ターン目で 71,695 まで戻っており、「圧縮直後の安さは1ターンしか続かない」というのが正しい姿です。

結果3: 収支は十数ターンで逆転する

3ターン目の 78,084 を圧縮後の定常値として、base input を1とした相対コストで計算します。

一時費用   = 43,844 tok × 2.0(1h書き込み) = 87,688
1ターン節約 = (164,690 − 78,084) × 0.1      = 8,661

損益分岐 = 87,688 ÷ 8,661 ≒ 10.1 ターン

この簡易計算では約10ターンです。実測の総コストを1ターンずつ累積すると12ターンでした。
節約額を一定と置いた簡易式でも、実測の累積でも、十数ターンという桁は変わりません。

問題はここから先です。その十数ターンが本当に続くのか。

圧縮後に続いたターン数を、セッション最後の compaction について数えました。

圧縮後に続いたターン 該当セッション
5ターン未満で終了 15 / 148(10.1%)
10ターン未満で終了 36 / 148(24.3%)
12ターン未満で終了 47 / 148(31.8%)
20ターン未満で終了 78 / 148(52.7%)

中央値は18ターン(p25 10 / p75 39 / 最大103)。
分岐点の12ターンに届かずに終わったセッションが、3回に1回あります。

「大半のケースで回収できている」とは言えません。正確には7割です。

セッションの途中で起きた圧縮まで含めると数字は良くなります。705回すべてを対象に、次の圧縮までに続いたターン数で数えると、12ターン以上は 88.5%(624/705)、中央値は41ターンでした。最後の圧縮だけが悪いのは当然で、セッションが終わったから最後になっているからです。

つまり回収できるかどうかは compaction 自体の性質ではなく、そのあと作業を続けるかどうかで決まります。作業を切り上げる直前の自動圧縮は、ほぼ確実に払い損です。

なお公式ドキュメントは、compaction の直後のターンについて「遅い部分ではない」と書いています。

the turn after compaction rebuilds the conversation cache for only the much shorter summary, so that turn is not the slow part

これは要約リクエストとの比較です。全履歴を読む要約リクエストのほうが重い、という話。
一方この記事の26倍は通常ターンとの比較です。比較対象が違うだけで、どちらも成り立ちます。

結果4: 本当に払っているのは時間

トークンより目立つ数字がこれでした。

要約にかかった時間: 中央値 157秒 / p90 198秒 / 最大 319秒
705回の合計       : 30.7 時間

1回あたり2分37秒、待たされます。 そのあいだ作業は進みません。

1セッションあたりの compaction 回数は中央値2回、最大53回。
148セッション中79セッションが2回以上圧縮しています。

トークンの収支は十数ターンで戻りますが、この時間は戻りません。
「使用量が跳ねた」という体感の実体は、コストより先にこちらだったのではないかと思います。

結果5: 99.6% は自分で選んでいない

trigger=auto   : 702 回(99.6%)
trigger=manual :   3 回( 0.4%)

ほぼすべてが自動発火です。

自動発火は文脈が上限に近づいたときに起きるので、タイミングを選べません。
タスクの真ん中で2分37秒止まる、という形になります。

公式が勧めているのは逆です。

To choose when its overhead happens, run /compact at a natural break in your work, such as between tasks, instead of waiting for auto-compaction to trigger mid-task.

作業の切れ目で自分から叩けば、同じコストを都合のいいタイミングに移せます。
実測 0.4% ということは、この推奨はほぼ実行されていません。私もでした。

圧縮しないという選択肢

公式が挙げている代替手段が2つあります。どちらも compaction より安い。

/clear — 継続性が要らないなら、こちらはコストゼロです。

When you want a fresh start instead of continuity, /clear costs nothing

/rewind — 進めた方向を捨てたいときは、圧縮より巻き戻しのほうが安い。

Rewinding truncates back to a prefix that is already cached, rather than building a new one as compaction does.

巻き戻し先はすでにキャッシュ済みの prefix なので、新しく作り直す compaction と違って書き込みが発生しません。

見返りなしで高い操作はこっち

ここまでの話には、少なくとも「以降のターンが安くなる」という見返りがありました。
公式が挙げているキャッシュ無効化の一覧には、見返りがまったくないものが並んでいます。

操作 何が起きるか
/model でモデル切替 モデルごとに別キャッシュ。全履歴を読み直す
/effort で effort 変更 effort もキャッシュキー。同じく全再計算
fast mode を ON リクエストヘッダがキャッシュキーに入る。深いセッションほど高い
MCP サーバーの接続/切断 ツール定義がプレフィックスに載る構成では全無効化
アップグレード後にセッションを再開 システムプロンプトが変わっている。全再計算

特に3つ、実務で刺さります。

opusplan を使っている場合、plan mode のトグルがそのままモデル切替です。

The opusplan model setting resolves to Opus during plan mode and Sonnet during execution, so each plan-mode toggle is a model switch and starts a fresh cache.

Shift+Tab を往復するたびに全再計算が走ります。

アップグレード後の再開は、最も高いリクエストになり得ます。

Resuming a session after an upgrade reprocesses the entire conversation history with no cache hits... The cost scales with how long the resumed conversation is, so the first turn back into a long session can be the most expensive request you send.

自動更新は次回起動時に適用されるので、「更新された→長いセッションを resume」の組み合わせが一番高い。

MCP サーバーは、自分が何もしなくても落ちて再接続します。 stdio サーバーのプロセス終了、HTTP セッションの期限切れ、一時的な失敗からの自動再接続。ツール定義がプレフィックスに載る構成なら、そのたびに無効化されます(既定の deferred なら影響しません)。

/compact を気にする前に、こちらを見たほうが効きます。

ここから先はバッチの話

前半の「十数ターンで回収できる」には前提があります。セッションがそのあとも続くこと。

claude -p や Agent SDK で1件ずつ投げて終わらせる使い方だと、この前提が成り立ちません。
ログには実行モードが記録されているので、分けて測れます。

{ "entrypoint": "sdk-cli" }

sdk-cli / sdk-py がプログラム実行、cli が対話です。

結果6: 発生率は170倍違う。ただし理由はモードではない

バッチ(sdk) 対話(cli)
セッション数 2,681 244
compaction が起きた 9(0.3%) 139(57.0%)
ターン数の中央値 2 61
最初に発生したターン 20 39
そのときの preTokens 183,128 tok 169,700 tok

差は大きいですが、モードが compaction を減らしているわけではありません。

下2行を見てください。最初に発火したときの文脈サイズは 183,128 と 169,700。ほぼ同じです。
compaction はモードに関係なく、同じトークン閾値で発火しています。
0.3% になった理由は単純で、バッチのセッションが短いからです(ターン数の中央値2)。

しかも発火に至ったセッションだけを見ると、バッチのほうが 20ターン目と早い。対話は39ターン目です。
バッチの1ターンは1ターンあたりの読み書きが重いので、届くときは対話より少ないターンで届きます。

「バッチだから安全」ではなく「短いから届いていないだけ」。長く回せば同じように発火します。

発生したセッションは、すべて17ターン以上だった

バッチをターン数で区切るとこうなります。

ターン数 compaction が起きたセッション
17未満 0 / 2,628
17以上 9 / 53

17ターン未満の2,628セッションでは、1件も起きていません。 こちらは母数が大きいので信頼できます。
17ターン以上のほうは母数53です。率としては当てにならないので、方向として読んでください。

そして「17ターン」という数字自体も目安でしかありません。
実際の引き金はトークン量なので、壁の位置は1ターンあたりどれだけ読むかで動きます。
大きなファイルを何本も読むタスクなら20ターンで届くし、短い応答の往復なら100ターンでも届かない。
自分のジョブについては次章のスクリプトで測ってください。

なぜバッチでは「起こさない」ほうがいいのか

前半で compaction を許容できた理由が、バッチでは全部消えます。

1. 劣化に気づく人がいない。
対話なら、圧縮後に応答がずれた時点で人間が「さっき言ったやつ」と補い直せます。
バッチの出力を毎回読む人はいません。ずれたまま完走します。

2. ルールが途中から効かなくなる。 これは公式に明記されています。

Project-root CLAUDE.md survives compaction: after /compact, Claude re-reads it from disk and re-injects it into the session. Nested CLAUDE.md files in subdirectories and rules with paths: frontmatter are not re-injected automatically

ルート直下の CLAUDE.md は戻ります。サブディレクトリの CLAUDE.mdpaths: 付きルールは戻りません。
モノレポで「このディレクトリではこう書く」を配っていると、ジョブの後半だけ規約が外れます。
しかもエラーは出ません。

3. 再現しなくなる。
同じ入力を投げても、compaction が起きたかどうかで出力が変わります。
「昨日は通ったのに今日は落ちた」の原因が、コードでも入力でもなく文脈の長さになる。
バッチ処理でこれは厄介です。

4. 待ち時間が件数倍で効く。
要約1回に中央値157秒。100件のジョブで半分に発生すれば、それだけで2時間強が消えます。
対話なら1回2分37秒の待ちですが、バッチでは総処理時間にそのまま乗ります。

5. 何を捨てたか残らない。
バッチで発生したセッションの cumulativeDroppedTokens は中央値 170,418 トークン
17万トークン分の文脈が無言で消えていますが、どの指示が消えたかは記録されません。

前半の「十数ターンで回収できる」は、この5つを引き受けたうえでの話です。
対話では引き受けられます。バッチでは引き受けられません。

起こさない設計

閾値が16万〜18万トークンなら、1回の実行をそこに届かせないのが本筋です。

1件1セッションに切る。
100件を1セッションで回すのではなく、100回起動する。各実行が独立して短くなるので閾値に届きません。
中間結果は会話ではなくファイルに書く。次の実行はそれを読む。状態を文脈ではなくディスクに置きます。

大きな読み込みはサブエージェントに投げる。 公式の推奨です。

Delegate large reads: send research to a subagent so the file contents stay in its context window, not yours.

サブエージェントは別コンテキストです(今回の集計で isSidechain を除いたのはこのため)。
本体には結果だけが返るので、読み込み量が多いジョブではこれが一番効きます。

規約はシステムプロンプト側に置く。
compaction が圧縮するのは会話です。--append-system-prompt / --append-system-prompt-file で渡した内容は
別の層に載るので、圧縮で落ちません。 公式もこの用途を挙げています。

For instructions you want at the system prompt level, use --append-system-prompt. This must be passed every invocation, so it's better suited to scripts and automation than interactive use.

サブディレクトリの CLAUDE.md に頼るより、スクリプトからは毎回渡すほうが確実です。
上の「2」で落ちるものが、そもそも落ちない場所に移ります。

--bare で起動する。
フック、スキル、プラグイン、MCP、auto memory、CLAUDE.md の自動探索をまとめて飛ばします。
起動が速くなるだけでなく、入力が実行環境に依存しなくなるので再現性が上がります。
(キーチェーンも読まないので ANTHROPIC_API_KEY が必要です)

--max-turns で上限を切る。

Limit the number of agentic turns (print mode only). Exits with an error when the limit is reached.

暴走して閾値に届く前に止められます。

それでも届いたら、黙って劣化させず落とす

ここが本題です。

--autocompact は窓の大きさを変えるフラグですが、位置を動かせるだけで切ることはできません
(100K〜1M、モデルのコンテキスト長が天井)。

切るのは別の設定です。

.claude/settings.json
{ "autoCompactEnabled": false }

環境変数なら DISABLE_AUTO_COMPACT=1。CI ではこちらのほうが扱いやすい。

切ると、閾値を超えた時点で Prompt is too long になって実行が止まります。
そのとき -p がどう終わるかを手元で確認しました(意図的に長すぎる入力を渡して同じ状態を作っています)。

$ cat huge.txt | claude -p "reply with just: ok" --output-format json > out.json
$ echo $?
1
{ "is_error": true, "result": "Prompt is too long", "total_cost_usd": 0 }

終了コード1、is_error: true、課金ゼロ。
シェルスクリプトなら set -e で止まり、CI なら赤くなります。

劣化した出力が exit 0 で返ってくるより、ここで落ちるほうが安全です。
落ちたら分割の粒度を見直す。それが正しい対応で、圧縮して続行は正しい対応ではありません。

これはバッチ限定の設定です。対話セッションで切ると、長い作業の途中で Prompt is too long に当たって進めなくなります。前半で見たとおり、対話では compaction は十数ターンで元が取れています。切る理由はありません。

自分の環境で測る

他人の平均値は、あなたの使い方について何も語りません。以下を保存して実行してください。
どちらも依存なし。会話内容・パス・プロジェクト名は読まず、トークン数とメタデータだけを集計します。

1. 収支を測る

compact_cost.py
#!/usr/bin/env python3
"""~/.claude/projects/ のセッションログから /compact の収支を測る。"""
import os, glob, json, statistics

BASE = os.path.expanduser("~/.claude/projects")
READ_MUL, WRITE_MUL_1H = 0.1, 2.0  # 公式の課金倍率(base input を 1 とする)

def events(path):
    seen = set()   # 1応答が複数レコードに分割され、同じ usage が重複記録されるため
    for line in open(path, encoding="utf-8", errors="replace"):
        if '"usage"' not in line and "compactMetadata" not in line:
            continue
        try:
            d = json.loads(line)
        except ValueError:
            continue
        if d.get("isSidechain"):      # サブエージェントは別コンテキストなので除外
            continue
        if d.get("compactMetadata"):
            yield "compact", d["compactMetadata"]
        elif d.get("type") == "assistant":
            m = d.get("message") or {}
            u = m.get("usage") or {}
            mid = m.get("id")
            if u and not (mid and mid in seen):
                if mid:
                    seen.add(mid)
                yield "turn", (u.get("cache_read_input_tokens") or 0,
                               u.get("cache_creation_input_tokens") or 0)

metas, pre_read, post_write, post_read3, normal_write, tail = [], [], [], [], [], []

for path in glob.glob(os.path.join(BASE, "*", "*.jsonl")):
    if os.path.getsize(path) < 4096:
        continue
    ev = list(events(path))
    marks = [i for i, (k, _) in enumerate(ev) if k == "compact"]
    if not marks:
        for k, v in ev:                           # 圧縮のないセッションの通常ターン
            if k == "turn":
                normal_write.append(v[1])
        continue
    for i in marks:
        metas.append(ev[i][1])
        before = [v for k, v in ev[:i]     if k == "turn"]
        after  = [v for k, v in ev[i + 1:] if k == "turn"]
        if before:
            pre_read.append(before[-1][0])
        if after:
            post_write.append(after[0][1])
        if len(after) >= 3:
            post_read3.append(after[2][0])   # 直後2ターンはまだ再構築中なので3ターン目
    tail.append(len([1 for k, _ in ev[marks[-1] + 1:] if k == "turn"]))

if not metas:
    raise SystemExit("compaction の記録がありません")

med = statistics.median
print(f"compaction {len(metas)} 回 / {len(tail)} セッション")
print(f"  auto の割合      : "
      f"{sum(1 for m in metas if m.get('trigger') == 'auto') / len(metas) * 100:.1f}%")
print(f"  圧縮前→後        : {med([m['preTokens'] for m in metas]):>9,.0f}"
      f"{med([m['postTokens'] for m in metas]):>8,.0f} tok")
dur = [m["durationMs"] / 1000 for m in metas if m.get("durationMs")]
if dur:
    print(f"  要約にかかる時間 : 中央値 {med(dur):.0f} 秒 / 合計 {sum(dur)/3600:.1f} 時間")
print()
if normal_write:
    print(f"  直後ターンの書込 : {med(post_write):>9,.0f} tok")
    print(f"  通常ターンの書込 : {med(normal_write):>9,.0f} tok"
          f"{med(post_write)/max(med(normal_write),1):.0f}")
print()
cost = med(post_write) * WRITE_MUL_1H
save = (med(pre_read) - med(post_read3)) * READ_MUL
print(f"  圧縮前の1ターン読込 : {med(pre_read):>10,.0f} tok")
print(f"  圧縮後の1ターン読込 : {med(post_read3):>10,.0f} tok")
print(f"  一時費用            : {cost:>10,.0f}")
print(f"  1ターンあたりの節約 : {save:>10,.0f}")
if save > 0:
    print(f"  → 損益分岐          : 約 {cost/save:.1f} ターン")
print(f"  → 実際に続いたターン: 中央値 {med(tail):.0f} ターン")

出力はこうなります(私の環境)。

compaction 705 回 / 148 セッション
  auto の割合      : 99.6%
  圧縮前→後        :   168,635 →   14,296 tok
  要約にかかる時間 : 中央値 157 秒 / 合計 30.7 時間

  直後ターンの書込 :    43,844 tok
  通常ターンの書込 :     1,712 tok   → 26 倍

  圧縮前の1ターン読込 :    164,690 tok
  圧縮後の1ターン読込 :     78,084 tok
  一時費用            :     87,688
  1ターンあたりの節約 :      8,661
  → 損益分岐          : 約 10.1 ターン
  → 実際に続いたターン: 中央値 18 ターン

このスクリプトの損益分岐は簡易モデル(一時費用=直後1ターン分の書き込み、節約額=一定)です。実測の総コストを1ターンずつ累積すると 12 ターンになります。最後の compaction のあとに続いたターンは中央値 18 なので、この分岐点は「だいたい超える」程度の余裕しかありません。

events() の中の seen が肝です。これを入れないと1回の応答を複数回数えます。

2. バッチと対話に分けて測る

自分のジョブが「壁」のどちら側にいるかは、これで分かります。

batch_split.py
#!/usr/bin/env python3
"""compaction の発生をバッチ実行と対話実行で分けて測る。"""
import os, glob, json, statistics

BASE = os.path.expanduser("~/.claude/projects")

def scan(path):
    """1セッションから (実行モード, ターン数, compaction のリスト) を返す。"""
    mode, turns, comps = None, 0, []
    seen = set()   # 1応答が複数レコードに分割されるので message.id で重複排除する
    for line in open(path, encoding="utf-8", errors="replace"):
        if '"entrypoint"' in line and mode is None:
            try: e = json.loads(line).get("entrypoint")
            except ValueError: e = None
            if e:
                mode = "batch" if str(e).startswith("sdk") else "interactive"
        if '"usage"' not in line and "compactMetadata" not in line:
            continue
        try: d = json.loads(line)
        except ValueError: continue
        if d.get("isSidechain"):          # サブエージェントは別コンテキスト
            continue
        m = d.get("compactMetadata")
        if m:
            comps.append((turns, m))      # 何ターン目で起きたか
        elif d.get("type") == "assistant":
            msg = d.get("message") or {}
            mid = msg.get("id")
            if msg.get("usage") and not (mid and mid in seen):
                if mid:
                    seen.add(mid)
                turns += 1
    return mode, turns, comps

groups = {"batch": [], "interactive": []}
for path in glob.glob(os.path.join(BASE, "*", "*.jsonl")):
    if os.path.getsize(path) < 4096:
        continue
    mode, turns, comps = scan(path)
    if mode and turns:
        groups[mode].append((turns, comps))

med = statistics.median
for name, rows in groups.items():
    if not rows:
        continue
    hit = [r for r in rows if r[1]]
    print(f"=== {name}  セッション {len(rows):,}")
    print(f"  compaction が起きた : {len(hit):,} ({len(hit)/len(rows)*100:.1f}%)")
    print(f"  ターン数 中央値     : {med([r[0] for r in rows]):.0f}")
    if hit:
        first = [r[1][0] for r in hit]                     # 各セッション最初の compaction
        print(f"  最初の発生ターン    : {med([f[0] for f in first]):.0f}")
        print(f"  そのとき preTokens  : {med([f[1]['preTokens'] for f in first]):,.0f} tok")
        print(f"  捨てた累計          : "
              f"{med([r[1][-1][1].get('cumulativeDroppedTokens', 0) for r in hit]):,.0f} tok")
    print()

print("=== バッチ: ターン数別の発生率")
for lo, hi in ((0, 10), (10, 17), (17, 30), (30, 60), (60, 10**9)):
    seg = [r for r in groups["batch"] if lo <= r[0] < hi]
    if not seg:
        continue
    n = sum(1 for r in seg if r[1])
    hi_s = "" if hi > 10**8 else hi
    print(f"  {lo:>4}-{hi_s:>4} ターン: {n:>4} / {len(seg):<6} ({n/len(seg)*100:>5.1f}%)")

出力はこうなります(私の環境)。

=== batch  セッション 2,681
  compaction が起きた : 9 (0.3%)
  ターン数 中央値     : 2
  最初の発生ターン    : 20
  そのとき preTokens  : 183,128 tok
  捨てた累計          : 170,418 tok

=== interactive  セッション 244
  compaction が起きた : 139 (57.0%)
  ターン数 中央値     : 61
  最初の発生ターン    : 39
  そのとき preTokens  : 169,700 tok
  捨てた累計          : 355,276 tok

=== バッチ: ターン数別の発生率
     0-  10 ターン:    0 / 2549   (  0.0%)
    10-  17 ターン:    0 / 79     (  0.0%)
    17-  30 ターン:    4 / 39     ( 10.3%)
    30-  60 ターン:    4 / 10     ( 40.0%)
    60-   ∞ ターン:    1 / 4      ( 25.0%)

見るのは「最初の発生ターン」ではなく「そのとき preTokens」です。
そこが自分の環境での閾値なので、1件あたりの実行がそこに届かないよう切ります。

(バッチ 2,681 と対話 244 の合計が全体の 2,932 に届かないのは、entrypoint を持たない古いログと 4KB 未満の小さなログを除いているためです)

セッション中に /usage を叩けば、公式の内訳も見られます。
Pro / Max / Team / Enterprise では、直近の使用量の10%以上を占める挙動(長い文脈、キャッシュミスなど)が
フラグとして表示されます。まずここを見るのが早いです。

この測定の限界

正直に書いておきます。

  • 要約リクエスト自体の usage はログに残りません。 計上できていないので、12ターンという分岐点もまだ楽観側です
  • 単一環境・単一ユーザーのデータです。 使い方が違えば数字は変わります。だからスクリプトを載せました
  • モデルが混在しています。 絶対額ではなく、base input を1とした相対コストで扱っています
  • 相対コストは公式の課金倍率(読み 0.1倍 / 1h書き 2倍)に基づく計算で、請求額そのものではありません
  • バッチ側の「17ターン以上で10〜40%」は母数53です。 率としては使えません。信頼できるのは「17ターン未満の2,628セッションで0件」のほうで、記事の主張はそちらに乗せています
  • 「17ターン」は閾値ではありません。 引き金はトークン量で、ターン数はその代理指標にすぎません。1ターンあたりの読み込み量が違えば壁の位置も変わります
  • バッチ側のセッションは私の使い方に偏っています。 中央値2ターンという短さがそのまま 0.3% を作っているので、長時間ジョブを回している環境では比率はまったく違うはずです
  • 初出時の集計には二重計上のバグがありました。 assistant レコードは1レコード1ブロックで、分割された全レコードが同じ usage を持ちます。message.id で重複排除しない集計はターン数で約1.94倍、トークン量で1.9〜2.9倍ふくらみます。本文の数値はすべて排除後のものに差し替え、掲載スクリプトにも排除処理を入れてあります

まとめ

  • 「compact のあと跳ねる」は事実。直後ターンは通常の約26倍を書き直す
  • ただし圧縮前は毎ターン16万トークンを読んでいた。12ターンで収支は逆転する
  • ただしセッション最後の圧縮では31.8%が12ターンに届かない。 回収できるかは「そのあと続けるか」で決まる
  • 失って戻らないのは時間。 1回157秒、705回で30.7時間
  • 99.6%が自動発火。タイミングは自分で選べる(作業の切れ目で /compact、継続不要なら /clear、方向転換なら /rewind
  • 見返りなしで高いのはモデル切替・effort変更・アップグレード後の再開。こちらが先

バッチでは結論が逆になります。

  • compaction はモードに関係なく同じトークン閾値(実測16万〜18万)で発火する。バッチで0.3%なのは短いからで、安全だからではない
  • バッチには回収の物語がない。失敗が無音になる — 誰も気づかず、ネストした CLAUDE.mdpaths: ルールは戻らず、再現性が壊れ、17万トークンが記録なしで消える
  • だから起こさない設計にする。1件1セッション、状態はファイル、大きな読み込みはサブエージェント、規約は --append-system-prompt--bare--max-turns
  • それでも届いたら autoCompactEnabled: false で落とす。 Prompt is too long / 終了コード1。黙って劣化するより、赤くなるほうがいい

体感を疑うのではなく、体感の実体を特定する。ログはローカルにあります。


関連記事

長いセッションで指示が効かなくなる現象については別記事にまとめています。
/compact はトークンだけでなく、サブディレクトリの規約や paths: 付きルールを再注入しません。

AIエージェントが「知っているはず」を間違える理由 — コンテキスト設計の実務
AGENTS.md と CLAUDE.md は解決規則が逆 — symlink する前に確認すること

規約が効かなくなる4つのパターンの見分け方(コンパクションで落ちるケースを含む)と、
層の切り方、構成パターン別の実例までまとめた本があります。

AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)

第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

1
0
1

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
1
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?