本記事は System Design Simple の英語記事の日本語版です。原文: https://systemdesignsimple.com/1000-people-ask-ai-at-once/
AI サーバーが大勢に同時に応答できるのは、2 つの工夫のおかげです。1 つは KV キャッシュで、新しいトークンのたびに前のトークンの作業をやり直さないこと。もう 1 つは continuous batching で、大勢のリクエストが生成ステップを共有することです。おもちゃのモデルでは、キャッシュによって 1 件の回答が 567 ms から 39 ms になり、1,000 件のリクエストのキューのシミュレーションでは、最初のトークンまでの待ち時間の中央値が 452 秒(7.5 分)から 14 秒に下がりました。
トークンを 1 つずつ書くと、なぜ作業が重複するのか?
トークン(Token): テキストの小さな断片で、多くの場合は単語の一部。言語モデルが 1 つずつ読み書きする単位です。
Time to first token (TTFT): リクエストを送ってから最初の出力トークンの準備ができるまでの時間。キューでの待ち時間とプロンプトの処理が含まれます。
decoder-only transformer は自己回帰的に生成します。トークン 1 から t をもとにトークン t+1 を予測し、それを付け加えて繰り返します。各 attention レイヤーの中では、すべてのトークンがクエリ、キー、バリューへ射影されます。新しいトークンのクエリは、それまでの全トークンのキーと比較され、その結果で各トークンのバリューに重みが付きます。
素朴なループは、ステップごとに列全体をモデルに通します。プロンプトが P トークン、回答が N トークンの場合、射影だけでも P + N - 1 行で済むところを、N·P + N(N-1)/2 行分行うことになります。私たちのラボのケース(P = 16、N = 48)では、63 行で済むところが 1,896 行です。トークン 5 のキーとバリューは、一度計算すれば二度と変わりません。decoder の attention は後ろしか見ないからです。つまり再計算は純粋な無駄です。さらに、ステップごとに因果 attention の全体を再計算するコストも上乗せされます。各パス自体が列の長さに対して二次関数的に増えるためで、これは Transformer の論文が示す通りです。
KV キャッシュは何を保持するのか?
KV キャッシュ: レイヤーごと、attention ヘッドごとに、処理済みの全トークンのキーとバリューのベクトルを保存したもの。後のステップが再計算せずに読み取れます。
キャッシュがある場合、生成は 2 つのフェーズに分かれます。Prefill はプロンプトをモデルに一度だけ並列に通し、そのキーとバリューをキャッシュに書きます。これは主に行列同士の演算です。その後の各デコードステップは新しいトークン 1 つを扱います。そのトークンのキー、バリュー、クエリを計算し、キーとバリューをキャッシュに追加し、保存されたすべてに対して attention を行います。これは主に行列とベクトルの演算です。
新しいトークンにも本物の計算は必要ですし、attention はキャッシュされたすべてのキーを読むため、長いコンテキストでのステップは短いコンテキストより高くつきます。キャッシュが取り除くのは、古いトークンの再計算だけです。Hugging Face のキャッシュのドキュメントは、新しいトークンを計算する部分と、保存されたキーとバリューを再利用する部分の、この分かれ方を説明しています。長いチャットに馴染みがない方は、このキャッシュが置かれるコンテキストウィンドウについて、長い AI チャットがあなたの名前を忘れる理由で説明しています。
continuous batching はどうやってサーバーを共有するのか?
静的バッチング(Static batching): B 件のリクエストを一緒に開始し、最長のものが終わるまでデコードステップを回します。短い回答は自分のスロットで空き待ちになります。
Continuous batching(iteration-level scheduling): ステップごとにバッチを作り直します。終わったリクエストはすぐ抜け、キューの次のリクエストが prefill とともに受け入れられます。
Orca の論文(OSDI 2022)が iteration-level scheduling を導入したのはこのためです。リクエスト単位のバッチングでは、早く終わったリクエストが最も遅いものを待ち、新しい到着はバッチ全体の終了を待ちます。
バッチングはまず公平さに効きます。1 件ずつのサービングでは、1,000 人目は完全な回答 999 件分を待つことになります。一方、16 スロットあれば大勢が早く開始できます。スループットも上がります。GPU では、小さなデコードステップはたいてい重みをメモリから読むことに律速されるため、行を増やすコストは安く、1 秒あたりのトークン数は大きく伸びます。私たちのシングルスレッド JavaScript の実装では、コンテキスト 64 で 16 行の場合、1 行の場合と比べてデコードのスループットが 1.85x でした。ボトルネックのプロファイリングはしておらず、GPU も測っていません。
ラボで測ったこと
ラボ(labs/ai-18、node run.mjs、Node 22、パッケージなし)は、ランダムな重みを持つ小さな transformer を組み立てます。パート A はモデル自体の時間を直接測ります。パート B は、そこで測ったステップ時間だけを使って 1,000 件のリクエストのキューを再生します。シミュレータはキューの順番にリクエストを受け入れ、長さの混ざったバッチは同じ長さの計測値の割合の合計で見積り、prefill の間はデコードを止めます。KV メモリの制限もネットワーク遅延もモデル化していません。つまり、列の先頭の人が必ず最初に始まり、ここでの TTFT は「到着から最初のトークンの準備完了まで」の時間で、クライアントへの配信は測っていません。コードと私たちの参照結果は GitHub にあるので、自分で動かせます: kv-cache-bench。
# measured, one 48-token answer to a 16-token prompt, 5 runs per path
no KV cache median 567.05 ms (min 566.47, max 575.72)
with KV cache median 39.19 ms (min 39.14, max 39.22)
ratio 14.5x, identical tokens on both paths
# measured, one decode step at context 64
1 row 0.751 ms 16 rows 6.482 ms -> 1.85x tokens per second
# simulated queue: 1,000 requests at t = 0, prompts 8 to 32, answers 16 to 96
policy median TTFT p95 TTFT all done tokens/s
one at a time, no cache 452.2 s 851.5 s 892.3 s 64
one at a time, KV cache 23.9 s 45.1 s 47.3 s 1,203
static batches of 16, cache 21.2 s 40.5 s 42.8 s 1,331
continuous batching, 16 slots 13.8 s 26.4 s 27.8 s 2,044
キャッシュだけで中央値は 452 秒から 24 秒に下がりました(18.9x)。その上に continuous batching を重ねると 14 秒になりました(さらに 1.73x)。回答 1 件で測った 14.5x が、射影の行数の 30x の削減より小さいのは、キャッシュに対する attention、出力レイヤー、固定のオーバーヘッドが残るためです。

