LLMの回答を質問文だけでキャッシュすると、参照文書を更新しても古い回答が返ることがあります。
原因はPythonのキャッシュ機構ではなく、キャッシュキーに回答を決める入力が含まれていないことです。
質問が同じでも、回答の前提が変われば別のキャッシュエントリとして扱う必要があります。
Pythonのhashlibとjsonだけで問題を再現し、入力の変更を反映するキーに直します。
質問文だけをキーにすると何が起きるか
たとえば、社内文書を検索して回答するRAGアプリケーションを考えます。
利用者から「経費精算の締切は?」と聞かれたとき、検索した社内規程をLLMに渡して回答を生成します。
推論の呼び出しを減らすため、同じ質問には前回の回答を返すキャッシュを追加したとします。
最初の規程には「毎月20日」と書かれています。その後、規程が更新されて「毎月25日」になりました。
ところが、質問文だけをキーにしていると、更新後も「毎月20日」という回答が返ります。
これはモデルの幻覚ではありません。モデルを呼び出す前に、古い回答がキャッシュから返されているためです。
まず、最小構成で再現します。
動作環境はPython 3.14です。外部ライブラリは不要です。
LLM APIを呼ぶ代わりに、参照文書を回答として返す関数を用意します。ここで確認したいのはモデルの生成品質ではなく、キャッシュの参照条件だからです。
cache = {}
def generate(question: str, document: str) -> str:
return f"{question} -> {document}"
def ask(question: str, document: str) -> str:
if question not in cache:
cache[question] = generate(question, document)
return cache[question]
question = "経費精算の締切は?"
print(ask(question, "毎月20日"))
print(ask(question, "毎月25日"))
実行結果です。
経費精算の締切は? -> 毎月20日
経費精算の締切は? -> 毎月20日
2回目の呼び出しでは、参照文書が変更されています。
しかしcacheのキーは質問文だけです。
2回目はキーが存在するため、generate()が実行されません。
この実装では、参照文書が更新されたことをキャッシュ側が知る方法がありません。
Pythonのfunctools.lru_cacheを使っても、同じ問題は起こり得ます。
lru_cacheは関数に渡された引数をもとに結果をキャッシュします。関数の内部で参照している外部文書や設定ファイルの変更を、自動的に追跡するわけではありません。
たとえば、質問文だけを引数に取り、内部で検索インデックスを参照する関数をキャッシュした場合、検索インデックスが更新されても同じ引数に対するキャッシュは残ります。
問題はキャッシュライブラリの選択ではなく、関数の結果に影響する入力をどこまでキーに含めるかという設計です。
回答を決める入力は質問文だけではない
LLMの回答は、質問文だけで決まるわけではありません。
RAGアプリケーションであれば、少なくとも次の入力を区別する必要があります。
- 質問文:利用者が入力した内容
- モデル識別子:使用するモデルと、その実体を識別できる情報
- プロンプトバージョン:システム指示や回答形式の変更
- 参照文書:検索によって取得した文書の内容
- 生成設定:temperatureなどの生成パラメータ
ここで注意したいのは、これらを単純に文字列連結すればよいわけではないことです。
たとえば、次の2組の入力を考えます。
質問: ab
文書: c
質問: a
文書: bc
区切り文字なしで連結すると、どちらもabcになります。
異なる入力なのに同じキーが生成されます。
区切り文字を追加する方法もありますが、入力自身に区切り文字が含まれる場合を考える必要があります。
そこで、入力を構造化したうえでシリアライズします。
Python標準ライブラリのjson.dumps()には、辞書のキーを並べ替えるsort_keys=Trueがあります。
さらにseparatorsを指定すれば、不要な空白を取り除いた表現にできます。
import json
data = {
"question": "経費精算の締切は?",
"model": "example-model-v1",
"temperature": 0,
}
canonical = json.dumps(
data,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
print(canonical)
結果は次のとおりです。
{"model":"example-model-v1","question":"経費精算の締切は?","temperature":0}
辞書を作る際のキーの挿入順序が違っても、同じ構造と値であれば同じJSON文字列を生成できます。
ただし、これは任意のJSONデータに対する完全な正規化ではありません。
浮動小数点数の表現、数値型の違い、Unicodeの正規化、リストの順序などは別途設計が必要です。
特に検索結果のリストは、順序によってモデルの回答が変わる可能性があります。
検索結果をキーに含める場合、単に文書の集合として扱うのではなく、実際にモデルへ渡した順序と内容を反映するほうが安全です。
文書IDだけをキーに含める方法もありますが、同じIDの文書が更新される場合には不十分です。
文書IDを使うなら、更新時に変わるリビジョンも必要になります。
SHA-256で入力全体をキャッシュキーにする
修正版では、質問文、モデル、プロンプト、参照文書、生成設定を一つの構造にまとめます。
そのJSON表現をUTF-8に変換し、SHA-256でハッシュ化します。
hashlib.sha256()は、入力されたバイト列から256ビットのダイジェストを計算します。
この用途では、入力全体を長い文字列のままキーに保存する代わりに、固定長の識別子を作れます。
次のコードは、そのまま実行できます。
Python 3.14、外部依存なしです。
import hashlib
import json
cache: dict[str, str] = {}
def cache_key(question: str, document: str,
prompt: str = "v1") -> str:
inputs = {
"question": question,
"document": document,
"prompt": prompt,
"model": "example-model-v1",
"generation": {"temperature": 0},
}
encoded = json.dumps(
inputs,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def ask(question: str, document: str,
prompt: str = "v1") -> str:
key = cache_key(question, document, prompt)
if key not in cache:
cache[key] = f"{question} -> {document}"
return cache[key]
question = "経費精算の締切は?"
print(ask(question, "毎月20日"))
print(ask(question, "毎月25日"))
print(ask(question, "毎月25日", "v2"))
print("cache entries:", len(cache))
実行結果です。
経費精算の締切は? -> 毎月20日
経費精算の締切は? -> 毎月25日
経費精算の締切は? -> 毎月25日
cache entries: 3
参照文書が変わると、JSONの内容が変わります。
その結果、SHA-256のダイジェストも変わり、別のキャッシュエントリが作られます。
3回目はプロンプトバージョンだけを変更しています。
簡易的なgenerate相当の処理はプロンプトを参照していないため、回答文字列自体は変わりません。しかし、キャッシュキーは異なるので新しいエントリになります。
これは意図した動作です。
プロンプトの変更によって、回答の文体、形式、判断基準が変わる可能性があるためです。
なお、SHA-256は暗号学的ハッシュ関数ですが、ハッシュ値が異なる入力を数学的に必ず区別できるわけではありません。衝突の可能性はあります。
通常のキャッシュキー用途では十分に小さいリスクとして扱えますが、アクセス制御やデータの真正性を保証する仕組みとして使うものではありません。
キャッシュの無効化をテストする
キャッシュキーの設計では、同じ入力から同じキーが得られることだけでなく、変更すべき入力が変わったときにキーも変わることを確認したいところです。
先ほどのcache_key()を使って、次のテストを追加します。
def test_cache_key() -> None:
q = "経費精算の締切は?"
original = cache_key(q, "毎月20日", "v1")
assert original == cache_key(
q, "毎月20日", "v1"
)
assert original != cache_key(
q, "毎月25日", "v1"
)
assert original != cache_key(
q, "毎月20日", "v2"
)
assert cache_key(
q, "毎月25日", "v1"
) == cache_key(
q, "毎月25日", "v1"
)
test_cache_key()
print("OK")
実行結果です。
OK
このテストで確認できるのは、同一入力の再現性と、文書・プロンプト変更時のキーの変化です。
モデル識別子や生成設定についても、同じ考え方でテストを追加できます。
実際のLLMアプリケーションでは、ここからもう一段考える必要があります。
まず、temperature=0であっても、生成結果が常に完全に一致するとは限りません。
モデルの実装や推論環境、モデル更新などによって出力が変わる場合があります。
今回のキャッシュは、同じ入力に対して必ず同じ回答が生成されることを保証するものではありません。
同じ条件で生成した過去の回答を、再利用してよいと判断する仕組みです。
そのため、回答の再利用が許される用途を先に決める必要があります。
たとえば、更新頻度の低い文書の要約なら、一定期間のキャッシュが有効な場合があります。
一方、現在の在庫、予約状況、権限情報など、呼び出し時点の状態が回答に影響する用途では、入力ハッシュだけでは十分ではありません。
文書が同じでも、利用者の権限が変わっている可能性があるからです。
また、今回のコードでは参照文書をそのままキーの材料にしていますが、実際のRAGでは検索結果の内容に加えて、テナントや利用者のアクセス可能範囲も考慮する必要があります。
異なる利用者が同じ質問と同じ参照文書に到達したとしても、その回答を共有してよいとは限りません。
キャッシュキーに権限のスコープを反映する方法はありますが、それだけでアクセス制御を代替することはできません。キャッシュヒット時にも、現在の利用者が回答を参照できるか確認する設計が必要です。
もう一つ、検索結果そのものをキャッシュしている場合は、検索段階の無効化も別に考えます。
今回の方式は、更新後の文書がcache_key()へ渡されることが前提です。
検索キャッシュが古い文書を返していれば、その古い文書に対応するキーが再び作られます。
つまり、回答キャッシュだけを修正しても、検索側の鮮度問題は解決しません。
最後に確認したいのは、キーに何を含めるかだけでなく、何を含めないかです。
タイムスタンプのように毎回変わる値を無条件に入れると、キャッシュヒット率は下がります。逆に、回答へ影響する値を省くと古い回答を再利用します。
実運用では、モデルに渡す入力の正規化、検索結果の更新検知、権限の再確認、TTLによる期限切れを組み合わせることになります。
今回の実装は、同じ入力条件の回答を再利用できる用途に限定したものです。次に確かめるべきなのは、実際のRAGパイプラインで文書更新が検索結果とキャッシュキーの両方に反映されるか、そしてキャッシュヒット時にも現在のアクセス権限が守られるかです。

