長時間のセッションで、使っていないツールを外そうとしたときのことでした。
ツールが12本ぶら下がったまま会話が進んでいて、明らかに後半では使わないものが混ざっている。ターン間でツールを追加・削除できるベータが来ていましたので、素直に4本ほど削りました。入力トークンが減るはずでした。
減りませんでした。むしろ増えました。
cache_read_input_tokens がゼロに落ちて、それまで積み上がっていた会話履歴が丸ごと再送されていたのです。ツールを削って節約したつもりが、キャッシュを壊して全部払い直していた。手が止まりました。
ツール定義はプレフィックスの最前列にいる
プロンプトキャッシュは接頭辞(プレフィックス)の一致で効きます。リクエストの先頭から一致している範囲までがキャッシュヒットの対象で、どこか一箇所でも変われば、そこから後ろは全部ミスになります。
問題は、ツール定義がその最前列に置かれることです。Claude API のリクエストは、おおよそ次の順序で直列化されます。
順序 ブロック 変更頻度 下流への影響
1 tools本来は低いはず system・messages のすべて
2 system低い messages のすべて
3 messages(過去ターン)追記のみ 以降のターン
4 messages(最新ターン)毎回 なし
つまりツール配列は、リクエスト全体で最も影響半径の大きい場所にいます。会話が100ターン積み上がっていようと、ツールを1本入れ替えた瞬間に、その100ターン分のキャッシュが道連れになる。
ターン間のツール変更がベータで解禁されたことと、その変更がキャッシュにとって安全であることは、別の話でした。API が変更を受け付けてくれることと、プレフィックスが保たれることは、同じではありません。
ここを取り違えていました。私自身、ベータの告知文にある「プロンプトキャッシュを維持したまま」という一節を、どんな変更でも維持されるという意味に読んでいたのです。
12種類の変更を指紋で測る
感覚で語っても仕方がないので、測ることにしました。ツール配列を先頭から1本ずつ足していったときの累積ハッシュ列を作り、変更前後でどこまで一致するかを見ます。
# toolprefix.py — ツールブロックのプレフィックス指紋を測る
import hashlib
import json
def canon (obj):
"""決定論的シリアライズ: キーをソートし、区切りを固定する"""
return json.dumps(obj, sort_keys = True , separators = ( "," , ":" ), ensure_ascii = False )
def prefix_chain (tools):
"""ツール配列を先頭から1本ずつ足したときの累積ハッシュ列を返す"""
chain, h = [], hashlib.sha256()
for t in tools:
h = h.copy() # copy しないと以降の update が混ざる
h.update(canon(t).encode())
chain.append(h.hexdigest())
return chain
def shared_prefix_len (a, b):
"""2つのハッシュ列が先頭から何要素一致しているか"""
n = 0
for x, y in zip (a, b):
if x != y:
break
n += 1
return n
h.copy() を挟んでいるのは、各段の途中経過を別々に取り出すためです。ここを省くと同じオブジェクトを更新し続けてしまい、全段が最終値になります。最初これで丸一晩、意味のない100%一致を眺めていました。
ベースになるツール配列は、実際の構成に近い5本にしました。
def mk (name, props):
return {
"name" : name,
"description" : f " { name } tool" ,
"input_schema" : {
"type" : "object" ,
"properties" : props,
"required" : sorted (props.keys()),
},
}
BASE = [
mk( "search_docs" , { "query" : { "type" : "string" }, "top_k" : { "type" : "integer" }}),
mk( "read_file" , { "path" : { "type" : "string" }}),
mk( "write_file" , { "path" : { "type" : "string" }, "body" : { "type" : "string" }}),
mk( "run_tests" , { "suite" : { "type" : "string" }}),
mk( "post_report" , { "channel" : { "type" : "string" }, "text" : { "type" : "string" }}),
]
base_chain = prefix_chain( BASE )
for label, mutate in CASES :
n = shared_prefix_len(base_chain, prefix_chain(mutate( BASE )))
print ( f " { label :<26 } { n } / { len ( BASE ) } { n / len ( BASE ) :.0% } " )
手元で12パターンを流した結果です。
変更パターン 共有プレフィックス 保全率 判定
末尾に1本追加 5/5 100% 安全
内容は同一・再構築のみ 5/5 100% 安全
末尾の1本を削除 4/5 80% 部分
末尾ツールの説明文を修正 4/5 80% 部分
末尾ツールに任意項目を追加 4/5 80% 部分
中間に1本挿入 2/5 40% 部分
中間の1本を削除 2/5 40% 部分
中間ツールを改名 2/5 40% 部分
2番目と3番目を入れ替え 1/5 20% 部分
先頭に1本追加 0/5 0% 全損
先頭の1本を削除 0/5 0% 全損
先頭ツールの説明文を修正 0/5 0% 全損
完全保全は12件中2件、平均保全率は48%でした。
そして、この表の「部分」という判定こそが落とし穴です。プレフィックスが4/5まで一致していても、cache_control のブレークポイントを置いていない位置での一致には何の価値もありません。キャッシュの単位はブレークポイントで区切られた区間であって、ツール1本ごとではないからです。
ブレークポイントをツール配列の最後尾にだけ置いている構成では、上の12パターンのうち実際に救われるのは「安全」の2件だけです。残り10件は、保全率が80%だろうと20%だろうと、等しくゼロとして扱われます。
自分が壊していたのは、まさにこれでした。使わないツールを削るとき、配列の中ほどにいた post_report を抜いていたのです。
同じ内容なのに指紋が変わる
もう一つ、測っていて背筋が寒くなった挙動があります。
ツールの中身が論理的にまったく同じでも、組み立てた順序が違うだけで指紋が変わります。Python の辞書は挿入順を保持しますので、properties に項目を足す順番が違えば、json.dumps の出力も違ってきます。
条件 組み立て順の通り数 素の json.dumps 正規化あり
フィールド順・プロパティ順ともに変動 36 36種の指紋 1種
フィールド順は固定・プロパティ順のみ変動 6 6種の指紋 1種
36通りすべてが別々の指紋になりました。偽の無効化率は35/35、つまり100%です。
これは机上の心配ではありません。ツール定義を条件分岐で組み立てているコード、たとえば「管理者ならこのプロパティを足す」「デバッグ時だけこの項目を入れる」といった書き方をしていると、分岐の順序次第で同じツールが別物として直列化されます。ログには「ツールは変えていない」と映るのに、キャッシュだけが静かに落ち続ける。
対策そのものは一行です。
json.dumps(tool, sort_keys = True , separators = ( "," , ":" ), ensure_ascii = False )
sort_keys=True でキー順を固定し、separators で空白の入り方を固定します。ensure_ascii=False は日本語の説明文を含むツールで出力を安定させるために付けています。これを通した辞書をリクエストに載せる、というところまでやって初めて意味を持ちます。指紋の計算だけ正規化して、実際に送るのは素の辞書、では何も変わりません。
安定コアと揮発テールに分ける
ここまでで方針が決まりました。ツール配列を一枚の板として扱うのをやめ、二つの区画に分けます。
安定コア : そのセッションの間、絶対に変えない。末尾に cache_control のブレークポイントを置く
揮発テール : コアの後ろに置く。この区画の中でなら、追加も削除も並べ替えも自由
コアが不変であるかぎり、テールで何をしてもコアまでのプレフィックスは一致します。ツールを「外す」のではなく「テールに移してから外す」に変えるだけで、影響半径が閉じ込められます。
import hashlib
import json
from dataclasses import dataclass
class ToolPrefixViolation ( Exception ):
"""コアが変化したまま送信されようとしたときに送出される"""
def canon (o):
return json.dumps(o, sort_keys = True , separators = ( "," , ":" ), ensure_ascii = False )
@dataclass ( frozen = True )
class ToolSegments :
core: tuple # 会話中は不変。末尾に cache_control を置く
tail: tuple # 差し替え自由
@ property
def core_fingerprint (self) -> str :
h = hashlib.sha256()
for t in self .core:
h.update(canon(t).encode())
return h.hexdigest()[: 16 ]
def to_request_tools (self) -> list :
# 正規化を通した辞書をそのまま送る(指紋だけ正規化しても意味がない)
tools = [json.loads(canon(t)) for t in self .core]
tools[ - 1 ][ "cache_control" ] = { "type" : "ephemeral" } # コア末尾に breakpoint
tools += [json.loads(canon(t)) for t in self .tail]
return tools
to_request_tools が返す配列で cache_control が付くのは、コアの最後の1本だけです。ここが区間の切れ目になります。テールに何本ぶら下がっていても、この切れ目より前は動きません。
コア改変を弾くガードを挟む
規律は、破れるようになっていると破れます。コアを触れないようにコードで縛りました。
class ToolSetSession :
"""1セッション分のツール構成。コアの指紋をピン留めして監視する"""
def __init__ (self, segments: ToolSegments):
self ._pinned = segments.core_fingerprint
self .segments = segments
def mutate_tail (self, new_tail) -> ToolSegments:
nxt = ToolSegments( core = self .segments.core, tail = tuple (new_tail))
if nxt.core_fingerprint != self ._pinned:
raise ToolPrefixViolation(
f "コアが変化しました pinned= { self ._pinned } got= { nxt.core_fingerprint } "
)
self .segments = nxt
return nxt
動かすとこうなります。
初期コア指紋: daf5a27a5273fe97
送信 tools の cache_control 位置: [2]
テール差し替え後のコア指紋: daf5a27a5273fe97 → 一致
コア改変を検出: コアが変化しました pinned=daf5a27a5273fe97 got=8ac9bd8f8b1072d2
ToolSegments を frozen=True にしているのは、うっかり segments.core.append(...) のような書き方が通らないようにするためです。タプルにしているのも同じ理由です。締めておかないと、半年後の自分が締め忘れます。
例外を投げる設計にしたのは、静かに劣化するより落ちてくれた方が安いからです。キャッシュミスは動作としては正常なので、テストでは検出できません。請求書で気づくことになります。
40ターンのセッションで比べる
分割の効き目を確かめるため、40ターンぶんのツール操作を乱数で作り、二つの運用方式に同じ操作列を流しました。
A : 単一セグメント。有効なツールを毎ターン集めて名前順にソートし、配列を組み直す(よく見る書き方です)
B : 安定コア6本を固定し、揮発テールだけを操作する
操作の内訳は add が10回、drop が12回、swap が9回、変更なしが9回です。
方式 コア指紋の一致 保持率 コアの無駄な再送
A 単一セグメント 1/40 ターン 2% 39ターン分 = 38,610 bytes
B 安定コア+揮発テール 40/40 ターン 100% 0 bytes
安定コア6本の直列化サイズは990バイトでした。A ではそれが39回、まるごと再送され直しています。
ここで効いているのは分割そのものよりも、A が毎ターン「名前順にソートして組み直す」という一見きれいな処理をしている点です。ツールが1本増減するだけでソート後の並びがずれ、コアの位置が動く。整列は決定論的なのだから安定するはず、という直感と真逆でした。決定論的であることと、変更に対して安定であることは別の性質です。
なお、ここで測っているのはツールブロックの直列化バイト数であって、課金トークンそのものではありません。実際の削減額は、コアの後ろにどれだけ会話履歴が積み上がっているかで大きく変わります。長い会話ほど、この990バイトの背後で失われるものが大きくなる、という読み方をしています。
運用で気をつけている点
個人開発でスケジュール実行を回していると、失敗が請求書まで見えてこないことがあります。私自身、壊れていた期間を後から特定できずに悔しい思いをしましたので、いくつか手を打ちました。
コアに入れる基準を決める : そのセッションで一度でも呼ばれる可能性があるものはコア。「たぶん使わない」は揮発テール。迷ったらテールに置きます。コアは小さいほど壊れにくくなります
ツール定義をコードから直接組み立てない : 宣言をモジュール定数として持ち、条件分岐は「どれを選ぶか」だけに使います。中身を分岐で組み立てた瞬間、順序が揺れます
cache_read_input_tokens をターンごとに記録する : ゼロに落ちた瞬間のツール指紋を一緒に残しておくと、原因の特定が数分で済みます。残していないと、どのターンで壊れたか分からなくなります
テールが育ったらセッションを切る : テールが膨らみすぎたら、新しいコアを組み直して会話を畳んだ方が安いことがあります。テールもトークンではあります
削除より無効化を先に検討する : tool_choice で選ばせない、あるいはツール側で「この段階では使えません」と返す方が、配列から抜くより安全な場面があります
3番目は、実際に助けられました。ミスが起きたターンの指紋と、その1つ前の指紋を並べれば、何が変わったかは一目で分かります。
構成別には、次のように振り分けています。
構成 推奨 理由
ツール5本以下・会話が短い 分割せず単一セグメントのまま 守る履歴が薄く、分割の管理コストが上回ります
ツール6本以上・履歴が積み上がる 安定コア+揮発テール 本記事の構成そのものです。効き幅が最も大きい領域です
権限でツールが増減する 全候補をコアに置き tool_choice で制御 配列を動かさずに実効的な絞り込みができます
ツールが動的に生成される 生成結果をテールに固定順で連結 生成順の揺れをコアから隔離できます
次の一歩
まずは、いま動いているエージェントのツール配列を1回だけ直列化して、sort_keys=True を通した場合と通さない場合でハッシュを比べてみてください。ここが割れているなら、ツールを何も変えていないターンでもキャッシュが落ちている可能性があります。
割れていなければ、次はブレークポイントの位置です。ツール配列のどこに cache_control が付いているかを出力してみると、自分の構成でどのツールまでが守られているのかが見えます。
ツールを削ってコンテキストを節約する、という最適化が逆に高くつくことがある。しばらく気づけませんでした。数字を並べて初めて腑に落ちた種類の話ですので、同じところで手を止めている方の役に立てば嬉しく思います。