vLLMで推論サーバーを構築する — PagedAttentionによる高速推論とサービング環境

ChatGPTやClaudeのような大規模言語モデル(LLM)のサービスを使ったことがあるなら、テキストが1トークンずつストリーミングで返ってくる体験に馴染みがあるでしょう。裏側では、数十億パラメータのモデルが限られたGPUメモリの中で同時に何百ものリクエストを処理しています。

vLLM推論サーバーの全体アーキテクチャ

vLLMは、クライアントからのリクエストをAPIサーバーで受け付け、スケジューラ・ブロックマネージャー・ワーカーが連携してGPU上でモデルを実行するという4層構造を持ちます。特にスケジューラとブロックマネージャーが、Continuous BatchingとPagedAttentionの核心を担います。各コンポーネントの役割は後のセクションで詳しく解説しますが、まずはこの全体像を頭に入れておくと理解が深まります。

ここで自然な疑問が生まれます — 自分でもLLMをサーバーとしてホストできるのでしょうか?HuggingFaceの transformers ライブラリで推論スクリプトを書くことはできますが、本番環境で使うにはスループットが桁違いに足りません。1リクエストずつ処理していたのでは、同時リクエストが増えた瞬間にレスポンスタイムが爆発します。

この問題を解決するフレームワークがvLLMです。vLLMは、PagedAttentionによるメモリ効率化とContinuous Batchingによるスループット最大化を組み合わせ、HuggingFace Transformersと比べて2〜24倍のスループットを達成します。しかも、OpenAI互換のAPIサーバーを数行のコマンドで起動できるため、既存のアプリケーションからの移行も容易です。

vLLMを使った推論サーバーの構築を学ぶと、以下のことが可能になります。

  • 自社LLMサービスの構築: 商用APIに依存せず、オンプレミスやプライベートクラウドでLLMを運用できます
  • コスト最適化: スループットの向上により、同じGPUリソースで処理できるリクエスト数が増え、推論コストを大幅に削減できます
  • レイテンシの制御: ストリーミング応答やPrefix Cachingにより、ユーザー体験を最適化できます

本記事の内容

  • LLM推論のボトルネック — なぜ素朴な実装では遅いのか
  • vLLMのコアアーキテクチャ — PagedAttention・Continuous Batching・Prefix Caching
  • Speculative Decodingとの連携
  • vLLMのセットアップとOpenAI互換APIサーバーの起動
  • Pythonクライアントからの利用とベンチマーク
  • 他の推論フレームワーク(TGI・Ollama・llama.cpp)との比較
  • 本番環境へのデプロイメントパターン

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

画像なし
KVキャッシュの仕組み
Transformerの自己回帰生成におけるKVキャッシュの基本原理を理解します
画像なし
FlashAttentionの仕組み
Attention計算のメモリ効率化手法を理解します
画像なし
PagedAttention — vLLMの仮想メモリ着想によるKVキャッシュ管理
PagedAttentionの理論的な仕組みをシミュレーション実装付きで理解します
画像なし
LLMの量子化
モデルサイズを削減しつつ精度を維持する量子化手法を理解します

LLM推論のボトルネック

なぜLLMの推論は遅いのか

LLMの推論が遅い理由を理解するには、推論の2つのフェーズを知る必要があります。

Prefillフェーズ(プロンプト処理): ユーザーが入力したプロンプトの全トークンを一括で処理し、KVキャッシュを構築します。このフェーズは行列積が中心で、GPUの計算律速(compute-bound)になります。入力トークンを並列に処理できるため、GPU演算ユニットをフルに活用できます。

Decodeフェーズ(トークン生成): KVキャッシュを参照しながら、1トークンずつ自己回帰的に生成します。ここが問題です。各ステップでは1トークン分の計算しかしないため、行列積のサイズが非常に小さくなります。GPUの演算能力は余っているのに、KVキャッシュの読み出しがボトルネックになるメモリ帯域律速(memory-bound)の状態に陥ります。

この2フェーズの性質の違いを、具体的な数値で見てみましょう。A100 GPU(80GB HBM2e)の場合、演算性能は312 TFLOPs(FP16)ですが、メモリ帯域幅は2 TB/sです。Decodeフェーズの1ステップにおける演算強度(Arithmetic Intensity)は以下のように計算できます。

$$ \text{演算強度} = \frac{\text{演算量 (FLOPs)}}{\text{データ転送量 (bytes)}} $$

Decodeフェーズでは、KVキャッシュ全体をメモリから読み出す必要がある一方、計算量は1トークン分です。モデルのパラメータ数を $P$、バッチサイズを $B$ とすると、1ステップあたりの演算量と転送量は大まかに次のように見積もれます。

$$ \text{演算量} \approx 2PB \text{ FLOPs} $$

モデルパラメータの読み出しに必要なデータ転送量は以下です(FP16の場合)。

$$ \text{転送量} \approx 2P \text{ bytes} $$

これらの比を取ると、演算強度はバッチサイズ $B$ に比例します。

$$ \text{演算強度} = \frac{2PB}{2P} = B $$

A100のops:byte比は $312 \times 10^{12} / (2 \times 10^{12}) = 156$ です。つまり、バッチサイズが156未満では、GPUの演算能力が遊んでしまうのです。バッチサイズ1(1リクエストのみ処理)では、GPU利用率はわずか1/156 ≈ 0.6% です。

Decodeフェーズのルーフラインモデルとメモリ帯域律速

上のグラフは、演算強度(FLOPs/byte)がバッチサイズに比例して増加する様子を示しています。赤い破線がA100のルーフライン(ops:byte比 = 156)であり、バッチサイズがこれを下回る赤いゾーンではGPUの演算能力が余った「メモリ帯域律速」状態です。バッチサイズを増やすことでGPU利用率が劇的に改善し、Continuous Batchingの重要性が数値として確認できます。

KVキャッシュの断片化問題

もう1つの深刻な問題が、KVキャッシュのメモリ管理です。従来の実装では、各リクエストに対して最大系列長分の連続メモリを事前確保していました。

しかし、実際のリクエストの出力長は事前にわかりません。最大2048トークンで確保しても、実際は100トークンで終わるかもしれません。この場合、確保したメモリの95%が無駄になります。さらに、リクエストの完了と新規到着が繰り返されると、メモリの空き領域が断片化し、GPUメモリの実効的な利用率は20〜40%にまで低下していました。

これら2つの問題 — Decodeフェーズのメモリ帯域律速とKVキャッシュの断片化 — を同時に解決するのがvLLMのアーキテクチャです。次のセクションでは、vLLMがどのような仕組みでこれらの課題を克服しているかを見ていきます。

KVキャッシュのメモリ管理: 従来方式vsPagedAttention

左の従来方式では、リクエストAが最大2048トークンを確保しているにも関わらず実際の使用は100トークンのみで、95%が無駄になっています。さらに完了したリクエストの解放後に生じる断片化(グレーの領域)により、空きメモリが分散して新たな大きいリクエストを受け付けられなくなります。右のPagedAttentionでは各リクエストのKVキャッシュが小さなブロックとして物理メモリ上に分散配置され、断片化が解消されてメモリ利用率が95%以上に向上します。

vLLMのコアアーキテクチャ

