TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

LLM サービングを OpenTelemetry で観測する: vLLM が実際に出力するシグナル、標準との距離、コレクターでの補完

ステータス: ドラフト(2026-10-07、2026-10-09 コレクターコストの実測値補強)。 この記事の数値はすべて筆者が直接測定した値です。環境は 8GB 級 GPU 1枚、vLLM 0.31.0、Qwen3-1.7B、OpenTelemetry Collector contrib 0.162.0、サーバー 1台です。GenAI セマンティック規約はまだ 開発(Development)段階 であるため名前が変更されている最中であり(この記事を書いている日にもメトリクス名が1つ変更されました)、SGLang と Dynamo のトレーシング、トレースストレージ(Tempo、Jaeger など)およびメトリクスストレージは 未実測 です。 作成ルール: 実測データが基本であり、測定できなかったものは「未実測」と表記します。

目次

LLM サーバーが遅くなったときに知りたいことは大抵3つあります。リクエストがキューでどれくらい待ったか、計算にどれくらいかかったか、このリクエストがどのサービスのどの呼び出しから始まったか。OpenTelemetry(OTel)はこれら3つを同じ方法で残すための標準であり、試験(OTCA)で問われるコレクター、サンプリング、セマンティック規約、コンテキスト伝播はすべてここで使用されます。この記事では vLLM 単体に実際に接続してみて、5つの疑問に答えます。

  1. vLLM は1つのリクエストにどのようなスパンと属性を残し、メトリクスはどのような名前で出力されるのか。
  2. クライアントから始まったトレースが vLLM までつながるのか。
  3. 有効化のコストはどれくらいか。
  4. 標準(GenAI セマンティック規約)とどれくらい異なり、コレクターで補完できるのか。
  5. 遅いリクエストのみを残すサンプリングはどのように動作し、確率サンプリングと何が異なるのか。

簡単な答え。 リクエストごとに llm_request スパンが 1つ traceparent に続いて残り、その属性だけでも初トークン時間を待ち時間と計算時間に切り分けることができました。トレーシングを有効化するコストは誤差の範囲内でした。属性名は標準と異なっていましたが(prompt_tokens 対 input_tokens)、コレクターの変換プロセッサーで変更することができました。遅いリクエストのみを残すテールサンプリングは遅いリクエスト 19件をすべて残し、確率サンプリング 10% は遅いリクエスト 22件のうち 1件だけを残しました。

1. 構成

클라이언트(계측, traceparent 전송) ──HTTP──▶ vLLM(--otlp-traces-endpoint) ──OTLP/gRPC──▶ OTel Collector ─▶ 파일(JSON)
                                              └─ /metrics(Prometheus) ◀──스크레이프── Collector(prometheus 수신기)

2. vLLM が出力するシグナル

2.1 トレース: リクエストごとに llm_request 1つ

クライアントが親スパンを作成し、W3C traceparent ヘッダーを付与してリクエストを3つ送信しました。コレクターに到着した vLLM スパンは3つすべて llm_request(種類 SERVER)であり、 親スパン ID がクライアントスパンの ID と同じであり 、トレース ID も同じでした。つまり、クライアントから始まったトレースが vLLM の内部までつながっていました。

1つのスパンの属性は12個であり、設定を変更しても同じ12個でした。

属性 値の例 意味
gen_ai.latency.time_to_first_token 0.0195(秒) リクエストが入ってから初トークンが出力されるまで
gen_ai.latency.time_in_queue 0.00002(秒) キューで待機した時間
gen_ai.latency.time_in_model_prefill 0.0176 プレフィル(入力処理)
gen_ai.latency.time_in_model_decode 0.4504 デコード(トークン生成)
gen_ai.latency.time_in_model_inference 0.4680 モデルが処理した全体
gen_ai.latency.e2e 0.4697 リクエスト全体
gen_ai.usage.prompt_tokens, gen_ai.usage.completion_tokens 10, 32 入力、出力トークン数
gen_ai.request.id, max_tokens, n, top_p cmpl-…, 32, 1, 1 リクエスト情報

含まれていないことも重要です。モデル名(ズバリgen_ai.request.model)と gen_ai.operation.name がこのスパンに存在せず、リソースの service.name は unknown_service でした。サービス名を指定しないと、複数のサーバーのスパンが1つの名前の下に混ざってしまいます。環境変数 OTEL_SERVICE_NAME で指定するか、コレクターの resource プロセッサーで service.name を埋めることができます(セクション 4 では後者を使用しました)。

