Claude API を組み込んだサービスで、レスポンスキャッシュを入れると数字はすぐ良くなります。私自身が個人開発で運用しているツールでも、応答は数秒から数十ミリ秒に縮み、ヒットした分の API コストは丸ごと消えました。ところが本番でしばらく回すと、平均ヒット率という見やすい指標の裏で、二種類の事故が静かに溜まっていきます。ひとつは内容が変わったのに古い回答を返し続ける「stale hit」、もうひとつは別の意図の質問に過去の回答を当ててしまう「false hit」です。
この二つは平均ヒット率を上げるほど増えます。つまり「キャッシュがよく効いている」という指標と、「キャッシュが間違えている」という現実は、同じ方向に伸びてしまうのです。ここでは Claude API のアプリケーション層キャッシュを、速度を稼ぐ仕組みではなく「間違えない仕組み」として設計し直す手順を、動くコードとともに整理します。
レスポンスキャッシュには二つの静かな事故がある
完全一致キャッシュ(リクエストのハッシュをキーに Redis へ保存)でも、セマンティックキャッシュ(埋め込みの近傍検索で似た質問に再利用)でも、壊れ方は同じ構造をしています。
事故 起きること 気づきにくい理由
stale hit(陳腐化) 参照元の文書・価格・在庫が変わったのに、変更前の回答を返す キャッシュは正常に動作している。ヒット率はむしろ上がる
false hit(誤ヒット) 「返金できますか」と「返金できませんと言われた、なぜ」に同じ回答を返す 類似度は高い。文字面は近いのに結論が正反対
どちらも例外を投げません。ユーザーは「なんか前と同じことを言っている」「質問とずれている」と感じても、多くは黙って離脱します。だからこそ、キャッシュ層には最初から「鮮度」と「誤ヒット」を測る目盛りを埋め込んでおく必要があります。
キャッシュキーは「答えを左右する全要素」を畳み込む
stale hit と tenant 混線の大半は、キーの作りが甘いことから来ます。プロンプト本文だけをハッシュ化すると、モデルを上げた・system を直した・検索で渡す文脈が変わった、といった「答えを変える要因」がキーに反映されず、古い回答が生き残ります。
私が本番で守っているのは、回答に影響しうるものは全部キーに入れる、という原則です。逆に、回答に影響しないもの(リクエストID・タイムスタンプ)は絶対に入れない。揺れるものを混ぜるとヒット率がゼロに張り付きます。
import { createHash } from "crypto" ;
// 回答を左右する全要素を一つの指紋に畳み込む。
// ここに含めなかった要素は「変わっても古い回答が返る」と理解しておく。
interface CacheFingerprintInput {
model : string ; // 例: claude-sonnet-4-6(世代が変われば回答も変わる)
systemPrompt : string ; // system の改訂で挙動が変わる
toolSchemaVersion : string ; // ツール定義のハッシュ。ツールが増減すれば別物
retrievalContext ?: string ; // RAG で渡す文脈。元文書が変われば指紋も変わるべき
tenantId : string ; // テナント越境を物理的に防ぐ
locale : string ; // 言語・地域で回答が変わる
userMessage : string ;
}
export function buildCacheKey ( input : CacheFingerprintInput ) : string {
// retrievalContext は本文そのものではなく「元文書の版」を入れるのが肝心。
// 本文を入れると毎回わずかに変わってヒットしなくなる。
const fingerprint = JSON . stringify ({
m: input.model,
s: sha (input.systemPrompt),
t: input.toolSchemaVersion,
r: input.retrievalContext ? sha (input.retrievalContext) : "none" ,
tn: input.tenantId,
l: input.locale,
u: input.userMessage. trim (). toLowerCase (),
});
// namespace に版番号を持たせ、デプロイ時に一括失効させられるようにする
return `claude:resp:v3:${ sha ( fingerprint ) }` ;
}
function sha ( s : string ) : string {
return createHash ( "sha256" ). update (s). digest ( "hex" ). slice ( 0 , 32 );
}
ポイントは三つあります。第一に、retrievalContext には文書本文ではなく「文書の版(更新時刻やコンテンツハッシュ)」を入れること。本文をそのまま入れると、わずかな差でキーが毎回変わりヒットしなくなります。第二に、tenantId をキーに含めること。これを忘れると、あるテナントの回答が別テナントに漏れる事故が起きます。第三に、v3 のような版番号を namespace に持たせ、モデル移行や system 大改訂のときに v4 へ上げて全件を安全に失効させられるようにすることです。
鮮度管理 — 揮発性クラス別の TTL とタグベース無効化
TTL を一律にすると必ずどこかで間違えます。30 分で十分なものに 24 時間を与えれば stale hit が増え、ほとんど変わらないものに 5 分を与えればヒット率が無駄に落ちます。私は回答の「揮発性」でクラス分けし、クラスごとに TTL を変えています。
揮発性クラス 例 TTL の目安 無効化方式
静的 用語説明・定型FAQ 7〜30日 版番号での一括失効
準動的 製品仕様・手順書ベースの回答 1〜24時間 タグベース無効化
動的 在庫・価格・残数に依存する回答 キャッシュしない —
TTL は「最悪どれだけ古くて許されるか」で決めます。そして TTL だけに頼らないことが重要です。元文書が更新された瞬間に、その文書を参照していたキャッシュを能動的に消す「タグベース無効化」を併用します。Redis の Set で「どのキャッシュがどの文書に依存しているか」を逆引きできるようにしておくのが実装の勘所です。
import Redis from "ioredis" ;
const redis = new Redis (process.env. REDIS_URL as string );
// 保存時: 回答が依存している文書IDをタグとして登録する
async function setWithTags (
key : string ,
value : string ,
ttlSeconds : number ,
docTags : string []
) : Promise < void > {
const pipe = redis. pipeline ();
pipe. set (key, value, "EX" , ttlSeconds);
// 各タグ(文書ID)から、それに依存するキャッシュキーを逆引きできるようにする
for ( const tag of docTags) {
pipe. sadd ( `tag:${ tag }` , key);
pipe. expire ( `tag:${ tag }` , ttlSeconds + 60 );
}
await pipe. exec ();
}
// 文書が更新されたら、その文書に依存する全キャッシュを即時に消す
async function invalidateByDoc ( docId : string ) : Promise < number > {
const keys = await redis. smembers ( `tag:${ docId }` );
if (keys. length === 0 ) return 0 ;
const pipe = redis. pipeline ();
pipe. del ( ... keys);
pipe. del ( `tag:${ docId }` );
await pipe. exec ();
return keys. length ;
}
この仕組みがあると、CMS や商品マスタの更新フックから invalidateByDoc(docId) を呼ぶだけで、その文書に紐づく回答だけをピンポイントで失効できます。TTL の自然消滅を待たずに済むので、stale hit の許容時間を「次の更新まで」ではなく「更新の瞬間まで」に縮められます。
セマンティック誤ヒットを抑える検証層
セマンティックキャッシュは強力ですが、類似度しきい値だけで採否を決めると false hit が必ず混ざります。埋め込み空間では「返金できますか」と「返金できないと言われたが納得できない」は近い位置に来ます。文字面は近く、求めている結論は正反対です。
しきい値を上げれば誤ヒットは減りますが、同時にヒット率も落ちます。私の結論は、しきい値を上げて誤ヒットを潰すのではなく、近傍が見つかった後に安価な検証を一段かませる、というものです。否定語と主要エンティティの一致を見るだけでも、結論が反転する誤ヒットのかなりの部分を弾けます。
// セマンティック近傍が見つかった後の「採否」を決める検証層。
// 類似度が高くても、否定の有無やエンティティがずれていれば不採用にする。
interface VerifyInput {
query : string ;
candidateQuery : string ; // キャッシュに入っていた元の質問
similarity : number ; // 0..1
}
const NEGATIONS = [ "ない" , "できない" , "不可" , "却下" , "拒否" , "未対応" , "非対応" ];
function extractEntities ( text : string ) : Set < string > {
// 実運用では固有表現抽出に置き換える。ここでは簡易に英数字トークンと
// カタカナ語をエンティティ候補として拾う。
const tokens = text. match ( / [A-Za-z0-9_] +| [゠-ヿ] {2,} / g ) ?? [];
return new Set (tokens. map (( t ) => t. toLowerCase ()));
}
export function acceptSemanticHit ( input : VerifyInput ) : boolean {
const { query , candidateQuery , similarity } = input;
if (similarity < 0.86 ) return false ; // 一次しきい値
// 否定ガード: 片方だけが否定文なら結論が反転している可能性が高い
const qNeg = NEGATIONS . some (( n ) => query. includes (n));
const cNeg = NEGATIONS . some (( n ) => candidateQuery. includes (n));
if (qNeg !== cNeg) return false ;
// エンティティガード: 主要エンティティの重なりが乏しければ別の問い
const qe = extractEntities (query);
const ce = extractEntities (candidateQuery);
const overlap = [ ... qe]. filter (( e ) => ce. has (e)). length ;
const denom = Math. max ( 1 , Math. min (qe.size, ce.size));
if (overlap / denom < 0.5 ) return false ;
return true ;
}
この検証は埋め込み計算より桁違いに安く、API コールも増やしません。acceptSemanticHit が false を返したら、キャッシュを無視して通常どおり Claude API を呼べばよいだけです。重要なのは「しきい値一本で全部を決めようとしない」ことです。しきい値は粗いフィルタにとどめ、結論を反転させる要因(否定・エンティティずれ)は専用ガードで個別に潰します。
汚染ヒット率を計測する — 観測なきキャッシュは入れない
ここが個人開発でいちばん痛い目を見て学んだ部分です。私が Dolice Labs で運用している技術ブログ群は Cloudflare Workers のエッジキャッシュで配信していますが、以前、生成に一瞬失敗した空の本文ページや、エラー画面の HTML がそのままエッジに数時間ピン留めされ、検索エンジンにまで配信されていたことがありました。キャッシュ自体は完璧に動いていて、ヒット率も高い。なのに配っていたのは壊れた回答でした。あの時の教訓は単純で、「汚染したヒットを測る目盛りを持たないキャッシュは、速いだけで信用できない」ということです。
平均ヒット率は健康診断にはなりません。本当に見るべきは、(1) 真のヒット率、(2) 汚染ヒット率(stale hit と false hit の合計)、(3) 検証層が弾いた割合、の三つです。汚染ヒット率は、一定サンプルでキャッシュ応答と非キャッシュ応答を裏で突き合わせる「シャドウ比較」で推定します。
// 本番トラフィックの数%だけ、キャッシュ採用と並行して実APIも叩き、
// 両者の差分を記録する。コストは sampleRate に比例して抑える。
async function shadowCompare (
key : string ,
cached : string ,
freshFetch : () => Promise < string >,
sampleRate = 0.02
) : Promise < void > {
if (Math. random () > sampleRate) return ;
const fresh = await freshFetch ();
const drifted = normalize (cached) !== normalize (fresh);
await redis. hincrby ( "cache:audit" , drifted ? "poisoned" : "clean" , 1 );
if (drifted) {
// 差分が出たキーは TTL を待たず失効させ、原因調査用にサンプルを残す
await redis. del (key);
await redis. lpush ( "cache:drift_samples" , JSON . stringify ({ key, cached, fresh }));
await redis. ltrim ( "cache:drift_samples" , 0 , 199 );
}
}
function normalize ( s : string ) : string {
return s. replace ( / \s + / g , " " ). trim ();
}
既存のキャッシュに観測を足す手順は、最小なら次の三つで足ります。
cache:audit に clean / poisoned のカウンタを追加し、真のヒット率と汚染ヒット率を分けて記録する。
シャドウ比較(shadowCompare)を 2% のサンプルで回し、差分が出たキーを即時失効させる。
汚染ヒット率が 1% を超えたらしきい値か TTL を見直す、というしきい値運用を一行のルールとして決めておく。
このシャドウ比較を 2% のサンプルで回すだけでも、汚染ヒット率が悪化したときに早期に気づけます。私の運用では、汚染ヒット率が 1% を超えたらしきい値か TTL を見直す、という単純な運用ルールにしています。数字を持っていれば、キャッシュを攻めるか守るかの判断を勘ではなく根拠で下せます。
キャッシュしない判断 — 向く問いと向かない問い
最後に、いちばん効くのに見落とされがちな最適化を挙げておきます。「キャッシュしない」という選択です。キャッシュは万能ではなく、向く問いと向かない問いがはっきりしています。
向くのは、繰り返しが多く、答えが安定していて、間違えても致命傷になりにくい問いです。製品 FAQ、用語説明、定型レポートの骨子などが該当します。向かないのは、強くパーソナライズされた回答、時間や残数に依存する回答、そして一度の誤りが信用を大きく損なう領域(医療や法務など)です。こうした問いは、応答が速くても古い・ずれた回答を返した瞬間に価値が反転します。
私はこのところ、新しい機能にキャッシュを足すときは「ヒットして嬉しい問いか」より先に「ハズれたとき何が壊れるか」を考えるようにしています。壊れたときの被害が大きい問いは、たとえ重複が多くてもキャッシュしない。逆に被害が小さく重複が多い問いには、TTL を長めに取り検証層を軽くして攻める。この線引きを最初に決めておくと、後からヒット率を追いかけて誤ヒットを増やす罠に落ちずに済みます。
次の一歩としては、いま動いているキャッシュに cache:audit のカウンタとシャドウ比較を一つ足してみてください。平均ヒット率しか見ていなかったキャッシュに「汚染ヒット率」という目盛りが付くだけで、攻めと守りの判断がまるで変わります。同じ課題に取り組んでいる方の参考になれば幸いです。