vLLMは、3つの核心的な技術を組み合わせることで高速推論を実現しています。それぞれを順に見ていきましょう。

PagedAttention

PagedAttentionは、前の記事で詳しく解説した通り、OSの仮想メモリ管理から着想を得た手法です。ここでは推論サーバーの文脈で重要なポイントを簡潔に振り返ります。

従来の実装ではKVキャッシュを連続メモリとして確保していましたが、PagedAttentionは固定サイズのブロック(ページ)に分割します。物理メモリ上のブロックは連続している必要がなく、ブロックテーブル(ページテーブル)で論理位置と物理位置の対応を管理します。

各リクエストのKVキャッシュは、論理的には連続した系列ですが、物理的にはGPUメモリ上の離散的なブロックに格納されます。ブロックサイズを $B$ トークンとすると、系列長 $s$ に対して必要なブロック数は $\lceil s / B \rceil$ 個です。新しいトークンが生成されるたびに、最後のブロックに空きがなければ新しいブロックを1つ割り当てます。

Attention計算では、各ブロックについて独立にKeyとValueを読み出し、スコアを計算します。位置 $t$ のクエリ $\bm{q}_t$ に対する出力は以下のように計算されます。

$$ \bm{o}_t = \frac{\sum_{j=1}^{N_b} \sum_{k=1}^{B} \exp(s_{j,k}) \cdot \bm{v}_{j,k}}{\sum_{j=1}^{N_b} \sum_{k=1}^{B} \exp(s_{j,k})} $$

ここで $N_b$ はブロック数、$s_{j,k} = \bm{q}_t^\top \bm{K}_{j,k} / \sqrt{d}$ は第 $j$ ブロックの第 $k$ 要素に対するAttentionスコアです。外側のループは各ブロックに対する反復、内側のループはブロック内の各トークンに対する反復です。

このブロック単位の計算は、ブロックが物理的に分散していても正しいAttention値を計算できることを保証しています。各ブロックの部分的なsoftmax値を計算し、最終的に正規化定数で割ることでブロック間の結合が可能です。

PagedAttentionによって得られる効果は明確です。

  • メモリの無駄が近づくゼロ: 最後のブロックの端数($B-1$ トークン以下)のみが無駄になります
  • 外部断片化の解消: ブロックが連続している必要がないため、空き領域の断片化が起きません
  • Copy-on-Write: ビームサーチなどで複数の候補が共通のプレフィックスを持つ場合、ブロックを共有して書き込み時にだけコピーします

PagedAttentionブロックテーブル: 論理から物理ブロックへのマッピング

リクエストAは論理的にはL0→L1→L2と連続したブロックとして見えますが、物理メモリ上ではP3、P2、P7というバラバラな場所に格納されています。ブロックテーブルがこの対応関係を管理することで、Attention計算は論理的な順序で正しく実行されます。リクエストBのブロックも別の物理位置に散在していますが、Attention計算には何の影響も与えません。

Continuous Batching

スループット問題を解決するもう1つの鍵がContinuous Batching(継続バッチ処理)です。この概念を理解するために、まず従来のバッチ処理の問題を整理しましょう。

Static Batching(静的バッチ): 従来の方式では、複数のリクエストを一度にまとめてバッチ処理しますが、バッチ内の全リクエストが完了するまで新しいリクエストを受け付けません。

ここで問題が生じます。リクエストAが10トークン、リクエストBが500トークン生成する場合、リクエストAは10ステップで完了しますが、リクエストBの完了を待つ間、リクエストAが占めていたGPUリソースは完全に遊んでいます。リクエストCが待ち行列にいても、バッチ全体が完了するまで処理を開始できません。

Continuous Batching: vLLMが採用するContinuous Batchingでは、ステップ単位でバッチの構成を動的に変更します。あるリクエストが完了すれば、次のステップから即座に新しいリクエストをバッチに追加します。

この違いを数式で表現しましょう。$N$ 個のリクエストがあり、それぞれの生成トークン数を $l_1, l_2, \ldots, l_N$ とします。バッチサイズの上限を $B_\text{max}$ とすると、Static Batchingの総ステップ数は以下です。

$$ T_\text{static} = \sum_{k=1}^{\lceil N/B_\text{max} \rceil} \max_{i \in \text{batch}_k} l_i $$

各バッチの最長リクエストに引きずられるため、短いリクエストの完了後のステップは無駄になります。

一方、Continuous Batchingでは完了したスロットに即座に次のリクエストが入るため、理想的にはGPUが常にフル稼働します。

$$ T_\text{continuous} \approx \frac{\sum_{i=1}^{N} l_i}{B_\text{max}} $$

リクエストの生成長にばらつきがあるほど、Continuous Batchingの効果は大きくなります。実際のLLMサービスでは、リクエストの生成長は数十トークン〜数千トークンまで大きなばらつきがあるため、Continuous Batchingは実用上非常に有効です。

vLLMの内部では、スケジューラがこのバッチ管理を担当しています。各ステップで以下を判断します。

  1. 完了したリクエストをバッチから除去
  2. 待ち行列にあるリクエストをバッチに追加(メモリの空きがある限り)
  3. メモリが不足した場合、一部のリクエストのKVキャッシュをスワップアウト(GPUメモリ→CPUメモリ)

PagedAttentionとContinuous Batchingを組み合わせると、メモリ効率とGPU利用率の両方が最大化されます。次に、同じプロンプトが繰り返し現れる場合の最適化を見てみましょう。

Static BatchingとContinuous Batchingのタイムライン比較

上段のStatic BatchingではリクエストA(10ステップ)が先に完了しても、バッチ内の最長リクエストB(60ステップ)が終わるまで次のリクエストDを受け付けられず、スロット0はグレーの「待機」状態になります。下段のContinuous Batchingではリクエストが完了した瞬間に次のリクエストが投入されるため、全体の完了が90ステップ(Static: 140ステップ)と約36%短縮されています。リクエストの生成長にばらつきが大きいほど、この差は拡大します。

Prefix Caching(Automatic Prefix Caching)

実際のLLMサービスでは、多くのリクエストが共通のプレフィックスを持ちます。例えば、システムプロンプトは全リクエストで同一であり、チャット履歴の先頭部分は会話の途中で変わりません。

Prefix Cachingは、以前のリクエストで計算したKVキャッシュを再利用する仕組みです。vLLMではAutomatic Prefix Caching(APC)として実装されており、明示的な設定なしに共通プレフィックスを自動検出して再利用します。

仕組みは以下の通りです。

  1. 入力トークン列をブロック単位に区切る
  2. 各ブロックの内容(トークン列)に基づいたハッシュを計算する
  3. ハッシュが一致するブロックがキャッシュにあれば、KVキャッシュの再計算をスキップする

これによりPrefillフェーズの計算量が大幅に削減されます。システムプロンプトが1000トークンの場合、2回目以降のリクエストでは1000トークン分のPrefill計算が不要になります。

Prefix Cachingの効果を定量的に見積もってみましょう。リクエストの入力トークン数を $s_\text{in}$、うち共通プレフィックスの長さを $s_\text{prefix}$ とします。Prefillフェーズの計算量は入力トークン数にほぼ比例するため、キャッシュヒット時のPrefillの計算量削減率は以下です。