サーバーを起動するプロセスもトレースとして残ります。 llm_request とは別に、起動時の数十個のスパンが1つのトレースとして出力されました。モデルファイルがすでに存在するサーバー4台の平均として、 Overall Loading が 18.7秒、 EngineCoreProc init が 14.1秒、 Prepare model が 6.7秒、 Executor init が 6.1秒、 Load model が 3.4秒、 Warmup (GPU) が 2.7秒、 Load weights が 1.8秒でした(スパンが互いに親子として含まれているため、単純に足し算してはいけません)。最初に起動したサーバーは重みのダウンロードに298秒かかり、 Overall Loading は 341秒でした。「サーバーがなぜ起動に時間がかかったのか」をログを探し回ることなくスパンの時間から読み取ることができます。

2.2 メトリクス: OTLP ではなく Prometheus /metrics

このバージョンでは、メトリクスは /metrics(Prometheus 形式)でのみ確認できました。コレクターの prometheus レシーバーで 5秒ごとに取得すると 93個の名前が出力され、有用なものは以下の通りです(すべて vllm: プレフィックス)。

メトリクス 種類 読み方
time_to_first_token_seconds ヒストグラム 初トークンまで
inter_token_latency_seconds ヒストグラム トークン間の間隔
e2e_request_latency_seconds ヒストグラム リクエスト全体
request_queue_time_seconds, request_prefill_time_seconds, request_decode_time_seconds ヒストグラム 段階別の時間
num_requests_running, num_requests_waiting ゲージ 現在実行中および待機中のもの
kv_cache_usage_perc ゲージ KV キャッシュ使用率
prefix_cache_queries_total, prefix_cache_hits_total 合計 プレフィックスキャッシュヒット
prompt_tokens_total, generation_tokens_total 合計 トークン処理スループット
num_preemptions_total 合計 プリエンプション(KV 不足)

3つの事柄を確認しました。

3. 有効化のコスト

同じ負荷(入力 512、出力 128、同時実行 1・8・32)を3つの設定でサーバーを2回ずつ再起動して測定しました。サーバーを起動するたびに KV キャッシュの割り当てが変わる可能性があるため(パート 14)サーバーログを確認し、6つのサーバーすべてで 26,560トークンでした。

設定 同時実行 1 スループット / TTFT 同時実行 8 同時実行 32
無効 66 tok/s / 73ms, 73ms 384, 384 tok/s / 349, 353ms 833, 831 tok/s / 670, 669ms
--otlp-traces-endpoint 66, 66 / 73, 73ms 385, 384 / 350, 348ms 832, 832 / 668, 668ms
上記 + --collect-detailed-traces all 66, 66 / 74, 73ms 385, 384 / 349, 351ms 832, 834 / 667, 667ms

4. 標準との距離、そしてコレクターでの補完

OpenTelemetry の GenAI セマンティック規約は別のリポジトリ(semantic-conventions-genai)に移行され、ステータスは 開発 です。確認した時点(2026-10-06 の最新コミット)の内容と vLLM が出力したものを比較すると以下の通りです。

項目 GenAI 規約 vLLM 0.31.0
推論スパン 名前 gen_ai.client.inference タイプ、スパン名 {operation} {model}、必須属性 gen_ai.operation.name, gen_ai.provider.name llm_request、サーバーサイドスパン、上記の必須属性なし
トークン数 gen_ai.usage.input_tokens, gen_ai.usage.output_tokens gen_ai.usage.prompt_tokens, gen_ai.usage.completion_tokens(旧名称)
モデル名 gen_ai.request.model スパンにはなし、メトリクスには model_name ラベル
初トークン時間 サーバーメトリクス gen_ai.server.time_to_first_token(ヒストグラム、秒) スパン属性 gen_ai.latency.time_to_first_token(独自の名前)、メトリクス vllm:time_to_first_token_seconds
トークン間隔、リクエスト時間 gen_ai.server.time_per_output_token, gen_ai.server.request.duration vllm:inter_token_latency_seconds, vllm:e2e_request_latency_seconds

標準がまだ開発段階であるため、どちらかが「間違っている」というよりは、vLLM が標準が固まる前の名前を使用している状態です。利用側としては、コレクターに翻訳させればサーバーを変更する必要はありません。以下の設定を実際に動かして確認しました。

