←記事一覧

LLM 呼び出しの負荷分散 — ラウンドロビンが効かない理由から KV キャッシュ考慮ルーティングまで

LLM 呼び出しは 1 件のコストが 2 桁ばらつき、レプリカはプレフィックスキャッシュを持つため、ラウンドロビンでは負荷が揃いません。エージェントを含む LLM アプリで、クライアントからエンジンまでの各層が局所性と均衡をどう選んでいるかを、動くシミュレーター付きで説明します。

LLMLoad BalancingDistributed SystemsAgentsSchedulingInteractive

ステートレスな HTTP API なら、ラウンドロビンはたいてい最初に選んでよい方式です。1 リクエストあたりの仕事量がほぼ揃っているので、件数を揃えれば資源も揃うからです。

LLM では、この判断を支える 4 つの前提がすべて崩れます。1 件の仕事量は 2 桁変わり、その量は投入時点では分からず、サーバーは前のリクエストの状態(KV キャッシュ)を持ち、応答は数秒から数分にわたって流れ続けます。エージェントになると、スケジューリングの単位も 1 リクエストから、数十〜数百回の呼び出しからなる 1 セッションに変わります。

この記事で示したいのは、クライアントからエンジンまでのどの層でも同じ 1 つのトレードオフを扱っていることです。局所性(すでに計算済みのもののそばへ送る)と均衡(空いているところへ送る)のどちらを取るかを、すでに古くなった信号をもとに、最初のトークンが出た瞬間に閉じる時間窓の中で決めます。層によって変わるのは信号の名前と時定数だけで、トレードオフの形は変わりません。

以下では層ごとに、どの信号を見て何を最適化しているかを整理します。振る舞いは数式とシミュレーターで示し、vLLM・SGLang・NVIDIA Dynamo・Kubernetes Gateway API Inference Extension・LiteLLM・Azure API Management が実際に何を計算しているかは、上流のソースで確かめます。

CPU スケジューリングや公平キューイングの系譜は、タスクスケジューリングの系譜で扱いました。この記事はその続きで、仕事を複数のサーバーへどう配るかを扱います。

動画で読む4:57合成音声ナレーション・字幕付き
文字起こしを表示

LLM の負荷分散では、ラウンドロビンだけでは足りません。仕事量の偏り、KV キャッシュ、ストリーミング、エージェントのセッション性が、判断を難しくします。核心は、局所性と均衡のどちらを取るかを、古い信号の上で、最初のトークンが出る前に決めることです。

普通の HTTP API では、リクエスト件数を揃えれば、だいたい資源も揃います。でも LLM では、プロンプト長と出力長で、1 件の仕事量が 2 桁変わります。しかも出力長は投入時点では分からず、応答は秒から分の単位で流れ続けます。

待ち行列の式は、このばらつきがそのまま待ち時間に効くことを示します。長い裾の例として、サービス時間の変動係数の二乗を 4 と置きます。同じ稼働率でも、待ちは指数分布の 2.5 倍です。許容待ち時間をサービス時間の 3 倍とします。使える稼働率は、決定的な場合 0.86、指数分布で 0.75、長い裾で 0.55 まで下がります。

まず指標を分けます。最初のトークンまでの時間 TTFT、トークン間の遅延 ITL、全体の遅延は、それぞれ別の原因を指します。判断はクライアント、ゲートウェイ、エンドポイント選択、エンジン内スケジューラ、アクセラレータ配置の層で起きます。ルーターが最善の 1 台を選んでも、エンジン内のバッチが詰まっていれば、最初のトークンは速くなりません。

同じ到着列で見ると、ラウンドロビンは件数を揃えても、裾の遅延では最小未完了数に負けます。2 択のべき乗は、ランダムな 2 台の軽い方へ送るだけで、全ルーターが同じ台へ殺到する群れ現象を避けやすくします。重要なのは、受付済みでまだ完了していない仕事を、キューの中まで数えることです。

LLM には、古典的な負荷分散にない信号があります。先頭部分をもう計算済みのレプリカがあるか、です。会話型エージェントでは、システムプロンプト、ツール定義、過去のターンが毎回同じ先頭になります。そのプレフィックスを持つレプリカへ送ればプレフィルを減らせますが、人気プレフィックスだけを追うと、1 台に負荷が寄ります。

SGLang は、負荷の差が絶対値でも比率でも設定値を超えると、キャッシュ親和性を捨てて最も空いたワーカーを選びます。NVIDIA Dynamo は、プレフィル負荷、デコード負荷、キャッシュの重なりを、KV ブロック単位のコストにします。どちらも、キャッシュヒットで TTFT を下げるか、負荷を揃えて ITL を守るかのつまみです。

でも、ルーターが見る負荷は必ず古くなります。判断はミリ秒、観測はもっと遅い周期だからです。複数のルーターが同じ古い最小値を信じると、全員が同じレプリカへ殺到し、次の観測で一斉に逃げます。対策は 4 つです。取得のタイミングをずらす、ランダムな 2 択にする、自分が投げた分を足す、新しいレプリカにはゆっくり流す。

レプリカの中では、連続バッチ処理が、完了したシーケンスを毎反復で外し、新しいものを入れます。ただし長いプレフィルは、実行中のデコードを止めます。チャンク化プレフィルは、プレフィルを小さく分けて、同じ反復でデコードと同居させます。さらに Splitwise や DistServe のように、プレフィルとデコードを別ワーカーへ分ける設計もあります。

過負荷では、善意のリトライが掛け算になります。4 つの層がそれぞれ 3、4、4、3 回試すと、上流への呼び出しは 14 回ではなく最大 144 回です。ヘッジもリトライもフェイルオーバーも、最初のトークンがユーザーに見える前だけが安全な時間です。過負荷を広げないために、キュー上限、デッドライン、サーキットブレーカーも必要です。Retry-After の尊重も、負荷分散の一部です。

エージェントでは、単位が 1 リクエストから、数十から数百回の呼び出しを持つセッションへ変わります。同じレプリカへ戻したいのは正しさのためではなく、そこにプレフィックスキャッシュがあるからです。ただしファンアウトは同じプレフィックスを一度に投げます。1 つの操作がプールを占有しないよう、セッション単位の同時実行上限が必要です。

まとめると、LLM の負荷分散は、局所性と均衡の綱引き、信号の鮮度、最初のトークンまでの窓で考えます。記事では、選択規則の比較、信号の古さ、プレフィックスルーティング、連続バッチ処理を、動くデモで試せます。

1. なぜ LLM のトラフィックは違うのか、その影響を見積もる 2 つの式

1.1 古典的な HTTP 負荷分散との差分

前提ステートレス HTTPLLM 推論
1 件の仕事量ほぼ均一(変動係数 CV が小さい)プロンプト長と出力長に応じて 2 桁変動
仕事量の見通しほぼ既知(リクエストサイズで推定可)出力長は生成が終わるまで不明
サーバーの状態原則ステートレスプレフィックスキャッシュが局所性を生む
応答時間ミリ秒秒〜分、ストリーミングで継続
主資源CPU 時間GPU 演算・HBM 帯域・KV キャッシュ容量の 3 次元
同時実行スレッド/コネクション連続バッチ処理の同居スロット
障害の形5xx が返る429 と Retry-After、あるいは静かな遅延

1.2 ばらつきが待ち時間に効く仕組み

ばらつきが待ち時間にどれだけ効くかは、待ち行列理論で見積もれます。到着がポアソン過程で、サービス時間の分布が任意(M/G/1)のとき、平均待ち時間は Pollaczek–Khinchine の公式で与えられます。

Wq=λE[S2]2(1−ρ)=ρ1−ρ⋅E[S](1+Cs2)2W_q=\frac{\lambda E[S^2]}{2(1-\rho)} =\frac{\rho}{1-\rho}\cdot\frac{E[S]\left(1+C_s^2\right)}{2}

ここで ρ=λE[S]\rho=\lambda E[S] は稼働率、Cs=σS/E[S]C_s = \sigma_S / E[S] はサービス時間の変動係数です。この式から 2 つのことが分かります。

  1. 1/(1−ρ)1/(1-\rho) の項により、稼働率が 1 に近づくと待ち時間は発散します
  2. (1+Cs2)/2(1+C_s^2)/2 の項により、同じ稼働率でも、待ち時間は 1+Cs21+C_s^2 に比例して延びます

決定的なサービス時間なら Cs2=0C_s^2=0、指数分布なら Cs2=1C_s^2=1 です。LLM のリクエストはプロンプト長と出力長の両方が広く分布するため、ここでは裾の長い分布の例として Cs2=4C_s^2=4 を仮定します。以下の数値はすべてこの仮定で変わるので、実運用では自分のワークロードで測ってください。Cs2=4C_s^2=4 なら (1+Cs2)/2=2.5(1+C_s^2)/2=2.5 なので、同じ稼働率でも、待ち時間は指数分布の場合の 2.5 倍、決定的な場合の 5 倍になります。

稼働率と待ち時間の膝

Pollaczek–Khinchine の式による、M/G/1 待ち行列の平均待ち時間(平均サービス時間で正規化)

03691200.250.50.750.95稼働率 ρ待ち時間 / サービス時間
決定的(CV² = 0・実線)指数分布(CV² = 1・破線)長い裾(CV² = 4・点線)

CV² はサービス時間の変動係数の 2 乗です。点線は、プロンプト長と出力長のばらつきが大きい LLM のリクエストを想定し、CV² = 4 と仮定した曲線です。同じ稼働率でも待ち時間が跳ね上がるため、稼働率を上げ切る運用は成立しません。

このグラフは横に読みます。許容できる待ち時間を 1 つ決め(たとえばサービス時間の 3 倍)、そこを横にたどると、到達する稼働率は決定的な場合で 0.86、指数分布で 0.75、Cs2=4C_s^2=4 で 0.55 です。決定的な場合と比べると、ばらつきの代償として使える稼働率の上限が約 30 ポイント下がります。

この式は、LLM のプールを高稼働率で回すべきでない理論的な根拠です。同時に、ばらつきを減らす打ち手(チャンク化プレフィル、出力長の上限、モデルの分離)が、容量を増やす打ち手と同じくらい価値を持つことも示しています。

1.3 リトルの法則で容量を見積もる

もう 1 つ、あらゆる層で使える道具がリトルの法則です。

L=λWL=\lambda W

系の中に平均して滞在する件数 LL は、到着率 λ\lambda と平均滞在時間 WW の積です。分布を仮定しないため、どんなスケジューラーにも成り立ちます。

推論エンジンの中だけを系として取ると、LL は「同居しているシーケンス数」、つまりバッチサイズで、WW はエンジン内の滞在時間(プロンプトを一括処理するプレフィルと、1 トークンずつ生成するデコードの合計)になります。バッチサイズには KV キャッシュ容量という上限 Lmax⁡L_{\max} があるので、

λmax⁡=Lmax⁡W\lambda_{\max}=\frac{L_{\max}}{W}

が 1 レプリカのスループット上限になります。WW 自体も同居数に応じて延びるため、この式は「その動作点で測った WW のもとでの上限」として読みます。そして WW は出力長にほぼ比例するので、出力が長いワークロードでは同じ GPU で捌けるリクエスト数が減るという当たり前の事実が、そのまま容量計画の式になります。逆に、プレフィックスキャッシュで WW の中のプレフィル成分を削れば λmax⁡\lambda_{\max} が上がります。

2. 何を測るか

負荷分散の議論が噛み合わなくなる最大の原因は、指標の取り違えです。「レイテンシーが悪化した」と言うとき、TTFT なのか ITL なのか E2E なのかで、原因も打ち手もまったく違います。

何を測るか

指標ごとに、支配する要因と効く打ち手が違います

指標一覧
指標定義支配要因効く打ち手
TTFT(最初のトークンまでの時間)到着から最初のトークンが返るまでキュー待ち時間 + プレフィル時間エンドポイント選択、プレフィックスキャッシュ、受付制御
ITL(トークン間遅延)/ TPOT(出力トークンあたりの時間)TPOT はリクエストごとに、最初のトークン以降の時間を 2 番目以降の出力トークン数で割った値。ITL は連続するストリーム出力の間隔で、全リクエスト分をまとめて集計する。通常のデコードでは両者は近い値になる(vLLM の定義)同居バッチの大きさとプレフィル割り込みバッチ上限、チャンク化プレフィル、プレフィルとデコードの分離
E2E レイテンシー到着から最後のトークンまでTTFT + 出力長 × TPOT出力長の制御、モデル選択
正規化レイテンシーE2E ÷ 出力トークン数TTFT ÷ 出力長 + TPOT負荷水準の異なる系の比較に使う
グッドプットSLO を満たした完了リクエストの割合または率裾を含めたレイテンシー分布全体容量計画の主指標。スループット単独より有用
KV キャッシュ使用率使用中の KV ブロック ÷ 総ブロック同居シーケンス数と文脈長受付制御、容量追加(飽和するとプリエンプションで ITL が跳ねる)

スループットとグッドプットは別物です。バッチサイズを上げれば秒あたりの総トークン数は増えますが、1 本あたりのトークン間遅延は延びます。SLO を満たした完了だけを数えるグッドプットで見ると、多くの構成でスループット最大点とグッドプット最大点はずれます。DistServe(Zhong ら, OSDI 2024)がプレフィルとデコードの分離を正当化した際の主指標もこれでした。