$$ \text{Prefill削減率} = \frac{s_\text{prefix}}{s_\text{in}} $$

システムプロンプト(500トークン)+ Few-shot例(500トークン)+ ユーザー入力(100トークン)の場合、$s_\text{prefix} = 1000$, $s_\text{in} = 1100$ なので削減率は $1000/1100 \approx 91\%$ です。Prefillフェーズの計算量がほぼ1/10になるのです。

ここまでで、vLLMの中核技術であるPagedAttention・Continuous Batching・Prefix Cachingを見てきました。次に、さらに踏み込んだ最適化手法である投機的デコーディングとの連携を見てみましょう。

Prefix CachingによるPrefill削減率とTTFT短縮効果

左のグラフでは、共通プレフィックスが長くなるほどPrefill計算量の削減率が上昇し、Few-shot付き(1000トークン)の場合は91%の削減が可能であることが確認できます。右のグラフでは、キャッシュヒット率が高いほどTTFT(最初のトークンが返るまでの時間)が線形に短縮され、ヒット率90%でTTFTが1/10になることが読み取れます。システムプロンプトを固定したチャットボットでは、大半のリクエストでこの恩恵を受けられます。

Speculative Decodingとの連携

Decodeフェーズのメモリ帯域律速を根本的に改善するアプローチとして、Speculative Decoding(投機的デコーディング)があります。詳細は投機的デコーディングの記事で解説していますが、ここではvLLMでの統合を中心に述べます。

Speculative Decodingの基本アイデア

CPUのアウトオブオーダー実行における投機的実行(Speculative Execution)と同じ発想です。小さなモデル(ドラフトモデル)で高速に $K$ トークンを先読みし、大きなモデル(ターゲットモデル)で1回のforward passで $K$ トークンをまとめて検証します。

ドラフトモデルが生成した候補トークン列 $\hat{y}_1, \hat{y}_2, \ldots, \hat{y}_K$ に対して、ターゲットモデルは1回のforward passで各位置の確率分布を計算します。

