GPTCacheでのLLMのレスポンスキャッシュを試してみる
LLMのAPIは...高い!
最近はGemini FlashなりGPT-4o miniなりの登場によりものすごく安くはなってきましたが、上位のモデルを使いたいこともありコストは抑えられるに越したことはありません。
また返答が返ってくるまでに数秒、なんなら10秒を超えることもあります。
という訳でキャッシュ(最近話題のコンテキストキャッシュもそうですがレスポンス丸々のキャッシュ)を検討してみたいなと思っていたところGPTCacheというライブラリを見つけました。MilvusというVector DBで有名なZillisという会社が作っています。
Semantic Cache for LLM Queries
と謳っている通り、単純な文字列一致でなく意味的に近いものをうまいことキャッシュしてくれるみたいです。
それではどうやって使うのかと内部でのキャッシュはどうやって行うのかを見ていきます。
使い方
まずはインストール
pip install gptcache
まずはクエリの完全一致で見てみます。
import time
from google.colab import userdata
import os
OPEN_API_KEY=userdata.get("openai")
os.environ["OPENAI_API_KEY"] = OPEN_API_KEY
def response_text(openai_resp):
return openai_resp['choices'][0]['message']['content']
print("Cache loading.....")
from gptcache import cache
from gptcache.adapter import openai
cache.init()
cache.set_openai_key()
question = "what's github"
for _ in range(2):
start_time = time.time()
response = openai.ChatCompletion.create(
model='gpt-4o-mini',
messages=[
{
'role': 'user',
'content': question
}
],
)
print(f'Question: {question}')
print("Time consuming: {:.2f}s".format(time.time() - start_time))
print(f'Answer: {response_text(response)}\n')
初回は4秒ほどかかっていたのですが、2回目は0秒と一瞬で返ってきてキャッシュできているのが確認できました。
Cache loading.....
Question: what's github
Time consuming: 0.00s
しかしこれはクエリの文字列が完全に一致した場合のみで、ちょっと使う文字が違うだけでキャッシュヒットしなくなります。なので次にEmbeddingを用いて意味の近いものもキャッシュヒットするものを試してみます。
(下記には差分だけ書いてます)
from gptcache import cache
from gptcache.adapter import openai
from gptcache.embedding import Onnx
from gptcache.manager import CacheBase, VectorBase, get_data_manager
from gptcache.similarity_evaluation.distance import SearchDistanceEvaluation
onnx = Onnx()
data_manager = get_data_manager(CacheBase("sqlite"), VectorBase("faiss", dimension=onnx.dimension))
cache.init(
embedding_func=onnx.to_embeddings,
data_manager=data_manager,
similarity_evaluation=SearchDistanceEvaluation(),
)
questions = [
"what's github",
"can you explain what GitHub is",
"can you tell me more about GitHub",
"what is the purpose of GitHub"
]
そうすると最初のリクエスト以外は0.8秒(おそらくVectorDBへの問い合わせにかかっている時間)で返ってきてキャッシュが活用できていることが確認できます。
Time consuming: 3.83s
Time consuming: 0.80s
Time consuming: 0.78s
Time consuming: 0.79s
どうやって動いているのか
先ほどのコード例を見るとわかりますがクエリを embedding に変えてキャッシュされたものとの類似度の高さからキャッシュを使うべきかどうかを判定しています。
テキトーにEmbeddingモデルを選んで魔法のようにうまくいくという類のものではないので、プロンプトのようにこちらもチューニングしてキャッシュヒット率を上げる必要があります。
キャッシュの精度を上げるためには以下の要素をいじることができます。
- Embeddingモデル
- Similarity Evaluator
Embeddingモデル
GPTCacheでは次のEmbeddingモデルの取得手法をサポートしています。(ドキュメントから取ってきたがコード見る感じはもっとサポートされてそうな気もする)
- OpenAI
- Cohere
- Huggingface
- ONNX
- SentenceTransformers
例えばHuggingfaceの場合は次のような指定方法ができます。
from gptcache.embedding import Huggingface
model = Huggingface(model='distilbert-base-uncased')
data_manager = get_data_manager(CacheBase("sqlite"), VectorBase("faiss", dimension=model.dimension))
cache.init(
embedding_func=model.to_embeddings,
data_manager=data_manager,
similarity_evaluation=SearchDistanceEvaluation(),
)
(なんかさっきより速い)
Time consuming: 4.58s
Time consuming: 0.11s
Time consuming: 0.10s
Time consuming: 0.09s
Similarity Evaluator
類似度のスコアを出すための関数です。
from gptcache.similarity_evaluation.distance import SearchDistanceEvaluation
...
cache.init(
embedding_func=model.to_embeddings,
data_manager=data_manager,
similarity_evaluation=SearchDistanceEvaluation(),
)
次のインターフェイスを継承して実装される模様。
class SimilarityEvaluation(metaclass=ABCMeta):
"""Similarity Evaluation interface,
determine the similarity between the input request and the requests from the Vector Store.
Based on this similarity, it determines whether a request matches the cache.
Example:
.. code-block:: python
from gptcache import cache
from gptcache.similarity_evaluation import SearchDistanceEvaluation
cache.init(
similarity_evaluation=SearchDistanceEvaluation()
)
"""
@abstractmethod
def evaluation(
self, src_dict: Dict[str, Any], cache_dict: Dict[str, Any], **kwargs
) -> float:
"""Evaluate the similarity score of the user and cache requests pair.
:param src_dict: the user request params.
:type src_dict: Dict
:param cache_dict: the cache request params.
:type cache_dict: Dict
"""
pass
@abstractmethod
def range(self) -> Tuple[float, float]:
"""Range of similarity score.
:return: the range of similarity score, which is the min and max values
:rtype: Tuple[float, float]
"""
pass
ここで出されるスコアは下記のようなrank_thresholdと比較され、高い場合はキャッシュを返します。
この値はコンフィグ内の similarity_threshold や cache_factor からいじれるので、それをゴニョゴニョすることによって最適化ができるという訳ですね。
similarity_threshold = chat_cache.config.similarity_threshold
min_rank, max_rank = chat_cache.similarity_evaluation.range()
rank_threshold = (max_rank - min_rank) * similarity_threshold * cache_factor
rank_threshold = (
max_rank
if rank_threshold > max_rank
else min_rank
if rank_threshold < min_rank
else rank_threshold
)
プロンプト/コンテキストキャッシュとの違い
最近はClaudeやGeminiがプロンプトの一部をキャッシュすることができる機能を装備しています。
それらとの比較で言うと
- いい点
- 「5分まで」みたいな制約がない(Geminiもお金かければないが)
- 短いプロンプトでもキャッシュ可能
- そもそもリクエストが発生しないのでRPMのRate Limitにひっかかるリスクを軽減できる
- 固定の要素ではなく柔軟に近いニュアンスにキャッシュが引っ掛かるようにするのでユースケースが広がる
- ガードレール済みの結果を使い回せる
- 悪い点
- 不確実性が高い。「キャッシュが効いて欲しいところで効かない」「効かないで欲しいところで効く」が起きる可能性がある
- このためテストを準備して精度の検査をする必要がある
- 多分これがめんどいのがこの手法が流行ってない理由な気がする
- 自前でキャッシュのDBやキャッシュのEvictionのルールを用意する必要性
- 不確実性が高い。「キャッシュが効いて欲しいところで効かない」「効かないで欲しいところで効く」が起きる可能性がある
思ったこと
セマンティックキャッシュはLLMのようにインプットが不定形になりがちなモノに対して有効でコストやレイテンシーの削減に寄与してくれそうです。
一方でLLMを使うタスクの中でもインプットのバリエーションには幅がかなりあると思うので、場合によっては大して有効でなくなることもありそうです。キャッシュヒットしてはいけないのでヒットしてしまった、みたいな副作用もいくらか発生はすると思うので、それらの評価をプロダクトの要件に合わせてしていきつつ、実際に入れてコスト・体験面で総合的にメリットあるかを判断していく形になるのでしょう。
あとこのライブラリではクエリ(というかプロンプト)全体をキャッシュのキーの対象としていますが、インプットだけで比較した方がより正確なキャッシュヒットになりそうだな〜とかQuery Expansionなどで統一した語彙に揃えるといいかもな〜とか同じくVector DBを扱うRAGのような精度向上試作が色々ありそうだなと感じました。
何にせよ、ユースケースによってはコスト削減に大きく繋がる可能性も秘めてるので、ハマりそうな機会があればぜひ活用してみたいところです。
また最後に、より具体的な説明が書かれた論文もあるのでご興味湧けば読んでみてください。
以上、お読みいただきありがとうございました!
Web3スタートアップ「Gaudiy(ガウディ)」所属エンジニアの個人発信をまとめたPublicationです。公式Tech Blog:techblog.gaudiy.com/
Discussion