ストリーミングは指標を歪めます。クライアントから見た「レスポンス完了」はコネクションが閉じたときですが、その時点ではサーバー側の資源はとうに解放されています。ロードバランサーが「アクティブ接続数」で負荷を測っていると、サーバー側の処理が終わったリクエストまで数え続けることになります。

正規化レイテンシーは比較のために要ります。E2E を出力トークン数で割ると、出力長の違いを取り除いて負荷水準を比較できます。ベンチマーク結果を読むときは、出力長を固定しているか(--ignore-eos を使っているか)を必ず確認してください。

この 6 行のうち TTFT だけは、もう 1 つの意味を持ちます。TTFT はユーザー体験の指標であると同時に、やり直しが効く時間の長さでもあります。最初の 1 トークンをクライアントへ書いた瞬間から、リトライもヘッジもフェイルオーバーも別プロバイダーへの切り替えも、不可能になるかユーザーに見えてしまうかのどちらかです。この点は、9 章と 12 章の仕組みを説明した後の 9.4 節で改めて扱います。

3. 決定が起きる 5 つの層

「LLM の負荷分散」と一括りにされるものは、実際には粒度も権限も異なる 5 段の判断の重なりです。ただし、リクエストごとに振り分けが起きるのは上の 4 層です。L5(TP = テンソル並列、PP = パイプライン並列、EP = エキスパート並列)は静的な配置の選択で、他の 4 層を制約する側に回ります。この記事では、L5 をこの役割の範囲でだけ扱います。

決定が起きる 5 つの層

「負荷分散」と呼ばれるものは、粒度も権限も異なる 5 段の判断の重なりです

  1. L1クライアント / SDK1 回の API 呼び出し
    • タイムアウト、リトライ回数、バックオフ
    • ヘッジ(投機的な二重発行)
    • ストリーミングを使うかどうか
  2. L2ゲートウェイ / ルーターテナントとモデル
    • どのモデル・どのプロバイダーへ出すか
    • テナント間の公平性、優先度、レート制限
    • サーキットブレーカーとフェイルオーバー
  3. L3エンドポイント選択1 リクエスト → 1 レプリカ
    • 最小未完了数、2 択のべき乗(P2C)、KV 使用量
    • プレフィックスキャッシュ親和性
    • プレフィル用とデコード用の振り分け
  4. L4エンジン内スケジューラー1 反復(イテレーション)
    • 連続バッチ処理での投入順序
    • チャンク化プレフィルの計算予算配分
    • KV 不足時のプリエンプションと再計算
    • 投機的デコードで 1 反復に下書きするトークン数
  5. L5アクセラレーター1 カーネル / 1 デバイス
    • テンソル並列・パイプライン並列・エキスパート並列
    • KV キャッシュの階層配置(HBM / ホスト / ディスク)

実線はリクエストが進む向き、破線は情報の流れです。「メトリクス」と「飽和の合図」の 2 本は逆向きの情報の流れで、L4 の状態が L3 の次の選択と L2 のサーキットブレーカーを左右します。このフィードバックには必ず遅れがあり、5.6 節で扱う振動の原因になります。「制約」の 2 本は、L5 の静的な配置が L4 の実行時判断に課す制約です。

層に分けて見ると、よくある失敗がどこで起きているかがはっきりします。

  • L3 で最善のレプリカを選んでも、L4 のバッチが飽和していれば TTFT は改善しない
  • L4 をいくら調整しても、L1 が無制限にリトライしていれば実効負荷は減らない
  • L2 のレート制限を件数で切っていると、L4 の実資源(トークン)とずれる

4. 選択規則の棚卸し

4.1 古典的な規則と、それが壊れる条件

選択規則の比較

「何を見て決めるか」と「その信号が壊れる条件」を並べます

選択規則の比較
規則見る信号1 決定の計算量壊れる条件
ラウンドロビンなし(順番のみ)O(1)1 件あたりの仕事量が違うと資源が偏る
重み付きラウンドロビン静的な容量比O(1)重みが静的なので実際の混雑に追随しない
最小未完了数未完了件数O(n)件数は仕事量の代理でしかない。分散環境では観測がずれる
2 択のべき乗(P2C)2 台ぶんの未完了件数O(1)全台走査より僅かに劣るが、群れ現象を避けやすい
レイテンシー加重(EWMA)観測レイテンシーの指数移動平均O(n)ストリーミングでは完了時刻が遅れて届き、信号が古くなる
一貫性ハッシュキー(会話 ID・プレフィックス)O(log n)キー分布が偏るとホットスポット。有界負荷版で緩和する
KV / キュー考慮実行中・待機中の件数と KV ブロックO(n)待機中の仕事を数えない実装は群れ現象を起こす
プレフィックス考慮件数・KV の負荷信号 + プレフィックスの重なりO(n) + 索引親和性を強くしすぎると負荷が偏る。閾値での切り替えが要る

ラウンドロビンが揃えるのは件数であって仕事量ではありません。1 件の仕事量の平均が E[S]E[S]、変動係数が CsC_s のとき、nn 件を kk 台へラウンドロビンで配ると、各台の総仕事量の標準偏差は σSn/k\sigma_S\sqrt{n/k} です。平均 nE[S]/kn E[S]/k に対する相対的なばらつきは Cs/n/kC_s/\sqrt{n/k} で、nn が大きいほど 0 に近づきます。問題は短期の偏りです。長いリクエストが 2 本続けて 1 台へ入れば、その台の TTFT はすぐに悪化します。

2 択のべき乗(Power of Two Choices、P2C)は、観測を 1 回増やすだけで指数的な改善が得られる規則です。nn 個のボールを nn 個の箱へランダムに投げると最大負荷は Θ(log⁡n/log⁡log⁡n)\Theta(\log n/\log\log n) ですが、毎回ランダムに 2 箱を選んで軽い方へ入れると ln⁡ln⁡n/ln⁡2+Θ(1)\ln\ln n/\ln 2+\Theta(1) に落ちます(Azar・Broder・Karlin・Upfal, 1994)。Mitzenmacher(1996)はこれを待ち行列モデルへ拡張し、動的な負荷分散でも同じ効果が出ることを示しました。

dd を 3 以上に増やしても、主項は 1/ln⁡d1/\ln d でしか縮みません。改善の大半は 1 から 2 へ増やした時点で得られ、それ以降は緩やかです。分散したルーターが多数ある環境では、全台走査より 2 択の方が群れ現象(herding)を避けられるという実用上の利点もあります。全ルーターが同時に「今いちばん空いている台」へ殺到する事故が起きにくいからです。

一貫性ハッシュは局所性の道具であって、負荷分散の道具ではありません。キー分布が偏ればホットスポットになります。Mirrokni・Thorup・Zadimoghaddam(2016)の有界負荷付き一貫性ハッシュは、mm 個のボールを nn 台へ入れるとき各ノードの容量を ⌈c m/n⌉\lceil c\,m/n\rceil(c>1c>1)に制限し、溢れたキーをリング上の次のノードへ送ることで最悪負荷を抑えます。後述するプレフィックス考慮ルーティングの「親和性と均衡の切り替え」も、動機は同じです。

4.2 同じ到着列で測った順位

次のシミュレーターでは、同じ到着列に対して選択規則だけを差し替えられます。

LLM ルーティング・ラボ

同じ到着列に対して選択規則だけを差し替え、TTFT の裾、キャッシュヒット率、レプリカ間の偏りがどう動くかを比べます。

未完了件数(待機+実行中)が最小のレプリカを選びます。全レプリカを毎回走査します。

ワークロード

平均プロンプト長
957 トークン
プロンプト長の変動係数 CV
0.75
最小 / 最大
436 / 3079
レプリカ数
4

この規則の結果

TTFT 中央値
2.0 ティック
TTFT P95
52.1 ティック
E2E P95
837 ティック
TTFT SLO 達成率
98%
プロンプトのキャッシュ充足率
39%
出力スループット
16.6 トークン/ティック

待ち行列の推移(各レプリカの未完了件数)

レプリカ 1レプリカ 2レプリカ 3レプリカ 4

レプリカ別の内訳

レプリカ別の内訳
レプリカ割当完了稼働率うちプレフィル
1454596%5%
2424282%7%
3383899%7%
4353591%5%

すべての規則の比較(同じ到着列・同じ設定)

すべての規則の比較(同じ到着列・同じ設定)
選択規則TTFT P95TTFT SLO 達成率プロンプトのキャッシュ充足率割当件数の最大差出力スループット
ランダム324.288%38%1014.4
ラウンドロビン84.394%46%015.1
最小未完了数52.1 (最良)98% (最良)39%1016.6
2 択のべき乗(P2C)90.194%37%816.6
最小 KV 使用量559.081%45%5012.5
プレフィックス考慮151.391%67% (最良)1117.2

これは教育用の離散イベントモデルです。1 ティックは 1 エンジン反復ぶんの予算で、実測のミリ秒ではありません。TTFT SLO 達成率は、TTFT が 80 ティック以内だったリクエストの割合です。レプリカは 1 ティックでプレフィルかデコードのどちらか一方だけを行い(チャンク化プレフィルは未実装)、キャッシュ済みブロックは KV 容量を消費しないものとして扱っています。テンソル並列、投機的デコード、プリエンプション、ネットワーク遅延も含みません。絶対値ではなく規則間の相対差を読んでください。

既定の設定(4 レプリカ、λ=0.20\lambda=0.20、長文リクエストの割合 20%)での実測値は次のとおりです。TTFT はティック単位、SLO は 80 ティックです。

選択規則TTFT P95SLO 達成率キャッシュ充足率割当件数の差出力スループット
ランダム324.288%38%1014.4
ラウンドロビン84.394%46%015.1
最小未完了数52.198%39%1016.6
2 択のべき乗90.194%37%816.6
最小 KV 使用量559.081%45%5012.5
プレフィックス考慮151.391%67%1117.2
  • ラウンドロビンは割当件数の差が 0 なのに、裾(TTFT P95)では最小未完了数に負けています。件数を揃えても資源は揃いません
  • ランダムは、負荷を見ない規則の中で裾が最も悪くなります。件数の偏りは小さいのに、重いリクエストが偶然集中して TTFT P95 を押し上げます
  • 2 択のべき乗は、ラウンドロビンと同等の SLO 達成率を O(1)O(1) の観測コストで得ています。全台を走査する最小未完了数には裾で届かないものの、スループットは同等です
  • 最小 KV 使用量は、キャッシュ充足率(45% で 3 位)を除く全項目で最下位です。この実装は「実行中およびプレフィル中のシーケンスが占有するブロック数」を主な信号としますが、待機中のリクエストは受け付けられるまで KV ブロックを 1 つも占有しないので、現在の占有量を読むこの信号には原理的に映りません。割り当て済みで未受付の仕事は同点時の副次的な判定材料にしかならないので、空いて見えるレプリカへ大きなプロンプトが次々と積み上がり、割当件数の差が 50 まで開きます
  • プレフィックス考慮はキャッシュ充足率とスループットで最良ですが、裾は最良ではありません。親和性のために負荷の偏りをある程度許容するからです

最小 KV 使用量の結果からは、負荷信号は「受け付けたがまだ完了していない仕事」を漏れなく数えなければならないという原則が導けます。Envoy の least-request が単なる接続数ではなく実行中リクエスト数を使うのも、Dynamo のコスト式が「待機中リクエストのプレフィル負荷」を含むのも、同じ理由です。

この順位はスライダーを動かすと変わります。到着率を下げていくと、負荷を見て選ぶ規則同士の差は消えます。混雑していなければ待ちがほとんど生じないので、負荷を見て配っても効果がないのは、待ち行列の式からの当然の帰結です。ただしプレフィックス考慮だけは例外で、他が TTFT P95 = 7、8 に並ぶところで 2 のままです。この規則は混雑を分散させるのではなく再計算を避けるので、その効果は低負荷でも消えません。

4.3 レプリカが均質でない場合

ここまでの規則はすべて、レプリカが交換可能であることを前提にしていました。実際のプールは、しばしばそうなっていません。GPU 世代が混在する、テンソル並列度が違う、量子化の有無が違う、あるいはスケールアウト直後でキャッシュの温まり具合が違う、といった状態は普通に起きます。

このとき、素直な最小未完了数は誤った配分をします。2 倍速いレプリカはおおよそ 2 倍の未完了リクエストを抱えているべきなのに、件数を揃えてしまうからです。Envoy の重み付き最小リクエストは、重みが不均一なときに次の式で実効重みを計算します(Supported load balancers)。

weight=load_balancing_weight(active_requests+1)active_request_bias\text{weight}=\frac{\text{load\_balancing\_weight}}{\left(\text{active\_requests}+1\right)^{\text{active\_request\_bias}}}

active_request_bias の既定値は 1.0 です。この 1 つのパラメーターで、重み付きラウンドロビンから最小未完了数までを連続的に調整できます。

  • 00 なら未完了数を無視し、単なる重み付きラウンドロビンになる
  • 大きくするほど未完了数が実効重みを強く下げ、厳密な最小未完了数へ近づく

Envoy のドキュメントは、この方式が定常状態では良好だが急な偏りへの追従は遅く、また P2C と違ってホストが完全にドレインされることはないと明記しています。