$$ p_\text{target}(y_t | y_{

各候補トークン $\hat{y}_t$ について受理確率を計算します。

$$ P(\text{accept } \hat{y}_t) = \min\left(1, \frac{p_\text{target}(\hat{y}_t | y_{

最初に棄却されたトークンの位置で修正サンプリングを行い、それ以降の候補を破棄します。この方式はターゲットモデルの出力分布を厳密に保存するため、品質の劣化がありません。

重要なのは、ターゲットモデルの1回のforward passで $K$ トークン分の検証を行うため、Decodeフェーズのステップ数が $1/K$ に近い割合で削減されることです。ドラフトモデルの受理率を $\alpha$ とすると、ステップあたりの期待生成トークン数は以下です。

$$ \mathbb{E}[\text{tokens per step}] = \frac{1 – \alpha^{K+1}}{1 – \alpha} $$

受理率 $\alpha = 0.8$、$K = 5$ の場合、$\mathbb{E} \approx 3.4$ トークン/ステップとなり、ステップあたりの生成速度が約3.4倍に向上します。

vLLMでのSpeculative Decoding

vLLMでは、Speculative Decodingを起動時のオプションで簡単に有効化できます。

# ドラフトモデルを指定してSpeculative Decodingを有効化
vllm serve meta-llama/Llama-3.1-70B-Instruct \
    --speculative-model meta-llama/Llama-3.1-8B-Instruct \
    --num-speculative-tokens 5 \
    --tensor-parallel-size 4

このコマンドは、70Bモデルをターゲット、8Bモデルをドラフトとして、5トークンの先読みを行います。--tensor-parallel-size 4 で4つのGPUにモデルを分割しています。

vLLMのSpeculative Decodingは、PagedAttentionと自然に統合されています。ドラフトモデルの推論で生成される暫定的なKVキャッシュもブロック単位で管理され、棄却されたトークンのブロックは即座に解放されます。

Speculative Decodingが特に効果を発揮するのは、レイテンシが重要な対話的アプリケーションです。スループット重視の場合は、Continuous Batchingでバッチサイズを大きくする方が効率的なこともあります。次のセクションでは、これらの最適化技術を実際にvLLMのサーバーとして動かしてみましょう。

Speculative Decodingのドラフト先読みと検証サイクル

ステップ1でドラフトモデル(小・高速)が5トークンを先読みし、ステップ2でターゲットモデル(大・高品質)が1回のforward passで全トークンを並列検証します。緑の「[OK]」は受理、オレンジの「修正」は最初の棄却位置で修正サンプリングを行うことを示しています。受理率 α=0.8、K=5 の場合、ターゲットモデルの実効スループットは通常の3.4倍になり、品質劣化は一切ありません。

vLLMのアーキテクチャ全体像

実際にサーバーを構築する前に、vLLMの内部構造を俯瞰しておきましょう。vLLMの全体アーキテクチャは、大きく4つのコンポーネントから構成されています。

APIサーバー層

vLLMのAPIサーバーはFastAPIベースで構築されており、OpenAI互換のREST APIを提供します。主要なエンドポイントは以下の通りです。

エンドポイント 説明
POST /v1/completions テキスト補完(GPT-3互換)
POST /v1/chat/completions チャット補完(ChatGPT互換)
GET /v1/models 利用可能なモデル一覧
GET /health ヘルスチェック

OpenAI互換であることの最大のメリットは、既存のアプリケーションコードを1行も変更せずにバックエンドをvLLMに切り替えられることです。OpenAIのPythonクライアント(openai パッケージ)の base_url を変更するだけで動きます。

スケジューラ

スケジューラは、vLLMの「頭脳」に相当します。各推論ステップで以下の判断を行います。

  1. PrefillとDecodeの優先度: 新しいリクエストのPrefillと既存リクエストのDecodeのどちらを優先するか
  2. メモリ管理: どのリクエストのKVキャッシュをGPUに保持し、どれをCPUにスワップするか
  3. バッチ構成: 次のステップで処理するリクエストの集合を決定する

スケジューラのポリシーにはFCFS(First Come First Served)が基本ですが、メモリ圧迫時にはプリエンプション(一時中断)も行われます。プリエンプションされたリクエストのKVキャッシュはCPUメモリにスワップされ、メモリに余裕ができたときにGPUに戻されます。

ワーカー(モデル実行)

ワーカーは実際のモデル推論を実行するコンポーネントです。各GPU上でモデルのforward passを実行し、KVキャッシュの管理を行います。テンソル並列やパイプライン並列を使ったモデル並列化もワーカー層で実現されています。

ブロックマネージャー

PagedAttentionのブロック管理を担当するコンポーネントです。論理ブロックと物理ブロックの対応表(ブロックテーブル)を管理し、ブロックの割り当て・解放・共有を行います。

これらのコンポーネントが連携して動作する流れを整理すると、以下のようになります。

  1. APIサーバーがリクエストを受け付け、スケジューラの待ち行列に追加
  2. スケジューラが次ステップで処理するリクエストを選択
  3. ブロックマネージャーが必要なブロックを割り当て
  4. ワーカーがforward passを実行し、出力トークンを生成
  5. 完了したリクエストの結果をAPIサーバー経由で返送
  6. ステップ2に戻る

この一連のループが毎ステップ(1トークン生成ごとに)高速に繰り返されます。次のセクションでは、実際にvLLMをインストールしてサーバーを起動してみましょう。

vLLMのセットアップ

インストール

vLLMのインストールはpipで行えます。CUDA対応のGPU環境が必要です。

# vLLMのインストール(CUDA 12.x環境)
pip install vllm

# バージョン確認
python -c "import vllm; print(vllm.__version__)"

対応するGPUメモリの目安は、モデルサイズの約1.2〜1.5倍(KVキャッシュ分を含む)です。例えば、7Bモデル(FP16で約14GB)には24GB以上のGPU(RTX 4090、A5000等)が推奨されます。

OpenAI互換サーバーの起動

vLLMの最も手軽な使い方は、コマンドラインからOpenAI互換サーバーを起動することです。

# 基本的な起動コマンド
vllm serve meta-llama/Llama-3.1-8B-Instruct \
    --host 0.0.0.0 \
    --port 8000 \
    --max-model-len 4096 \
    --gpu-memory-utilization 0.9

# 量子化モデルを使う場合(メモリ節約)
vllm serve TheBloke/Llama-2-7B-Chat-GPTQ \
    --quantization gptq \
    --host 0.0.0.0 \
    --port 8000

主要なオプションをまとめます。

オプション 説明 デフォルト
--host バインドするアドレス localhost
--port リッスンポート 8000
--max-model-len 最大系列長 モデル設定に依存
--gpu-memory-utilization GPU メモリの利用率上限 0.9
--tensor-parallel-size テンソル並列のGPU数 1
--quantization 量子化方式(gptq, awq, squeezellm等) None
--enable-prefix-caching Prefix Cachingの有効化 False
--max-num-seqs 同時処理の最大リクエスト数 256

サーバーが起動すると、http://localhost:8000 でOpenAI互換APIが利用可能になります。次のセクションでは、Pythonクライアントからこのサーバーを呼び出す方法を見ていきましょう。

Pythonクライアントからの利用

OpenAIクライアントを使ったアクセス

vLLMのOpenAI互換APIは、OpenAI公式のPythonクライアントをそのまま使えます。base_url をvLLMサーバーに向けるだけです。

from openai import OpenAI

# vLLMサーバーに接続
client = OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="dummy"  # vLLMではAPIキー不要だが、クライアントが要求する
)

# チャット補完
response = client.chat.completions.create(
    model="meta-llama/Llama-3.1-8B-Instruct",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Explain PagedAttention in 3 sentences."}
    ],
    max_tokens=256,
    temperature=0.7
)

print(response.choices[0].message.content)

このコードを実行すると、vLLMサーバーが推論を行い、PagedAttentionの説明が返されます。重要な点は、このコードはOpenAI APIを呼ぶ場合と全く同じ構造であることです。base_urlhttps://api.openai.com/v1 に変えれば、OpenAIのAPIに切り替わります。これにより、開発時はvLLMのローカルサーバーを使い、本番ではクラウドAPIに切り替えるといった運用が容易になります。

ストリーミング応答

対話的アプリケーションでは、トークンが生成されるたびに逐次送信するストリーミングが重要です。

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="dummy"
)

# ストリーミングモードで応答を取得
stream = client.chat.completions.create(
    model="meta-llama/Llama-3.1-8B-Instruct",
    messages=[
        {"role": "user", "content": "Write a haiku about GPU memory."}
    ],
    max_tokens=64,
    temperature=0.8,
    stream=True  # ストリーミング有効
)

# トークンを逐次受信して表示
for chunk in stream:
    if chunk.choices[0].delta.content is not None:
        print(chunk.choices[0].delta.content, end="", flush=True)
print()  # 改行

ストリーミングモードでは、Server-Sent Events(SSE)プロトコルでトークンが1つずつ送信されます。ユーザーは最初のトークンが生成された瞬間から応答を読み始めることができるため、体感レイテンシが大幅に改善されます。TTFT(Time To First Token)は数百ミリ秒程度に短縮されます。

vLLMのPython APIを直接使用する

サーバーを経由せず、Pythonコード内で直接vLLMを使うこともできます。バッチ推論やオフライン処理に便利です。

from vllm import LLM, SamplingParams

# モデルの読み込み
llm = LLM(
    model="meta-llama/Llama-3.1-8B-Instruct",
    gpu_memory_utilization=0.9,
    max_model_len=4096
)

# サンプリングパラメータの設定
sampling_params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=256
)

# バッチ推論(複数プロンプトを一度に処理)
prompts = [
    "What is PagedAttention?",
    "Explain continuous batching in LLM serving.",
    "How does KV cache work in transformers?",
    "What are the benefits of model quantization?"
]

outputs = llm.generate(prompts, sampling_params)

for output in outputs:
    prompt = output.prompt
    generated = output.outputs[0].text
    print(f"Prompt: {prompt[:50]}...")
    print(f"Output: {generated[:100]}...")
    print("-" * 60)

llm.generate() は内部でContinuous Batchingを自動的に適用し、4つのプロンプトを効率的にバッチ処理します。リクエストを逐次処理する場合と比較して、スループットが大幅に向上します。注目すべきは、ユーザーがバッチ処理の詳細(パディング、メモリ管理、スケジューリング等)を一切気にする必要がないことです。vLLMが全てを自動で最適化します。

次のセクションでは、vLLMの性能を定量的に計測するベンチマークの方法を見ていきましょう。

ベンチマークと性能計測

推論サーバーの性能を評価するには、適切なメトリクスを計測する必要があります。ここでは主要なメトリクスの定義と、Pythonでの計測方法を示します。

主要な性能メトリクス

LLM推論サーバーの性能は、以下の4つのメトリクスで評価するのが一般的です。

メトリクス 定義 単位
Throughput 単位時間あたりの生成トークン数 tokens/s
TTFT リクエスト送信から最初のトークンが返るまでの時間 ms
TPOT 2番目以降のトークン間の平均時間 ms
E2E Latency リクエスト送信から完了までの総時間 ms

これらのメトリクスの間には以下の関係があります。

$$ \text{E2E Latency} = \text{TTFT} + (\text{出力トークン数} – 1) \times \text{TPOT} $$

TTFTはPrefillフェーズの時間を反映し、TPOTはDecodeフェーズの速度を反映します。スループット重視のバッチ処理ではThroughputが重要で、対話的アプリケーションではTTFTとTPOTが重要です。

Pythonでのベンチマーク実装

以下のスクリプトで、vLLMサーバーに対する基本的なベンチマークを実行できます。

import time
import numpy as np
from openai import OpenAI

def benchmark_single_request(client, model, prompt, max_tokens=128):
    """1リクエストのレイテンシとトークン数を計測"""
    start_time = time.perf_counter()
    first_token_time = None
    token_count = 0

    stream = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        max_tokens=max_tokens,
        temperature=0.7,
        stream=True
    )

    for chunk in stream:
        if chunk.choices[0].delta.content:
            if first_token_time is None:
                first_token_time = time.perf_counter()
            token_count += 1

    end_time = time.perf_counter()

    ttft = (first_token_time - start_time) * 1000 if first_token_time else 0
    e2e_latency = (end_time - start_time) * 1000
    tpot = (end_time - first_token_time) * 1000 / max(token_count - 1, 1) if first_token_time else 0

    return {
        "ttft_ms": ttft,
        "e2e_latency_ms": e2e_latency,
        "tpot_ms": tpot,
        "tokens": token_count,
        "throughput_tps": token_count / (end_time - start_time)
    }


# ベンチマーク実行
client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
model = "meta-llama/Llama-3.1-8B-Instruct"

prompts = [
    "Explain the concept of virtual memory in operating systems.",
    "What is the difference between TCP and UDP?",
    "Describe how a neural network learns through backpropagation.",
    "What are the ACID properties in database systems?",
    "Explain the PageRank algorithm used by Google."
]

results = []
for i, prompt in enumerate(prompts):
    result = benchmark_single_request(client, model, prompt, max_tokens=128)
    results.append(result)
    print(f"Request {i+1}: TTFT={result['ttft_ms']:.1f}ms, "
          f"TPOT={result['tpot_ms']:.1f}ms, "
          f"Throughput={result['throughput_tps']:.1f} tok/s")

# 統計サマリ
ttfts = [r["ttft_ms"] for r in results]
tpots = [r["tpot_ms"] for r in results]
throughputs = [r["throughput_tps"] for r in results]

print(f"\n--- Summary ---")
print(f"TTFT:       mean={np.mean(ttfts):.1f}ms, p50={np.median(ttfts):.1f}ms, p99={np.percentile(ttfts, 99):.1f}ms")
print(f"TPOT:       mean={np.mean(tpots):.1f}ms, p50={np.median(tpots):.1f}ms, p99={np.percentile(tpots, 99):.1f}ms")
print(f"Throughput: mean={np.mean(throughputs):.1f} tok/s")

このベンチマークスクリプトは、5つの異なるプロンプトに対して逐次的にリクエストを送信し、各メトリクスを計測します。実際の運用では同時リクエストの負荷テストも重要ですが、まずは単一リクエストの基本性能を把握するのが出発点です。

計測結果を読み取る際のポイントは以下の通りです。TTFTはプロンプトの長さに比例して増加します。これはPrefillフェーズで入力全体を処理するためです。一方、TPOTはプロンプトの長さにはあまり依存せず、モデルサイズとGPUの性能で決まります。Throughput(単一リクエスト)はTPOTの逆数に近い値になります。

並行リクエストのベンチマーク

本番環境では複数のリクエストが同時に到着します。並行リクエスト時のスループットを計測するベンチマークも重要です。

import time
import asyncio
import numpy as np
from openai import AsyncOpenAI

async def benchmark_concurrent(
    base_url: str,
    model: str,
    prompts: list[str],
    max_tokens: int = 128,
    concurrency: int = 10
):
    """並行リクエストのスループットを計測"""
    client = AsyncOpenAI(base_url=base_url, api_key="dummy")
    semaphore = asyncio.Semaphore(concurrency)

    async def single_request(prompt):
        async with semaphore:
            start = time.perf_counter()
            response = await client.chat.completions.create(
                model=model,
                messages=[{"role": "user", "content": prompt}],
                max_tokens=max_tokens,
                temperature=0.7
            )
            elapsed = time.perf_counter() - start
            tokens = response.usage.completion_tokens
            return {"elapsed": elapsed, "tokens": tokens}

    # 全リクエストを並行実行
    overall_start = time.perf_counter()
    tasks = [single_request(p) for p in prompts]
    results = await asyncio.gather(*tasks)
    overall_elapsed = time.perf_counter() - overall_start

    total_tokens = sum(r["tokens"] for r in results)
    overall_throughput = total_tokens / overall_elapsed
    latencies = [r["elapsed"] * 1000 for r in results]

    print(f"Concurrency: {concurrency}")
    print(f"Total requests: {len(prompts)}")
    print(f"Total tokens: {total_tokens}")
    print(f"Overall time: {overall_elapsed:.2f}s")
    print(f"Overall throughput: {overall_throughput:.1f} tok/s")
    print(f"Latency: mean={np.mean(latencies):.1f}ms, "
          f"p50={np.median(latencies):.1f}ms, "
          f"p99={np.percentile(latencies, 99):.1f}ms")

    return overall_throughput

# 実行
prompts = [f"Explain concept number {i} in computer science." for i in range(50)]

# 並行度を変えてスループットを計測
async def run_benchmark():
    base_url = "http://localhost:8000/v1"
    model = "meta-llama/Llama-3.1-8B-Instruct"

    throughputs = []
    concurrencies = [1, 5, 10, 20, 50]

    for c in concurrencies:
        print(f"\n=== Concurrency = {c} ===")
        tp = await benchmark_concurrent(base_url, model, prompts, concurrency=c)
        throughputs.append(tp)

    return concurrencies, throughputs

concurrencies, throughputs = asyncio.run(run_benchmark())

並行度を1から50まで変化させた際のスループット変化を計測しています。Continuous Batchingの効果により、並行度が増えるとスループットは大幅に向上します。典型的には、並行度1で50 tokens/s程度だったスループットが、並行度20で500 tokens/s以上に達することもあります。これはバッチサイズが大きくなることでDecodeフェーズのメモリ帯域律速が緩和され、GPU演算ユニットの利用率が上がるためです。ただし、並行度をさらに上げると、GPUメモリの制約やスケジューラのオーバーヘッドにより、スループットは頭打ちになります。

ベンチマーク結果の可視化

計測結果をグラフで可視化することで、性能特性を直感的に把握できます。

import numpy as np
import matplotlib.pyplot as plt

# 典型的なベンチマーク結果(vLLM + Llama-3.1-8B on A100 80GB)
concurrencies = np.array([1, 2, 5, 10, 20, 50, 100])

# スループット(tokens/s)
throughputs_vllm = np.array([45, 88, 210, 400, 720, 1100, 1250])
throughputs_hf = np.array([42, 42, 42, 42, 42, 42, 42])  # 逐次処理

# E2E Latency(ms, per request)
latency_vllm = np.array([2800, 2900, 3100, 3500, 4200, 6500, 11000])
latency_hf = np.array([2800, 5600, 14000, 28000, 56000, 140000, 280000])

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# スループット比較
axes[0].plot(concurrencies, throughputs_vllm, 'o-', color='#00bcd4',
             linewidth=2, markersize=8, label='vLLM')
axes[0].plot(concurrencies, throughputs_hf, 's--', color='#ff5722',
             linewidth=2, markersize=8, label='HuggingFace (sequential)')
axes[0].set_xlabel('Concurrent Requests', fontsize=12)
axes[0].set_ylabel('Throughput (tokens/s)', fontsize=12)
axes[0].set_title('Throughput vs Concurrency', fontsize=14)
axes[0].set_xscale('log')
axes[0].set_yscale('log')
axes[0].legend(fontsize=11)
axes[0].grid(True, alpha=0.3)

# レイテンシ比較
axes[1].plot(concurrencies, latency_vllm, 'o-', color='#00bcd4',
             linewidth=2, markersize=8, label='vLLM')
axes[1].plot(concurrencies, latency_hf, 's--', color='#ff5722',
             linewidth=2, markersize=8, label='HuggingFace (sequential)')
axes[1].set_xlabel('Concurrent Requests', fontsize=12)
axes[1].set_ylabel('E2E Latency per Request (ms)', fontsize=12)
axes[1].set_title('Latency vs Concurrency', fontsize=14)
axes[1].set_xscale('log')
axes[1].set_yscale('log')
axes[1].legend(fontsize=11)
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

左のグラフから、vLLMのスループットは並行リクエスト数の増加に対してほぼ線形に成長し、並行度50付近で飽和する特性が読み取れます。一方、HuggingFace Transformersの逐次処理ではスループットは並行度によらず一定(バッチ処理を行わないため)です。並行度50の時点でvLLMは約26倍のスループットを達成しています。

右のグラフでは、vLLMのレイテンシは並行度の増加に対して緩やかに増加するのに対し、逐次処理のレイテンシは並行度に比例して増大します。これはContinuous Batchingにより、vLLMが複数リクエストを効率的に並行処理しているためです。並行度100でもvLLMのレイテンシは11秒程度で、逐次処理の280秒(4分40秒)と比べて圧倒的に短いことがわかります。

vLLMとHuggingFace Transformersのスループット・レイテンシ比較

改めて整理されたグラフで確認すると、vLLMのスループット優位性は並行度が増えるほど拡大し、並行度100では逐次処理の約25倍に達することが視覚的に明確です。レイテンシグラフでは、逐次処理が右肩上がりに増大する(各リクエストが前のリクエストの完了を待つ)のに対し、vLLMは緩やかな増加にとどまっています。本番環境で複数ユーザーが同時にリクエストを送る状況では、この差が実際のユーザー体験に直結します。

次のセクションでは、vLLM以外の推論フレームワークとの比較を通じて、各ツールの適材適所を考えます。

推論フレームワークの比較

LLMの推論を効率化するフレームワークはvLLM以外にも複数存在します。ここでは主要な4つのフレームワークを比較し、それぞれの得意領域を明確にします。

比較対象

vLLM: 本記事で解説した、PagedAttentionベースの高スループット推論エンジンです。Pythonで書かれており、OpenAI互換APIを提供します。

TGI(Text Generation Inference): HuggingFaceが開発した推論サーバーです。Rustで書かれたHTTPサーバーに、Pythonのモデル実行レイヤーを組み合わせています。Flash AttentionやContinuous Batchingを搭載し、HuggingFace Hubとの統合が強みです。

Ollama: ローカル環境でのLLM利用に特化したフレームワークです。Go言語で書かれ、llama.cppをバックエンドとして使用します。インストールと操作が非常に簡単で、Macでも手軽に動作しますが、マルチGPUやテンソル並列には対応していません。

llama.cpp: C/C++で書かれたLLM推論ライブラリです。CPU推論に最適化されており、GPUなしでもLLMを実行できます。GGUF形式の量子化モデルを使用し、メモリ消費を抑えた推論が可能です。

機能比較表

特徴 vLLM TGI Ollama llama.cpp
PagedAttention あり なし(独自実装) なし なし
Continuous Batching あり あり 限定的 なし
Prefix Caching あり(APC) あり あり なし
Speculative Decoding あり あり なし あり
OpenAI互換API あり あり あり あり(server)
テンソル並列 あり あり なし 限定的
CPU推論 限定的 限定的 あり 最適化済み
量子化 GPTQ/AWQ/FP8 GPTQ/AWQ/EETQ GGUF GGUF
マルチモーダル あり あり あり あり
実装言語 Python Rust+Python Go+C++ C/C++
セットアップ容易性

使い分けのガイドライン

フレームワークの選択は、ユースケースとハードウェア環境で決まります。

vLLMを選ぶべき場合: 高スループットが最優先のサーバー用途。複数GPU環境で大量のリクエストを処理する場合に最適です。A100やH100などのデータセンターGPUで最もパフォーマンスを発揮します。

TGIを選ぶべき場合: HuggingFace Hubのモデルを手軽にデプロイしたい場合。Dockerでの運用が整備されており、HuggingFaceエコシステムとの親和性が高いです。

Ollamaを選ぶべき場合: ローカルマシンで手軽にLLMを使いたい場合。Mac(Apple Silicon)での動作が最適化されており、ollama run llama3 のような直感的なCLIで即座に利用できます。開発・検証用途に最適です。

llama.cppを選ぶべき場合: GPUなしのCPU環境でLLMを動かしたい場合。エッジデバイスやCPUサーバーでの推論に最適です。GGUF形式の量子化により、メモリが限られた環境でも動作します。

性能比較の可視化

以下は、各フレームワークの典型的な性能特性をレーダーチャートで比較したものです。

import numpy as np
import matplotlib.pyplot as plt

categories = ['Throughput', 'Latency\n(low is good)', 'Memory\nEfficiency',
              'Setup\nSimplicity', 'Multi-GPU\nSupport', 'CPU\nSupport']
N = len(categories)

# 各フレームワークのスコア(1-5, 5が最良)
scores = {
    'vLLM':      [5, 4, 5, 3, 5, 1],
    'TGI':       [4, 4, 4, 3, 4, 1],
    'Ollama':    [2, 3, 3, 5, 1, 3],
    'llama.cpp': [2, 3, 4, 3, 2, 5],
}

angles = np.linspace(0, 2 * np.pi, N, endpoint=False).tolist()
angles += angles[:1]  # 閉じる

fig, ax = plt.subplots(figsize=(8, 8), subplot_kw=dict(polar=True))

colors = {'vLLM': '#00bcd4', 'TGI': '#4caf50', 'Ollama': '#ff9800', 'llama.cpp': '#9c27b0'}

for name, vals in scores.items():
    vals_closed = vals + vals[:1]
    ax.plot(angles, vals_closed, 'o-', linewidth=2, label=name, color=colors[name])
    ax.fill(angles, vals_closed, alpha=0.1, color=colors[name])

ax.set_xticks(angles[:-1])
ax.set_xticklabels(categories, fontsize=11)
ax.set_ylim(0, 5.5)
ax.set_yticks([1, 2, 3, 4, 5])
ax.set_yticklabels(['1', '2', '3', '4', '5'], fontsize=9)
ax.set_title('LLM Inference Frameworks Comparison', fontsize=14, pad=20)
ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1), fontsize=11)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

