Claude APIを組み込んだ自作ツールの利用料が3日で$63.63になり、内訳を調べたところ「同じテキストを何度も送り直している」ことが原因の半分を占めていました。プロンプトキャッシュを実装したら、その部分の単価は文字どおり1/10になります。本記事は、実際の消費データの読み方から、PHPでの実装、そして実装中にハマった点までをまとめたものです。
3日で$63.63 — 内訳を見ると「同じ入力の再送」が半分だった
対象は、WordPressの仕組みからClaude API(Opus)を呼んで長文コンテンツを自動生成している自作ツールです。コンソールの集計は入力664万トークン・出力133万トークンで、金額にすると入力と出力がほぼ半々でした。
出力は生成物そのものなので減らしようがありません。問題は入力側です。呼び出しごとのログを調べると、1件あたりの入力が平常時の7千トークン前後に対して、多いものでは53万トークン・24回呼び出しに達していました。原因ははっきりしていて、次の2つの構造がどちらも「同じテキストの再送」を繰り返していたためです。
- 資料を全工程に渡す構造 — コンテンツの見出し案→構成→本文→検証と工程が進むたびに、参照用の資料(数万〜数十万トークン)を毎回そのまま送り直していた
- ツール使用のエージェントループ — AIにツールを渡して自律的に調べさせる構造は、ターンごとに「それまでの会話履歴全体」を再送する。ツールの結果が大きいと、後半のターンは1回ごとに数万トークンになる
どちらも内容は毎回まったく同じです。まったく同じものを定価で送り続けるのをやめるのが、プロンプトキャッシュです。
プロンプトキャッシュの料金構造 — 書き込み1.25倍、読み取り0.1倍
Anthropicのプロンプトキャッシュは、リクエスト中の任意のブロックに cache_control という印を付けると、そこまでの前半部分(プレフィックス)がサーバー側に約5分間保持され、次のリクエストで同じ前半部分が来たら計算を再利用してくれる仕組みです。料金は次のようになります。
- キャッシュへの書き込み(初回): 通常の入力単価の1.25倍
- キャッシュからの読み取り(2回目以降): 通常の入力単価の0.1倍
つまり同じ前半部分を2回以上送るなら、ほぼ確実に得をします。今回の構成(Opus・入力$5/100万トークン)なら、読み取りは$0.5/100万トークンまで下がります。53万トークンを4〜5回再送していた処理は、1件あたり$3前後かかっていたものが$0.5程度になる計算です。
効かせるための条件が1つだけあり、これが実装のすべてを決めます。キャッシュは「完全に同一の先頭部分」にしか効きません。1文字でも違えば別物として全額課金されます。また、キャッシュ対象になる最小長(Opusで1,024トークン)未満の短い部分には付けても効きません。
PHP実装① 複数の工程で同じ資料を使い回す場合
ポイントは、キャッシュしたい部分を独立したcontentブロックにして、そこに cache_control を付けることです。Messages APIの content は文字列だけでなくブロックの配列を受け付けます。
$body = array(
'model' => $model,
'max_tokens' => 16000,
'messages' => array(
array(
'role' => 'user',
'content' => array(
array(
'type' => 'text',
'text' => $shared_context, // 巨大な資料(全工程で同一)
'cache_control' => array( 'type' => 'ephemeral' ),
),
array(
'type' => 'text',
'text' => $step_prompt, // 工程ごとに変わる指示
),
),
),
),
);
$response = wp_remote_post(
'https://api.anthropic.com/v1/messages',
array(
'headers' => array(
'x-api-key' => $api_key,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
),
'body' => wp_json_encode( $body ),
)
);
コードより大事なのは設計のほうです。「完全に同一」を守るために、共有部分は必ず1つの関数で組み立てるようにしました。以前は工程ごとにプロンプトの冒頭が微妙に違っていて(役割の説明文が先か、資料が先か、という程度の違い)、そのままではキャッシュが一切効きません。共有部分を組み立てる関数を1箇所に集約し、各工程は「共有部分+自分の指示」という形に統一しています。
PHP実装② エージェントループでは「区切りを毎ターン付け替える」
ツール使用のループでは、キャッシュしたい前半部分がターンのたびに伸びていきます。そこで、毎ターン送信直前に既存の印を全部外して、最新メッセージの末尾1箇所にだけ付け直す方式にしました。こうすると「前ターンまでの履歴全体」が常にキャッシュ読み取りになります。
// 送信直前: 前ターンで付けた印を外し、常に末尾1箇所だけにする
foreach ( $messages as &$msg ) {
if ( is_array( $msg['content'] ) ) {
foreach ( $msg['content'] as &$block ) {
unset( $block['cache_control'] );
}
unset( $block );
}
}
unset( $msg );
$last = count( $messages ) - 1;
if ( is_array( $messages[ $last ]['content'] ) ) {
$b = count( $messages[ $last ]['content'] ) - 1;
$messages[ $last ]['content'][ $b ]['cache_control'] = array( 'type' => 'ephemeral' );
}
印を付けっぱなしで追加していくと、リクエストあたりの区切り上限(4箇所)をすぐ超えてAPIエラーになります。「付け替える」のが正解です。固定のsystemプロンプトにも1箇所付けておくと、ループ全体を通して再利用されます。
実装でハマった点 — 3つとも仕様どおりだが気づきにくい
usageのinput_tokensにキャッシュ分は含まれない
実装後、応答の usage.input_tokens が急に小さくなって一瞬バグを疑いますが、キャッシュ関連は cache_creation_input_tokens(書き込み)と cache_read_input_tokens(読み取り)という別のフィールドで返ってきます。実際の入力規模は3つの合計です。自前でコスト集計をしている場合は、この2フィールドも記録しないと「なぜか安い」ように見えて実態を見失います。
空のtextブロックはAPIに拒否される
ブロック配列方式に変えると、資料が空のケースで空文字のブロックを送ってしまいがちです。空の text ブロックはエラーになるため、組み立て時に空セグメントを除外する処理を入れておきます。
PHPのjson_decodeで空オブジェクトが配列になる
これはキャッシュ自体ではなくエージェントループ側の話ですが、応答を json_decode( $body, true ) で連想配列にしていると、引数なしのツール呼び出しの input: {} が空配列 [] になり、履歴として送り返すときにJSONの配列 [] へ化けて「Input should be an object」で弾かれます。ツールに必須引数を1つ持たせるか、送り返す直前に空配列をオブジェクトへ戻す対処が要ります。
実測 — 2回目の呼び出しでキャッシュ読み取りを確認
同一の資料ブロック(約9,600トークン)を先頭に付けた呼び出しを2回続けた結果です。
1回目: input=25 cache_write=9604 cache_read=0
2回目: input=25 cache_write=0 cache_read=9604
2回目は9,604トークン分が0.1倍の単価になりました。エージェントループ側も、2ターン目で履歴約5,000トークンがそのままキャッシュ読み取りになることを確認しています。
冒頭の内訳に当てはめると、入力側のコストはおおよそ1/3以下、全体では4割前後の削減になる見込みです。「同じ資料を工程をまたいで使い回す」「エージェントループを回す」——どちらか一方でも当てはまる構成なら、実装は上記のとおり小さく済むので、先に入れておく価値があります。
EC-CUBE公式パートナー