LLM の場合は、さらに「重み」を静的に決められないという問題が残ります。レプリカの実効容量は、プロンプト長と出力長の混合比で変わるからです。5.2 節で扱う Kubernetes Gateway API Inference Extension(GIE)のロードマップも「レイテンシーとリクエストコストを考慮した不均質アクセラレーター向け負荷分散」を未完了項目として挙げており、この問題はまだ解決していません。

5. LLM 固有の信号

推論エンジンは、ルーティングに使える内部状態を指標として公開しています。LLM 向けのルーターは、古典的な負荷指標の代わりにこれを使います。

5.1 推論エンジンが出す数字

vLLM は Prometheus 互換の /metrics に、ルーティングへ直接使える指標を出しています(vllm/v1/metrics/loggers.py)。

指標型意味
vllm:num_requests_runningGauge実行バッチに入っているリクエスト数
vllm:num_requests_waitingGaugeスケジュール待ちのリクエスト数
vllm:num_requests_waiting_by_reasonGauge待ち理由別(capacity / deferred)の内訳
vllm:kv_cache_usage_percGaugeKV キャッシュ使用率(1 が 100%)
vllm:prefix_cache_queries / vllm:prefix_cache_hitsCounterプレフィックスキャッシュの問い合わせ・ヒット(トークン数)
vllm:num_preemptionsCounterプリエンプションの累計回数
vllm:time_to_first_token_secondsHistogramTTFT
vllm:inter_token_latency_secondsHistogramトークン間遅延
vllm:request_queue_time_secondsHistogramキュー待ち時間
vllm:request_prefill_time_seconds / vllm:request_decode_time_secondsHistogramフェーズ別の所要時間
vllm:kv_block_reuse_gap_secondsHistogramKV ブロックの再利用間隔(サンプリング)
vllm:kv_block_idle_before_evict_secondsHistogram追い出されるまでのアイドル時間(サンプリング)
vllm:kv_block_lifetime_secondsHistogramKV ブロックの生存時間(サンプリング)

(表の指標名は、ソースで登録されている名前です。Prometheus クライアントは収集時に Counter へ _total を付与するため、/metrics やアラート式では vllm:num_preemptions_total と書きます。末尾の 3 つは既定では出力されません。有効化のスイッチは --kv-cache-metrics(真偽値・既定 False)で、--kv-cache-metrics-sample(既定 0.01)はそのサンプリング率だけを決めます。加えてログ統計が有効である必要があるため、--disable-log-stats を付けていると出ません。)

ルーティングでは、キューの深さと KV 使用率が互いに独立した飽和軸になります。KV に余裕があってもバッチスロットが埋まっていれば待たされますし、逆にスロットが空いていても KV が足りなければ受け付けられません。

vllm:num_preemptions(収集時は _total)が増えているときは、KV 不足で実行中のシーケンスが追い出されて再計算されています。この状態はトークン間遅延を大きく乱すので、ルーター側で受付を絞る(あるいは容量を足す)べきだという明確な信号です。

5.2 標準化の動き:Gateway API Inference Extension

Kubernetes の Gateway API Inference Extension(GIE)は、この「エンジンの内部状態を見てエンドポイントを選ぶ」層を標準化する公式プロジェクトです。仕組みは次のとおりです。

  1. Gateway が HTTPRoute に従って InferencePool(同じモデルを提供するエンドポイント群)を選ぶ
  2. Gateway が Envoy の External Processing を通じて、そのプールの Endpoint Picker(EPP)へリクエスト情報を渡す
  3. EPP がエンドポイントのメトリクスを取得し、設定された目的に最も合う 1 台を選ぶ
  4. Gateway がその 1 台へ転送する

EPP が参照できる信号には、プレフィックスキャッシュの状態や LoRA アダプターの搭載状況が含まれます。EPP は差し替え可能な拡張点として定義されており、2026 年にはこの拡張点に沿ってリポジトリーが分割されました。現在の README によれば、フル機能の EPP、InferenceObjective と InferenceModelRewrite API は llm-d/llm-d-router へ、Body Based Router は llm-d/llm-d-inference-payload-processor へ移管され、元のリポジトリー内のパッケージは近いうちアーカイブされます。GIE リポジトリー自体は、適合性テスト用の軽量 EPP(LWEPP)と InferencePool API、そして適合性テストの開発に範囲を絞っています。

プールの API と適合性テストは上流で標準化し、ルーティングアルゴリズムの実装は別リポジトリーで競わせる、という分業です。この移行はまだ途中で、プロジェクトの FAQ によれば InferencePool と InferencePoolImport の API 自体と適合性テストも Gateway API リポジトリーへ移される予定です。InferencePoolImport はクラスターをまたぐための API 型ですが、まだ v1alpha1 で、破壊的変更がありうると明記されています。

GIE のロードマップには、この分野の未解決課題が並んでいます。リモートキャッシュへのインターフェースを含むプレフィックス考慮負荷分散、LoRA アダプターの推奨ロールアウト手順、同一重要度帯内での公平性と優先度、ロードバランサー由来の集約メトリクスによる HPA(Horizontal Pod Autoscaler)対応、大きなマルチモーダル入出力、拡散モデルなど補完以外の生成モデル、不均質アクセラレーター、独立スケール可能な分離プールです。その多くは、この記事の後半で扱う論点と重なります。マルチモーダルと補完以外の生成モデルは、この記事では扱いません。

エンジン側では、vLLM が ORCA(Open Request Cost Aggregation)形式のエンドポイント負荷レポートに vllm:kv_cache_usage_perc と vllm:num_requests_waiting をマッピングして返します(vllm/entrypoints/serve/utils/orca_metrics.py)。ルーターに渡す数字が、プロトコルとして固まりつつあります。

5.3 プレフィックスの重なりが信号になる理由

ここまでの信号は、古典的な負荷指標を LLM 用に置き換えたものでした。次の信号には、古典的な負荷分散に対応するものがありません。このリクエストの先頭を、すでに計算し終えているレプリカがあるかという信号です。

エージェントの会話では、システムプロンプト、ツール定義、これまでの全ターンが、次の呼び出しでもそのまま先頭に並びます。10 ステップ目の呼び出しでは、プロンプトの 95% が前ステップと共通ということも普通に起こります。このとき、同じ会話を同じレプリカへ送るかどうかでプレフィル計算量が 1 桁変わります。

プレフィルはプロンプトの全トークンを並列に処理できるため演算律速で、デコードは 1 トークンずつしか進まないためメモリー帯域律速です。長さ nn のプロンプトのうち先頭 mm トークンぶんの KV がすでに計算済みなら、残り n−mn-m トークンぶんだけ計算すればよくなります。

削減率は層の種類で違います。QKV 射影や MLP などトークン数に線形な層はちょうど m/nm/n を削減します。一方 Attention は、未キャッシュの n−mn-m トークンがキャッシュ済みのプレフィックス全体にも Attention をかけなければならないため、削減率は

1−n2−m2n2=(mn)21-\frac{n^2-m^2}{n^2}=\left(\frac{m}{n}\right)^2

となり、m/nm/n より小さくなります。したがって全体の削減率は (m/n)2(m/n)^2 と m/nm/n の間に収まり、Attention の占める割合が大きいほど m/nm/n を下回ります。

実用上は、この二次項は長文脈でなければほとんど効きません。モデルの隠れ層幅を dd、1 ブロックのパラメーター数を P≈c d2P \approx c\,d^2 と書くと、定数倍を無視したプレフィルの演算量はトークン数に線形な部分がおよそ 2n⋅P2n\cdot P、因果マスク付きの Attention 項がおよそ 2n2d2n^2 d です。すなわち Attention が占める割合は n/(n+c d)n/(n+c\,d) 程度で、cc は典型的に 11 から 19(古典的な MHA + 4d GELU なら 4d2+8d24d^2+8d^2 でちょうど 12)です。d=4096d=4096 なら、nn が 5 万トークンに近づくまで Attention は全体の半分にもなりません。したがって普通の長さのプロンプトでは、削減率は m/nm/n に近いと考えて構いません。二次項が効いてくるのは、長文脈のエージェントや文書全文を投げる用途です。

Dynamo と SGLang は、この削減できるプレフィル量を、それぞれ別の形でルーティングの判断に使います。

5.4 コスト関数として定式化する:NVIDIA Dynamo

NVIDIA Dynamo の KV ルーターは、エンドポイント選択を明示的なコスト最小化として書いています(router-design.md)。

overlap_credit_blocks = device_overlap_blocks * effective_device_credit
                      + host_overlap_blocks   * host_cache_hit_weight   // 既定 0.75
                      + disk_overlap_blocks   * disk_cache_hit_weight   // 既定 0.25
                      + shared_beyond_blocks  * shared_cache_multiplier  // 共有キャッシュ未設定なら無効
 
adjusted_prefill_blocks = max(prefill_blocks - overlap_credit_blocks, 0)
 
cost = prefill_load_scale * adjusted_prefill_blocks       // 既定 1.0
     + potential_decode_blocks
     + decode_active_request_weight * active_requests     // 既定 0(任意)

この式には、3 つの設計判断が表れています。

単位をブロックに統一している。プレフィル負荷もデコード負荷もキャッシュヒットも、すべて KV ブロック数という共通の単位で表されるため、そのまま足し引きできます。

キャッシュの階層に応じて割引率を変えている。GPU 上(device)にあるブロックは満額、CPU ピン留めメモリーなら 0.75、ディスクなら 0.25 の重みで「プレフィルしなくてよい分」として控除されます。転送コストの差が重みとして表現されています。shared_beyond_blocks は、クラスター共有キャッシュのヒットのうち、そのワーカーの device プレフィックスを超えた分だけを数えたものです。この乗数の既定値は上流の資料の間で食い違っていますが(Python バインディングは 0.0、フロントエンド CLI リファレンスは 0.5)、共有キャッシュを設定しない限りどちらにせよ無効です。

過度な親和性を弱める減衰項がある(既定では無効)。実効的な device 重みは

effective_device_credit=overlap_score_credit1+decay×normalized_excess_prefill\text{effective\_device\_credit}=\frac{\text{overlap\_score\_credit}}{1+\text{decay}\times\text{normalized\_excess\_prefill}}

で計算され、キャッシュを持っているワーカーのプレフィル負荷が最も軽いワーカーより大きいほど、親和性の効きが弱まります。ただし --router-kv-overlap-score-credit-decay の既定値は 0 で、このままでは親和性を抑える項が働かないため、必要なら明示的に設定します。router_temperature を 0 以外にすると、正規化したコストに対する softmax サンプリングになり、決定的な集中を避けられます。

ドキュメントの計算例(overlap_score_credit = 1.0、decode_active_request_weight = 0)を示します。

ワーカー生プレフィルdevice 重なりデコードコスト
110210(10−2)+10=18(10-2)+10=18
21055(10−5)+5=10(10-5)+5=10 ← 選択
31089(10−8)+9=11(10-8)+9=11

ワーカー 3 は最もキャッシュを持っていますが、デコード負荷が重いため選ばれません。キャッシュヒットは TTFT を、デコード負荷はトークン間遅延を支配するので、この式は両者のバランスを 1 つのスカラーで取っています。ドキュメントも「重なりの重みを上げると TTFT が改善し、下げると負荷が均等になり ITL が改善する」と明記しています。

5.5 閾値で切り替える:SGLang

SGLang のゲートウェイは、同じ問題をコスト関数ではなく 2 つのモードの切り替えとして解いています(policies/cache_aware.rs)。

imbalanced = (max_load - min_load) > balance_abs_threshold
             AND max_load > min_load * balance_rel_threshold
 
if imbalanced:
    最小負荷のワーカーを選ぶ(同点はランダム)
else:
    近似基数木でプレフィックス一致率を求める
    match_rate = matched_chars / input_chars
    if match_rate > cache_threshold:
        一致したワーカーを選ぶ
    else:
        最小負荷のワーカーを選ぶ(同点はランダム)
選んだワーカーを基数木へ挿入する

木はトークン ID ではなく生の文字列を格納します。ルーター側でトークナイズせずに済む代わりに、一致率は近似になります。木は (プール, モデル) ごとに 1 本保持され、各ワーカーはその木の中のテナントとして表現されます(一致検索は「このプレフィックスを最も長く保持しているテナントはどれか」を返します)。プールごとに分けるのは、プレフィル用プールとデコード用プールが互いのエントリーを追い出さないようにするためです。

既定値は入口によって異なります。Rust のゲートウェイバイナリー(main.rs)は cache_threshold = 0.3、balance_abs_threshold = 64、balance_rel_threshold = 1.5、eviction_interval = 120 秒、max_tree_size = 67108864 を採り、Python ランチャー(router_args.py)はエビクション間隔だけ 60 秒です。一方、CacheAwareConfig::default()(policies/mod.rs)は 0.5 / 32 / 1.1 / 30 / 10000 です。

Dynamo の連続的なコスト関数と SGLang の離散的な閾値切り替えは、同じトレードオフに対する 2 つの表現です。前者は重みの調整で滑らかに動かせ、後者は「いつ親和性を捨てるか」が設定ファイルから直接読めます。

5.6 信号の鮮度 — 古い値を全員で信じると何が起きるか

ここまでの議論は、ルーターがレプリカの現在の負荷を知っていることを前提にしていました。実際には、ルーターはミリ秒単位で判断し、数秒周期でしか観測しません。この時間差によって、古典的な分散系の失敗モードがそのまま持ち込まれます。