processors:
  resource/service:
    attributes:
      - {key: service.name, value: vllm, action: upsert}
  transform/genai:
    trace_statements:
      - context: span
        statements:
          - set(attributes["gen_ai.usage.input_tokens"], attributes["gen_ai.usage.prompt_tokens"]) where attributes["gen_ai.usage.prompt_tokens"] != nil
          - set(attributes["gen_ai.usage.output_tokens"], attributes["gen_ai.usage.completion_tokens"]) where attributes["gen_ai.usage.completion_tokens"] != nil
          - delete_key(attributes, "gen_ai.usage.prompt_tokens")
          - delete_key(attributes, "gen_ai.usage.completion_tokens")
  metrics_transform/genai:
    transforms:
      - {include: "vllm:time_to_first_token_seconds", action: update, new_name: gen_ai.server.time_to_first_token}
      - {include: "vllm:inter_token_latency_seconds", action: update, new_name: gen_ai.server.time_per_output_token}
      - {include: "vllm:e2e_request_latency_seconds", action: update, new_name: gen_ai.server.request.duration}

その結果、スパンのサービス名が vllm になり、トークン数の属性が input_tokens, output_tokens に変更され(旧名称は消滅)、メトリクス名 3つが gen_ai.server.* に変更されました。ただし、値の 意味 まで標準と同じであるかは名前だけでは分かりません。例えば、標準の初トークン時間は「成功したレスポンスの初トークンまで」であり、vLLM の値が同じ区間であるかは確認していません(未実測)。単位も先ほど見たように空であるため、別途補う必要があります。

5. 遅いリクエストをどのように見つけるか

5.1 スパン属性だけで初トークン時間を切り分ける

サーバー 4台(トレーシング有効 2、詳細有効 2)で収集したリクエストスパン 500個のうち、初トークンに 1秒以上かかったリクエスト 69個と 0.2秒未満の 38個を比較しました。

リクエスト 初トークン平均 待ち時間 プレフィル
1秒以上かかった 69個 1,494ms 886ms 491ms
0.2秒未満 38個 74ms 0ms 65ms

遅いリクエストの初トークン時間のうち、待ち時間が占める割合の中央値は 59% でした。遅いリクエストは計算が遅いからではなく、行列に並んだために遅くなっており、これは「GPU をより速いものにする」のではなく「同時処理の限界とレプリカ数を増やすか、リクエストを制限する」という対策につながります。初トークン時間が待ち時間とプレフィラの和と完全に一致するわけではなく、差の中央値は 78ms、最大 221ms でした(トークナイゼーションや最初のデコード段階などと推測されますが、分解はしていません)。リクエストの全体時間は、初トークン時間とデコード時間の和と 1.1ms 以内で一致していました。

5.2 テールサンプリング vs 確率サンプリング

混雑負荷(入力長が64、512、1,500トークン、同時16)でリクエスト120件を送信しました。クライアントで測定したリクエスト時間は中央値2.08秒、p90 2.73秒、最大3.02秒であり、サーバ側のスパン時間で2.5秒以上のリクエストが19件ありました。

ポリシー 残ったトレース 遅い(2.5秒以上)リクエスト
すべて保持(変換のみ) 120 19
テールサンプリング、 latency ポリシー 2,500ms以上 19(15.8%) 19(すべて)
確率サンプリング 10%(リクエスト300件の負荷) 39(13%) 22個中1個

6. 発生した問題

7. OTCAの範囲と関連リンク

この実験がテスト範囲のどの項目を直接示しているのかを整理します。

テスト範囲 この記事で確認した内容
コンテキスト伝播 traceparent によりクライアントスパンとvLLMスパンが親子としてつながる
リソースとセマンティック規約 service.name がない場合、 unknown_service、標準名とサーバー名の違い
コレクターパイプライン レシーバー(otlp、prometheus)、プロセッサ(resource、transform、metrics_transform、tail_sampling、batch)、エクスポーター(file)とプロセッサの順序
サンプリング 確率サンプリングとテールサンプリングが遅いリクエストを残す度合いの違い
指標 累積ヒストグラム、単位の欠落、ゲージと合計

LabHubのOTCAコースには、この内容を実際のスパンとヒストグラムで直接計算してみるモジュール(otca-llm-serving、実習7段階)を追加しました。デプロイが完了した環境で表示されます。

8. 制限事項と未計測事項

参考資料

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます