Skip to content

AIインフラ

LLM推論の解説:モデルをサービングするとき実際に何が起きているのか

HTTPリクエストから生成トークンまでの間に何が起きているのかを解き明かす実務者ガイド。本番環境でモデルを動かす前に、すべてのプラットフォームチームに必要な概念をまとめます。

Ivan Porta

創業者 兼 プリンシパルエンジニア

14 分で読了
#llm#inference#vllm#platform-engineering
LLM推論の解説:モデルをサービングするとき実際に何が起きているのか

ChatGPT、Claude、GeminiのようなフロンティアAIサービスへの支出が膨らみ、利用上限が開発者の手を縛り、オープンソースモデルの人気が高まるにつれて、ほとんどのプラットフォームチームはいずれ、自社インフラで大規模言語モデル(LLM)をサービングする必要に直面します。一見すると、ロードバランサーの背後でHTTP越しに動くほかのワークロードと変わらないように見えますが、似ているのはそこまでです。モデル自体は浮動小数点数の詰まったフォルダーにすぎず、実行できるものは何もありません。APIはステートレスで、会話を続けるにはクライアントが毎回会話履歴全体を送り直さなければならず、普段ならミリ秒で終わるAPI呼び出しが、今では数秒、時には数分かかります。

これらの違いはすべて、モデルがプロンプトをトークンに変える仕組みに関するいくつかの基本的な事実から生まれ、GPUの購入からゲートウェイやオートスケーリングの構築まで、あらゆるプラットフォーム上の意思決定を左右します。この記事では、その流れ全体を順にたどります。デプロイするファイル、それをサービングするエンジン、そして1つのリクエストが生のテキストからストリーミングされるトークンになるまでに何が起きるのかを見ていきます。

モデル:数値の詰まったフォルダー

モデルとは、重みと呼ばれる数十億個の数値に、トークナイザーといくつかの設定を加えて収めたファイル(より正確には、たいてい複数ファイルの集まり)です。プログラムではなく、実行するものは何もありません。モデルはそれ単体では文字どおり何もしません。たとえば、次はQwen3-4Bのファイル一覧です。

3.7G model-00002-of-00003.safetensors
3.7G model-00001-of-00003.safetensors
 95M model-00003-of-00003.safetensors
 11M tokenizer.json
2.6M vocab.json
1.6M merges.txt
 32K model.safetensors.index.json
 16K README.md
 11K LICENSE
9.5K tokenizer_config.json
726B config.json
239B generation_config.json

これが1か所に収まった構造のすべてです。生の数値からなる3つのシャードがデータの99%以上を占め、その使い方を説明する小さなJSONファイルとテキストファイルがいくつか付いています。標準的なHugging Faceのレイアウトに従う主なファイルは次のとおりです。

  • config.jsonは設計図です。エンジンが重みを配置し、メモリのサイズを見積もるために必要なアーキテクチャ情報を持っています。
{
  "architectures": [
    "Qwen3ForCausalLM"
  ],
  ...
  "max_window_layers": 36,
  "model_type": "qwen3",
  "num_attention_heads": 32,
  "num_hidden_layers": 36,
  "num_key_value_heads": 8,
  "torch_dtype": "bfloat16",
  "transformers_version": "4.51.0",
  "use_cache": true,
  "use_sliding_window": false,
  "vocab_size": 151936
}
  • tokenizer.jsonはモデルの辞書の役割を果たします。モデルはテキストを直接読みません。このファイルには、人間のテキストをモデルが使う整数IDに変換するために必要なすべてが入っています。
{
  "version": "1.0",
  "truncation": null,
  "padding": null,
  "added_tokens": [
    {
      "id": 151643,
      "content": "<|endoftext|>",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false,
      "special": true
    },
    ...
  ],
  "normalizer": {...},
  "decoder": {...},
  "model": {
    "type": "BPE",
    "vocab": {
      "!": 0,
      "\"": 1,
      "#": 2,
      "$": 3,
      ...
    }
  },
  "merges": [...]
}
  • tokenizer_config.jsonには、ほかの設定に加えてチャットテンプレートが含まれています。このテンプレートは、構造化された会話(system、user、assistantの各ターンとツール呼び出し)を、モデルが理解できるように学習された単一のトークンストリームへ変換するためのレシピです。
{
  "chat_template": "{%- if tools %}\n    {{- '<|im_start|>system\\n' }}\n    {%- if messages[0].role == 'system' %}\n        {{- messages[0].content + '\\n\\n' }}\n    {%- endif %}\n    {{- \"# Tools\\n\\nYou may call one or more functions to assist with the user query.\\n\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>\" }}\n    {%- for tool in tools %}\n        {{- \"\\n\" }}\n        {{- tool | tojson }}\n    {%- endfor %}\n    {{- \"\\n</tools>\\n\\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\\n<tool_call>\\n{\\\"name\\\": <function-name>, \\\"arguments\\\": <args-json-object>}\\n</tool_call><|im_end|>\\n\" }}\n{%- else %}\n    {%- if messages[0].role == 'system' %}\n        {{- '<|im_start|>system\\n' + messages[0].content + '<|im_end|>\\n' }}\n    {%- endif %}\n{%- endif %}\n{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}\n{%- for message in messages[::-1] %}\n    {%- set index = (messages|length - 1) - loop.index0 %}\n    {%- if ns.multi_step_tool and message.role == \"user\" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}\n        {%- set ns.multi_step_tool = false %}\n        {%- set ns.last_query_index = index %}\n    {%- endif %}\n{%- endfor %}\n{%- for message in messages %}\n    {%- if message.content is string %}\n        {%- set content = message.content %}\n    {%- else %}\n        {%- set content = '' %}\n    {%- endif %}\n    {%- if (message.role == \"user\") or (message.role == \"system\" and not loop.first) %}\n        {{- '<|im_start|>' + message.role + '\\n' + content + '<|im_end|>' + '\\n' }}\n    {%- elif message.role == \"assistant\" %}\n        {%- set reasoning_content = '' %}\n        {%- if message.reasoning_content is string %}\n            {%- set reasoning_content = message.reasoning_content %}\n        {%- else %}\n            {%- if '</think>' in content %}\n                {%- set reasoning_content = content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n') %}\n                {%- set content = content.split('</think>')[-1].lstrip('\\n') %}\n            {%- endif %}\n        {%- endif %}\n        {%- if loop.index0 > ns.last_query_index %}\n            {%- if loop.last or (not loop.last and reasoning_content) %}\n                {{- '<|im_start|>' + message.role + '\\n<think>\\n' + reasoning_content.strip('\\n') + '\\n</think>\\n\\n' + content.lstrip('\\n') }}\n            {%- else %}\n                {{- '<|im_start|>' + message.role + '\\n' + content }}\n            {%- endif %}\n        {%- else %}\n            {{- '<|im_start|>' + message.role + '\\n' + content }}\n        {%- endif %}\n        {%- if message.tool_calls %}\n            {%- for tool_call in message.tool_calls %}\n                {%- if (loop.first and content) or (not loop.first) %}\n                    {{- '\\n' }}\n                {%- endif %}\n                {%- if tool_call.function %}\n                    {%- set tool_call = tool_call.function %}\n                {%- endif %}\n                {{- '<tool_call>\\n{\"name\": \"' }}\n                {{- tool_call.name }}\n                {{- '\", \"arguments\": ' }}\n                {%- if tool_call.arguments is string %}\n                    {{- tool_call.arguments }}\n                {%- else %}\n                    {{- tool_call.arguments | tojson }}\n                {%- endif %}\n                {{- '}\\n</tool_call>' }}\n            {%- endfor %}\n        {%- endif %}\n        {{- '<|im_end|>\\n' }}\n    {%- elif message.role == \"tool\" %}\n        {%- if loop.first or (messages[loop.index0 - 1].role != \"tool\") %}\n            {{- '<|im_start|>user' }}\n        {%- endif %}\n        {{- '\\n<tool_response>\\n' }}\n        {{- content }}\n        {{- '\\n</tool_response>' }}\n        {%- if loop.last or (messages[loop.index0 + 1].role != \"tool\") %}\n            {{- '<|im_end|>\\n' }}\n        {%- endif %}\n    {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n    {{- '<|im_start|>assistant\\n' }}\n    {%- if enable_thinking is defined and enable_thinking is false %}\n        {{- '<think>\\n\\n</think>\\n\\n' }}\n    {%- endif %}\n{%- endif %}",
  ...
}
  • generation_config.jsonは、モデルの作者が推奨する実行時設定を提供します。リクエストで別の値が指定されない限り、エンジンはこれをデフォルト設定として使います。
{
    "bos_token_id": 151643,
    "do_sample": true,
    "eos_token_id": [
        151645,
        151643
    ],
    "pad_token_id": 151643,
    "temperature": 0.6,
    "top_k": 20,
    "top_p": 0.95,
    "transformers_version": "4.51.0"
}
  • 最後に、複数のmodel-*.safetensorsシャードと、その目次の役割を果たすmodel.safetensors.index.jsonがあります。このJSONファイルは、各テンソル名を、それが入っているシャードに対応付けます。シャードの構造は単純です。小さなJSONヘッダーがテンソル名、データ型、形状、バイトオフセットを列挙し、その後に生のテンソルバイトが続きます。Qwen3-4Bの場合、約40億個のbfloat16値が各2バイトで、合計およそ8 GBになります。シャードを開くと、レイヤーごとに名前の付いた数値の配列が見えます。
model.embed_tokens.weight
tensor([[-0.0287,  0.0117,  0.0104,  ..., -0.0055, -0.0270, -0.0096],
        [-0.0291, -0.0212,  0.0109,  ..., -0.0033, -0.0062, -0.0004],
        [ 0.0035,  0.0177,  0.0012,  ..., -0.0058,  0.0022,  0.0130],
        ...,
        [ 0.0060,  0.0131,  0.0190,  ...,  0.0068, -0.0049, -0.0040],
        [ 0.0060,  0.0131,  0.0190,  ...,  0.0068, -0.0049, -0.0040],
        [ 0.0060,  0.0131,  0.0190,  ...,  0.0068, -0.0049, -0.0040]],
       dtype=torch.bfloat16)
model.layers.0.input_layernorm.weight
tensor([0.0598, 0.0288, 0.0576,  ..., 0.0184, 0.0222, 0.0223],
       dtype=torch.bfloat16)
model.layers.0.mlp.down_proj.weight
tensor([[ 0.1396,  0.0056,  0.0054,  ..., -0.0084,  0.0781, -0.0693],
        [-0.0859, -0.0074, -0.0142,  ..., -0.0161, -0.0022, -0.0403],
        [ 0.0649, -0.0021,  0.0081,  ...,  0.0110,  0.0223,  0.0283],
        ...,
        [ 0.0106,  0.0208,  0.0176,  ...,  0.0060, -0.0349, -0.0060],
        [-0.0079, -0.0007, -0.0603,  ..., -0.0059, -0.0271,  0.0085],
        [-0.0221, -0.0306,  0.0121,  ..., -0.0089, -0.0233,  0.0089]],
       dtype=torch.bfloat16)
model.layers.0.mlp.gate_proj.weight
tensor([[ 0.0014,  0.0057, -0.0032,  ..., -0.0415, -0.0276, -0.0154],
        [-0.0178,  0.0078,  0.0043,  ..., -0.0186, -0.0028, -0.0018],
        [-0.0042, -0.0018, -0.0016,  ...,  0.0330, -0.0026,  0.0244],
        ...,
        [-0.0081, -0.0007, -0.0052,  ..., -0.0076, -0.0114,  0.0062],
        [-0.0061,  0.0118, -0.0116,  ..., -0.0164, -0.0015,  0.0237],
        [ 0.0130,  0.0046, -0.0036,  ..., -0.0105,  0.0297, -0.0347]],
       dtype=torch.bfloat16)
model.layers.0.mlp.up_proj.weight
tensor([[ 0.0137,  0.0089,  0.0046,  ..., -0.0339,  0.0061, -0.0117],
        [ 0.0077,  0.0078,  0.0036,  ...,  0.0134,  0.0216, -0.0040],
        [-0.0105,  0.0057,  0.0053,  ...,  0.0251,  0.0044,  0.0092],
        ...,
        [ 0.0067, -0.0003, -0.0082,  ..., -0.0049,  0.0247, -0.0181],
        [-0.0021,  0.0044, -0.0056,  ..., -0.0111,  0.0133,  0.0135],
        [ 0.0046, -0.0028,  0.0004,  ...,  0.0167,  0.0505,  0.0347]],
       dtype=torch.bfloat16)
model.layers.0.post_attention_layernorm.weight
tensor([-2.0862e-05,  2.7466e-04, -5.5313e-05,  ...,  2.1582e-01,
         2.3145e-01,  2.1484e-01], dtype=torch.bfloat16)
model.layers.0.self_attn.k_norm.weight
tensor([ 1.9453e+00,  1.1172e+00,  1.7031e+00,  1.7969e+00,  1.4688e+00,
         1.9141e+00,  1.9844e+00,  1.8281e+00,  1.8203e+00,  1.6406e+00,
         ...,
         6.4844e-01,  1.8203e+00,  2.3750e+00,  2.9062e+00,  4.0938e+00,
         2.0625e+00,  2.1094e+00,  3.0781e+00,  1.5234e+00,  2.3125e+00,
         1.9141e+00,  2.0469e+00,  2.0156e+00], dtype=torch.bfloat16)

denseとMixture-of-Expertsのトランスフォーマーアーキテクチャ

先へ進む前に、強調しておくべき重要な違いが1つあります。業界最大級の公開モデルが最近変わったからです。長年、ダウンロードできるほとんどすべてのモデルは単一の設計を共有しており、それは現在dense(密)と呼ばれています。一方、最新のフラッグシップモデルは別の設計、Mixture-of-Experts(MoE)を使っています。両者の核心的な違いは、各トークンがそれらのテンソルのうちいくつを通過するかです。

  • denseモデル:Llamaファミリーをはじめ、皆さんがこれまでサービングしてきたほとんどのモデルがこれに当たり、すべてのトークンをシャード内のすべてのテンソルと掛け合わせます。先ほどのQwen3-4Bの一覧を見返すと、各レイヤーにはmlp.gate_proj、mlp.up_proj、mlp.down_projが1つずつあり、すべてのトークンのベクトルがそのすべてを通過します。保存された数値を飛ばす仕組みは存在しないため、トークンあたりの計算量はモデルサイズに比例します。70Bのdenseモデルは、8Bのモデルのおよそ9倍の計算をトークンごとに行います。

  • Mixture-of-Experts(MoE)モデルはファイル構造こそ同じですが、各レイヤーにそのフィードフォワード3点セットの小さなコピーを多数保存し(mlp.experts.0.gate_proj、mlp.experts.1.gate_projといった名前のテンソル群)、さらにごく小さなテンソルを1つ追加します。学習されたルーターです。ルーターはトークンごとにすべてのエキスパートを採点し、スコア上位のわずかなエキスパート(加えて、最近の設計の多くでは常時稼働の共有エキスパート1つ)だけが実際に乗算に加わります。残りのエキスパートのテンソルは、そのトークンに関しては手つかずのままです。Mixtral-8x7Bは47Bのパラメータを積んでいますが、各トークンが掛け合わされるのはそのうち約13Bで、DeepSeek-R1は671Bを積んで約37Bだけを使います。トークンあたりの計算コストは小さなモデル並みのまま、大きなモデルの品質が得られるということです。圧縮KVキャッシュ(MLA。メモリの節で改めて触れます)のような推論レイヤーの革新と併せて、これがDeepSeekがフロンティア級の品質をあれほど安価にサービングできた大きな理由です。

厄介なのは、メモリ要件がどのテンソルを使うかに左右されないことです。設計にもよりますが、各トークンが使うのは全体のおよそ3–30%にすぎない(Mixtral‑8x7Bで約28%、DeepSeek‑R1で約5%)にもかかわらず、すべてのパラメータをロードしてアクセス可能にしておかなければなりません。MoEモデルは動かすのは安上がりでも、保持するのは高くつきます。これらのモデルを効率よくサービングするには、多数のGPUとホストにエキスパートを分散し、その間でトークンを素早くルーティングする必要がしばしばあり、そこではネットワークとトポロジーを意識したスケジューリングが求められます。

サービングエンジンは新しいWebサーバー

モデルは、WebサイトのHTMLファイルと同じくらい受け身の存在です。Webサーバーがロードしてリクエストを処理するまでは何もしません。サービングエンジンは、モデルにとってのWebサーバーだと考えることができます。重みをアクセラレーターにロードし、HTTP APIを提供し、クライアントのJSONとGPUの行列演算の間を橋渡しします。リクエストをキューに入れ、ハードウェアに合わせてバッチにまとめ、用意でき次第トークンを1つずつストリーミングするなど、それ以外のすべての動作は1つの大きな目標のためにあります。どのリクエストも長く待たせすぎることなく、高価なアクセラレーターを遊ばせないことです。

執筆時点のエンジンの顔ぶれは次のとおりです。

vLLMオープンソースセルフホストのサービングにおける事実上のデフォルト。PagedAttentionを開拓し、連続バッチングが標準です。
SGLangオープンソースvLLMの最有力のオープンな対抗馬。RadixAttentionによるプレフィックスキャッシュを備えます。
TGIHugging Face成熟しており、Hubと密に統合されています。
NIMNVIDIA商用。特定のモデルとエンジンをコンテナとして事前パッケージ化したもの。NVIDIAが出荷するものをそのまま動かします。
Ray ServeAnyscale / RayMLライフサイクルのフレームワーク。LLMでは通常、内部でvLLMをラップします。
OllamaオープンソースノートPCやエッジに向いており、この記事の実験には最適。マルチGPUの本番サービング向けには作られていません。

エンジンの名前そのものより重要なことが2つあります。エンジンはモデルに依存します。新しいモデルアーキテクチャは、エンジンがそのサポートを追加して初めて動きます。どのエンジンもサポート対象モデルの一覧を公開しているのはこのためで、ビジネス側に約束をする前に、そのモデルをサービングできるかどうかを必ず確認すべき理由でもあります。また、ほぼすべてのエンジンがOpenAI API仕様/v1/chat/completions/v1/models)を使うため、クライアント、ゲートウェイ、ベンチマークはエンジンをまたいで機能します。

トークンとコンテキストウィンドウ

モデルは単語を見ません。見ているのはトークン、つまり先ほどtokenizer.jsonで見つけたトークナイザーが生成する整数IDです。この先にあるものはすべて、トークン単位で課金され、ベンチマークされ、メモリ予算が組まれます。次は、3つの短い文に対するQwen3-4Bのトークナイザーの結果です。

from urllib.request import urlopen
from tokenizers import Tokenizer
TOK_URL = "https://huggingface.co/Qwen/Qwen3-4B/resolve/main/tokenizer.json"
tok = Tokenizer.from_buffer(urlopen(TOK_URL).read())
for text in ["Why is the sky blue?", "我喜欢吃火锅!", "김치찌개를 좋아해요!"]:
    enc = tok.encode(text)
    pieces = [tok.decode([i]) for i in enc.ids]
    print(f"{text!r}")
    print(f"  {len(enc.ids):>2} tokens -> {pieces}")
 
'Why is the sky blue?'
   6 tokens -> ['Why', ' is', ' the', ' sky', ' blue', '?']
 
'我喜欢吃火锅!'
   4 tokens -> ['我喜欢', '吃', '火锅', '!']
 
'김치찌개를 좋아해요!'
   9 tokens -> ['김', '치', '찌', '개', '를', ' 좋아', '해', '요', '!']

ご覧のとおり、文字数とトークン数の間に決まった比率はありません。英語の5単語が6トークンになることもあれば、韓国語の1単語が9トークンになることもあります。これが重要なのは、モデルの主要な上限がトークン単位で測られるからです。コンテキストウィンドウとは、モデルが1つのリクエストで扱える最大トークン数(プロンプトと応答の両方を含む)のことで、ホスティングされたモデルが「100万トークンのコンテキスト」を謳うときに目にする数字がこれです。

トークン化の前に、もう1つ手順があります。アプリケーションは構造化されたメッセージの配列を送りますが、モデルは、誰が話しているかを特殊トークンで示した単一のフラットなストリームで学習されています。tokenizer_config.jsonのチャットテンプレートが、配列をストリームに平坦化することでこのギャップを埋め、そのストリームがトークン化されます。

プリフィルとデコード:あらゆる応答の裏にある2つのフェーズ

推論とは、それらのトークンを新しいトークンに変える計算のことです。プロンプトが入り、補完(completion)が出てきます。エンジンの内部では、推論は2つのフェーズで実行されます。

  • プリフィルは、プロンプト全体を1回の大きな並列パスで取り込みます。プロンプトの各トークンについてキーとバリューのベクトルを計算し、KVキャッシュに保存します。KVキャッシュとは、そのシーケンスのアテンション状態を保持するGPUメモリのことです。最終的な出力は最初の応答トークンの予測で、ここでユーザーの最初のトークンまでの時間(time-to-first-token、TTFT)が止まります。

  • デコードは、応答の残りを1トークンずつ生成します。トークンごとにキーとバリューを計算してキャッシュに追加し、それ以前のすべてのトークンについては、再計算する代わりに保存済みのエントリを読みます。KVキャッシュがなければ、500番目のトークンを生成するには先行する499トークンを再処理しなければなりません。キャッシュがあれば、各トークンのアテンション計算は、そのトークンが初めてシーケンスに入ったときの1回で済みます。

ステートレス性と会話のコスト

KVキャッシュはサーバー側に残りますが、APIはステートレスです。モデルが使えるセッションオブジェクトや会話IDはありません。ユーザーが「ベルギーの首都はどこですか?」と尋ね、続けて「その都市には何人住んでいますか?」と聞いた場合、アプリケーションがリクエストのたびに会話全体を送り直さない限り、モデルは2つの質問を結び付けられません。

POST /v1/chat/completions HTTP/1.1
Host: localhost:11434
Content-Type: application/json
{
  "messages": [
    { "role": "user", "content": "What is the capital of Belgium?" },
    { "role": "assistant", "content": "The capital of Belgium is **Brussels**..." },
    { "role": "user", "content": "How many people live in that city?" }
  ],
  "model": "qwen3:8b"
}
 
HTTP/1.1 200 OK
Content-Type: application/json
{
  ...
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "As of 2023, the **Brussels-Capital Region** has a population
                    of approximately **1.2 million people**..."
      },
      ...
    }
  ],
  "usage": { "prompt_tokens": 134, "completion_tokens": 843, "total_tokens": 977 }
}

では、会話が長くなるにつれて計算のコストも増え続けるのかというと、これはもう少し微妙な問いで、ここでKVキャッシュが再登場します。ステートレスとは、履歴の再送がアプリケーションの仕事だという意味であって、サーバーが毎回それを計算し直すという意味ではありません。エンジンがその会話の以前のターンのKVキャッシュをまだ保持しており、再送されたプレフィックスがトークン単位で一致すれば、その分のプリフィルを飛ばして、ほぼ一直線にデコードへ進みます。自分の会話のキャッシュをすでに持つレプリカに着地したリクエストは、何も持たないところに着地したリクエストより、桁違いに安くサービングできることがあります。

バッチング:エンジンがスループットを稼ぐ仕組み

ここまでは1つのリクエストだけを追いかけてきました。しかし本番環境は、あっという間に1日数十万リクエストに達することがあります。高い稼働率を保ち、無駄を減らすために、エンジンは2つの戦略を採用しています。

  • 静的バッチングは、バッチが満杯になるまでリクエストを溜めてから、まとめて一度に処理します。スループットは素晴らしいものの、バッチ内の最初のリクエストは最後のリクエストが届くまで待たされ、そのぶんレイテンシが増えます。このトレードオフは、ドキュメントの要約、夜間の埋め込みジョブ、バッチ分類のような、誰も即時の応答を待っていないオフラインタスクにはよく合います。

  • 連続バッチングは、ほかのリクエストが終わり次第、新しいリクエストを実行中のバッチに合流させ、トークン単位で処理します。この方式はリクエストあたりのレイテンシを大きく減らし、チャットボットのような対話型ワークロードで重要になります。