ルーターの取得タイミング(位相)が揃っていると、この失敗は次の順に起きます。

  1. NN 台のルーターが同じスナップショットを見る
  2. 全員が同じ「今いちばん空いているレプリカ」を選ぶ
  3. そのレプリカが次の取得までの間、全トラフィックを受ける
  4. 次の取得でそのレプリカが最悪に見え、全員が一斉に逃げる
  5. 1 へ戻る

この振動は収束せずに続きます。振幅を決めるのは、1 枚の古いスナップショットをもとに何件を投入するかです。その件数はおよそ min⁡(λ×Tscrape, L)\min(\lambda \times T_{\text{scrape}},\ L)、つまり取得間隔の間に到着する件数と、同時に滞留している総量 L=λWL = \lambda W の小さい方です。この LL は、1.3 節でリトルの法則に代入した滞留数をプール全体へ拡げたものです。取得間隔を延ばすほど、振幅はこの上限まで大きくなります。ルーター台数は振幅そのものには効きません。全ルーターが同じスナップショットを見ている以上、投入の総数は台数によらないからです(下のラボで取得タイミングをずらさずにルーター台数だけを動かしても、最上段の方式の数値はまったく変わりません)。ルーター台数が影響するのは、後述する対策の 1 番目(自分の投入分を数える補正)の効き具合です。

信号の鮮度と群れ現象

ルーターはミリ秒単位で判断し、メトリクスは数秒周期でしか更新されません。この時間差だけを変えて、負荷の振動がどう生まれ、どう収まるかを 1 ティックずつ追います。

取得したスナップショットで値が最小のレプリカを選びます。取得タイミングをずらさない場合、全ルーターが同じスナップショットを見るため、更新までの間は同じレプリカへ投げ続けます(同点はランダムに分散します)。

実際の未完了数

ティック 0 · 最大値 − 最小値 2

実際の未完了数:レプリカ 1: 0, レプリカ 2: 2, レプリカ 3: 0, レプリカ 4: 0, レプリカ 5: 0

未完了今回
この時点で取得: ルーター 1, ルーター 2, ルーター 3, ルーター 4

ルーターが見ている値

ルーターが見ている値
ルーターレプリカ 1レプリカ 2レプリカ 3レプリカ 4レプリカ 5この時点の投入先
ルーター 1#100000レプリカ 2
ルーター 2#200000レプリカ 2

偏りの推移

偏りの推移を示す棒グラフ。最大 21、平均 12.0。

1 / 90

4 つの選択規則の比較

4 つの選択規則の比較
選択規則偏りの最大値偏りの平均振動幅(偏りのティック間変化の平均)全到着が 1 台へ向かったティックの割合
最小未完了数(古い値をそのまま信じる)2112.01.1964%
最小未完了数 + 自分が投げた分209.11.2948%
ランダムに 2 台だけ比較(補正なし)95.20.8021%
両方104.40.9623%

サービス時間を固定した抽象モデルです。実際には、スクレイプの位相がルーターごとにずれていることも、メトリクス自体が平滑化されていることもあります。ここで見るべきは絶対値ではなく、「同じ古い値を全員が信じると振動が持続する」という構造です。

既定設定(5 レプリカ、4 ルーター、取得間隔 10 ティック)での結果は次のとおりです。

選択規則偏りの最大値偏りの平均集中率(全到着が 1 台へ向かったティックの割合)
最小未完了数(古い値をそのまま信じる)2112.064%
最小未完了数 + 自分が投げた分209.148%
ランダムに 2 台だけ比較(補正なし)95.221%
両方104.423%

(下 3 行は、それぞれ最上行の規則を置き換えたものです。「ランダムに 2 台だけ比較」は自分の投入分の加算を含まず、2 つを組み合わせたのは「両方」だけです。また集中率は、この設定の中での比較にしか使えません。取得間隔を 1 ティック(完全に新鮮な値)にすると集中率は 87% まで上がりますが、これは全ルーターが正しい値を見て同じ結論に至っているだけで、偏りの最大値は 21 から 2 へ落ちます。)

対策は 4 つあります。

0. そもそも位相を揃えない。いちばん安いのは、ルーターごとに取得タイミングをずらすことです。上のラボで「取得タイミングをずらす」を入れると、選択規則を一切変えないまま最上段の偏りが減ります。ただしこれが使えるのは取得を中央で調停できる場合に限られ、それぞれ独立に起動するサイドカー群には使えません。以下の 3 つは、取得タイミングをずらせない場合の対策です。

1. ルーター側で自分の投入を数える。取得したスナップショットに、前回の取得以降に自分が投げた件数を足してから選びます。この対策の効きはルーター台数に直接依存します。ルーターが 1 台なら補正は正確で、偏りの最大値は 3、平均は 1.44、集中率は 6% まで下がります(P2C より良い)。しかし 4 台になると、各ルーターは全体の 1/4 しか見えないため、平均の偏りは 12.0 から 9.1 までしか下がりません。この改善は、12.0 から表の最小値 4.4(「両方」)までの幅のおよそ 4 割にとどまり、最大値も 21 から 20 へとほとんど改善していません。

2. ランダム化する。P2C は「全員が同じ最小値へ殺到する」経路そのものを断ちます。Envoy のドキュメントは、重みが等しいときの LEAST_REQUEST がまさに P2C(choice_count の既定値は 2)であることを明記し、「P2C は群れ現象への耐性のためにロードバランサー実装にとくに有用だ」と述べています。Dynamo の router_temperature による softmax サンプリングも、同じ目的の別実装です。

3. 古い値も新しいエンドポイントも信じない。Envoy の ClientSideWeightedRoundRobin は、ORCA レポートから重みを

weight=qpsutilization+epsqps×error_utilization_penalty\text{weight}=\frac{\text{qps}}{\text{utilization}+\dfrac{\text{eps}}{\text{qps}}\times\text{error\_utilization\_penalty}}

として導きます。この式には、古い値や新しいエンドポイントを扱う規則が伴います。レポートが無効であるとき、初期の blackout_period 中であるとき、あるいは weight_expiration_period を過ぎたとき、そのエンドポイントには有効な重みを持つエンドポイントの中央値が割り当てられます(すべて無効なら 1)。このブラックアウトと失効の仕組みは ClientSideWeightedRoundRobin 固有ですが、スロースタート(新規・復帰したエンドポイントへ徐々にトラフィックを入れる)はラウンドロビンや最小リクエストでも使えます。

3 番目の対策は、LLM ではとくに重要です。起動直後のレプリカは未完了数 0 で、しかもキャッシュが冷えているからです。この問題は 11.3 節で扱います。ただし Envoy のドキュメントは、スロースタートは「新規エンドポイントが少数のときに最も効果的で、Kubernetes の新規デプロイのように全エンドポイントが新しい場合はあまり効かない」と警告しています。スケールアウトで 1 台ずつ増やす場面には効きますが、プール全体を入れ替えるデプロイには別の手が要ります。

6. 親和性と均衡の折り合いをどこで付けるか

5 章では、プレフィックスの重なりをコストに換算する方法を扱いました。残る問題は、そのコストをどこまで優先させるかです。

6.1 局所性と均衡のトレードオフ

プレフィックスキャッシュ考慮ルーティング

同じ到着列を 2 つのルーターに流し、プレフィルすべきトークン数の差を追います。一方は最小未完了数だけを見て、もう一方はプレフィックス親和性を優先し、負荷差が閾値を超えたら最小負荷へ逃がします。レプリカ 3 台、各レプリカのキャッシュ枠は 1 プレフィックスぶんです。

リクエスト R1プレフィックス P1プロンプト長 4180 トークン (4000 + 180)

最小未完了数のみ

振り分け先レプリカ 1キャッシュミス
各レプリカのキャッシュ(LRU 1 枠)
1
P1
1
2
空
0
3
空
0
累積割当件数
このリクエストのプレフィル
4180
累積プレフィルトークン
4180

プレフィックス考慮

振り分け先レプリカ 1キャッシュミス

このプレフィックスは初出のため最小負荷へ

各レプリカのキャッシュ(LRU 1 枠)
1
P1
1
2
空
0
3
空
0
累積割当件数
このリクエストのプレフィル
4180
累積プレフィルトークン
4180
プレフィル削減率: 0.0%(4180 / 4180 トークン)
1 / 12

キャッシュ枠を 1 プレフィックスぶんに絞ることで競合を強調しています。実装ではキャッシュ状態の追跡がルーター側の近似木(SGLang)、KV イベント購読(Dynamo)、外部インデクサーのいずれかになり、一致の粒度も文字・トークン・KV ブロックのいずれかです。

キャッシュ枠を 1 レプリカあたり 1 プレフィックスに絞り、1 つのプレフィックス(P1)が全 12 件のうち 8 件を占める偏ったリクエスト列を流しています。2 つのルーターを並べると、次の 3 点が分かります。

親和性は効く。負荷だけを見るルーターのヒットは 12 件中 4 件、プレフィックス考慮ルーターは 6 件です。累積プレフィルトークン数は 29,180 対 21,180 で、27.4% の削減になります。負荷だけを見るルーターもヒットはします。3 台を順に埋めていく過程で、たまたま同じレプリカに戻ってきたときです。プレフィックス考慮ルーターでは、そのヒットが偶然ではなくなります。

それでも逃げ道は必要。P1 が集中するため、親和性だけに従うとレプリカ 1 に仕事が積み上がります。負荷差が閾値(この実装では 2)を超えた時点で親和性を放棄し、最小負荷のレプリカへ逃がします。12 件のうち 2 件がこの経路を通ります。

逃げ道にはコストがある。逃がした 2 件はどちらもミスになります。閾値を無限大にすればヒット率は 6/12 より上がりますが、最終的な負荷は 8, 2, 2 のように偏ります。閾値は、キャッシュ親和性と負荷均衡の折り合いをどこで付けるかを決めるつまみです。プレイヤーを 1 ステップずつ進めると、各判断の理由(初出・親和性・負荷差による放棄)が表示されます。

5 章の 2 つの実装も、この逃げ道をそれぞれ用意しています。SGLang は閾値、Dynamo は減衰項で、既定で有効なのは SGLang だけです。設計の選択肢は次のように整理できます。

設計軸選択肢
キャッシュ状態の追跡方法ルーター側の近似木 / KV イベント購読 / 外部インデクサーへの問い合わせ
一致の粒度文字 / トークン / KV ブロック
親和性を捨てる条件負荷差の絶対値と相対値 / 連続的な減衰項 / softmax の温度
キャッシュの階層GPU HBM のみ / ホストメモリー / ディスク / クラスター共有ストア

最後の行は、判断の形そのものを変えます。KV ブロックをクラスター共有ストアから取得できるなら、別のレプリカでの「ミス」のコストはプレフィル全額ではなく転送コストで済みます。親和性は二値ではなくなり、帯域と演算の比較に変わります。Dynamo の 0.75 と 0.25 の階層重みが 1 台の内側で値付けしていたものを、マシン間へ拡げた形です。vLLM は vllm/distributed/kv_transfer/ 下にこのためのコネクターを持ち、LMCache・Mooncake・NIXL、そして単純な CPU / ディスク退避をカバーしています。これが GIE ロードマップの「リモートキャッシュへのインターフェース」であり、この項目がまだ未解決である理由でもあります。転送が十分に安くなれば、ルーターのコスト関数には演算だけでなくネットワークの項が必要になるからです。

6.2 軽量な代替:プレフィックスハッシュ

基数木の維持が重いと感じる場合、SGLang には prefix_hash という軽量版もあります(policies/prefix_hash.rs)。

  1. リクエストの先頭 kk トークンを取り出す(prefix_token_count)
  2. xxhash でハッシュする
  3. 一貫性ハッシュリングで担当ワーカーを引く
  4. そのワーカーの負荷が平均 × load_factor を超えていたら、最小負荷のワーカーへ回す

発想は 4.1 節で触れた「有界負荷付き一貫性ハッシュ」と同じです。ただしこの実装は、溢れたときにリングを順に歩くのではなく、全体で最小負荷のワーカーを選ぶ点が理論モデルと異なります。木の維持が不要で、リング参照は O(log⁡n)O(\log n)、ワーカーを nn 台から n+1n+1 台へ増やしたときに移動するキーは全体の 1/(n+1)1/(n+1) 程度で済みます。エージェントの会話の先頭をキーにするなら、実用上はこれで足りることが多いでしょう。

6.3 同じ構造の別問題:LoRA アダプター

複数 LoRA アダプターのルーティングも、プレフィックスキャッシュと同じ構造をしています。レプリカは有限個のアダプターしか同時に搭載できず、未搭載のアダプターを入れ替えるには実時間がかかります。そのためルーターは、アダプター X をすでに持っているレプリカと、空いているレプリカのどちらを選ぶかを判断することになります。

違いは、親和性の値が離散で、取りうる値が少ないことです。プレフィックス一致率は 0 から 1 の連続値ですが、アダプターは「ある」か「ない」のどちらかで、しかも同時に搭載できるのは数枚です。親和性項の値が粗いので、人気のあるアダプターへの集中はプレフィックスの場合より深刻になります。

