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?

「キャッシュ読出がトークンの81倍」はコストの話ではなかった — 課金の3分の1は書込で、同じ内容を平均3.7回書いていた

0
Posted at

3行で

  • セッションログの usage を全部足したら、キャッシュ読出はトークン数で出力の 81.1 倍だった。ここで「コストの実体はコンテキストの再読だ」と書きかけて、単価を掛けたら金額では 1.62 倍しかなかった。
  • 本当の発見は別のところにあった。キャッシュ書込がトークン量では読出の 6.3% しかないのに、金額では総額の 32.7% を占めている。
  • さらに書込の総量は、そのセッションが到達した最大コンテキスト長の 合計 3.7 倍(中央値 2.1 倍、最大 31.6 倍)。同じ内容を何度も書き直している。

危うく間違ったことを書くところだった

計測して最初に出たのがこれだった。

cache_read  1,554,989,984 tok
output         19,173,210 tok
→ 81.1 倍

15億トークン。出力の 81 倍。「エージェントの課金は、生成ではなくコンテキストの再読で決まる」という書き出しが頭に浮かんだ。数字としては正しいし、直感にも合う。

単価が違うことを忘れていた。 キャッシュ読出は入力の 1/10、出力に対しては 1/50 の単価で課金される。Opus 系の公開レートで掛け直すとこうなる。

種別 トークン 単価(/MTok) 金額 構成比
入力(非キャッシュ) 223,956 $15.00 $3.36 0.1%
キャッシュ書込 97,701,564 $18.75 $1,831.90 32.7%
キャッシュ読出 1,554,989,984 $1.50 $2,332.48 41.6%
出力 19,173,210 $75.00 $1,437.99 25.7%
合計 $5,605.74

トークンで 81 倍のものが、金額では 1.62 倍。 「81倍」を根拠に何かを最適化していたら、20 分の1 の効果しか出ない場所を削っていたことになる。

トークン数はコストの代理変数にならない。単価が 50 倍違うものを1つの単位で足しているのだから、当たり前といえば当たり前だった。

目立たない行が一番高い

表をもう一度見ると、キャッシュ書込の行がおかしい。

  • 読出の 6.3% のトークン量しかない(97.7M vs 1,555.0M)。
  • なのに金額は読出の 78.5%($1,831.90 vs $2,332.48)。
  • 総額の 32.7% を1行で持っていっている。

単価が読出の 12.5 倍($18.75 vs $1.50)だからだ。読んだ回数ではなく、書いた回数で効いてくる。

そして書込は例外的なイベントではなかった。assistant のターン 18,462 回のうち、cache_creation が 0 でないターンは 18,414 回、99.7%。ほぼ毎ターン書いている。1ターンあたりの書込は中央値 1,978 トークン。

これは仕組みとしては自然だ。会話は毎ターン伸びる。伸びた分は次のターンで読めるようにキャッシュへ書く。だから毎ターン書込が出る。

問題は量のほうだった。

同じ内容を、平均3.7回書いている

「伸びた分だけ書く」なら、1セッションの書込の総和は、そのセッションが最終的に到達したコンテキスト長とだいたい同じになるはずだ。500 行の文章を1行ずつ書き足していけば、書いた総量は 500 行になる。

測ったらそうならなかった。

書き直しの回数(10ターン以上の 213 セッション)
  書込総量 / 到達した最大コンテキスト
    中央値 2.1 倍 / p90 4.5 倍 / 最大 31.6 倍
    1.5倍以下(ほぼ1回だけ書けている) 58 本 = 27%
    総和で見ると 91,256,256 / 24,838,167 = 3.7 倍
  ターン数の多い順に5本:
     1845 ターン  最大  216,527 tok  書込計  5,223,989 tok  =  24.1 倍
     1456 ターン  最大  272,947 tok  書込計  6,707,632 tok  =  24.6 倍
     1421 ターン  最大  328,362 tok  書込計  6,522,815 tok  =  19.9 倍
      678 ターン  最大  167,123 tok  書込計  5,273,649 tok  =  31.6 倍
      544 ターン  最大  193,536 tok  書込計  2,223,838 tok  =  11.5 倍

総和で 3.7 倍。つまり書込コスト $1,831.90 のうち、内容の総量ぶんは $495 程度で、残りの約 $1,337 は書き直しにあたる。総額 $5,605.74 の 23.8%。

そして長いセッションほど悪化する。 1,000 ターンを超えるセッションはどれも 20 倍前後。678 ターンで 31.6 倍というものもある。「1回書けている」と言える 1.5 倍以下は 213 本中 58 本(27%)だけで、それらは全部短いセッションだった。

比率が 1 に近いセッションと 30 のセッションで、やっていることは変わらない。違うのは長さだけ。

なぜ書き直しが起きるのか(ここは推測が混じる)

キャッシュされた前置きが無効になれば、次のターンで丸ごと書き直しになる。無効になる要因として一般に知られているのは、

  • 有効期限(TTL)の切れ。既定は短く、ターンの間隔が空くと消える。
  • 前置きの変化。ツール定義や system プロンプトが変われば、そこから後ろは全部作り直しになる。
  • 区切り点の数の上限。キャッシュの区切りは無制限には置けないので、会話が伸びると区切りの外側の量が増える。

私が見えているのは usage の数字だけで、どれがどれだけ効いたかは分離できていない。ただ「長いセッションほど倍率が上がる」という観測は、上の3つのどれとも整合する。ターンが多ければ間隔の空く機会も増え、区切りの外側も伸びる。

はっきり言えるのは因果ではなく相関で、倍率はターン数とともに上がり、コストの4分の1がそこに乗っているということ。

それで何をするか

計測から出てくる手は、意外に地味だった。

1. 長いセッションを1本続けるより、区切って始め直したほうが安いことがある。 これは直感に反する。「続けたほうがキャッシュが効く」と思っていた。実際には、続けるほど書き直しの倍率が上がっていた。24 倍払っている 1,456 ターンのセッションは、途中で切って必要なものだけ持ち込めば、書込の大半が消える。

ただし切ると文脈を失うので、失った文脈を取り戻すための読み直しコストと釣り合わせる必要がある。**どちらが安いかはセッションによる。**測らないと分からない、というのが正直な結論。

2. ターンの間隔を空けない。 期限切れが要因のひとつである以上、放置は書き直しを呼ぶ。長時間の外部処理を待つ間に会話を止めるくらいなら、待ちを別の仕組みに逃がしたほうがいい。

3. コンテキストに入る量そのものを減らす。 書込は書く内容の量に比例する。ここで最初の表が効いてくる。

ツール結果 9,948 件 / 総量 19,508,134 文字
  中央値 686 / p90 4,793 / p99 19,454 / 最大 54,197

上位が占める割合
  上位   1.0% (   99 件) が総量の  15.0%
  上位  10.0% (  994 件) が総量の  53.5%

上位 10% の呼び出しが、コンテキストに入る量の 53.5% を持ってくる。 中央値 686 文字に対して p99 は 19,454 文字、最大 54,197 文字。分布の裾が全部を決めている。

ツール別に見ると、どこを絞ればいいかが分かる。

  Bash              6436 件  計 10,887,376 字  中央値     835  最大   29,763
  Read              1332 件  計  6,962,441 字  中央値   2,760  最大   54,197
  WebFetch           146 件  計    626,966 字  中央値     791  最大   35,885
  Edit              1239 件  計    217,230 字  中央値     174  最大      936

Read は呼び出し回数が Bash の 5 分の1 なのに、総量は 64%。1回あたり 2,760 文字で、最大は 54,197 文字。ファイルを丸ごと読むのだから当然だが、これがそのまま書込トークンになり、以降の全ターンで読出トークンになる。

必要な範囲だけ読む(行範囲を指定する、grep で当たりをつけてから読む)が、そのまま書込コストに効く。「速さのため」ではなく「課金のため」の理由がここにある。

逆に、キャッシュ自体は効いている

念のため、キャッシュを使わなかった場合と比べておく。読出ぶんが全部通常の入力単価だったとすると、

  キャッシュ無しなら $24,766.20 → 実際 $5,605.74(77.4% 減)

77.4% 減っている。 キャッシュは明確に効いていて、問題は「効いていない」ことではなく「書き直しのぶんを取りこぼしている」ことだ。$24,766 が $5,606 になり、そのうち $1,337 が書き直し。まだ削れる。

自分のログで測る