レーダーチャートから、各フレームワークの得意分野が明確に読み取れます。vLLMはThroughput・Memory Efficiency・Multi-GPU Supportの3軸で最高評価を獲得しており、サーバーサイドの高負荷推論に特化していることがわかります。一方、OllamaはSetup Simplicityで突出しており、ローカル開発に適しています。llama.cppはCPU Supportで唯一の最高評価を持ち、GPUなし環境で選択すべきフレームワークです。

LLM推論フレームワーク比較レーダーチャート

レーダーチャートを見ると、vLLM(シアン)がスループット・メモリ効率・マルチGPUの3点で最大値を占めており、高負荷サービング用途に最適化されていることが一目でわかります。Ollama(オレンジ)は設定容易性でのみ突出しており、個人利用や開発検証に向いています。フレームワーク選択は「どこで・何を優先するか」によって明確に決まります。

ここまでで、vLLMの位置づけが明確になりました。次のセクションでは、vLLMを使った本番環境へのデプロイメントパターンを解説します。

デプロイメントパターン

パターン1: 単一GPU + Dockerデプロイ

最もシンプルなデプロイメントパターンです。7B〜13Bクラスのモデルを1台のGPUサーバーで運用します。

# Docker Composeでの構成例
# docker-compose.yml
# docker-compose.ymlに相当する構成をPythonで記述
config = {
    "services": {
        "vllm": {
            "image": "vllm/vllm-openai:latest",
            "ports": ["8000:8000"],
            "volumes": ["./models:/models"],
            "environment": {
                "HUGGING_FACE_HUB_TOKEN": "${HF_TOKEN}"
            },
            "command": [
                "--model", "meta-llama/Llama-3.1-8B-Instruct",
                "--host", "0.0.0.0",
                "--port", "8000",
                "--max-model-len", "4096",
                "--gpu-memory-utilization", "0.9",
                "--enable-prefix-caching"
            ],
            "deploy": {
                "resources": {
                    "reservations": {
                        "devices": [{
                            "driver": "nvidia",
                            "count": 1,
                            "capabilities": ["gpu"]
                        }]
                    }
                }
            }
        }
    }
}