vLLM はこの判断のために vllm:lora_requests_info を公開しています。値は発行時刻で、ラベルに running_lora_adapters、waiting_lora_adapters、max_lora(バッチに同居できるアダプター数の上限)を持ちます。ただし前 2 つの中身は、ドキュメントの説明と異なります。vLLM のメトリクス設計ドキュメントは「アダプター別の件数をカンマ区切り文字列にしたもの」と説明していますが、loggers.py が実際に結合しているのは辞書のキーだけで、件数は落ちています。実際に送られているのは、いま走っている、または待っているアダプター名の一覧です。同ドキュメントはこのカンマ区切りエンコード自体を「かなり見当違い(quite misguided)」と評し、ラベルで分けるべきだと記しています。信号はあるものの、形式はまだ整っていません。GIE のロードマップも「LoRA アダプターの推奨ロールアウト手順」を未完了項目として挙げています。

7. エンジン内スケジューリング

ルーターがどれだけ賢くても、レプリカ内部のスケジューラーが仕事を待たせたままにしていれば効果は出ません。負荷分散の議論で最も見落とされやすいのがこの層です。

7.1 静的バッチから連続バッチへ

初期の推論サーバーは、リクエストをバッチにまとめて一括処理していました。この方式では、バッチ内で最も長い生成が終わるまで全スロットが解放されません。10 トークンで終わるリクエストが 1,000 トークン生成するリクエストを待つことになります。

Orca(Yu ら, OSDI 2022)が導入したイテレーション単位スケジューリングは、これを反復ごとの判断へ置き換えました。1 反復ごとに完了したシーケンスを外し、待機列から新しいシーケンスを入れます。これが、今日「連続バッチ処理(continuous batching)」と呼ばれているものの原型です。

vLLM の PagedAttention(Kwon ら, SOSP 2023)は、これを KV キャッシュ側から支えます。KV を固定サイズのブロックに分割して仮想メモリーのように管理することで、断片化を抑えつつシーケンスの出入りを安価にしました。

7.2 プレフィルがデコードを止める問題

連続バッチ処理にも、まだ 1 つ問題があります。新しいシーケンスのプレフィルは 1 反復を丸ごと占有するため、その間、実行中の全シーケンスのデコードが止まります。長いプロンプトが 1 本入るたびに、他の全ユーザーのトークン間遅延が跳ね上がります。

Sarathi-Serve(Agrawal ら, OSDI 2024)のチャンク化プレフィルは、プレフィルを計算予算に収まるチャンクへ分割し、同じ反復でデコードと同居させます。これにより「デコードが止まる反復」がなくなります。

エンジン内スケジューリング:静的 / 連続 / チャンク化プレフィル

同じ到着列に対して 3 つのバッチ処理方式を切り替え、スロットの空きとデコードの停止がどこで生じるかを 1 反復ずつ示します。

反復ごとに空きスロットを補充します。ただしプレフィルは 1 反復を占有するため、その間デコードは停止します。

反復 1 / 19

この反復の出力トークン: 0
スロット 1
A · プレフィル
スロット 2
B · プレフィル
スロット 3
空き

出力トークン 0 の反復

待機中
なし
完了
なし
1 / 19

3 方式の比較

3 方式の比較
方式全完了までの反復数出力 0 の反復数スロット充填率
静的バッチ25657%
連続バッチ19891%
チャンク化プレフィル14193%

プレフィルとデコードの仕事量を、整数のチャンク数とステップ数に単純化した模式図です。実機のチャンク化プレフィルには 1 反復あたりのトークン予算があり、プレフィルとデコードへの配分を変えるとスループットとトークン間遅延がトレードオフになります。

3 つの方式を切り替えると、2 つの効果が別々の指標に現れます。スロット充填率は静的バッチの 57% から連続バッチで 91%、チャンク化プレフィルで 93% へと上がります。この上昇の大部分は連続バッチによるものです。一方で出力 0 の反復数は、静的バッチ 6、連続バッチ 8、チャンク化プレフィル 1 です。連続バッチのこの 8 は実際にデコードが止まった反復で、ここでは悪化しています。スロットを常に埋めようとする分だけ、新規プレフィルの割り込みが増えるからです(静的バッチの 6 は大半が初回のプレフィルで、そもそも止まるデコードが存在しません)。チャンク化プレフィルはこの悪化を打ち消し、出力 0 の反復を静的バッチより少なくします。なおこの模式図では、最初の反復だけはまだ 1 本もプレフィルを終えていないため出力トークンが 0 になります。

チャンク化プレフィルにもコストがあります。1 反復あたりのトークン予算をプレフィルとデコードで分け合うため、予算配分がそのままスループットとトークン間遅延のトレードオフになります。プレフィル寄りに振ればスループットが上がり、デコード寄りに振れば ITL が安定します。

7.3 プレフィルとデコードを別のマシンへ

さらに進めて、プレフィルとデコードを別のワーカーへ分けるのが Splitwise(Patel ら, ISCA 2024)と DistServe(Zhong ら, OSDI 2024)です。2 つのフェーズは資源の使い方が違うからです。

プレフィルデコード
律速要因演算(FLOPS)メモリー帯域
並列度プロンプト内の全トークンシーケンス数のみ
バッチ効果小さい(単体で飽和する)大きい
主な調整手段並列化の形態より大きなバッチ

分離すると、フェーズごとに異なる並列度・異なる GPU 世代・異なるバッチ方針を選べます。なお DistServe は、プレフィル側でどの並列化が有利かは到着率に依存すると報告しており、「プレフィルは常にテンソル並列」のような単純な規則にはなりません。代償は、プレフィル後の KV キャッシュをデコードワーカーへ転送する必要があることです。転送コストが利得を上回る短いプロンプトでは分離しない方がよく、Dynamo はこれを条件付き分離として実装しています。切り替え規則は isl_bounding(実効入力長)、prefill_load(プレフィル側の混雑度)、isl_or_load(いずれか)の 3 つから選べます。

ルーティングの観点では、分離構成は決定を 2 つに増やします。プレフィルワーカーの選択はプレフィックス親和性が支配し、デコードワーカーの選択は KV 容量と現在のバッチが支配します。Dynamo の実装では、通常の分離時にデコード側ルーターへ overlap_score_credit = 0 を強制して純粋な負荷分散にし、条件付き分離のときはこの上書きを行わず基底のクレジットをそのまま使います(prefill_router/mod.rs の build_decode_router_override)。

7.4 投機的デコードと、それが壊すメトリクス

投機的デコードは、エンジン側の手段のなかでも、デコード 1 反復の中身そのものを変えます。小さなモデル・n-gram 照合・EAGLE や MTP のような追加ヘッドを使って複数トークンを安く下書きし、ターゲットモデルの 1 パスでまとめて検証して、受理された最長の接頭辞を残します(Leviathan, Kalman, Matias, ICML 2023)。受理されたトークンはほとんどコストがかからず、棄却されたぶんの計算は無駄になります。

負荷分散から見ると、帰結が 2 つあります。

リクエストあたりのコストが予測しにくくなる。1.2 節の議論はすべてサービス時間のばらつきについてのものでした。投機はそこへ 2 つ目のばらつきの源を足します。受理率が内容に依存するからです。構造的で低エントロピーな出力は長く受理され、自由記述はそうなりません。そのため同じプロンプト長でも、同じレプリカ上で消費する GPU 時間がはっきり変わりえます。しかもその変動はランダムではなくワークロードの構成と相関します。固定的な容量モデルが外れるのは、こういう変動があるときです。

トークン間遅延(ITL)が TPOT に連動しなくなる。投機を使うと、ストリームの 1 チャンクが複数トークンを運びます。vLLM のベンチマークドキュメントによれば、ITL はストリーム出力どうしの間隔だけを記録し、同じ出力にまとめられたトークンに長さゼロの間隔を足しません。一方 TPOT は、デコード時間を全出力トークンで按分します(Benchmark CLI)。このため両者は乖離し、ドキュメントの例では ITL が 40 ms、TPOT が 20 ms です。SLO を ITL で書いたまま投機を有効にしても、数字は体験ほどには良くなりません。逆に、投機ありと投機なしを ITL で比較すると、同じ量を比べていないことになります。SLO は TPOT か、正規化した E2E レイテンシーに紐づけてください。

8. 公平性 — 誰に対する公平か

8.1 リクエスト数もトークン数も、そのままでは公平ではない

マルチテナントの LLM ゲートウェイでよく使われるレート制限は RPM(分あたりリクエスト数)と TPM(分あたりトークン数)です。どちらも単独では公平性を保証しません。

RPM は仕事量を見ていません。200 トークンのプロンプトと 100,000 トークンのプロンプトが同じ 1 件と数えられます。

TPM は入力と出力を区別しません。入力トークンはプレフィルで並列に処理されますが、出力トークンは 1 つずつ生成され、その間ずっと KV キャッシュのスロットを占有します。GPU 秒に対する寄与はまったく違います。

両者ともキャッシュヒットを反映しません。95% がキャッシュ済みのプロンプトと、まったく新規のプロンプトが同じコストとして数えられます。

これはタスクスケジューリングの記事で扱った「公平性の主体と単位をどう定義するか」と同じ問題です。

8.2 VTC:トークン処理量で公平性を定義する

Sheng ら(OSDI 2024)の VTC(Virtual Token Counter)は、この問題を直接扱います。各クライアントに「これまで受けたトークン処理量」を重み付きで累積するカウンターを持たせ、カウンターが最小のクライアントから次のリクエストを投入します。入力トークンと出力トークンに別々の重みを与えられるため、プレフィルとデコードのコスト差を表現できます。

構造としては、Linux の CFS / EEVDF における vruntime、ネットワークの SFQ(Start-time Fair Queueing)における開始タグと同じ系統です。累積した正規化処理量が最小の主体を選ぶ、という骨格はそのままで、資源の単位がトークンに置き換わっています。

実用上の注意が 2 つあります。1 つ目に、投入時点では出力長が分からないため、カウンターへの加算は生成が進むにつれて少しずつ行う必要があります。2 つ目に、厳密なトークン公平性を守るとプレフィックス局所性が壊れます。同じテナントのリクエストを連続で投入した方がキャッシュは効くからです。ここでも局所性と公平性のトレードオフが現れます。

8.3 優先度とプリエンプション

公平性とは別に、優先度が要る場面があります。対話型のチャットと夜間のバッチ要約が同じプールを共有するなら、前者を優先したいはずです。GIE はモデルごとの提供優先度を宣言できるようにしており、低優先度のトラフィックは混雑時に待たされるか落とされます。

エンジン側にもプリエンプションがあります。KV が不足すると、vLLM は実行中のシーケンスを追い出して後で再計算します。容量を守るための機構ですが、追い出されたリクエストのトークン間遅延は大きく乱れます。vllm:num_preemptions_total が増え続けているなら、ルーター側で受付を絞るべきです。

9. 過負荷制御 — 失敗の伝播を止める

9.1 リトライの多層増幅

エージェントのスタックは層が深く、各層がそれぞれリトライを実装しています。ここで間違えやすいのは、各層の試行回数が足されると考えてしまうことです。4 層がそれぞれ 3・4・4・3 回試行するとき、上流に到達しうる呼び出しは 3+4+4+3 = 14 件ではなく、3×4×4×3=1443 \times 4 \times 4 \times 3 = 144 件です。

リトライの多層増幅

各層が独立にリトライすると、上流から見た試行回数は積になります(括弧内は各層のリトライ回数)

クライアント SDK(+2)
×3
エージェントのツール実行ループ(+3)
×12
ゲートウェイのルーター(+3)
×48
プロバイダー SDK(+2)
×144

1 回のユーザー操作が、最悪の場合 144 回のモデル呼び出しになります。厄介なのは、どの層も「自分は 2、3 回しかリトライしていない」と正しく主張できることです。各層の試行回数は、足し算ではなく掛け算で効きます。

対策は 4 つです。

  1. リトライは 1 層に集約する。LiteLLM のルーターはリトライを自分で担い、プロバイダー SDK の max_retries を 0 に固定します。デプロイの num_retries: N が二重に適用されて、1 リクエストが (1+N)2(1+N)^2 回の上流呼び出しになるのを防ぐための設計です(Router ドキュメント)
  2. リトライ予算を持つ。「成功件数の 10% まで」のような比率上限を設けると、平常時は十分にリトライでき、障害時に増幅しません
  3. Retry-After を尊重する。Azure OpenAI が 429 を返すとき、Retry-After の値は 1 日といった大きな値になることがあります。Azure API Management のサーキットブレーカーには、このヘッダーを受け入れる acceptRetryAfter オプションがあります(Backends ドキュメント)
  4. 指数バックオフにジッターを入れる。ジッターがないと、リトライが同期して周期的な負荷の山を作ります

9.2 ヘッジ:裾を削る代わりに何を払うか

Dean と Barroso の "The Tail at Scale"(CACM, 2013)が示したヘッジ要求は、P95 を超えた時点で別のレプリカへ複製を送り、先に返った方を採用する手法です。第 95 パーセンタイルでヘッジすれば定義上複製されるのは全体の 5% であり、論文も「追加負荷をおおよそ 5% に抑える」と報告しています。

ヘッジ(投機的二重発行)の損得

P95 を超えたときだけ二重発行すると、追加負荷を数 % に抑えつつ裾を削れます

ヘッジなし・運が悪い場合920 ms・追加負荷なし

レプリカ A(遅い・920 ms)

P95(400 ms)でヘッジ700 ms・追加負荷は約 5%

レプリカ A(B の応答時に中断)、レプリカ B(ヘッジ・300 ms で応答)