この記事の動画: メモ帳が KV キャッシュになり、椅子がバッチのスロットになります。
KV キャッシュのメモリが同時実行数を制限する仕組み
各リクエストは、実行中ずっと自分の KV キャッシュを保持します。割り当てのオーバーヘッドを除いた、素の KV テンソルの容量はトークンあたり次の通りです:
bytes per token = 2 (key and value) x layers x KV heads x head size x bytes per number
assumed example: 32 layers, 8 KV heads, head size 128, 16-bit numbers
2 x 32 x 8 x 128 x 2 = 131,072 bytes = 128 KiB per token
a 4,096-token conversation = 4,096 x 128 KiB = 512 MiB for one request
常駐できるトークンの数は、KV 用に使えるメモリで決まります。計算能力やレイテンシの目標によっては、さらに小さいバッチが必要になることもあります。vLLM のチューニングドキュメントは、こうしたスケジューリングとレイテンシのトレードオフを順に説明しています。PagedAttention の論文(SOSP 2023)は、キャッシュを仮想メモリのページのような固定サイズのブロックに保存すると、断片化や予約済み領域による無駄が減り、より多くのリクエストが収まることを示しました。長いチャットは短いチャットより提供コストが高くなります。キャッシュがトークンのたびに大きくなるからです。エージェントの次のリクエストには蓄積された長い履歴が含まれることがあり、KV の容量の要求が増えます。変わっていないプレフィックスを再送信すると、サーバーがそれを再利用しない限り prefill の作業が繰り返されます。そのパターンについてはAI エージェントが履歴を送り直す理由で見ています。
本番で注意すべき失敗モード: アクセラレータが遊んでいるのにキューの時間が TTFT を支配している(スケジューラや受け入れの制限が厳しすぎる)、長いコンテキストが同時に到着したときのメモリ不足によるプリエンプション(リクエストが追い出されて再計算される)、長いプロンプトが全員のデコードステップを止めること(サーバーは chunked prefill でこれを分割します)。
トレードオフ
| 選択肢 | 向いている用途 | 払うコスト | 使う場面 |
|---|---|---|---|
| キャッシュなし、1 件ずつ | 教育用、小さなテスト | 射影の繰り返しが回答の長さに対して二次関数的に増える。attention 全体の再計算も加わる。キューは直列 | サービングでは使わない |
| KV キャッシュ、1 件ずつ | 単一ユーザーでの最小レイテンシ | リクエストごとのメモリ。他の全員が待つ | 自分のノート PC 上のローカルモデル |
| 静的バッチ | 単純なオフラインジョブ | パディング。短い回答が長い回答を待つ | 長さのそろったバッチスコアリング |
| Continuous batching | 大勢のユーザー、長さが混在 | スケジューラ、キャッシュメモリの管理 | 共有のチャットや API サーバー全般 |
実際に動かして分かったこと
これは上のラボの表とは別の、後からの実行です。2026-11-06 の 15:37 UTC に行ったため、タイミングはあの表と少し異なります。Apple M2 Pro 上で Node v22.13.1、1 スレッドで node labs/ai-18/run.mjs を実行し、全体で 8.6 秒かかりました。画面にはまず直接のタイミングが表示されました。48 トークンの回答は、キャッシュありで中央値 39.6 ms、キャッシュなしで 577.8 ms で、どの実行でも同じトークンが出ました。続いて、同時に到着する 1,000 件のシミュレーションリクエストを再生しました。

