なぜLLMレスポンスをキャッシュするか

LLM APIのコストはトークン数に比例します。同じ質問や似た質問が繰り返されるアプリでは、毎回APIを呼び出すのはコストの無駄です。キャッシュを挟むことで、同一または類似のリクエストには保存済みの回答を返し、API呼び出し回数を削減できます。

特にFAQチャットボット、コンテンツ要約、商品説明生成など、類似リクエストが集中するユースケースでは効果が大きく、うまく設計すれば70〜80%のAPI呼び出しをキャッシュで賄えるケースもあります。

2種類のキャッシュ戦略

完全一致キャッシュ(Exact Match Cache)

入力プロンプトの文字列をキーとしてハッシュ値を計算し、そのハッシュに紐づくレスポンスを返す方法です。

実装はシンプルです。Redisを使った例を示します。

import hashlib
import json
import redis
from anthropic import Anthropic

r = redis.Redis(host="localhost", port=6379)
client = Anthropic()

def get_llm_response(prompt: str, ttl: int = 3600) -> str:
    cache_key = hashlib.sha256(prompt.encode()).hexdigest()
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)
    
    response = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}]
    )
    result = response.content[0].text
    r.setex(cache_key, ttl, json.dumps(result))
    return result

欠点は「全く同じ文字列」にしかヒットしない点です。「AIとは何ですか?」と「AIって何?」は別のキャッシュエントリーになります。

セマンティックキャッシュ(Semantic Cache)

入力テキストをエンベディング(意味ベクトル)に変換し、過去のキャッシュエントリーと意味的な類似度を計算して近いものがあれば返す方法です。

「AIとは何ですか?」と「人工知能について教えてください」が同じキャッシュにヒットします。

import numpy as np
from openai import OpenAI

def cosine_similarity(a: list, b: list) -> float:
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

class SemanticCache:
    def __init__(self, threshold: float = 0.92):
        self.threshold = threshold
        self.cache: list[dict] = []  # 本番ではベクトルDBを使う
        self.embed_client = OpenAI()
    
    def get_embedding(self, text: str) -> list:
        response = self.embed_client.embeddings.create(
            model="text-embedding-3-small",
            input=text
        )
        return response.data[0].embedding
    
    def lookup(self, query: str) -> str | None:
        query_vec = self.get_embedding(query)
        for entry in self.cache:
            sim = cosine_similarity(query_vec, entry["embedding"])
            if sim >= self.threshold:
                return entry["response"]
        return None
    
    def store(self, query: str, response: str):
        self.cache.append({
            "query": query,
            "embedding": self.get_embedding(query),
            "response": response
        })

閾値の設定が重要

セマンティックキャッシュの類似度閾値(threshold)の設定は慎重に行う必要があります。

0.95以上にすると、非常に似た文章しかヒットしないため精度は高いですが命中率が低くなります。0.85以下にすると多くの入力がヒットしますが、意味が異なる質問に誤ったキャッシュを返すリスクが増します。

アプリのドメインに合わせてA/Bテストで調整するのが現実的です。技術的な質問(定義が明確)は高閾値、感情的・曖昧な質問は低閾値が向きます。

本番環境でのベクトルDB活用

キャッシュエントリーが増えると、すべてのエントリーとの類似度計算が遅くなります。本番ではRedisのVector Search(redis-stack)やPineconeなどのベクトルDBを使って近似最近傍探索(ANN)を行います。

Redis Stack(Redis Vector Search)の例:

from redis.commands.search.field import VectorField, TextField
from redis.commands.search.indexDefinition import IndexDefinition

# インデックス作成
r.ft("idx").create_index([
    TextField("query"),
    VectorField("embedding", "FLAT", {
        "TYPE": "FLOAT32",
        "DIM": 1536,
        "DISTANCE_METRIC": "COSINE"
    })
], definition=IndexDefinition(prefix=["cache:"]))

Prompt Cachingとの使い分け

AnthropicやOpenAIが提供するPrompt Caching(プロバイダー側のキャッシュ)は、同じシステムプロンプトやドキュメントを繰り返し使う場合にトークンコストを削減する機能です。アプリ側で実装するキャッシュとは仕組みが異なります。

Prompt Cachingは「長いシステムプロンプトを毎回送る必要がなくなる」効果で、アプリ側キャッシュは「同じ質問に対してAPIを呼ばなくて済む」効果です。両者を組み合わせると最大の効果が得られます。

TTL(有効期限)の設計

キャッシュのTTL(Time To Live)はコンテンツの性質に合わせます。

  • ニュース要約・時事情報:30分〜2時間
  • 製品説明・マニュアル:24〜72時間
  • 数学の説明・概念解説:7日〜無期限

情報が変化しないコンテンツは長めのTTLでキャッシュヒット率を上げ、リアルタイム性が必要なコンテンツは短めに設定します。

まとめ

LLMキャッシュ戦略の基本は完全一致キャッシュで、類似質問への対応にはセマンティックキャッシュが有効です。Redisを使った実装は比較的シンプルに始められ、エントリーが増えてきたらベクトルDBに移行するのが現実的な成長戦略です。プロバイダー側のPrompt Cachingと組み合わせることで、API費用を大幅に圧縮できます。