# この構成をYAMLに変換して docker-compose.yml として保存
import json
print(json.dumps(config, indent=2))

上の構成は、vLLMの公式Dockerイメージを使い、単一GPUで8Bモデルをサービングする最小構成を示しています。--enable-prefix-caching を有効にしているため、同じシステムプロンプトを持つリクエストの2回目以降はPrefillが高速化されます。本番ではこの前段にNginxやCaddyなどのリバースプロキシを配置し、TLS終端やレート制限を行うのが一般的です。

パターン2: マルチGPU + テンソル並列

70B以上の大型モデルは、1つのGPUに収まりません。テンソル並列で複数GPUにモデルを分割します。

# 4GPU でテンソル並列
vllm serve meta-llama/Llama-3.1-70B-Instruct \
    --tensor-parallel-size 4 \
    --host 0.0.0.0 \
    --port 8000 \
    --max-model-len 8192 \
    --gpu-memory-utilization 0.9 \
    --enable-prefix-caching

テンソル並列では、各レイヤーの行列演算をGPU間で分割します。NVLinkなどの高速インターコネクトが利用可能な場合、通信オーバーヘッドは最小限に抑えられます。テンソル並列のGPU数はモデルのAttentionヘッド数の約数にする必要があります(例: 64ヘッドなら1, 2, 4, 8, 16, 32, 64が選択可能)。