可観測性と重要な4つの数字

可観測性の観点では、フェーズ、キャッシュ、バッチなど、ここまでのすべてが4つのメトリクスに表れます。

  • TTFT(time to first token)は、何かが表示されるまでにユーザーが待つ時間で、キュー待ちとプリフィルに支配されます。

  • TPOT(time per output token)は、生成が始まってからのペースで、デコードに支配されます。

  • 1秒あたりのトークン数はスループットです。リクエスト単位でも、フリート全体の合計でも見ます。

  • 1秒あたりのリクエスト数は、典型的なトークン数を把握したあとの、主にキャパシティプランニングで重要になります。

自分のインフラに対してモデルを手早くベンチマークするには、公式のvLLMベンチマーククライアントvllm bench serveを使う方法があります。OpenAI互換のエンドポイントであればどこにでも、制御された負荷をかけられます。

vllm bench serve \
  --model Qwen/Qwen3-4B-AWQ \
  --base-url http://localhost:8000 \
  --dataset-name random \
  --random-input-len 512 --random-output-len 128 \
  --num-prompts 64 --max-concurrency 8

そのレポートは、まさに上の4つの数字を軸に構成されています(ITLは、トークン間の間隔を1つずつ測ったTPOTです)。

================= Serving Benchmark Result =================
Successful requests:                     64        
Failed requests:                         0         
Maximum request concurrency:             8         
Benchmark duration (s):                  38.83     
Total input tokens:                      32707     
Total generated tokens:                  8192      
Request throughput (req/s):              1.65      
Output token throughput (tok/s):         210.95    
Peak output token throughput (tok/s):    392.00    
Peak concurrent requests:                16.00     
Total token throughput (tok/s):          1053.20   
--------------------Time to First Token---------------------
Mean TTFT (ms):                          1632.54   
Median TTFT (ms):                        1355.88   
P99 TTFT (ms):                           5376.73   
P90 TTFT (ms):                           4301.80   
----------Time per Output Token (excl. 1st token)-----------
Mean TPOT (ms):                          25.34     
Median TPOT (ms):                        24.11     
P99 TPOT (ms):                           40.39     
P90 TPOT (ms):                           32.41     
--------------------Inter-token Latency---------------------
Mean ITL (ms):                           25.36     
Median ITL (ms):                         20.61     
P99 ITL (ms):                            29.10     
P90 ITL (ms):                            21.39     
---------------------End-to-end Latency---------------------
Mean E2EL (ms):                          4851.09   
Median E2EL (ms):                        4414.25   
P99 E2EL (ms):                           7989.19   
P90 E2EL (ms):                           7964.52   
============================================================

同時実行数を増やしながら実行すると、連続バッチングの働きが見えてきます。合計の1秒あたりのトークン数が数倍に伸びる一方、個々のリクエストのTTFTとTPOTは緩やかにしか悪化しません。

バッチがハードウェアの容量を超えると、スループットは頭打ちになり、最初の犠牲者はTTFTです(リクエストがキューに積まれます)。LLMバックエンドで意味のあるルーティング信号がCPUではなくキューの深さである理由がここにあります。そして、これらのメトリクスが何を無視しているかにも注目してください。CPUとメモリの使用率、つまり既存のオートスケーリングが見張り方を知っている2つの数字です。この食い違いが壊すのはダッシュボードだけではありません。LLMトラフィックが標準的なKubernetesのロードバランシングとオートスケーリングを打ち破る理由については、別の記事を丸ごと充てています。

重み、量子化、そしてメモリの請求書

