PaPoo
cover
technews
Author
technews
世界の技術ニュースをリアルタイムでキャッチし、日本語でわかりやすく発信。AI・半導体・スタートアップから規制動向まで、グローバルテックシーンの「今」をお届けします。

Node.jsでLLMの返答をキャッシュして、AI利用料を減らす話

キーポイント

まず何が問題なのか

この記事の出発点はとてもシンプルです。
​「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回。評価用のテストを回すたびに、ひたすらお金が減っていく。正直、なかなかにイヤな世界です。

image_0003.svg

もちろん、自前で Map を使ってキャッシュする方法はあります。

const cache = new Map();

async function summarize(text: string) {
  if (cache.has(text)) return cache.get(text);
  // ...
}

ただし、これには弱点があります。
完全一致しか見られない んです。

たとえば、

この2つは、人間から見ればかなり近いですが、単純な文字列比較だと別物です。
LLMは中身としてほぼ同じ答えを返しそうなのに、キャッシュは効かない。ここがもったいない。

image_0004.svg

llm-cacherは何を目指しているのか

著者が狙ったのは次の3つです。

  1. 既存コードをほぼ変えずに使えること
  2. いろいろなstorage backendに対応すること
  3. 意味が近いプロンプトも同じキャッシュとして扱えること

この設計方針はかなり筋がいいと思います。
キャッシュって、理屈では便利でも「導入が面倒」「既存コードを大改造しないと使えない」と一気に使われなくなります。そこをProxyで包んでしまうのは、かなり実用寄りです。

どんな人向けなのか

この記事では、他の選択肢も整理されています。

image_0005.svg

結論として、llm-cacherは
​「OpenAIやAnthropicのSDKを直接使いたい。フレームワークもSaaSも増やしたくない。しかも自前でホストしたい」​
という人向け、という整理です。
これはかなり現場っぽいニーズだと思います。

すぐ使えるのがうれしい

導入はかなり軽いです。

npm install llm-cacher

そしてこう書けます。

import OpenAI from "openai";
import { createCachedClient } from "llm-cacher";

![image_0006.svg](https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg)

const openai = createCachedClient(new OpenAI(), {
  ttl: "24h",
  storage: "memory",
});

ここで大事なのは、createCachedClientProxy を返すこと。
Proxyは、ざっくり言うと「元のオブジェクトに見えるけど、裏で振る舞いを差し替えられる仕組み」です。

つまり、既存の openai.chat.completions.create(...) の形をほぼそのまま保てる。
この「コードを書き換えなくて済む」感じは、導入障壁をかなり下げるはずです。個人的にはここが一番えらいと思います。

どうキャッシュしているのか

基本のキャッシュキーは、リクエストの内容をもとに作られます。

それらをまとめて SHA-256 hash にしてキー化します。
SHA-256は、入力から固定長のハッシュ値を作る仕組みで、キャッシュキーや改ざん検知でよく使われます。

面白いのは、​streamフラグはキーに含めない ことです。
つまり、同じ内容なら streaming でも non-streaming でも同じキャッシュを使えるようにしています。

image_0007.svg

これはかなり実用的です。
なぜなら、アプリ側では「普段はテキストをまとめて受け取りたいけど、UIではstreamingしたい」みたいな場面が普通にあるからです。キャッシュがそこをまたいで効くのは賢い設計だと思います。

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 ?? "");
}

これがキャッシュでもそのまま動く。
こういう「使ってる側から見て違いがない」設計は、地味ですがとても重要です。

storage backend が選べるのも強い

llm-cacherは、用途に応じて保存先を選べます。

image_0008.svg

この選択肢の幅はかなりいいです。
キャッシュって、アプリの規模で最適解が変わるので、​​「とりあえずmemory」から「本番Redis」まで滑らかに移れる のは現実的です。

しかも、必要なものだけを optional peer dependencies にしていて、​使うbackendだけ入れればいい
こういう配慮は、ライブラリとしてかなり好感が持てます。

image_0009.png

semantic caching が本命っぽい

この記事でいちばん面白いのは、ここだと思います。
単純な完全一致ではなく、​意味が近いプロンプトを同じキャッシュとして扱う 仕組みです。

たとえば以下は、人間ならほぼ同じ依頼です。

でも、文字列としては別物。
そこで 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,
  },
});

image_0011.png

threshold は閾値で、これを超えたら「似ている」と判定します。
高くすると厳しめ、低くするとゆるめです。

LocalEmbedder が地味に便利

LocalEmbedderall-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は、ざっくり言うと高速に近いベクトルを探すためのグラフ構造です。大規模データで効きます。

image_0016.png

ここまで考えているのはちゃんとしてるな、と思います。
「semantic caching できます」だけなら簡単ですが、​実際に増えたときどうするのか まで触れているのが良いです。

フレームワーク統合も自然

著者は、フレームワークごとの流儀に合わせて integration を用意しています。

このあたりは、使う側のストレスをかなり減らすはずです。
ライブラリが「自分の流儀に従って」と言ってくると面倒ですが、ここはむしろその現場の書き方に寄せる。賢いです。

実装してみてわかったこと

この記事の後半で、著者は学びも共有しています。ここがまた良いです。

1. streaming のキャッシュは思ったより難しい

ただ横取りして保存、では済まない。
呼び出し元へリアルタイムで流しながら、裏で保存する 必要があります。

さらに、ストリームが終わった後にstorage保存が失敗しても、​もうユーザーには返してしまっている ので、その時点で例外を投げるべきではない。
だから .catch(() => undefined) で握りつぶしている。
これは雑に見えて、実はかなり筋が通っています。失敗してもユーザー体験を壊さないための設計です。

image_0017.png

2. similarity index は放置できない

キャッシュが期限切れでstorageから消えても、embeddingのインデックス側には残り続けることがある。
つまり、​保存先だけ消えて、検索用データは残骸として残る 問題があるわけです。

こういう「見えない掃除」が必要なのが、キャッシュの面倒なところです。
実装すると、派手さはないけど運用の泥臭さが一気に見えてきます。

この記事の面白さ

個人的には、この記事は「AIで何か作るときの現実」をかなり素直に描いているのが良いと思いました。
AIアプリって、つい「モデルの賢さ」ばかりに目が行きますが、実際には

みたいな、​地味だけど重要な問題 が山ほどあります。

llm-cacherは、その地味な問題にかなり真正面から向き合っている感じがあります。
派手なAI機能というより、​AIをちゃんと運用するための下支え。こういうツールは本当に大事です。

もちろん、semantic caching は万能ではありません。
意味が近くても、文脈によっては別の答えが必要な場合もあります。なので、threshold の調整や、どのリクエストをキャッシュ対象にするかは慎重に考えるべきだと思います。
でも、その上で「キャッシュを入れられるところは入れる」という発想は、とても現実的です。

image_0018.png

まとめ

この記事は、Node.jsでLLMを使うときに返答キャッシュをどう作るか を、かなり実践的にまとめたものです。
完全一致のキャッシュだけでなく、embeddingを使ったsemantic cachingまで含めているのがポイントでした。

特に印象的だったのは、

の4点です。

AI開発って、モデルそのものより「周辺の面倒ごと」で差がつくことが多いんですよね。
このライブラリは、まさにその面倒ごとを減らすための道具だと思います。かなり実用寄りで、好感が持てるアプローチです。


参考: How I Cut My AI Bill by Caching LLM Responses in Node.js

同じ著者の記事