常時二重発行300 ms・費用と負荷は 2 倍

レプリカ A(破棄)、レプリカ B(300 ms・採用)

ミリ秒の値は 1 回分の例で、比べるべきは追加負荷です。P95 でヘッジすると、二重発行されるのは定義上 5% のリクエストだけなので、定常状態の追加負荷はおよそ 5% です。常時二重発行では 100% です。閾値を置く理由は、この差にあります。

LLM ではヘッジの代償が大きくなります。破棄した側もプレフィルとデコードを実行済みで、課金対象になり、KV キャッシュも消費します。ストリーミング中は、どちらのストリームを採用するかを最初のトークンまでに決める必要があります。ツールを呼ぶエージェントでは、副作用の二重実行を避けるために冪等性も前提になります。

現実的な使いどころは、プレフィル完了までのヘッジです。TTFT が閾値を超えたら別レプリカへ投げ、先に最初のトークンを返した方を採用し、もう一方は即座に中断します。デコードまで二重に走らせるより、はるかに安く済みます。

9.3 受付制御とバックプレッシャー

キューは無限ではありません。無限のように扱うと、待ち時間だけが延びてグッドプットは上がらない、という最悪の状態になります。

  • キュー長の上限を設け、超えたら即座に 429 を返す(待たせてから失敗させるより良い)
  • デッドラインを持たせる。すでに SLO を超過したリクエストは、実行しても価値がないので落とす
  • キュー時間の上限を、平均サービス時間の定数倍で決める
  • サーキットブレーカーで、繰り返し失敗するバックエンドを一定時間切り離す

Azure API Management のサーキットブレーカーは、失敗回数・観測窓・対象ステータスコード範囲・トリップ継続時間をルールとして持ちます。ただし制約もあります。1 バックエンドにつき設定できるルールは 1 つで、Consumption ティアでは利用できず、ゲートウェイインスタンス間で状態が同期されないため判定は近似です。

9.4 コミット点 — 最初のトークンを返した瞬間に選択肢は消える

ここまでの信頼性機構には、共通の上限があります。最初のトークンをクライアントへ流した後は、どの層も、ユーザーに気づかれずにリトライ・ヘッジ・再ルーティング・フェイルオーバーを行えないことです。やり直せば、ユーザーには文章が途中で消えて書き直される様子が見えてしまいます。

ここから次のことが言えます。

  • 9 章と 12 章の機構はどれも最初のトークンより前でしか働かないので、その有効時間は定義上ちょうど TTFT になる。2 章で TTFT を他の 5 行と区別したのはこのためである
  • 長寿命の SSE(Server-Sent Events)接続は、リクエストの寿命ぶんだけゲートウェイの 1 インスタンスに固定される。ゲートウェイインスタンス間で状態が同期されないという制約と組み合わさると、この固定の影響は大きくなる
  • どうしても途中から復旧したいなら、アプリ側でトークンをバッファーし、失敗時に生成済みの接頭辞をプロンプトへ入れて続きを書かせる、といった独自の再開機構が要る。プロトコル側にこの仕組みはない

9.5 推論モデルはコミット点を動かす — そしてルーターに手がかりを渡す

9.4 節では、信頼性の仕組みをすべて TTFT に紐づけました。推論モデルでは、これまでの前提が次の 3 点で変わります。

窓が長くなる。答える前に考えるモデルは、最初の数秒を、ユーザーには見せない推論トークンの生成に使うか、何も出さずに過ごします。表示されるものはその後に来ます。そのため、リトライやフェイルオーバーがまだユーザーに見えない時間の長さは、エンジンが報告する TTFT ではなく、ユーザーに見える最初のトークンまでの時間になります。9.2 節で「破棄した側もデコードを実行済みだから高い」と述べたヘッジは、相対的に安くなります。どちらの試行もユーザーに見える出力を出していないうちに、片方を打ち切れるからです。ただし、その間は両方が推論トークンを生成しています。

コストの一部が投入時点で分かる。この記事の冒頭では、リクエストあたりの仕事量は投入時点では分からないと書きました。推論モデルはこの前提に例外を作ります。reasoning.effort(OpenAI)や thinking.budget_tokens(Anthropic)はリクエストのフィールドで、仕事量のうち最も大きくばらつく成分に上限を与えます。この記事に出てくる負荷信号のなかで、あとから取得するのではなくリクエストと一緒に届くのはこれだけです。そのため L2 で取り出す価値があります。レート制限の重みとして使う、優先度クラスとして使う、あるいは高 effort のトラフィックを別プールへ送り、対話ターンと同じバッチに同居させないための根拠にする、といった使い方です。

effort はキャッシュ対象プレフィックスの一部である。どちらのプロバイダーも推論の設定をプロンプトへ埋め込むため、これを変えるとメッセージ側のキャッシュが無効になり、モデルによってはシステムとツールのキャッシュも巻き込まれます(OpenAI, Prompt caching、Anthropic, what invalidates the cache)。1 つの会話のなかでリクエストごとに effort を変えると、システムプロンプトを変えるのと同じ悪影響があります。5 章と 6 章で扱った親和性が、キーの一部だと誰も意識していなかったフィールドによって崩れます。

10. 支出を減らす — 吸収する・格下げする・先送りする

1.2 節の式は、ρ\rho が 1 に近いところではルーティング規則を変えるより到着率 λ\lambda を下げるほうが効くことを示しています。ここまでの章ではプールへ届いたリクエストの配り方を扱いました。この章では、届く前に減らす方法を扱います。

10.1 ゲートウェイでの意味的キャッシュ

プレフィックスキャッシュはプレフィルの計算を減らしますが、リクエストそのものは残ります。ゲートウェイで応答を返せば、そのリクエストはプールに届きません。リクエストを拒否せずに安く答えるので、厳密には負荷を落とすのとは違い、プールの手前で需要を吸収するティアを作ることになります。

Azure API Management は、これを 2 つのポリシーで提供しています。受信側の llm-semantic-cache-lookup と、送信側の llm-semantic-cache-store です。前者の主な属性は次のとおりです。

  • score-threshold(必須、0.0〜1.0):類似度の許容幅。小さいほど厳しく、ドキュメントは 0.05 から始めることを勧め、0.2 を超えると誤ヒットが増えると警告しています
  • embeddings-backend-id(必須)と embeddings-backend-auth(必須、system-assigned)
  • ignore-system-messages(既定 false、true 推奨)
  • max-message-count と、キャッシュを区切る <vary-by> 要素。ドキュメントはこれを、ある利用者の応答が別の利用者へ漏れないようにする手段として位置づけています

ドキュメントには、注意が 2 つ書かれています。1 つ目に、キャッシュ参照ポリシーの直後に rate-limit ポリシーを置くことを推奨しています。理由は「キャッシュが利用できないときにバックエンドが過負荷にならないようにするため」です。キャッシュが吸収する前提で容量を設計していると、キャッシュが落ちた瞬間に吸収していた分がすべて戻ってきます。準安定障害(metastable failure)の典型的な形です。2 つ目に、類似度ベースのキャッシュは「不正確・古い・安全でない応答を返す可能性がある」と明記されています。

13.2 節と対になる点もあります。ignore-system-messages="true" は類似度の比較からシステムプロンプトを除外しますが、13.2 節で推奨するのは共有プレフィックスをルーティングのハッシュキーにすることです。KV キャッシュは共通の先頭部分を手がかりにし、意味的キャッシュはリクエストごとに異なる末尾部分を比較します。目的が違うので、見る場所も違います。

10.2 モデルカスケード — 到着過程そのものが変わる

3 章の層モデルは L2 を「モデル選択・プロバイダー選択」と定義しました。プロバイダー選択は 12 章で扱いますが、多くの場合、効果が大きいのはモデル選択のほうです。

カスケードは、まず小さいモデルへ投げ、検証器や自己申告の確信度が閾値を下回ったときだけ大きいモデルへ昇格させる方式です。平均コストは大きく下がります。ただし負荷分散の観点では、見落とされやすい副作用があります。

昇格したリクエストは難しいものに偏っているため、大きいモデルのプールが受け取る仕事は生のトラフィックとは別物です。ただし、その影響には直感と逆の部分があります。サービス時間分布の左側を切り落とす操作なので、サービス時間の変動係数の 2 乗 Cs2C_s^2 はむしろ下がります。指数分布のサービス時間で上位 10% だけを昇格させると、大きいプールが見る Cs2C_s^2 は 1.00 から 0.09 へ落ち、代わりに平均サービス時間は 3.3 倍になります。1.2 節の式の (1+Cs2)/2(1+C_s^2)/2 の項だけを見れば、条件は良くなっているように見えます。

実際に問題になるのは、次の 3 つです。

1 つ目に、平均サービス時間が大きく増えます。大きいプールの容量をリクエスト件数から見積もることはできません。生のトラフィックから求めた RPM/TPM の見積もりは、そのままでは大きく外れます。

2 つ目に、到着過程が変わります。昇格の判断は小さいモデルの出来と問い合わせの中身に依存する状態依存の間引きなので、ポアソン過程ではありません。プロンプトの変更、モデルの差し替え、入力分布の変化といった事象で昇格率が不連続に跳ね、昇格リクエストは相関して到着します。Kingman の近似

Wq≈ρ1−ρ⋅Ca2+Cs22⋅E[S]W_q\approx\frac{\rho}{1-\rho}\cdot\frac{C_a^2+C_s^2}{2}\cdot E[S]

で言えば、上がるのは Cs2C_s^2 ではなく、到着間隔の変動係数の 2 乗 Ca2C_a^2 のほうです。

3 つ目に、10.1 節と同じ準安定障害の形をしています。安いティアが劣化すると、そこが吸収していた分が一斉に高いティアへ移ります。

もう 1 つの代償は往復です。昇格したリクエストは小さいモデルの生成を最後まで(あるいは判定に必要なところまで)待ってから大きいモデルへ行くので、P50 のコストを P95 のレイテンシーと交換していることになります。ユーザーが待っている対話では、この交換が割に合わないことがよくあります。

10.3 コスト考慮ルーティングと予算

料金がモデルごとに違う以上、ルーティングはコスト最適化でもあります。LiteLLM の cost-based-routing はトークン単価が最小のデプロイを選びますが、これは 1 リクエスト単位の局所最適にすぎません。

エージェントで実際に効くのは予算のほうです。テナント単位・セッション単位・タスク単位で消費トークンに上限を設け、超えたら止めます。9.3 節の受付制御と同じ構造ですが、実施点がエージェントループそのものになりうる点が違います。「もう 1 ステップ回すか」の判断に予算を組み込めるのは、従来の負荷分散にはなかった選択肢です。

その延長として、バッチ API を降格先として使う方法があります。対話的でない仕事(夜間の要約、インデックス再構築、評価実行)を、レイテンシー保証のない安価なティアへ回します。9.3 節の対策がリクエストを落とすのに対し、こちらは後回しにするので、SLO を割らずに λ\lambda を下げられます。

11. 弾力性 — オートスケーリングとの結合

ここまで、レプリカ数は固定として扱ってきました。実際には 2 つ目の制御ループが同時に回っていて、この 2 つのループは互いに干渉します。

11.1 うまく分散するほどスケール判断が難しくなる

スケール指標の取り方には、向きの違う失敗モードが 2 つあります。

レプリカ別の指標を見る場合。完璧に負荷分散されたプールでは、全レプリカの値が揃います。飽和は見えますが、早期警報が失われます。以前は最も負荷の高いレプリカが先に飽和点を越えて知らせてくれましたが、うまく分散すると全台が同時に飽和点を越えます。健全から飽和への遷移が急になり、対処の余裕が減ります。

プール全体の平均だけを見る場合。今度は逆に、1 台だけが詰まっている状況が平均に埋もれます。

ロードバランサーは、エンドポイントを選ぶ前の待ち時間を観測できる唯一の位置にいます。レプリカ内のキューにはすでに送られてきた仕事しか含まれませんが、ルーターはどこへも送れなかった仕事も見ています。GIE のロードマップにある「ロードバランサー由来の集約メトリクスによる HPA 対応」は、この位置を使う項目です。

11.2 GPU 使用率はスケール指標として使えない

デコード中の GPU 使用率は、何か 1 つでもデコードしていれば、演算器の実効利用率にかかわらず高い値に張り付きます。utilization.gpu はサンプル区間のうちカーネルが実行されていた割合であって、演算能力のどれだけを使ったかではないからです。デコードはメモリー帯域律速なので、演算器が空いていても「使用中」に見えます。使うべき指標は、5.1 節でルーティング用に導入したものと同じです。

  • キューの深さ(vllm:num_requests_waiting)
  • KV キャッシュ使用率(vllm:kv_cache_usage_perc)
  • TTFT の分位点

vLLM のメトリクス設計ドキュメントは、オートスケーリングの未解決問題を「モデルサーバーの飽和点、すなわち到着率を上げてもスループットが伸びず遅延だけが増え始める変曲点はどこか」と定義しています。これは 1.2 節の曲線で待ち時間が急に増え始める点(膝)にあたります。ルーティングとスケーリングは、同じ曲線の別の場所を見ています。

11.3 立ち上がりが遅く、しかも冷たい