さて、予算の話です。これらすべてを保持するには何が必要でしょうか。重みについては、パラメータ数にバイト数を掛ければ専有量が出ます。しかし今日の大規模言語モデルでは、そのコストは相当なものです。16ビット精度では、70Bパラメータのモデルはトークンを1つも処理しないうちからおよそ141 GBを占有し、H100 GPUで使える80 GBをはるかに超えます。つまり課題は、需要に合わせたスケーリングではなく、そもそもモデルをメモリに収めることなのです。ここで量子化の出番です。量子化は重みをより低い精度で保存してバイト数とその分のメモリを減らします。その精度面の代償は、現代の4ビット手法では驚くほど小さく抑えられています。

KVキャッシュは、重みに加えてGPUメモリを消費し、同時進行中のすべての会話のトークン1つごとに増えていきます。エンジンはそのためにVRAMの大きな割合を確保します。たとえばvLLMは、GPUメモリ全体に対する設定可能な割合(v0.20以降はデフォルト92%、それ以前は90%)として定義された予算の中で動作します。重みをロードし、アクティベーションのオーバーヘッドを測るプロファイリング用のフォワードパスを実行し、予算の残り全部をKVキャッシュ用の領域として事前に確保するのです。KVの余裕が大きいほどバッチは大きくなりスループットは向上し、小さいほどキュー入りが早まります。その余裕がどれだけあるかを、この1つの数字が決めます。

次の表は、代表的な4つのモデルについて両方の計算を行ったものです。各モデルのレイヤー数、KVヘッド数、ヘッド次元はそれぞれのconfig.jsonから取っています(トークンあたりのKVキャッシュ = 2 × レイヤー数 × KVヘッド数 × ヘッド次元 × バイト数)。

Llama-3.1-8B(dense)16 GB8 GB4 GB128 KB16 GB
Llama-3.3-70B(dense)141 GB71 GB35 GB320 KB40 GB
Qwen3-235B-A22B(MoE)470 GB235 GB118 GB188 KB23.5 GB
Kimi K2.5(MoE)2,080 GB*1,040 GB520 GB~69 KB(MLA)†~8.6 GB†

† Kimi K2.5はMulti-head Latent Attention(マルチヘッド潜在アテンション)を使っています(DeepSeek-V3の系譜から受け継いだものです)。

実践的な提案

トークンのコスト、利用上限、コンプライアンス要件などの理由で自社インフラでのモデル運用を考えているなら、GPUを買う前にノートPCで試してみてください。qwen3:8bやllama3.2:1bのような小さなモデルをOllamaでダウンロードします。次にOllamaでサービングし、vllm bench serveをそこに向けて、--max-concurrencyを1から32まで徐々に上げながら、手元のハードウェアが負荷をどうさばくかを観察します。こうすると、TTFT、TPOT、KVキャッシュをはじめとする重要な推論の統計値に慣れることができます。この実験は無料で、仕組みを学びながらよくある失敗を避ける助けになります。

FAQ

LLMのサービングについて、繰り返し寄せられる4つの質問。

モデルをサービングするにはMLエンジニアが必要ですか?

モデルをサービングするだけならMLエンジニアは必要ありません。推論は主に、メモリ、キュー、バッチング、ルーティングといったインフラの問題です。MLの専門知識が必要なのはモデルの学習と評価であって、モデルを動かすことではありません。

より大きなGPUにすれば推論は速くなりますか?

計算律速(compute-bound)の場合だけです。通常、それはプリフィルの比重が大きいワークロードを意味します。デコードはメモリ帯域幅律速なので、FLOPSを増やしても何も変わらないことがよくあります。購入する前にTTFTとTPOTを測定してください。

Ollamaを本番環境で使えますか?

Ollamaは、ノートPCやエッジデバイス、そしてこの記事で扱ったすべての実験にはとてもよく合います。ただし、マルチGPUや高い同時実行数でのサービングを想定した設計ではありません。本番用途では、代わりにvLLMかSGLangを検討してください。

モデルにはどれくらいのGPUメモリが必要ですか?

GPUメモリの必要量を見積もるには、重みについてパラメータ数にパラメータあたりのバイト数を掛け、そこに同時実行のコンテキストに応じて増えるKVキャッシュを足します。

このサイトは分析のためにCookieを使用しています。