LLM プロンプトキャッシュ #5:本当にヒットする LangChain の設定
目次
以下は、いかにもまともに見えるのに、キャッシュがまったく効かない LangChain の system プロンプトだ:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", BIG_STABLE_SYSTEM_PROMPT), # the syntax every tutorial uses
("human", "{question}"),
])
これを 1,800 token の同一 system プロンプトで claude-sonnet-5 に 2 回投げ、usage フィールドを確認した。どちらの呼び出しも cache write 0、cache read 0。部分ヒットでもなければ、断片化したキャッシュでもない。ゼロだ。理由はこうだ。Anthropic は cache_control でマークした部分しかキャッシュしないが、("system", ...) タプルの中のただの文字列にはマーカーを置く場所がない。LangChain で一番手軽な書き方が、割引をまるごと捨てる書き方でもあり、しかもエラーは何も出ない。
TL;DR
- LangChainの
("system", "string")タプルはcache_controlを保持できないため、Claudeは何もキャッシュしません。同一の1,800トークンのシステムプロンプトをclaude-sonnet-5で計測したところ、キャッシュ書き込み0、読み込み0でした。 - 修正方法は、コンテンツブロックに
cache_controlを持つSystemMessageを使うことです。システムブロックに1つマーカーを付ければ、bind_toolsでバインドされたツールもカバーされます。 langchain-anthropic1.4.8では、実際の書き込みがあってもinput_token_details.cache_creationは0のままです。本当のカウントはephemeral_5m_input_tokensにあります。- 順序を誤ったRAGプロンプト(安定したルールの前に変動するコンテキストを置く)は、毎回キャッシュ書き込みの割増料金、約1.25倍を支払うことになります。まったくキャッシュしない場合よりも高コストです。
これはキャッシュシリーズの第 5 回だ。第 1 回で prefix キャッシュの仕組みを、第 3 回で生の SDK を使ったチュートリアルを扱っている。今回は、LangChain がプロンプトを組み立てるようになると何が変わるかがテーマだ。以下の内容はすべて 2026-07-04 に、Synthorai ゲートウェイ経由で langchain-core 1.4.8、langchain-anthropic 1.4.8、langchain-openai 1.3.3 を使って実測した。
まず、どちらの「キャッシュ」を探しているのか?
無関係な 2 つの機能が同じ単語を共有していて、検索してたどり着く LangChain のドキュメントページはたいてい間違ったほうだ。
レスポンスキャッシュ(LangChain の InMemoryCache) | プロンプトキャッシュ(本シリーズ) | |
|---|---|---|
| 何を保存するか | 完成したレスポンス全体を、自分のアプリ内に | プロンプト prefix の KV 状態を、プロバイダ側に |
| いつ節約になるか | まったく同じリクエストが繰り返されたとき | 異なるリクエストが prefix を共有するとき |
| どこで | set_llm_cache(InMemoryCache())、SQLite、Redis | cache_control マーカー、または自動 prefix マッチング |
| エージェントループ、RAG、チャット | ほぼ役に立たない(毎回リクエストが違う) | 主要な手段。system と tools が毎ターン繰り返されるから |
そして「まったく同じリクエスト」は文字通り同じである必要がある。組み込みのキャッシュは(シリアライズしたプロンプト、model-config の文字列)のペアをキーにする。実測では、同一の繰り返しは API 呼び出しなしで 0 ms で返ってきた。プロンプトにスペースを 1 つ足しただけで外れた。同じプロンプトでも max_tokens を 1 だけ変えると外れた。(キャッシュから返す際は元の呼び出しの usage の数値をそのまま返すので、素朴に token を集計すると二重にカウントされる。)セマンティックキャッシュはサードパーティ統合として存在するが、組み込みは完全一致のみだ。
というわけで、set_llm_cache はテストで同一の呼び出しを重複排除するには十分だ。だが、エージェントの毎ターンで送り直す 2,000 token の system プロンプトはプロンプトキャッシュの仕事であり、そのためにはプロンプトを正しく組み立てる必要がある。
解決策:文字列ではなくコンテンツブロックを使う
cache_control はコンテンツブロックの内側に付与するものなので、system メッセージも素の文字列ではなく、ブロック形式の内容を持つ SystemMessage にする必要がある。
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage
from langchain_core.prompts import ChatPromptTemplate
llm = ChatAnthropic(
model="claude-sonnet-5",
base_url="https://synthorai.io", # any Anthropic-compatible endpoint
)
prompt = ChatPromptTemplate.from_messages([
SystemMessage(content=[{
"type": "text",
"text": BIG_STABLE_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}, # a bare string has nowhere to put this
}]),
("human", "{question}"),
])
chain = prompt | llm
同じ 1,800 token の system プロンプトを、同じ gateway 経由で計測した結果がこれだ。
| 呼び出し | 文字列タプル構文 | コンテンツブロック構文 |
|---|---|---|
| 1 回目(cold) | write 0 / read 0 | write 1,875 / read 0 |
| 2 回目、別の質問 | write 0 / read 0 | write 0 / read 1,875 |
warm read の課金は input 価格のおよそ 10% だ。つまり Claude では、この構造上の変更ひとつで、毎回フルプライスを払い続けるか、各呼び出しの安定部分に 90% の割引を効かせるかが分かれる。コストの試算は part 1 にある。マーカーの仕組みは LangChain Anthropic integration docs と Anthropic’s prompt caching guide にある生 SDK の使い方と一致している。
テンプレート変数をどこに置くかでヒット率が決まる
LangChain のテンプレートはどこにでも変数を差し込めて便利だが、そこがまさに落とし穴だ。キャッシュのキーはバイト単位で一致する prefix。キャッシュされるブロックの中に日付を入れて計測してみた。
SystemMessage(content=[{
"type": "text",
"text": f"Today is {today}. " + BIG_STABLE_SYSTEM_PROMPT, # variable INSIDE the block
"cache_control": {"type": "ephemeral"},
}])
| 呼び出し | 結果 |
|---|---|
| A 日目、質問 1 | write 1,865(この値にとっては cold) |
| A 日目、質問 2 | read 1,865(同じ値なのでヒット) |
| B 日目、質問 1 | write 1,865(新しい値なので再び cold) |
キャッシュが壊れたわけではない。変数の値がキーに含まれてしまっただけだ。日付のように繰り返される値なら、値ごとに 1 回 cache write を払い、その後はヒットする。タイムスタンプやリクエスト ID のように呼び出しごとに一意な値だと、毎回 cold write になり、ヒット率はきっかり 0 になる。
このミスが実運用で高くつく典型例が RAG だ。多くのチェーンで使われるテンプレートは、取得したコンテキストを system プロンプトの先頭、つまり静的な指示より前に置く。呼び出しごとに変わる 800 token の取得コンテキストとマーカー付き system ブロックで、両方の順序を計測した。
| プロンプト内の順序 | 1 回目 | 2 回目(新しいクエリ、新しいコンテキスト) |
|---|---|---|
| コンテキストが先、次にルール | write 3,133 | 再び write 3,133、read 0 |
| ルールが先(マーカー付き)、コンテキストは human turn | write 1,852 | read 1,852 |
間違った側の行は単に「割引なし」で済む話ではない。毎回、3,133 token 全体に対して通常の input 価格のおよそ 1.25 倍の cache-write プレミアムを払い、しかも一度も read されない。順序を間違えた RAG プロンプトでキャッシュを有効にすると、キャッシュしないよりコストが高くなる。 固定コンテンツが可変コンテンツの後ろに来ているので、あってもないのと同じだ。
計測から導かれるルールはこうなる。
- 静的なテキストを先に、マーカー付きブロックの中に置く。 system ルール、tool 定義、few-shot の例。
- 変わりうるものはマーカーより後ろに置く。 できれば human turn に。取得コンテキスト、日付、ユーザーの質問がこれにあたる。
- ブロック内に変数を置いてよいのは、その変数の cache write を償却できるだけの頻度で繰り返される場合に限る。
ツール定義もキャッシュされる
エージェントは呼び出しのたびにツールスキーマを送り直す。そして Anthropic のリクエスト構造では、ツールは system プロンプトの前に置かれる。マーカーは「リクエストの先頭からそこまでを全部キャッシュする」という意味なので、実務上ふたつの疑問が出てくる。system ブロックに付けたマーカーは、その前にあるツールもカバーするのか。そして LangChain の bind_tools は、毎回まったく同じバイト列にツールをシリアライズするのか。シリアライズがぶれれば prefix が変わり、すべての呼び出しがキャッシュミスになる。
どちらも実測で答えが出た。同じマーカー付き system プロンプトで、ウォームキャッシュの read はツールなしで 1,861 token、ツールを 2 つ bind した状態で 2,389 token だった。増えた 528 token はキャッシュから戻ってきたツールスキーマだ。しかもこの 2,389 は 3 回連続の呼び出しで正確に一致した。つまり bind_tools は毎回同じようにシリアライズしており、フレームワークが prefix にノイズを混ぜ込むことはない。はっきり言えば、system ブロックがマーカーを持っている限り、ツール自体に cache_control は不要だ。その後ろにある 1 つのマーカーがすべてを処理する。
逆の配置が有効な特定のケースもある。送るなかで最も大きく安定しているのがツールで、system プロンプトが薄いか存在しない場合だ。それでもリクエストにはどこかにマーカーが必要で、そのマーカーはツールに置ける。これが使えるのは Anthropic フォーマットの生の dict だけだ。@tool でデコレートした関数にはマーカーを持たせるフィールドがない。bind_tools は dict をそのまま素通しする:
# variant: NO marked system block anywhere; the tool carries the request's only marker
llm.bind_tools([{
"name": "get_weather",
"description": LONG_TOOL_DESCRIPTION,
"input_schema": {...},
"cache_control": {"type": "ephemeral"}, # passes through bind_tools verbatim
}])
実測では、cold で 3,002 write、warm で 3,002 read。リクエストにマーカー付きの system メッセージは一切ない。
マルチターン:マーカーを最後のメッセージに移す
会話は別の順序問題に見えるが、実際は逆のケースだ。履歴は追記されるだけなので順序はすでに完璧で、トランスクリプト全体が安定した prefix になる。ここでの問題はカバー範囲だ。system ブロックに付けたマーカーは system ブロックだけをキャッシュし、それより後は何もキャッシュしない。履歴が伸びても warm read は system のサイズのまま横ばいで、蓄積したターンはすべて通常の入力として課金される。
これを解決するパターンは、生の SDK が使うものと同じだ。マーカーを最新のメッセージに付ける。こうするとブレークポイントが前方にスライドし、そこまでの会話全体がキャッシュされた prefix になる:
def marked(text):
return HumanMessage(content=[{
"type": "text", "text": text,
"cache_control": {"type": "ephemeral"},
}])
# each turn: history stays plain, only the newest human message carries the marker
llm.invoke([system, *history, marked(new_question)])
2 ターンでの実測:ターン 1 で 1,864 write、ターン 2 で1,864 read、write は 15 token の差分だけ(前回の回答と新しい質問)。それ以前の prefix は read レートである約 10% で課金された。これがエージェントループに求められる形であり、LangChain では普通のメッセージリストでそれを表現できる。Anthropic は 1 リクエストあたり最大 4 つのマーカーを許可しているので、このスライドするマーカーは、system ブロックやツールに付けた固定マーカーと組み合わせられる。
メーターを読み、その名前を知る
LangChain は使用量を usage_metadata に標準化してくれる。ここで我々がハマった落とし穴がある。langchain-anthropic 1.4.8 では、我々のすべての実行で、標準の input_token_details.cache_creation フィールドがキャッシュ書き込みが発生したときでも 0 のままだった。実際の書き込み数は、標準外のキーに入る。
r = chain.invoke({"question": "..."})
det = r.usage_metadata["input_token_details"]
det["cache_read"] # correct on hits (1875 above)
det["cache_creation"] # 0 even on a cold write; do not alert on this
det["ephemeral_5m_input_tokens"] # the actual write count (1875)
プロバイダー側は書き込みを正しく報告していた(生のレスポンスでは cache_creation_input_tokens: 1875 で、r.response_metadata["usage"] から見える)。標準化されたマッピングが、それを TTL バケットのキーの下に振り分けているだけだ。cache_creation を監視するコストダッシュボードは「キャッシュは無料だ」と言い続ける一方で、書き込みのプレミアムは静かに積み上がっていく。生の usage オブジェクトを信頼するか、バケットのキーを把握しておくこと。これはゲートウェイがキャッシュフィールドを誤報告するのと同種の問題で、Does Your LLM Gateway Lie About Cache? で監査している。
暗黙的キャッシュ:順序を間違えると無言で失敗するので、ここが最も要注意
Claude のキャッシュは明示的だ。GPT やほとんどの open-weight プロバイダーは、マーカーなしでプレフィックス一致で自動的にキャッシュする。LangChain 経由なら、コンストラクタを 1 箇所変えるだけで同じチェーンが動く。
llm = ChatOpenAI(model="glm-5.2", base_url="https://synthorai.io/v1")
プレーンな文字列の system プロンプト、マーカーなし。GLM 5.2 は 2 回目の呼び出しで、約 1,850 token のプレフィックスのうち 1,088 token を読んだ。(全部ではない。自動キャッシュはバイト単位で末尾まで一致するのではなく、粗いブロック単位で一致する。OpenAI は 128 token 粒度と記載している。)ここまでは棚ぼたの節約だ。だが上の RAG 表で見た順序間違いの危険は、ここでも全力で当てはまり、しかもより厄介な失敗の仕方をする。自動パスで同じ順序の実験を、毎回新しい検索コンテキストを与えて再実行した。
| 順序(マーカーなし、自動キャッシュ) | 1 回目 | 2 回目(新しいクエリ、新しいコンテキスト) |
|---|---|---|
| コンテキストが先、次にルール | read 0 | read 0 |
| ルールが先、コンテキストは human turn に | read 0 | read 1,088 |
間違った順序は無条件でゼロになる。変化するコンテキストが先頭に置かれ、2 つの呼び出しがプレフィックスを共有せず、割引が一度も来ない。明示的パスなら、同じミスは少なくとも毎回のキャッシュ書き込みプレミアムとして請求書に現れる。暗黙的パスにはプレミアムもエラーもシグナルもない。プロンプトはただ静かに条件を満たさないままで、あなたは「自動」=「機能している」だと思い込む。しかも配置すべきマーカーがないので、プロンプトの順序が暗黙的パスで唯一いじれるつまみになる。
だからメーターで、テストで一度だけでなく本番で検証すること。input_token_details.cache_read(LangChain)または prompt_tokens_details.cached_tokens(生)だ。OpenAI の自動キャッシュはさらに最小プレフィックス 1,024 token を記載しており、TTL や適格条件はプロバイダーごとに異なる。これはパート 2 の領域だ。
チェックリスト
- Claude では,
("system", "...")という文字列タプルにはcache_controlを載せる場所がない。何もキャッシュされず,警告も出ない。キャッシュ可能な system プロンプトは,content ブロックとマーカーを持つSystemMessageに入れる。 - キャッシュキーはバイト単位で一致するプレフィックスだ。静的なコンテンツを先頭に置き,変数はマーカーの後か human ターンに置く。ルールより前に RAG コンテキストを置くと,キャッシュに当たらないだけでなく,毎回 write のプレミアムを払うことになる。
- キャッシュ対象ブロックの中に変数があると,値ごとに 1 つのキャッシュエントリができる。値が繰り返されるものはコストが償却されるが,呼び出しごとに一意な値(タイムスタンプ,リクエスト ID)は決してヒットしない。
- ツールはプレフィックス内で system プロンプトより前に置かれるので,system マーカーはバインドされたツールもキャッシュする(
bind_toolsは決定論的にシリアライズされる)。最も安定した大きなブロックがツールなら,マーカーは Anthropic 形式の tool dict に付けてもよい。 - 会話では,system ブロックに固定したマーカーだと,増え続ける履歴はフルプライスのままになる。マーカーは最新のメッセージに付ける。そうすれば各ターンで直前のプレフィックスを読み,差分だけを write する。
input_token_details.cache_creationを監視してはいけない。write のときでも 0 のままなので,ダッシュボードは「キャッシュはタダだ」と結論づける一方で,write のプレミアムは積み上がっていく。本当の値はephemeral_5m_input_tokensにあるか,生のresponse_metadata["usage"]を読む。- 自動キャッシュのモデル(GPT,GLM,DeepSeek)では,プロンプトの順序が唯一のつまみで,順序を間違えても静かに失敗する。プレミアムもエラーもなく,ただ届くはずの割引が届かないだけだ。ヒットは usage フィールドで確認する。
set_llm_cacheは,プロンプトとモデル設定が完全に一致することをキーにレスポンス全体を保存する。同一のリクエストが繰り返されるときだけ効果があり,エージェントのループでは効かない。
習慣としては小さなものだ。文字列ではなく content ブロックを使う,変数より静的なものを先に置く,会話とともに動くマーカー,正しく読んだ 1 つの usage フィールド。計測での差は,安定した全トークンに対する 90% の割引と,まったくなしとの差だった。順序を間違えた RAG のケースでは,余分に払うか否かの差になる。LangChain はプロンプトキャッシュの邪魔をするわけではない。ただ,間違ったプロンプト形状を,正しいものと同じくらい簡単に書けてしまうだけだ。
免責事項
計測は 2026-07-04 に https://synthorai.io/ に対して行った。環境は langchain-core 1.4.8,langchain-anthropic 1.4.8,langchain-openai 1.3.3,モデルは claude-sonnet-5 と glm-5.2,約 1,800 トークンの英語 system プレフィックス,小さなサンプル,そして連続する呼び出しの間に 1〜2 秒の間隔を空けて cache write が反映される時間を確保した。各実験では毎回ランダム化した新しいプレフィックスを使い,コールドキャッシュを保証した。ベースラインのトークン数が表ごとにわずかに異なる(1,852〜1,875)のはこのためだ。ライブラリのフィールドマッピングやプロバイダのキャッシュ挙動はバージョン間で変わる。数値に依存する前に,自分のスタックで計測し直すこと。