LLM レプリカのコールドスタートは秒ではなく分の単位です。イメージの取得、重みのロード、CUDA グラフのキャプチャや JIT コンパイルが積み上がります。そのため、

  • ゼロスケールは大きいモデルでは現実的でない。レプリカが 0 台のときは吸収する先がなく、リクエストはコールドスタートをそのまま待つしかない
  • 温まったプールからのスケールアウトでは、制御ループの遅れをロードバランサーが吸収することになる。そのため判断は予測的でないと間に合わない。反応的な閾値では、Pod が Ready になるころには負荷の波が過ぎている

ここで 5.6 節で触れた問題がもう一度出てきます。新しく Ready になったレプリカは未完了数 0 で、しかもキャッシュが冷えています。最小未完了数はここへ全トラフィックを流し込んで TTFT を再び跳ね上げ、プレフィックス考慮ルーターは逆に、キャッシュのないこのレプリカをなかなか選びません。対処は Envoy が実装しているブラックアウト期間とスロースタート、つまりしばらくは中央値として扱い、徐々にトラフィックを入れることです。

12. マルチプロバイダーとマルチリージョン

自前ホストではなくマネージドサービスを使う場合、負荷分散の主目的は「GPU を使い切る」ことから「クォータとリージョンをまたいで容量を確保する」ことへ移ります。

12.1 予約容量から従量課金へ溢れさせる

Azure OpenAI のスピルオーバーは、プロビジョニング済み(PTU)デプロイが捌けないリクエストを、同一リソース内の標準デプロイへ自動的に回す機能です(ドキュメント)。

  • 有効化はデプロイのプロパティー spilloverDeploymentName、またはリクエストヘッダー x-ms-spillover-deployment で行う(両方指定された場合はデプロイのプロパティーが優先)
  • 発動条件は PTU 枯渇による 429、PTU の文脈長上限を超えたことによる 400、およびサーバー側エラーの 500 / 503
  • スピルオーバーしたリクエストの応答には x-ms-spillover-from-deployment と x-ms-spillover-error ヘッダーが付き、後者には元の非 200 応答コードが入る
  • 実際に応答を返したデプロイ名は x-ms-deployment-name ヘッダーで分かる。スピルオーバー率は、Azure Monitor のメトリクスをデプロイ別に分割して監視する
  • 課金は PTU 側が時間課金のみ、標準デプロイ側がトークン課金

これは優先度付きバックエンドプールを、プラットフォーム側が実装したものと読めます。安価で予測可能な予約容量を優先し、溢れたぶんだけ従量課金へ逃がす、という形です。

12.2 ゲートウェイでのプールとブレーカー

Azure API Management のバックエンドプールは、より一般的な道具立てを提供します。

方式挙動
ラウンドロビンプール内へ均等に配る
重み付き相対重みに応じて配る。ブルーグリーン展開などに使う
優先度ベース高優先度グループから使う。下位グループは、上位グループの全バックエンドがサーキットブレーカーでトリップして初めて使われる

セッションアフィニティー(session affinity)を有効にすると、Cookie でセッション ID を保持して同じバックエンドへ送れます。会話型のエージェントで意味を持つのはこの機能です。制約は 2 つあり、1 プールあたり最大 30 バックエンドであることと、負荷分散もサーキットブレーカーもゲートウェイインスタンス間で同期しないため近似的であることです。

12.3 SDK レベルのルーター

LiteLLM のルーターは、複数プロバイダーを 1 つの論理モデル名の裏に束ねます。用意されている方針は次のとおりです。

方針選択規則
simple-shuffle(既定)設定した RPM / TPM / weight に比例する重み付きランダム
usage-based-routing-v2レート制限の残余を見て選ぶ
latency-based-routing観測レイテンシーの移動平均が小さいものを選ぶ
least-busy実行中リクエスト数が最小のものを選ぶ
cost-based-routingトークン単価が最小のものを選ぶ

信頼性側の道具も揃っています。order によるデプロイ優先度、クールダウン(429 は即時、直近 1 分の失敗率 50% 超、401 / 404 / 408 のいずれでも発動し、既定の切り離し時間はいずれも 5 秒)、max_parallel_requests によるデプロイごとの同時実行上限、そして事前チェック(文脈長超過の除外、EU リージョン限定)です。

自前ホストのプールとマネージドプロバイダーでは、見える信号がまったく違います。前者は KV 使用率もキューの深さも観測できますが、後者で分かるのは実測レイテンシー、429、そして自分が数えたトークンだけです。信号が乏しい環境では、レイテンシー加重と重み付きランダムに落ち着くのは妥当な設計です。

12.4 プロバイダー側も同じキャッシュを持っている — ただし操作できる範囲は限られる

5 章と 6 章では、プレフィックス親和性を自分で実装するものとして扱いました。マネージドプロバイダーを使えば実装はしなくて済みますが、このトレードオフがなくなるわけではありません。プロバイダー側も類似のキャッシュ考慮ルーターを動かしており、利用者には一部の制御だけを公開しているからです。

OpenAI は仕組みを具体的に説明しています。キャッシュされた KV は個々のマシン上にあり、およそ毎分 15 リクエストを超えると別マシンへ溢れる場合があり、溢れたリクエストはミスします。組織と処理リージョンの内側でのルーティングは、そのときのマシン負荷、先頭トークン列のハッシュ、そして任意の prompt_cache_key によって決まります。このキーはリクエストのグループ間でキャッシュの再利用を分けます。GPT-5.6 より前のモデルでは、関連するリクエストを同じキャッシュへ寄せるのにも役立ちますが、特定マシンへ固定するものでもヒットを保証するものでもありません。GPT-5.6 以降はキャッシュを考慮したルーティングを OpenAI が自動で行うので、キーが必要なのはキャッシュの集計を分けたい場合だけです(Prompt caching)。2026 年 11 月時点では、GPT-5.6 以降で prompt_cache_options も公開されています。暗黙的キャッシュと明示的キャッシュを選べ、1 リクエストで最大 4 つのキャッシュ書き込みを行えます。ttl で指定する最低保持期間は、既定の 30m(30 分)か、Responses API だけで使える 12h(12 時間)です。非キャッシュ入力トークン単価に対して、書き込みは 30m で 1.25 倍、12h で 2.5 倍、読み取りは保持期間によらず 0.1 倍(GPT-6.1 Sol と GPT-6.1 Terra では 0.05 倍)です。より古いモデルでは代わりに prompt_cache_retention を使います。

構造的には 4.1 節の「有界負荷付き一貫性ハッシュ」で、負荷項はプロバイダー側にあり、利用者が持つのはキャッシュキー、ブレークポイントの配置、保持期間の選択だけです。GPT-5.6 より前のモデル向けに文書化されている、キーが過熱したときの対処法も、これまでの議論と同じ形をしています。あるグループへのトラフィックが多くキャッシュヒットが減るなら、安定かつ決定的な対応付けで複数のキーへ分散させる、というものです。親和性の単位を分割するという 6.1 節の逃げ道を、呼び出し側が担うことになります。

Anthropic は同じトレードオフを価格として明示しています。2026 年 11 月時点では、5 分間保持のキャッシュ書き込みは基本入力単価の 1.25 倍、1 時間保持の書き込みは 2 倍、読み取りは通常 0.1 倍です。ただし Claude Opus 5.5 と Claude Sonnet 5.5 では 0.05 倍、Claude Fable 5.1 と Claude Mythos 5.1 では 0.025 倍の読み取り単価が文書化されています。既定のエントリー寿命は 5 分で、使うたびに更新されます(Prompt caching)。

文書化されている細部のうち 2 つは、13.1 節の議論に直接関わります。1 つ目に、寿命はエントリーに触れたリクエストの開始時点から計られ、応答の終了時点からではありません。そのため生成にかかった時間が TTL から差し引かれ、4 分間ストリームすれば戻れる猶予は 1 分ほどしか残りません。13.1 節の長時間エージェントでは、生成中もキャッシュの寿命が減り続けます。2 つ目に、キャッシュエントリーが利用可能になるのは最初の応答が始まってからなので、並列リクエストでキャッシュヒットを得るには、最初の応答を待ってから後続を送る必要があります。そのため 13.1 節のファンアウトのバーストは、1 つのプレフィックスへ負荷を集めるだけではありません。マネージドプロバイダー上では、子リクエストが全部まとめてミスします。対処は最初の 1 本を直列化することです。

ここから、14 章の設定例に関わる帰結が 2 つ出てきます。

  • レイテンシーだけでなく、プロバイダー側のヒット率を測る。usage.prompt_tokens_details.cached_tokens(OpenAI)と cache_read_input_tokens(Anthropic)を見れば、ルーティングが効いたかどうかを数字で確かめられます。12.3 節の「マネージドプロバイダーで見える信号は乏しい」という指摘は、負荷については正しいものの、局所性には当てはまりません。
  • 会話をリージョンにまたいで散らすとキャッシュは消える。2 つのリージョンにある 2 つのデプロイは 2 つの別々のサービング群なので、プレフィックスキャッシュを共有できません。14.3 節の重み付きフェイルオーバーは 2 つのリージョンを等しい重みで 1 つのモデル名の裏に置くので、リクエスト単位でルーティングされるマルチターン会話はおよそ半分の確率で別リージョンに落ち、ゼロから再プレフィルされます。リージョンはフェイルオーバー用に重み付けし、セッションは 1 つのリージョンに固定してください。

13. エージェンティックアプリの負荷分散

ここまでは、1 リクエストをどのサーバーへ送るかを扱いました。エージェントでは、この単位そのものが変わります。

エージェント化で変わる負荷の形

スケジューリングの単位が「1 リクエスト」から「1 セッション」へ移ります(呼び出し数は目安)

作業単位ごとの負荷の形
作業単位モデル呼び出し数所要時間状態負荷の形
チャット 1 往復
~1
数秒会話履歴のみ単発
RAG 応答
~2
数秒検索結果を含む長いプロンプト直列 2 段
ツール利用エージェント
~12
数十秒〜数分毎ステップ伸び続ける同一プレフィックス長い直列
並列サブエージェント
~40
数分共有プレフィックス + 枝ごとの差分ファンアウトの突発負荷
長時間走るエージェント
~300
数時間圧縮・要約を挟む長期文脈断続的な直列

読むべきは「モデル呼び出し数」の列です。チャットの 1 ターンは 1 リクエスト、RAG の回答は 2、ツールを使うエージェントは 12、並列サブエージェントはファンアウトの山として 40、長時間走るエージェントは数時間に散らばった 300 です。1 行目より下の各行が、それぞれ別の前提を壊しています。

13.1 5 つの構造的な違い

スケジューリングの単位がセッションになる。ユーザーから見た 1 つの作業は、数十から数百回のモデル呼び出しです。kk ステップの直列チェーンなら、セッションの完了時間は各ステップのレイテンシーの和です。そのため 1 ステップあたり 200 ms の改善は、10 ステップで 2 秒になります。個々の呼び出しの P95 を最適化しても、セッション全体の完了時間が改善するとは限りません。和を取ればばらつきが平均化される(変動係数が Cs/kC_s/\sqrt{k} へ下がる)と考えたくなりますが、これは成り立ちません。エージェントのステップは独立でもなければ直列とも限らないからです。

アフィニティーが性能要件になる。従来のセッションアフィニティーは、サーバーがセッション状態を持つから必要でした。エージェントの場合、状態は会話履歴としてリクエストに全部入っているので、正しさのためのアフィニティーは不要です。それでも同じレプリカへ送りたいのは、プレフィックスキャッシュがそこにあるからです。性能のための最適化にすぎないので、負荷が偏ったらためらわずに捨ててかまいません。

ファンアウトが突発負荷を作る。サブエージェントを並列に走らせる設計では、1 つの親呼び出しが 10 本の子呼び出しを同時に生みます。到着過程はポアソンではなくバッチ到着になり、同じ平均到着率でも待ち行列は深くなります。しかも子呼び出しは同じプレフィックスを共有することが多く、キャッシュ親和性で全部を 1 台へ送ると、その 1 台が過負荷になります。親和性と均衡の対立が最も激しくなるのはここです。

寿命が長い。数時間走るエージェントは、その間のデプロイ、スケールイン、ノード退避をまたぎます。「セッションを最後まで同じレプリカで」は現実的でないため、チェックポイントと再開が前提になります。会話履歴を外部に持ち、任意のレプリカで再開できる設計にしておけば、負荷分散の自由度が保たれます。

ボトルネックが LLM とは限らない。ツール呼び出しは外部 API、データベース、コード実行を含みます。エージェントのステップ時間の内訳を測ると、LLM 呼び出しが半分以下ということも珍しくありません。LLM プールだけを最適化しても、全体のスループットが変わらない場合があります。

13.2 設計指針

ルーティングのキーは会話 ID ではなくプレフィックスにする。会話 ID でハッシュすると、システムプロンプトを共有する別会話の間でキャッシュが再利用されません。プレフィックス(システムプロンプト + ツール定義 + 会話の先頭)でハッシュすれば、テナント内の全会話でキャッシュを共有できます。

そのうえで、共有する範囲を決める。共有には代償がある。直前の指針は性能上の判断ですが、セキュリティー上の影響を伴います。キャッシュヒットは TTFT の短さとして外から観測できるからです。Song らは KV キャッシュと意味的キャッシュの両方に対してこれを実証しており、時間計測と分類器でヒットを判別し、トークン単位の探索で共有プレフィックス(システムプロンプトや他ユーザーが書いたプレフィックスを含む)を復元し、実在のオンラインサービスへのブラックボックス試験で確認しています(arXiv:2409.20002、IEEE TIFS 採録)。プロバイダー側の答えは分離です。OpenAI は組織をまたいでもリージョン処理境界をまたいでもキャッシュを共有しません。自前ホストで同じことをするなら、意識的な設計判断が必要です。共有範囲の中の全主体が、互いの送った内容を知ってもよい関係でないかぎり、テナント識別子をルーティングキーとキャッシュキーの一部にし、ヒット率の低下を受け入れます。