パターン3: ロードバランシング + 複数インスタンス

スループット要件が1インスタンスでは満たせない場合、複数のvLLMインスタンスをロードバランサーの背後に配置します。

# Nginxロードバランサーの設定概要をPythonで構成
nginx_config = """
upstream vllm_backend {
    least_conn;
    server gpu-server-1:8000;
    server gpu-server-2:8000;
    server gpu-server-3:8000;
}

server {
    listen 443 ssl;
    server_name llm-api.example.com;

    location /v1/ {
        proxy_pass http://vllm_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_read_timeout 300s;  # LLMの応答は長い場合がある
        proxy_buffering off;      # ストリーミング対応
    }

    location /health {
        proxy_pass http://vllm_backend;
    }
}
"""

print("Nginx configuration for vLLM load balancing:")
print(nginx_config)

least_conn ポリシーにより、アクティブ接続数が最も少ないサーバーに新しいリクエストがルーティングされます。LLM推論ではリクエストの処理時間が大きくばらつくため、ラウンドロビンよりもleast_connの方が負荷が均等になります。proxy_buffering off はストリーミング応答に必須の設定で、これがないとNginxがレスポンスをバッファリングしてしまい、ストリーミングの効果が失われます。

パターン4: Kubernetesデプロイ

大規模な運用では、KubernetesによるオーケストレーションとGPUリソースの動的管理が有効です。

# Kubernetes Deploymentマニフェストの構造
k8s_deployment = {
    "apiVersion": "apps/v1",
    "kind": "Deployment",
    "metadata": {
        "name": "vllm-server",
        "labels": {"app": "vllm"}
    },
    "spec": {
        "replicas": 3,
        "selector": {"matchLabels": {"app": "vllm"}},
        "template": {
            "metadata": {"labels": {"app": "vllm"}},
            "spec": {
                "containers": [{
                    "name": "vllm",
                    "image": "vllm/vllm-openai:latest",
                    "ports": [{"containerPort": 8000}],
                    "args": [
                        "--model", "meta-llama/Llama-3.1-8B-Instruct",
                        "--host", "0.0.0.0",
                        "--port", "8000",
                        "--gpu-memory-utilization", "0.9",
                        "--enable-prefix-caching"
                    ],
                    "resources": {
                        "limits": {"nvidia.com/gpu": 1},
                        "requests": {"nvidia.com/gpu": 1}
                    },
                    "readinessProbe": {
                        "httpGet": {"path": "/health", "port": 8000},
                        "initialDelaySeconds": 120,
                        "periodSeconds": 10
                    }
                }]
            }
        }
    }
}

import json
print(json.dumps(k8s_deployment, indent=2))

Kubernetesデプロイでは、readinessProbeinitialDelaySeconds に注意が必要です。vLLMはモデルのロードに1〜3分程度かかるため、短すぎるとPodが起動前にリクエストを受けてしまいます。nvidia.com/gpu: 1 はNVIDIA Device Pluginによりスケジューリングされるため、GPUの空き状況に応じてPodが配置されます。

監視とオブザーバビリティ

本番環境では、vLLMサーバーの状態を継続的に監視する必要があります。vLLMはPrometheus形式のメトリクスを GET /metrics エンドポイントで公開しています。

import matplotlib.pyplot as plt
import numpy as np

# 本番環境の監視ダッシュボードで追跡すべき主要メトリクス
metrics = {
    "vllm:num_requests_running": "処理中のリクエスト数",
    "vllm:num_requests_waiting": "待機中のリクエスト数",
    "vllm:gpu_cache_usage_perc": "GPUブロックキャッシュ使用率",
    "vllm:cpu_cache_usage_perc": "CPUブロックキャッシュ使用率",
    "vllm:avg_generation_throughput": "平均生成スループット",
    "vllm:request_success_total": "成功リクエスト累計",
}

# 模擬的なメトリクス推移データ(30分間)
time_minutes = np.arange(0, 30, 0.5)

# GPUキャッシュ使用率の推移をシミュレーション
np.random.seed(42)
base_usage = 0.3 + 0.4 * np.sin(2 * np.pi * time_minutes / 15)  # 周期的な負荷
noise = np.random.normal(0, 0.03, len(time_minutes))
gpu_cache_usage = np.clip(base_usage + noise, 0, 1)

# 処理中リクエスト数
requests_running = np.clip(
    10 + 20 * np.sin(2 * np.pi * time_minutes / 15) + np.random.normal(0, 3, len(time_minutes)),
    0, 50
).astype(int)

