llm-cacher というNode.js向けライブラリを作り、LLMレスポンスのキャッシュを試したmemory / file / SQLite / Redis / DynamoDB などを選べるこの記事の出発点はとてもシンプルです。
「AIに同じような質問を何度もしていたら、請求額が思ったより高くなった」 という話。
これ、かなりあるあるだと思います。
開発中って、つい同じプロンプトを何回も投げますよね。文言は少し違っても、やっていることはほぼ同じ。しかもLLMは1回ごとに課金される。つまり、テストを重ねるほどお金が溶けるわけです。
著者はここで気づきます。
これ、開発中だけの話じゃなくて、本番のユーザーでも絶対に起きる のではないか、と。最初の1000人くらいのユーザーって、結局かなり似た質問を繰り返しがちです。チャットボットならなおさらです。
この問題に対して、著者は llm-cacher というライブラリを作りました。
要するに、LLMの返答をキャッシュして、同じ(または似た)リクエストなら再利用する 仕組みです。
たとえば、こんなコードがあるとします。
async function summarize(text: string) {
const res = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "system", content: "Summarize the following text." },
{ role: "user", content: text },
],
});
return res.choices[0].message.content;
}
この summarize() が同じ text で2回呼ばれたら、2回とも課金 です。
100回なら100回。評価用のテストを回すたびに、ひたすらお金が減っていく。正直、なかなかにイヤな世界です。
もちろん、自前で Map を使ってキャッシュする方法はあります。
const cache = new Map();
async function summarize(text: string) {
if (cache.has(text)) return cache.get(text);
// ...
}
ただし、これには弱点があります。
完全一致しか見られない んです。
たとえば、
Summarize this articleSummarize this article pleaseこの2つは、人間から見ればかなり近いですが、単純な文字列比較だと別物です。
LLMは中身としてほぼ同じ答えを返しそうなのに、キャッシュは効かない。ここがもったいない。
著者が狙ったのは次の3つです。
この設計方針はかなり筋がいいと思います。
キャッシュって、理屈では便利でも「導入が面倒」「既存コードを大改造しないと使えない」と一気に使われなくなります。そこをProxyで包んでしまうのは、かなり実用寄りです。
この記事では、他の選択肢も整理されています。
LangChain.js
キャッシュ機能はあるけれど、LangChainの抽象の上で全部組む前提。
すでに使っているならいいですが、キャッシュのためだけに導入するのは重い という見方です。
Helicone / Portkey
これはSaaSのproxyで、キャッシュだけでなく観測性やログ、rate limitingなどもまとめて面倒を見てくれるタイプ。
便利ですが、リクエストが相手のサーバーを通る のがトレードオフです。
GPTCache
semantic caching は強いけれど、Python寄りでDocker sidecar前提。Node.jsに直接npm installして完結、という感じではない。
Upstash Semantic Cache
JavaScript SDKはあるけれど、Upstashのmanaged serviceに依存する。
Anthropicのprompt caching
これは少し別物で、モデル内部の長いsystem promptの再処理コストを減らす もの。
一方、llm-cacherは返ってきたレスポンスそのものを保存する。
なので、両者は競合というより補完関係です。
結論として、llm-cacherは
「OpenAIやAnthropicのSDKを直接使いたい。フレームワークもSaaSも増やしたくない。しかも自前でホストしたい」
という人向け、という整理です。
これはかなり現場っぽいニーズだと思います。
導入はかなり軽いです。
npm install llm-cacher
そしてこう書けます。
import OpenAI from "openai";
import { createCachedClient } from "llm-cacher";

