はじめに — 「合言葉は誰が決めるのか」問題
前回(問題編)で、ローカル vLLM の Prefix Cache は合言葉(cache_salt)なしだと、同じ文章が利用者・案件の区切りと無関係に同じキャッシュへ入ることを実測しました。反応時間は数倍変わり、使い回したトークン数でも裏が取れました。そして合言葉を変えれば、キャッシュはきれいに分かれました。
すると次の問いが立ちます。では運用で、その合言葉は誰が決めるのか。
素朴に浮かぶ「利用者やアプリに付けてもらう」は、共有 API の対策になりません。結論は 1 つです。vLLM を直接公開せず、インフラ層に公開用の窓口(LLM Gateway / API Proxy)を 1 枚設ける。その窓口を管理者が管理し、ログイン情報から合言葉を自動で付ける。
前提の実測は問題編にまとめてあります。問題編は未読でも、要点は本文だけで追えます。
要点
共有 vLLM で合言葉を利用者やアプリ任せにすると、付け忘れやなりすましで分離が簡単に崩れます。だから対策は、vLLM を直接公開せず、前段に窓口(LLM Gateway / API Proxy)を一段挟み、合言葉は利用者ではなく窓口を管理する側(管理者)が一元管理すること。同じ環境の実測でも、分けたい相手とは分かれ、共有してよい相手とは速さが残りました。
- 分けたい相手とは分かれる。 別の合言葉の利用者は互いのキャッシュを使い回さない(反応 278.8ms / 使い回し 0)。
- 同じグループ内は速いまま。 自分の再送は従来どおり速い(72.0ms / 使い回し 15,840 トークン)。
- 共有してよい相手とは共有できる。 同じチームの合言葉なら別メンバーでも使い回された(73.6ms)。分けるか共有するかは方針で選べる。
つまり「Prefix Cache を切る」必要はありません。どの単位で分けるかを設計するのが対策です。
対象読者
- ローカル LLM を個人利用から社内共有・チーム共有・案件別提供へ広げたい人
- vLLM / SGLang などの推論サーバを、複数利用者・複数アプリの前段でどう公開するか考えている人
- 「外部 API に投げないから安全」の一歩先、共有サーバ内の区切りまで詰めたい人
問題編で分かったこと・残った宿題
- 分かったこと:合言葉なしだと同じ文章は誰が送っても同じキャッシュに入り、合言葉を変えれば分かれる(数値は問題編)。
- 残った宿題:その合言葉を運用で誰が・どう付けるかは未解決。その解き方として、シンプルな案を 1 つ検討し試した。
なぜ「利用者に付けさせる」では対策にならないか
合言葉を利用者やアプリ任せにすると、共有 API としては次の穴が残ります。
| 利用者・アプリ任せだと | 何が起きるか |
|---|---|
| 付け忘れる | 合言葉なし=問題編の全員共有に落ちる。1 つのミスで分離が崩れる |
| 申告を信用できない | 他人の合言葉を騙る余地が残る。合言葉は「利用者が入力する値」ではなく「サーバがログインから決める値」であるべき |
| 方針が散らばる | 分ける単位(利用者 / 案件 / チーム)はインフラの都合。各アプリに散らすと一貫性を保てない |
だから、合言葉を決めるのは利用者ではなく、**窓口を管理する側(管理者)**の役目に寄せます。
推奨構成 — vLLM を直接公開せず、窓口で合言葉を付ける
公開するのは vLLM 本体ではなく、その前段の**窓口(LLM Gateway / API Proxy)**です。
窓口がログインで「誰の・どの案件の」リクエストかを確定し、そこから決まった合言葉を、vLLM へ渡す直前に付けます。クライアントが送ってきた合言葉は捨ててから上書きします。
窓口側の役目を整理すると、こうなります。
| やること | 目的 |
|---|---|
| vLLM 本体を直接公開しない | 合言葉なしで届く経路をなくす |
| 窓口でログインを確認する | 誰の・どの案件かを確定する |
| 窓口で合言葉を自動付与する | 分ける単位をサーバ側で決める |
| クライアントの合言葉は捨てて上書き | 利用者任せ・なりすましをなくす |
| 単位ごとの使い回し率を監視する | 想定外の共有や設計ミスを見つける |
考え方が伝わる最小限の擬似コードにすると、こうです(本格実装ではありません)。
SERVER_SECRET = load_server_secret() # サーバ側だけが持つ鍵。クライアントは知らない
def build_cache_salt(auth_context):
# 生の利用者名やプロジェクト名をそのまま使わず、サーバの秘密鍵で安定した文字列に変換する
#(鍵を知らないクライアントは同じ値を再現できない=なりすまし防止)
namespace = f"{auth_context.workspace_id}:{auth_context.project_id}"
return stable_hmac(SERVER_SECRET, namespace)
def proxy_to_vllm(request, auth_context):
payload = request.json()
payload.pop("cache_salt", None) # クライアント指定は捨てる
payload["cache_salt"] = build_cache_salt(auth_context) # 窓口が自動で付ける
return post_to_internal_vllm(payload) # 社内ネットワークの vLLM へ
ポイントは 3 つです。合言葉は利用者入力ではなく窓口が決める。生の名前ではなく安定した文字列に変換する。vLLM へ直接届く経路を閉じる。
どの単位で分けるか
分ければよいというものではありません。分けすぎるとキャッシュ共有の恩恵が減り、共有しすぎると区切りが崩れます。用途で選びます。
| 利用形態 | 分ける単位 | 理由 |
|---|---|---|
| 個人 PC の単独利用 | 分けない | 共有相手がいない |
| 小チーム共有 | チーム単位 | 共通 system prompt などを再利用しやすい |
| 社内文書 QA | 文書集合ごと | 同じ文書を使う範囲でキャッシュを揃える |
| 案件別・顧客別 | 案件・顧客単位 | 案件の区切りとキャッシュの区切りを合わせる |
| 利用者ごとの記憶 | 利用者単位 | 他の人と混ぜない |
「機密の部分だけ分け、共有してよい部分は共有する」という選択的な分け方の考えに沿います(SafeKV)。
対策データ — 分けても速さは残る
同じローカル vLLM 環境で、利用者 A が送った後に、利用者 B が同じ文章を送るシナリオを 3 つの方針で測りました(各 10 試行、反応時間は中央値)。反応時間は TTFT(最初の 1 トークンが返り始めるまでの時間)です。入力処理で Prefix Cache が効くため、生成を最小化(max_tokens 4)してこの部分の差だけを見ています。
- 区切りなし:合言葉を付けない
- 同じチーム:A と B が同じチームの合言葉(共有を許す相手)
- 分離:A と B で違う合言葉(分けたい相手)。さらに B が自分の文章を再送した場合も測る
測定環境(問題編と同一条件)
- ハード: GeForce RTX 4070 12GB / NVIDIA driver 580.126.20 / Ubuntu 24.04
- 推論エンジン: vLLM 0.21.1rc1.dev243+ga5bbd81e2(コンテナ
vllm/vllm-openai:nightly, digestsha256:484204…)+--enable-prefix-caching/--enforce-eager/--max-model-len 32768/--gpu-memory-utilization 0.90 - CUDA / PyTorch: CUDA 13.0 / PyTorch 2.11.0(コンテナ同梱)
- モデル: RedHatAI/Qwen3.5-4B-FP8-dynamic(FP8 量子化)
- 測定条件: temperature 0 / max_tokens 4 / 各条件 10 試行 / 反応時間は中央値
中くらいの長さの prompt
| 方針 | 誰が | 反応時間(TTFT) | 使い回し | 読み取り |
|---|---|---|---|---|
| 区切りなし | B(別人) | 72.7ms | 15,840 | 区切りを越えて使い回し(問題編の再現) |
| 同じチーム | B(同僚) | 73.6ms | 15,840 | 共有してよい相手なので使い回し(意図どおり) |
| 分離 | B(別合言葉) | 278.8ms | 0 | 別グループは使い回さない(分離成功) |
| 分離 | B(再送) | 72.0ms | 15,840 | 同じグループ内では速さが残る |
長めの prompt
| 方針 | 誰が | 反応時間(TTFT) | 使い回し | 読み取り |
|---|---|---|---|---|
| 区切りなし | B(別人) | 86.7ms | 31,680 | 区切りを越えて使い回し(問題編の再現) |
| 同じチーム | B(同僚) | 90.2ms | 31,680 | 共有してよい相手なので使い回し(意図どおり) |
| 分離 | B(別合言葉) | 517.1ms | 0 | 別グループは使い回さない(分離成功) |
| 分離 | B(再送) | 88.7ms | 31,680 | 同じグループ内では速さが残る |
中くらいと同じ傾向で、桁が上がっても結論は変わりません。
要点は 2 つです。
- 分離すると、別の合言葉の B は使い回し 0・反応は 1 回目相当(278.8ms / 517.1ms)。 別の利用者が同じ文章を送っても、キャッシュを共有しません。分離が効いています。
- その分離下でも、B が自分の文章を再送すれば 72.0ms / 88.7ms で使い回す。 分けても、同じグループの中では速さがそのまま残ります。
そして「同じチーム」の方針なら、別メンバーでも使い回されました(73.6ms / 90.2ms)。共有したい相手とはちゃんと共有できるも同時に示せました。分離と高速化はどちらか一方をあきらめる関係ではありません。
考察 — わかったこと・限界
わかったこと
- 今回のローカル vLLM 環境では、合言葉を利用者・案件・チームなどの運用単位から決めることで、Prefix Cache の速さと、共有範囲の制御を両立できた。
- 分離は別利用者の使い回しを 0 にし、同じグループ内の再送では速さを維持した。共有単位は意図どおり共有された。
- 対策の実装位置は利用者ではなく窓口(Gateway)。vLLM を直接公開しない構成なら、合言葉なしで届く経路自体をなくせる。
限界
- これは RTX 4070・Qwen3.5-4B-FP8・vLLM nightly・合成 prompt・10 試行という特定条件の実測です。すべてのモデル・バージョン・負荷で同じ数値になるとは限りません。
- 合言葉は共有範囲を分ける手段であって、ログイン認証・権限・監査・レート制限などをまとめて解決するものではありません。窓口でそれらと組み合わせて初めて運用になります。
- 今回示したのは「共有範囲を分けられた」ことです。情報の混入そのものを実証したわけではありません(問題編と同じ線引きです)。
まとめ
- ローカル LLM 共有サーバでは、Prefix Cache は速さの仕組みであると同時に、共有範囲を設計すべき内部状態でもある。
- 対策は「Prefix Cache を切る」ではなく「どの単位で分けるかを設計する」。利用者に合言葉を任せず、vLLM を直接公開せず、窓口がログイン情報から自動で付ける。
- 実測のとおり、分けたい相手とは分け(別合言葉は使い回し 0)、共有してよい相手とは速さを残せる(同じグループの再送は数倍速)。
ローカルだから区切りは不要、ではありません。外部 API に投げないことと、共有サーバの中でキャッシュの区切りを設計することは、別の話です。前段に窓口を 1 枚置くだけで、この区切りは運用側の手に戻せます。
役に立ったら LGTM を、続編や関連記事はストック(更新時に通知)が便利です。運用での工夫や指摘があればコメントで教えてください。追記します。
実験に使ったローカルベンチのリポジトリ: https://github.com/ai-systems-notes/local-llm-rag-cag-benchmark
参考文献
- vLLM, "Automatic Prefix Caching"(
cache_saltによる Cache Isolation を含む。正式機能) - vLLM PR #17045, "[Core] Prevent side-channel attacks via cache salting"(
cache_saltの実装。2025-04-30 マージ) - vLLM RFC #16016, "Cache Salting for Secure and Flexible Prefix Caching in vLLM"(
cache_saltの提案・議論。closed) - NDSS 2025, "I Know What You Asked: Prompt Leakage via KV-Cache Sharing in Multi-Tenant LLM Serving"
- "Selective KV-Cache Sharing to Mitigate Timing Side-Channels in LLM Inference"(SafeKV。機密の部分だけ分ける方向性)
- NVIDIA, "Structuring Applications to Secure the KV Cache"
- 前回:ローカルLLMを複数人で共有するとKV Cacheはどうなるか(問題編)