実行後の私たちのターミナル。キャッシュのタイミングとキューの表が表示されています。
意外だったのは静的バッチングの行です。キャッシュありで 16 件のバッチにしても、最初のトークンの準備ができるまでのシミュレーション上の待ち時間の中央値は 24.14 秒から 21.41 秒にしか下がりませんでした。同じ 16 スロットの continuous batching では 13.96 秒まで下がり、列の最後のリクエストは最初のトークンの準備が 27.90 秒でできました。キャッシュなしで 1 件ずつの場合は 903.42 秒待っていました。ただし、この最後の差にはキャッシュとスケジューリングの 2 つの変更が混ざっています。1 本の CPU スレッドで動く小さなランダム重みモデルで、シミュレーションにメモリ制限もネットワーク遅延もない条件では、最も大きな単独の効果はキャッシュで、終わったリクエストをすぐにバッチから抜くことが次の効果でした。本物のサーバーでは、どちらも自前のメモリとトラフィックに照らして確認する必要があります。

Hugging Face Transformers のキャッシュ解説ページ。
私たちのキャッシュの経路は、そのページが説明する分かれ方に従っています。新しいトークンを計算し、前のトークンの保存されたキーとバリューを再利用します。
私たちの見解
このラボでより大きな違いを生んだのは KV キャッシュでした。前のトークンに対する射影とフィードフォワードの繰り返しを取り除きます。ただし、dense attention は増え続ける履歴を読み続けます。バッチングはそのうえで、誰が待つかを決めます。おもちゃのモデルでは、この 2 つの組み合わせで待ち時間の中央値が分単位から秒単位になりました。これらのタイミングは、CPU の 1 コア、ランダムな重み、シミュレーションされたワークロードによるもので、この実装についてだけ当てはまります。ネットワークなしで答えるように自分のマシンでモデルを動かしているなら、キャッシュはすでに使っています。バッチングが効いてくるのは、サーバーを複数人で共有するようになってからです。
このラボは、1 台のマシン上のおもちゃのモデルで作ったシミュレーションです。公開された論文とドキュメントに基づく一般的なサービングのパターンを説明するもので、特定の企業の非公開システムを説明するものではありません。