const openai = createCachedClient(new OpenAI(), {
ttl: "24h",
storage: "memory",
});
ここで大事なのは、createCachedClient が Proxy を返すこと。
Proxyは、ざっくり言うと「元のオブジェクトに見えるけど、裏で振る舞いを差し替えられる仕組み」です。
つまり、既存の openai.chat.completions.create(...) の形をほぼそのまま保てる。
この「コードを書き換えなくて済む」感じは、導入障壁をかなり下げるはずです。個人的にはここが一番えらいと思います。
基本のキャッシュキーは、リクエストの内容をもとに作られます。
それらをまとめて SHA-256 hash にしてキー化します。
SHA-256は、入力から固定長のハッシュ値を作る仕組みで、キャッシュキーや改ざん検知でよく使われます。
面白いのは、streamフラグはキーに含めない ことです。
つまり、同じ内容なら streaming でも non-streaming でも同じキャッシュを使えるようにしています。
これはかなり実用的です。
なぜなら、アプリ側では「普段はテキストをまとめて受け取りたいけど、UIではstreamingしたい」みたいな場面が普通にあるからです。キャッシュがそこをまたいで効くのは賢い設計だと思います。
LLMのstreamingは、返答を一気に返すのではなく、少しずつ chunk で返す 方式です。
ユーザーは文字が1文字ずつ増えていくのを見るので、体感が良い。
でも、キャッシュする側からすると厄介です。
ただ保存するだけではなく、その場でchunkを呼び出し元に流しつつ、裏で全部集めて保存する 必要があるからです。
著者はこれをやっています。
キャッシュにヒットしたときは、保存済みのchunkを AsyncGenerator として再生し、for await のループから見るとAPI通信と区別がつかないようにしている。
const stream = await openai.chat.completions.create({
model: "gpt-4o",
messages: [...],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
これがキャッシュでもそのまま動く。
こういう「使ってる側から見て違いがない」設計は、地味ですがとても重要です。
llm-cacherは、用途に応じて保存先を選べます。
memory
デフォルト。依存がなく、テスト向き。
file
ローカル開発やCIで便利。永続化できる。
SQLite
1プロセスで動くアプリに向いている。
Redis
複数インスタンスで動かす本番向け。
DynamoDB
serverless向け。expiryの管理をインフラ側に寄せたいときに便利。
この選択肢の幅はかなりいいです。
キャッシュって、アプリの規模で最適解が変わるので、「とりあえずmemory」から「本番Redis」まで滑らかに移れる のは現実的です。
しかも、必要なものだけを optional peer dependencies にしていて、使うbackendだけ入れればいい。
こういう配慮は、ライブラリとしてかなり好感が持てます。

この記事でいちばん面白いのは、ここだと思います。
単純な完全一致ではなく、意味が近いプロンプトを同じキャッシュとして扱う 仕組みです。
たとえば以下は、人間ならほぼ同じ依頼です。
Summarize this article.Summarize the article above.Can you summarize this article please?でも、文字列としては別物。
そこで llm-cacher は、embedding を使います。embedding とは、文章の意味を数値のベクトルに変換する仕組みです。
近い意味の文章ほど、ベクトル同士の距離も近くなります。
そして、その近さを cosine similarity で判定します。
cosine similarity は、2つのベクトルがどれくらい同じ方向を向いているかを見る指標です。1に近いほど似ています。
import { LocalEmbedder } from "llm-cacher";
const openai = createCachedClient(new OpenAI(), {
storage: "sqlite",
semantic: {
embedder: new LocalEmbedder(),
threshold: 0.92,
},
});

threshold は閾値で、これを超えたら「似ている」と判定します。
高くすると厳しめ、低くするとゆるめです。
LocalEmbedder は all-MiniLM-L6-v2 を使い、@huggingface/transformers 経由でローカル実行します。
つまり、API key不要・追加課金なし。これはかなりうれしい。
もちろん、精度をもっと優先したいなら OpenAI embeddings も使えます。
import { OpenAIEmbedder } from "llm-cacher";
semantic: {
embedder: new OpenAIEmbedder({ client: new OpenAI() }),
threshold: 0.95,
indexType: "hnsw",
}
semantic search は、キャッシュが増えると検索が重くなります。
最初は全件をなめる linear scan で十分ですが、件数が増えると遅くなる。
そこで indexType: 'hnsw' が使えます。
HNSWは、ざっくり言うと高速に近いベクトルを探すためのグラフ構造です。大規模データで効きます。

ここまで考えているのはちゃんとしてるな、と思います。
「semantic caching できます」だけなら簡単ですが、実際に増えたときどうするのか まで触れているのが良いです。
著者は、フレームワークごとの流儀に合わせて integration を用意しています。
req を拡張する形でこのあたりは、使う側のストレスをかなり減らすはずです。
ライブラリが「自分の流儀に従って」と言ってくると面倒ですが、ここはむしろその現場の書き方に寄せる。賢いです。
この記事の後半で、著者は学びも共有しています。ここがまた良いです。
ただ横取りして保存、では済まない。
呼び出し元へリアルタイムで流しながら、裏で保存する 必要があります。
さらに、ストリームが終わった後にstorage保存が失敗しても、もうユーザーには返してしまっている ので、その時点で例外を投げるべきではない。
だから .catch(() => undefined) で握りつぶしている。
これは雑に見えて、実はかなり筋が通っています。失敗してもユーザー体験を壊さないための設計です。

キャッシュが期限切れでstorageから消えても、embeddingのインデックス側には残り続けることがある。
つまり、保存先だけ消えて、検索用データは残骸として残る 問題があるわけです。
こういう「見えない掃除」が必要なのが、キャッシュの面倒なところです。
実装すると、派手さはないけど運用の泥臭さが一気に見えてきます。
個人的には、この記事は「AIで何か作るときの現実」をかなり素直に描いているのが良いと思いました。
AIアプリって、つい「モデルの賢さ」ばかりに目が行きますが、実際には
みたいな、地味だけど重要な問題 が山ほどあります。
llm-cacherは、その地味な問題にかなり真正面から向き合っている感じがあります。
派手なAI機能というより、AIをちゃんと運用するための下支え。こういうツールは本当に大事です。
もちろん、semantic caching は万能ではありません。
意味が近くても、文脈によっては別の答えが必要な場合もあります。なので、threshold の調整や、どのリクエストをキャッシュ対象にするかは慎重に考えるべきだと思います。
でも、その上で「キャッシュを入れられるところは入れる」という発想は、とても現実的です。

この記事は、Node.jsでLLMを使うときに返答キャッシュをどう作るか を、かなり実践的にまとめたものです。
完全一致のキャッシュだけでなく、embeddingを使ったsemantic cachingまで含めているのがポイントでした。
特に印象的だったのは、
の4点です。
AI開発って、モデルそのものより「周辺の面倒ごと」で差がつくことが多いんですよね。
このライブラリは、まさにその面倒ごとを減らすための道具だと思います。かなり実用寄りで、好感が持てるアプローチです。
参考: How I Cut My AI Bill by Caching LLM Responses in Node.js