ファンアウトには専用の同時実行上限を設ける。サブエージェントの並列度を無制限にすると、1 ユーザーの 1 操作がプール全体を占有します。セッションあたりの同時実行数に上限を設け、超過分はキューへ入れます。

対話的なステップとバックグラウンドのステップを分ける。ユーザーが待っている呼び出しと、非同期に進むバッチ処理は、SLO も優先度も違います。別プールに分けるか、同じプールを共有するなら別の優先度クラスを与えます。

ツールは冪等に設計する。リトライとヘッジは、冪等でないツール呼び出しの前で止まります。冪等キーを持たせておくと、負荷分散の選択肢が広がります。

公平性の主体をセッションにする。リクエスト単位の公平性は、多数のステップを走らせるエージェントに有利に働きます。テナント単位あるいはセッション単位で累積トークン処理量を数える方が、意図に合います。

14. 設定例

14.1 Kubernetes:InferencePool と EPP

GIE の構成要素は、HTTPRoute が InferencePool を参照し、そのプールに EPP が紐づくという形です。

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: llm-route
spec:
  parentRefs:
    - name: inference-gateway
  rules:
    - backendRefs:
        - group: inference.networking.k8s.io
          kind: InferencePool
          name: vllm-llama-pool

エンドポイント選択のロジックは EPP 側にあります。リポジトリー同梱の EPP は適合性テスト用の参照実装なので、本番では独自実装か既存実装を使ってください。エンジン側は /metrics を公開し、必要なら ORCA 形式の負荷レポートを返します。

14.2 SGLang ゲートウェイ

python -m sglang_router.launch_router \
    --worker-urls http://worker1:8000 http://worker2:8000 \
    --policy cache_aware \
    --cache-threshold 0.5 \
    --balance-abs-threshold 32 \
    --balance-rel-threshold 1.5 \
    --eviction-interval-secs 120 \
    --max-tree-size 67108864

すべて明示的に書いていますが、Python ランチャーの既定値と違うのは --cache-threshold(既定 0.3)、--balance-abs-threshold(既定 64)、--eviction-interval-secs(既定 60 秒)の 3 つだけです。--balance-rel-threshold と --max-tree-size は既定値と同じ値です(既定値の一覧は 5.5 節)。--cache-threshold を上げるほど親和性は保守的になり、--balance-abs-threshold を下げるほど負荷均衡へ早く切り替わります。なおフラグ名は入口で異なり、Python ランチャーは --eviction-interval-secs、Rust のゲートウェイバイナリーは --eviction-interval です。

14.3 LiteLLM:複数リージョンの重み付きフェイルオーバー

model_list:
  - model_name: gpt-4.1-mini
    litellm_params:
      model: azure/gpt-4.1-mini
      api_base: https://eastus2.example.azure.com
      api_key: os.environ/AZURE_EASTUS2_KEY
      weight: 1
  - model_name: gpt-4.1-mini
    litellm_params:
      model: azure/gpt-4.1-mini
      api_base: https://swedencentral.example.azure.com
      api_key: os.environ/AZURE_SWEDEN_KEY
      weight: 1
 
router_settings:
  routing_strategy: simple-shuffle
  enable_weighted_failover: true
  num_retries: 2

enable_weighted_failover を有効にすると、失敗したデプロイを除外したうえで同じモデルグループ内で選び直し、全ピアを使い切ってから初めて fallbacks の別グループへ移ります。同一モデルのリージョン冗長を組むときは、この形が適しています。ただし 12.4 節で述べたとおり、例のように等しい重みで置くと、マルチターン会話がリージョン間で散ってキャッシュを失います。動作上の制約も 3 つあります。routing_strategy が simple-shuffle(既定値。上の例もこれ)のときにしか動かず、非同期エントリーポイント専用で(同期の router.completion() は通常の fallbacks に落ちます)、フェイルオーバーの段数は max_fallbacks(既定 5)で上限がかかります。

14.4 Azure API Management:優先度プールとブレーカー

resource pool 'Microsoft.ApiManagement/service/backends@2023-09-01-preview' = {
  name: 'myAPIM/aoai-pool'
  properties: {
    description: 'PTU first, PAYG as overflow'
    type: 'Pool'
    pool: {
      services: [
        { id: ptuBackendId, priority: 1, weight: 1 }
        { id: paygBackendId, priority: 2, weight: 1 }
      ]
    }
  }
}

優先度 2 のバックエンドは、優先度 1 のバックエンドがすべてサーキットブレーカーでトリップして初めて使われます。したがってブレーカーの設定がフェイルオーバーの発火条件そのものになります。429 と Retry-After を扱うルールを必ず入れてください。

15. 評価のしかた

負荷分散の変更は、測り方を間違えると効果が見えません。

開ループで測る。固定同時実行数(閉ループ)のベンチマークは、サーバーが遅くなると到着率も自動的に下がるため、過負荷を再現できません。ポアソン到着で到着率を固定した開ループで測ってください。

グッドプットの曲線を描く。到着率を掃引し、SLO(例:TTFT P95 < 2 秒、TPOT P95 < 50 ms)を満たしたまま出せる最大の到着率を求めます。これが実効容量です。グッドプットは SLO を満たしたリクエストを数えるので、SLO の指標には、全リクエストの出力間隔をまとめて集計する ITL ではなく、TTFT や TPOT のようにリクエストごとに値が決まるものを使います。単一点のスループットは、どのくらい SLO を割っているかを隠します。

プレフィックス再利用率を実データに合わせる。合成ベンチマークのランダムなプロンプトは、プレフィックスキャッシュを完全に無力化します。キャッシュ考慮ルーティングの評価はこの比率に大きく左右されるので、自分のワークロードの再利用率を先に測ってください(vllm:prefix_cache_hits と vllm:prefix_cache_queries の比がそのまま使えます)。

裾の分布を出す。P50 と P95 だけでなく、P99 と最悪値も見ます。ヘッジや受付制御の効果は裾にしか現れません。

キャッシュを温めてから測る。冷えたキャッシュでの計測は、プレフィックス考慮ルーティングを過小評価します。逆に、温めすぎた状態は実運用のエビクションを無視することになります。

ルーティングの変更は単純なカナリアでは測れない。キャッシュ局所性は新方式が扱うトラフィックの割合に依存するためです。リクエスト単位で 5% を割り当てるカナリアなら、同じ会話の連続 2 ターンがともにカナリアへ入る確率は 0.25% しかなく、損失は二乗で効いてきます。セッション単位の割り当てなら、損失はずっと小さくなります。さらに、カナリアと対照が同じレプリカプールを共有していると、対照側のトラフィックがカナリアのプレフィックスを追い出し、カナリア側の書き込みが対照側のヒットを温めるので、2 つの群はそもそも独立していません。シャドートラフィック(LiteLLM の Traffic Mirroring など、本番応答に影響を与えずに複製を流す仕組み)で全量を流すか、プールごと分けて A/B するのが現実的です。

層ごとのスパンを取る。本番で性能の劣化を見つけたときに 5 つの層のどこが原因かを切り分けるには、クライアント・ゲートウェイ・ルーター・エンジンをまたぐトレースが要ります。待ち時間・プレフィル・デコードを別のスパンとして分けられない限り、「TTFT が悪化した」から先へ進めません。

ベンチマークのフラグでこれらを実装する。vLLM benchmark CLI ドキュメント には、上の確認に使える次のフラグがあります。

やりたいことフラグ
開ループのポアソン到着--request-rate <rps> と --burstiness 1.0。後者はガンマ分布の形状母数で、CV は 1/burstiness1/\sqrt{\text{burstiness}} になるため 1.0 がポアソン、0.1 は激しいバーストです。--request-rate を既定の inf のままにすると閉ループになり、上で述べた問題がそのまま起きます
プレフィックス再利用率を制御する--dataset-name prefix_repetition と --prefix-repetition-prefix-len / --prefix-repetition-num-prefixes / --prefix-repetition-suffix-len
膝を探す--ramp-up-strategy linear と --ramp-up-start-rps / --ramp-up-end-rps
出力長を固定する2 節で触れた --ignore-eos

スパンの名前を揃える。「層ごとのスパンを取る」は、スパンの名前が揃って初めて役に立ちます。OpenTelemetry GenAI セマンティック規約がこれを定義しており、その分け方はこの記事の層とよく対応します。サーバー側の time-to-first-token と time-per-output-token は 2 節の TTFT と TPOT に、エージェント単位の所要時間と呼び出し回数の指標は 13 章のセッション完了時間に対応します。クライアント側の指標は、トークンではなくストリームのチャンク単位で測ります(7.4 節で見たとおり、1 チャンクが 1 トークンとは限らないため)。両側を計装すれば、クライアント側とサーバー側のヒストグラムの差が、そのまま自分のルーティング層のコストになります。ルーターのコストを知るには、これが最も安上がりな方法です。

16. まとめ — 1 つのトレードオフと 2 つの制約

層も道具もベンダーも多いですが、どこでも同じことをしていました。局所性と均衡のどちらを取るかを、古い信号をもとに、閉じつつある時間窓の中で決めることです。本文ではこれを 3 つの形で見ました。

局所性と均衡の選択は程度の問題である。6.1 節のプレイヤーで見たとおり、親和性を優先するとヒット率は上がりますが負荷は偏り、逃げ道を使えば負荷は揃いますが、そのリクエストはキャッシュを失います。SGLang の閾値も Dynamo の減衰項も同じつまみです。ただし前者は既定で有効、後者は既定で無効です。

信号は必ず古い。5.6 節で見たとおり、全員が同じ古い値を信じると振動が止まらなくなります。振幅を決めるのはルーター台数ではなく、取得間隔と同時に滞留している件数です。そのため対策は、位相をずらす、選択をランダム化する、自分の投入分を補正する、のいずれかになります(取得間隔を縮めても効きますが、自分では動かせないことが多い変数です)。

窓は最初のトークンで閉じる。9.4 節で見たとおり、TTFT は体験の指標であると同時に、リトライ・ヘッジ・フェイルオーバー・スピルオーバーが使える時間の長さでもあります。選択規則を議論する前に、自分のシステムのコミット点がどこにあるのかを確かめてください。

新しいルーティング手法やベンダー機能に出会ったときは、次の 3 つを確かめてください。何と何を比べて選んでいるのか、見ている値はどれだけ古いのか、その判断は最初のトークンの前に行われているのか。

17. チェックリスト

レビュー時の確認項目を層ごとに並べます。

L1 クライアント
  • リトライを持つ層は 1 つだけか
  • 指数バックオフにジッターが入っているか
  • Retry-After を尊重しているか
  • タイムアウトが出力長の分布に対して現実的か
L2 ゲートウェイ
  • レート制限の単位はトークンか、リクエスト数か。それは意図した公平性と一致しているか
  • ルーター自体のコストを把握しているか(クライアント側とサーバー側のレイテンシーヒストグラムの差)
  • サーキットブレーカーの閾値は、正常時の失敗率より十分高いか
  • フェイルオーバー先の容量は、元の負荷を吸収できるか
  • 優先度グループの発火条件(=ブレーカーの設定)を理解しているか
  • キャッシュで吸収している分を、キャッシュ障害時にプールが吸収できるか
  • 推論モデルなら、reasoning.effort(や相当するフィールド)を無視せず重みとして使っているか
L3 エンドポイント選択
  • 負荷信号は「受け付けたがまだ完了していない仕事」を漏れなく数えているか
  • 全台走査か 2 択か。ルーターが複数ある場合、群れ現象は起きないか
  • プレフィックス親和性に逃げ道(閾値または減衰)があるか
  • プレフィックスキャッシュを共有する範囲はどこまでか。その範囲の全主体は、互いの送った内容を知ってもよい関係か
  • 信号の鮮度はどれくらいか。取得間隔と判断間隔の差を把握しているか
  • 新しく加わったレプリカにブラックアウトとスロースタートがあるか
  • レプリカが均質でない場合、容量差を重みとして表現しているか
L4 エンジン
  • 連続バッチ処理が有効か
  • チャンク化プレフィルが有効か。予算配分は ITL の目標に合っているか
  • プリエンプションが常時発生していないか
  • KV 使用率とキューの深さのどちらが先に飽和しているか
  • 並列化の形態(L5)は、プレフィルとデコードそれぞれの律速要因に合っているか
弾力性
  • スケール判断の指標に GPU 使用率を使っていないか
  • コールドスタートの長さをロードバランサーが吸収できるか
  • スケールアウトの判断は反応的か、予測的か
全体
  • グッドプットを開ループで測っているか
  • プレフィックス再利用率が実トラフィックに近いか
  • セッション単位の完了時間を見ているか(リクエスト単位だけになっていないか)
  • 層ごとのスパンがあり、性能劣化の原因を 1 つの層に特定できるか
  • スパンの名前が標準に揃っていて、クライアント側とサーバー側のヒストグラムを引き算できるか

参考文献