以下を context_volume.py として保存する。RATE は自分の契約のレートに書き換える。

#!/usr/bin/env python3
"""ツール結果がコンテキストに入れた文字数と、トークン課金の内訳を測る。

    python3 context_volume.py [~/.claude/projects]
"""
import sys, os, glob, json, collections, statistics

# Opus 系の公開レート(USD / 100万トークン)。自分の契約に合わせて書き換える。
RATE = {"input": 15.0, "output": 75.0, "cache_write": 18.75, "cache_read": 1.50}


def scan(root):
    sizes = collections.defaultdict(list)   # tool -> [文字数, ...]
    usage = collections.Counter()
    # 1ファイル = 1つのコンテキストの流れ。sessionId でまとめると、
    # 別コンテキストを持つサブエージェントの分まで親に合算されてしまう。
    turns = collections.defaultdict(list)   # file -> [(read, write, input), ...]
    for path in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
        pending = {}
        with open(path, encoding="utf-8", errors="replace") as fh:
            for line in fh:
                try:
                    ev = json.loads(line)
                except ValueError:
                    continue
                msg = ev.get("message") or {}
                u = msg.get("usage")
                if isinstance(u, dict):
                    usage["input"] += u.get("input_tokens", 0)
                    usage["output"] += u.get("output_tokens", 0)
                    usage["cache_write"] += u.get("cache_creation_input_tokens", 0)
                    usage["cache_read"] += u.get("cache_read_input_tokens", 0)
                    turns[path].append((
                        u.get("cache_read_input_tokens", 0),
                        u.get("cache_creation_input_tokens", 0),
                        u.get("input_tokens", 0)))
                content = msg.get("content")
                if not isinstance(content, list):
                    continue
                for blk in content:
                    if not isinstance(blk, dict):
                        continue
                    if blk.get("type") == "tool_use":
                        pending[blk.get("id")] = blk.get("name")
                    elif blk.get("type") == "tool_result":
                        body = blk.get("content")
                        if isinstance(body, list):
                            body = " ".join(b.get("text", "") for b in body
                                            if isinstance(b, dict))
                        name = pending.get(blk.get("tool_use_id"), "?")
                        sizes[name].append(len(str(body or "")))
    return sizes, usage, turns