# スループット
throughput = np.clip(
    500 + 300 * gpu_cache_usage + np.random.normal(0, 30, len(time_minutes)),
    100, 1200
)

fig, axes = plt.subplots(3, 1, figsize=(12, 10), sharex=True)

axes[0].plot(time_minutes, gpu_cache_usage * 100, color='#00bcd4', linewidth=1.5)
axes[0].axhline(y=90, color='#ff5722', linestyle='--', alpha=0.7, label='Alert threshold (90%)')
axes[0].set_ylabel('GPU Cache Usage (%)', fontsize=11)
axes[0].set_title('vLLM Server Monitoring Dashboard', fontsize=14)
axes[0].legend(fontsize=10)
axes[0].set_ylim(0, 100)
axes[0].grid(True, alpha=0.3)

axes[1].plot(time_minutes, requests_running, color='#4caf50', linewidth=1.5)
axes[1].set_ylabel('Running Requests', fontsize=11)
axes[1].grid(True, alpha=0.3)

axes[2].plot(time_minutes, throughput, color='#ff9800', linewidth=1.5)
axes[2].set_ylabel('Throughput (tok/s)', fontsize=11)
axes[2].set_xlabel('Time (minutes)', fontsize=11)
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

3段構成のダッシュボードから、推論サーバーの健全性を判断できます。上段のGPUキャッシュ使用率が90%を超えると、KVキャッシュのスワップアウト(GPU→CPU)が頻発し始め、スループットが低下します。中段の処理中リクエスト数は、負荷のパターンを把握するのに役立ちます。下段のスループットと上段のキャッシュ使用率を比較すると、キャッシュ使用率が高い時期ほどスループットも高い傾向が見られます。これは、GPUメモリを有効に活用しているときほど多くのリクエストを並行処理できるためです。キャッシュ使用率が90%を超えてスループットが急落するパターンが見られたら、GPUの追加やモデルの量子化を検討すべきサインです。

vLLMサーバー監視ダッシュボード: GPUキャッシュ・リクエスト数・スループット

シミュレーション結果では、GPUキャッシュ使用率(上段・シアン)と処理中リクエスト数(中段・緑)が連動して波打っており、負荷の周期的な変動を表しています。スループット(下段・オレンジ)もキャッシュ使用率に追従して変動し、キャッシュが高く埋まっているほど並行処理効率が高まっていることが確認できます。赤い破線の警告閾値(90%)を継続的に超えている場合は、スワップ発生によるレイテンシ悪化のサインです。

本番運用のベストプラクティス

最後に、vLLMを本番環境で運用する際のベストプラクティスをまとめます。

メモリ最適化

GPUメモリの管理は推論サーバーの性能を直接左右します。

--gpu-memory-utilizationの調整: デフォルトの0.9は、GPUメモリの90%をKVキャッシュ(とモデル)に使用します。メモリの10%はCUDAのワーキングメモリとして予約されます。OOM(Out of Memory)が発生する場合は0.85〜0.8に下げてください。

量子化の活用: LLMの量子化で解説した通り、AWQやGPTQによる4bit量子化でモデルサイズを約1/4に削減できます。量子化により空いたメモリはKVキャッシュに回せるため、同時処理可能なリクエスト数が増えます。

--max-model-lenの制限: モデルが対応する最大系列長が長い場合でも、実用上必要な長さに制限することでメモリ効率が向上します。例えば、モデルが128Kトークンに対応していても、実際のリクエストが4096トークン以下なら --max-model-len 4096 とします。

スケーリング戦略

リクエスト量の増減に対して、以下の戦略で対応します。

垂直スケーリング(スケールアップ): より高性能なGPU(A100 → H100)に切り替え、またはテンソル並列のGPU数を増やすことで1インスタンスの処理能力を上げます。

水平スケーリング(スケールアウト): vLLMインスタンスを増やし、ロードバランサーで分散します。各インスタンスは独立に動作するため、線形にスケールします。

オートスケーリング: KubernetesのHPA(Horizontal Pod Autoscaler)やKEDA(Kubernetes Event Driven Autoscaler)を使い、GPUキャッシュ使用率や待ち行列長に基づいてインスタンス数を自動調整します。

セキュリティ

LLM推論サーバーには、一般的なWebサーバーに加えて固有のセキュリティ考慮事項があります。

APIキー認証: vLLMは --api-key オプションでAPIキー認証を有効化できます。リバースプロキシ側でもBearerトークンの検証を行うと二重の防御になります。

レート制限: LLM推論は計算コストが高いため、ユーザーごとのリクエストレートを制限します。Nginx の limit_req_zone やAPI Gateway のスロットリングを利用します。

入力検証: プロンプトの最大トークン数を制限し、極端に長い入力によるリソース占有を防ぎます。--max-model-len はサーバー側の制限ですが、クライアント側でも入力長の事前チェックを行うのが望ましいです。

まとめ

本記事では、vLLMを使ったLLM推論サーバーの構築について、理論的な背景から実践的なデプロイメントまでを解説しました。

  • LLM推論のボトルネック: Decodeフェーズのメモリ帯域律速とKVキャッシュの断片化が、素朴な実装のスループットを大幅に制限します
  • PagedAttention: OSの仮想メモリのページングをKVキャッシュに適用し、メモリ断片化を解消します。ブロック単位の管理により、メモリの無駄がほぼゼロになります
  • Continuous Batching: ステップごとにバッチ構成を動的に変更し、GPU利用率を最大化します。リクエストの生成長にばらつきがある実環境で特に効果的です
  • Prefix Caching: 共通プレフィックスのKVキャッシュを自動的に再利用し、Prefillフェーズの計算量を大幅に削減します
  • Speculative Decoding: ドラフトモデルによる先読みでDecodeフェーズのステップ数を削減し、レイテンシを改善します
  • OpenAI互換API: vLLMはOpenAI互換のAPIを提供するため、既存のアプリケーションコードをほぼ変更なしに移行できます
  • デプロイメントパターン: 単一GPU・マルチGPU・ロードバランシング・Kubernetesの4パターンを用途に応じて選択できます

vLLMは急速に発展しており、新しい最適化手法(Chunked Prefill、FP8量子化のネイティブサポート、マルチモーダルモデル対応等)が継続的に追加されています。推論サーバーの構築は、モデルの学習と並んでLLMの実用化に不可欠なスキルです。

次のステップとして、以下の記事も参考にしてください。

画像なし
PagedAttention — vLLMの仮想メモリ着想によるKVキャッシュ管理
PagedAttentionの理論をシミュレーション実装付きでより深く理解します
画像なし
投機的デコーディング
Speculative Decodingの理論と数学的な正当性を詳しく解説します
画像なし
モデル並列化
テンソル並列・パイプライン並列などのモデル並列化手法を理解します
画像なし
LLMの量子化
モデルサイズ削減と推論高速化のための量子化手法を理解します
画像なし
KVキャッシュの仕組み
TransformerがDecodeフェーズでKVキャッシュをどのように構築・利用するかを基礎から解説します。PagedAttentionを理解する前に押さえておくべき概念です
画像なし
FlashAttentionの仕組みと実装
Attentionのメモリ効率化手法FlashAttentionを理論から解説します。vLLMの内部でも採用されているコアアルゴリズムです