Skip to content
Published on

vLLM 内部構造 (5) — プレフィックスキャッシュ、システムプロンプト設計が性能になる理由

シェア
Authors

同じ先頭部分を二度計算しない

第2回で、ブロック単位の管理が共有を可能にすると述べた。今回はその共有をリクエストとリクエストの間に広げた機能、プレフィックスキャッシュを扱う。

実サービスのリクエストは、思っている以上に重なり合っている。チャットボットなら、すべてのリクエストが同じシステムプロンプトで始まる。ツールを使うエージェントなら、ツール定義のかたまりが毎回先頭に付く。マルチターンの会話なら、二番目のリクエストは一番目のリクエストの内容をまるごと含む。この重なる先頭部分のKVを毎回計算し直すのは、純粋な無駄だ。

プレフィックスキャッシュはこの無駄をなくす。効果はスループットより先に、最初のトークンまでの時間に現れる。プリフィルを飛ばした分だけ、ユーザーが待つ時間が減るからだ。mainブランチのCacheConfigソースでは、enable_prefix_cachingの宣言上のデフォルト値はtrueになっている。つまり最近のバージョンでは、あえて有効化しなくても動く方がデフォルトだ。

内容は 2026-08-12 に公式ドキュメントとソースで確認しました。vLLM は変化が速いため、設定値と挙動は使用中のバージョンのドキュメントで再確認してください。

ブロックハッシュが作られる仕組み

核心はブロックをどう識別するかにある。vLLM公式設計ドキュメントの説明は明確だ。各KVキャッシュブロックをハッシュする際、そのブロック内のトークンだけを使うのではなく、そのブロックより前にあった接頭部分のトークンまで合わせて反映する。実装上は、直前のブロックのハッシュ値、今回のブロックのトークン群、そして追加要素が一緒に入る。追加要素にはLoRA識別子、マルチモーダル入力のハッシュ、キャッシュソルトが含まれる。

この鎖構造がすべてだ。実務に必要な結論は、ここからすべて導き出される。

ブロックサイズが4のとき

プロンプト:  [あなたは親切な] [案内係です今日] [の日付は8月] [12日質問は]
                ブロック0            ブロック1              ブロック2         ブロック3

ハッシュ0 = H(なし,           ブロック0のトークン, 追加要素)
ハッシュ1 = H(ハッシュ0,      ブロック1のトークン, 追加要素)
ハッシュ2 = H(ハッシュ1,      ブロック2のトークン, 追加要素)
ハッシュ3 = H(ハッシュ2,      ブロック3のトークン, 追加要素)

→ ブロック2のトークンが一日経つだけで変われば、ハッシュ2が変わり、
  ハッシュ3はハッシュ2を材料に使うため、一緒に変わる。
  ブロック0とブロック1はそのまま生き残る。

一度ずれれば、それ以降はすべてずれる。逆に言えば、ずれるまではすべて生きている。だからプレフィックスキャッシュの性能は「どれだけ多く同じか」ではなく「先頭から何トークンまで同じか」で決まる。

なぜフルに埋まったブロックだけがキャッシュされるのか

設計ドキュメントが釘を刺しているルールがもう一つある。フルに埋まったブロックだけをキャッシュする。部分的にしか埋まっていないブロックは、完全に埋まるまではキャッシュから取り出して使えない。

ドキュメントが挙げる例が、このルールをよく示している。ブロックサイズが4のとき、あるリクエストが前の二つのブロック、つまり8トークンまでしかキャッシュにヒットしない状況が出てくる。三番目のブロックは4トークンのうち2トークンしか一致しないため、ヒットとして扱われない。

ここで、第2回で予告したトレードオフが実体を現す。ブロックが大きいほど、この端数の損失は大きくなる。ブロックサイズが32で共通接頭部分が40トークンなら、ヒットするのは32トークンまでで、残りの8トークンは計算し直しになる。共通接頭部分が非常に長いワークロードではこの損失は無視できる程度だが、短いプロンプトが多いサービスでは体感できる。