def main():
    root = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/.claude/projects")
    sizes, usage, turns = scan(root)
    allsz = sorted(s for v in sizes.values() for s in v)
    if not allsz:
        print("ツール結果が見つからない。パスを確認する。")
        return 1
    total = sum(allsz)
    n = len(allsz)
    pick = lambda p: allsz[min(int(n * p), n - 1)]

    print(f"ツール結果 {n:,} 件 / 総量 {total:,} 文字")
    print(f"  中央値 {pick(.50):,} / p90 {pick(.90):,} / p99 {pick(.99):,} / 最大 {max(allsz):,}")
    print(f"  平均   {total // n:,}(中央値の {total / n / max(1, pick(.50)):.1f} 倍)")

    print("\n上位が占める割合")
    for p in (0.001, 0.01, 0.05, 0.10, 0.50):
        k = max(1, int(n * p))
        share = sum(allsz[-k:]) / total * 100
        print(f"  上位 {p * 100:>5.1f}% ({k:>5} 件) が総量の {share:>5.1f}%")

    print("\nツール別(100件以上)")
    for tool, v in sorted(sizes.items(), key=lambda x: -sum(x[1])):
        if len(v) >= 100:
            print(f"  {tool:<16}{len(v):>6} 件  計 {sum(v):>10,}"
                  f"  中央値 {statistics.median(v):>7,.0f}  最大 {max(v):>8,}")

    if usage:
        print("\nトークン課金")
        cost = {k: usage[k] / 1e6 * RATE[k] for k in RATE}
        gross = sum(cost.values())
        for k in ("input", "cache_write", "cache_read", "output"):
            print(f"  {k:<12}{usage[k]:>16,} tok   ${cost[k]:>9,.2f}"
                  f"   {cost[k] / gross * 100:>5.1f}%")
        print(f"  {'合計':<12}{'':>16}      ${gross:>9,.2f}")
        if usage["output"]:
            print(f"\n  cache_read / output = トークン {usage['cache_read'] / usage['output']:.1f}"
                  f" / 金額 {cost['cache_read'] / cost['output']:.2f}")
        alt = ((usage["input"] + usage["cache_read"]) / 1e6 * RATE["input"]
               + usage["output"] / 1e6 * RATE["output"])
        print(f"  キャッシュ無しなら ${alt:,.2f} → 実際 ${gross:,.2f}"
              f"{(1 - gross / alt) * 100:.1f}% 減)")

    # 同じ内容を何回書き直しているか。
    # 1セッションが到達した最大コンテキスト長 ≒ 書くべき内容の総量。
    # 書込の総和がそれを大きく超えるなら、同じ前置きを繰り返し書いている。
    ratios = []
    for v in turns.values():
        if len(v) < 10:
            continue
        peak = max(r + w + i for r, w, i in v)
        wtot = sum(w for _, w, _ in v)
        if peak > 1000 and wtot > 0:
            ratios.append((wtot / peak, len(v), peak, wtot))
    if ratios:
        rs = sorted(x[0] for x in ratios)
        pick = lambda p: rs[min(int(len(rs) * p), len(rs) - 1)]
        print(f"\n書き直しの回数(10ターン以上の {len(ratios)} セッション)")
        print(f"  書込総量 / 到達した最大コンテキスト")
        print(f"    中央値 {statistics.median(rs):.1f} 倍 / p90 {pick(.90):.1f}"
              f" / 最大 {max(rs):.1f}")
        one = sum(1 for x in rs if x <= 1.5)
        print(f"    1.5倍以下(ほぼ1回だけ書けている) {one} 本 = {one / len(rs) * 100:.0f}%")
        tw = sum(x[3] for x in ratios)
        tp = sum(x[2] for x in ratios)
        print(f"    総和で見ると {tw:,} / {tp:,} = {tw / tp:.1f}")
        print("  ターン数の多い順に5本:")
        for r, n, peak, wtot in sorted(ratios, key=lambda x: -x[1])[:5]:
            print(f"    {n:>5} ターン  最大 {peak:>8,} tok"
                  f"  書込計 {wtot:>10,} tok  = {r:>5.1f}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

セッションのまとめ方に注意がいる。 最初 sessionId でまとめたら倍率が 7.8 倍になった。サブエージェントは親と別のコンテキストを持つのに、ログ上は同じ sessionId を共有することがある。別コンテキストの書込を親の最大長で割ると、倍率が水増しされる。1ファイル=1つの流れ、として数えると 3.7 倍になった。

この記事の限界

  • 単価は公開レートを手で入れたもので、実際の請求書と突き合わせていない。契約や割引で変わるし、モデルによっても違う。構成比の議論には耐えるが、金額そのものは目安として読んでほしい。
  • モデルが混在している。 私のコーパスは Opus 系が主だが Sonnet や Haiku も混じっていて、全部に Opus のレートを掛けている。安いモデルのぶんは過大評価になっている。
  • 「到達した最大コンテキスト」は書くべき内容の総量の近似でしかない。会話の途中で内容が縮む(要約されるなど)と、この近似は崩れる。3.7 倍という数字はそのぶん幅を持って読む必要がある。
  • 書き直しの原因は分離できていない。 上に書いたとおり、期限切れ・前置きの変化・区切り点、どれがどれだけ効いたかは usage からは見えない。

まとめ

  • トークン数はコストの代理変数にならない。単価が 50 倍違うものを足しているので、「81 倍」は金額では 1.62 倍だった。
  • 一番高い行は、一番トークンの少ない行だった。キャッシュ書込は読出の 6.3% の量で、総額の 32.7%。
  • その書込の総量は、内容の総量の 3.7 倍。差額は書き直しで、総額の約 4 分の1。
  • **長いセッションほど倍率が上がる。**1,000 ターン級はどれも 20 倍前後。
  • コンテキストに入る量は上位 10% の呼び出しが半分を持ってくる。Read は1回 2,760 文字で、そこが最大の蛇口。

トークンを数える前に、単価を掛ける。


関連

キャッシュ書込は、コンテキストに何を積んだかがそのまま金額に出る場所です。参照させる文書を増やした分は、セッションの回数だけ課金されます。

何を削り、何をどの層に置くかをまとめた本があります。

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

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

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?