プロンプト設計がそのまま性能になる理由

ここまでを実務上のルール一つにまとめる。変わらないものを前に、毎回変わるものを後ろに置く

これがなぜそれほど重要なのかは、ハッシュの鎖を見れば自明だ。プロンプトの先頭行に現在時刻を入れると、その後にどれほど優れた長い共通指示があっても、すべて無効になる。最初のブロックのハッシュが毎リクエスト変わり、それに続くすべてのハッシュがそれを材料として使うからだ。

悪い配置 — キャッシュヒット0
[現在時刻09:31:07] [ユーザーID 8823] [長いシステム指示…] [ツール定義…] [質問]
   毎回異なる            毎回異なる            常に同じ           常に同じ

良い配置 — 前の二つの塊がまるごとヒット
[長いシステム指示…] [ツール定義…] [ユーザーID 8823] [現在時刻09:31:07] [質問]
     常に同じ           常に同じ         毎回異なる            毎回異なる

同じ内容の順序を入れ替えただけなのに、結果はまったく変わる。プロンプト設計が性能になるという表現は比喩ではなく、この構造そのものを直接述べたものだ。

もう一つある。先頭部分は一文字まで同じでなければならない。トークナイザーが見ているのはトークン列なので、空白一つや改行一つが違うだけでもトークンが変わりうる。システムプロンプトをコード側で文字列結合によって作っているなら、結合結果が本当に毎回同一バイト列になっているか確認してみる価値がある。

キャッシュが合わないよくある理由

ヒット率が期待より低いとき、順番に疑ってみるべきリストだ。

第一に、先頭側にある可変要素だ。タイムスタンプ、セッション識別子、ユーザー名、ランダムに並べ替えた例の順序。これらが先頭にあると、すべてキャッシュを殺す。

第二に、共通接頭部分がブロック一つを埋められない場合だ。システムプロンプトが短いと、キャッシュすべきフルなブロック自体が生まれない。この場合、キャッシュが壊れているのではなく、キャッシュすべきものがそもそもないだけだ。

第三に、追い出し(エビクション)だ。キャッシュされたブロックは永遠には残らない。新しいリクエストがブロックを要求していて余裕がなければ、古いブロックから回収される。同時リクエストが多くKVキャッシュが常にぎりぎりのデプロイでは、キャッシュしておく余地自体がない。この場合の解決策はプロンプトではなく、第4回で扱った容量の側にある。

第四に、リクエストのグループを分ける要素だ。設計ドキュメントが明らかにしているとおり、LoRA識別子とマルチモーダル入力、キャッシュソルトもハッシュの材料になる。アダプタが違えば、同じテキストであっても同じキャッシュは使われない。

セキュリティ: cache_saltが存在する理由

プレフィックスキャッシュには影が一つある。キャッシュヒットは応答を速くし、その速さは観測できる。複数のユーザーが一つのエンジンを共有する環境で、攻撃者がプロンプトを変えながら応答時間を測れば、どの接頭部分がすでにキャッシュにあるかを絞り込んでいくことができる。

そのためOpenAI互換サーバーにはcache_saltパラメータがある。ソースの説明ははっきりしている。指定すると、与えられた文字列をプレフィックスキャッシュに混ぜ込み、複数ユーザー環境で攻撃者がプロンプトを推測できないようにする。ソルトはランダムであるべきで、外部に露出してはならず、推測できないほど長くなければならない。ソースは例として、256ビットに相当するbase64 43文字を挙げている。

実務での適用は単純だ。テナントごとに異なるソルトを与えれば、キャッシュがテナントの境界を越えなくなる。その代わりテナント間の共有の利益はあきらめることになるため、同じ組織内ならテナント単位、完全に分離された顧客ならば顧客単位が現実的な落としどころになる。ちなみにハッシュアルゴリズム自体も設定項目だ。mainブランチのCacheConfigにおけるprefix_caching_hash_algoの宣言上のデフォルト値はsha256だった。

試してみる

参考資料