私は大学病院のリサーチラボを率いており、ここ数週間、社内のLLMサーバを構成してきました。サーバの設定、ソフトウェアスタック、モデルについて多くのことを考えました。現在は、負荷がかかってもきちんと耐えられる状態になっていて、2台のH200でGPT-OSS-120Bを提供し、1日あたり1Bトークン以上(おおよそ2/3がイングェスト、1/3がデコード)を処理しています。これと同じようなことをしようとしている方にとって面白いかもしれないと思い、またフィードバックも期待しています。以下にソフトウェアスタックを共有し、あわせて私がGPT-OSS-120Bを選んだ理由についても考慮点を挙げます。
免責事項 この記事を書くのにClaudeを使いました。
Hardware
サーバにはH200 GPUが2枚あります。ほかはそれほど強力ではなく、124GB RAM、16コアCPU、512GBのディスク容量です。モデル、Dockerイメージ、ログを保持するには十分です。
Model
私は数週間前にいくつかのモデルを試しました。Qwen 3、GLM-Air、GPT-OSSです。GPT-OSS-120Bが私たちにとって最も良いようでした:
- スループットが重要です。大量のデータを処理する複数のジョブがあるためです。GPT-OSSの単一ユーザのデコードでは最大で約~250 tok/s(主に~220 tok/s)に到達します。私が試した他のモデルは最大で~150 tok/s程度でした。唯一GPT-OSS-20Bはより速く、~300 tok/sでしたが、120Bとの差はそれほど大きくありませんでした。残念ながら20Bモデルは120Bよりかなり賢くありません。
- モデルはかなり賢いです。臨床での構造化に十分で、JSON出力にもよく従い、ツール呼び出しを確実に行います。それでもバカなミスはしますが、少なくともそうしたミスを非常に速く行います。
- GPT-OSS-120Bの公開評価(eval)をより信頼しています。というのも、デプロイされる重みが評価された重みだからです(mxfp4で学習されました)。コミュニティの量子化版だと、主張されている性能が本当の性能なのかが常に少し不確かだと思います。そのため、モデル間の比較が難しくなります。
- mxfp4がvLLMとホッパーGPUでとてもよくサポートされているようです。
H200でより悪かった試したこと:
- nvfp4/GGUF → ~100-150 tok/s(単一ユーザ)
- GPT-OSS-120B向けのスペキュラティブデコーディング → ~150 tok/s(ドラフトモデルのオーバーヘッドが、この構成では効いてしまいました)
H200でのmxfp4は、今のところ非常にうまく最適化されているように感じます。それでも私は、より良い性能のモデルを常に探しています。現在注目しているのはMistral Small 4(vision、こちらも120B)、Qwen 3.5、Gemma 4です。ただし、Gemmaは密なモデルなので、スループットで同等を満たせるか疑っています。また、小さめのMoEモデルが120Bモデルと同じくらい賢いとは信じていません。Qwenモデルについても同様です。現状では、需要が高すぎて、より多くのモデルを適切にテストするためにGPT-OSSをオフラインにできなくなっています。しかし、ハードウェアをスケールできるようになれば、すぐに試してみたいです。
Architecture
私は大きめのdocker composeで、全部をdockerで行っています(以下参照)。
Client → LiteLLM proxy (4000) → vLLM GPU 0 (8000) → vLLM GPU 1 (8000) ↓ PostgreSQL (keys, usage, spend) Prometheus (scrapes vLLM /metrics every 5s) Grafana (dashboards) MkDocs (user docs)
- vLLMが実際の提供を行います(GPUごとに1つのコンテナ)。
- OpenAI互換API向けのLiteLLM:キー、レート制限、優先キュー、ルーティングを扱います。
- usageデータを保存するためのPostgres
- 見栄えの良いダッシュボードのためのPrometheus + Grafana
私は、両GPUに対するテンソル並列よりも、GPUごとに1インスタンスを選びました。これは、このモデルサイズでmxfp4を使うと、単一のH200に十分収まるためです。さらに独立したレプリカを2つ用意すると、より良いスループットが得られ、NCCLの通信オーバーヘッドが発生しません。KVキャッシュも、私たちのケースではボトルネックではありません。simple-shuffleのルーティングを使うと、負荷分割はほぼ完璧です(稼働約6日後で2.10B対2.11Bのプロンプトトークン)。他のルーティング戦略ではうまくいきませんでした(litellmもドキュメントでsimple-shuffleを推奨しています)。
vLLM
--quantization mxfp4 --max-model-len 128000 --gpu-memory-utilization 0.80 --max-num-batched-tokens 8192 --enable-chunked-prefill --enable-prefix-caching --max-num-seqs 128
加えて環境変数:
VLLM_USE_FLASHINFER_MXFP4_MOE=1 NCCL_P2P_DISABLE=1
詳細について:
VLLM_USE_FLASHINFER_MXFP4_MOE=1 はH200上でこのモデルを動かすのに必要でした。
NCCL_P2P_DISABLE=1 は、各コンテナがGPUを1枚ずつしか見ていないにもかかわらず必要です。記憶が正しければ、これなしだとNCCLが暗号のような分かりにくいエラーを吐きます。
TIKTOKEN_RS_CACHE_DIR=/root/.cache/tiktoken 通常はコンテナがtiktokenをダウンロードするはずだと思いますが、社内のファイアウォールの内側にいるためウェブに接続できません。そのためトークナイザを手動で用意する必要があります。
--enable-prefix-caching 私たちは非常に似たシステムプロンプトを大量に送っています(テンプレート化された構造化タスク、エージェントの土台など)。キャッシュヒット率が高いので、この設定によりTTFTが下がります。
--max-num-seqs 128 はインスタンスあたりなので、箱全体で並行シーケンスは256になります。KVキャッシュは私たちのケースでは滅多にボトルネックになりません(Grafanaでは通常25〜30%で、バースト時にたまに90%へ跳ねることがあります)。実際の天井はデコードスループットです。max-num-seqsをさらに高くしても、各ストリームが遅くなるだけで、本当の意味での余裕(ヘッドルーム)を買えるわけではありません。512の並行リクエストまで試しましたが、デコード速度は3000 token/sを超えることはありませんでした。代わりに、個々の応答が遅くなるだけでした。
gpu-memory-utilization 0.80 と--max-num-batched-tokens 8192(現在は使っていませんが必要なら差し替えます)は、両方ともログプロブ(logprobs)リクエストのために設定しています。vllmサーバが謎のクラッシュを何度か起こした後、分かってきたのですが、クライアントが長いコンテキストでtop-kのlogprobsを要求すると、vLLMが急速に増えるメモリの塊を実体化し、その結果GPUがOOMになってサーバがクラッシュします。バッチトークンを8kに上限を設け、VRAMを20%分空けておくことで、そのようなスパイクを吸収しつつ、定常時のスループットを損なわずに済みます。--max-num-batched-tokens 8192 は、8192トークン分のlogprobsを一度に計算するだけなので、バーストサイズを制限します。KVキャッシュが制限要因にならないため、私はgpu-memを常に0.8に保っています。
ヘルスチェック start_period: 900s 。120B MoEをコールドスタートから起動するのに10〜15分かかります。これより短いと、LiteLLMが非健全なアップストリームについてログを大量に吐きます。
docker-compose (vLLM + LiteLLM)
vllmとlitellmだけに絞りました。Postgres、Prometheus、Grafanaは除外していますが、これらは標準的です。
```yaml services: vllm-gpt-oss-120b: image: vllm/vllm-openai:latest container_name: vllm-gpt-oss-120b environment: - VLLM_USE_FLASHINFER_MXFP4_MOE=1 - NCCL_P2P_DISABLE=1 - TIKTOKEN_RS_CACHE_DIR=/root/.cache/tiktoken volumes: - /srv/cache/tiktoken:/root/.cache/tiktoken:ro - /srv/models/gpt-oss-120b:/models/gpt-oss-120b expose: - "8000" ipc: host deploy: resources: reservations: devices: - driver: nvidia device_ids: ['0'] capabilities: [gpu] healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"] interval: 30s timeout: 5s retries: 20 start_period: 900s command: > /models/gpt-oss-120b --served-model-name gpt-oss-120b --quantization mxfp4 --max-model-len 128000 --gpu-memory-utilization 0.80 --enable-chunked-prefill --enable-prefix-caching --max-num-seqs 128
--max-num-batched-tokens 8192
vllm-gpt-oss-120b_2: image: vllm/vllm-openai:latest container_name: vllm-gpt-oss-120b_2 environment: - VLLM_USE_FLASHINFER_MXFP4_MOE=1 - NCCL_P2P_DISABLE=1 - TIKTOKEN_RS_CACHE_DIR=/root/.cache/tiktoken volumes: - /srv/cache/tiktoken:/root/.cache/tiktoken:ro - /srv/models/gpt-oss-120b:/models/gpt-oss-120b expose: - "8000" ipc: host deploy: resources: reservations: devices: - driver: nvidia device_ids: ['1'] capabilities: [gpu] healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"] interval: 30s timeout: 5s retries: 20 start_period: 900s command: > /models/gpt-oss-120b --served-model-name gpt-oss-120b_2 --quantization mxfp4 --max-model-len 128000 --gpu-memory-utilization 0.80 --enable-chunked-prefill --enable-prefix-caching --max-num-seqs 128
--max-num-batched-tokens 8192
litellm: image: ghcr.io/berriai/litellm:main-latest container_name: litellm-proxy ports: - "4000:4000" volumes: - ./litellm_config.yaml:/app/config.yaml environment: - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY} - DATABASE_URL=postgresql://litellm:${POSTGRES_PASSWORD}@postgres:5432/litellm command: > --config /app/config.yaml --port 4000 --num_workers 4 depends_on: vllm-gpt-oss-120b: condition: service_healthy vllm-gpt-oss-120b_2: condition: service_healthy postgres: condition: service_healthy redis: condition: service_healthy ```
2つ目のレプリカにおける提供(served)モデル名は、意図的にgpt-oss-120b_2(gpt-oss-120bではありません)にしています。LiteLLMの上流(upstream)のmodelフィールドは、公開向けの名前が同じでも、区別する必要があるためです。
LiteLLM config
```yaml model_list: - model_name: gpt-oss-120b litellm_params: model: openai/gpt-oss-120b api_base: http://vllm-gpt-oss-120b:8000/v1 api_key: "EMPTY" timeout: 600 stream_timeout: 60
- model_name: gpt-oss-120b litellm_params: model: openai/gpt-oss-120b_2 api_base: http://vllm-gpt-oss-120b_2:8000/v1 api_key: "EMPTY" timeout: 600 stream_timeout: 60
router_settings: routing_strategy: "simple-shuffle" # 高負荷時はこれが最良でした。「least-busy」なども試しましたが、うまくいきませんでした。 cooldown_time: 5 # 多数のリクエストが失敗した場合、すぐにvllmインスタンスを復帰させます。失敗の原因はvllm側のレート制限である可能性があるため、これは本当に必要なクールダウンではありません enable_priority_queue: true redis_host: "litellm-redis" redis_port: 6379
litellm_settings: cache: false max_parallel_requests: 196 request_timeout: 600 num_retries: 20 allowed_fails: 200 drop_params: true # どうやらClaude Codeとの互換性のため。未検証です。 ```
同じmodel_nameを持つモデルエントリを2つ用意することで、LiteLLMがそれらに負荷分散するようになります。どうやらこれはネイティブに行えます。設定は不要です。
約6日間稼働後の数字
| 指標 | 値 |
|---|---|
| 処理済みトークン総数 | 6.57B |
| プロンプトトークン | 4.20B |
| 生成トークン | 2.36B |
| 入力:出力比 | 1.78:1 |
| 総リクエスト数 | 2.76M |
| 1リクエストあたりの平均トークン数 | ~2,380 |
スループット
| 1分あたりのレート | 1時間あたりの平均 | |
|---|---|---|
| 生成tok/s | 2,879 | 2,753 |
| プロンプトtok/s | 24,782 | 21,472 |
| 合計tok/s | 27,661 | 24,225 |
インスタンスごとの負荷分割
| インスタンス | プロンプト | 生成 |
|---|---|---|
| GPU 0 | 2.10B | 1.18B |
| GPU 1 | 2.11B | 1.19B |
高負荷時のレイテンシ
これは、稼働中173件でキュー待ち29件の時点で取得しました。
| p50 | p95 | p99 | |
|---|---|---|---|
| TTFT | 17.8s | 37.8s | 39.6s |
| E2E | 41.3s | 175.3s | 750.7s |
| ITL | 35ms | 263ms | — |
| キュー待ち | 18.7s | 29.4s | — |
TTFTはキュー時間に支配されています(p50のキュー 18.7s vs p50のTTFT 17.8s)。負荷が軽い場合はTTFTは数秒台になります。E2Eのp99が750sなのは、100kコンテキストから4k以上のトークンを生成する1ユーザーによるもので、これは問題なく、想定通りです。ただ、現在の一つの課題は「ping pong効果」で、下で詳しく説明します。
ITLのp50が35msということは、この筐体が満杯のとき、各個別のストリームで約28 tok/sが見えていることを意味します。これはおそらく多くのインタラクティブ用途では問題ないでしょう。
コストの計測
LiteLLMは、設定された1トークンあたりのレートに対して「相当コスト(equivalent spend)」を追跡します。私はこれをAmazon Bedrock上のGPT-OSS-120Bの料金($0.15/M in、$0.60/M out)に設定しました。過去7日間の仮想的なコストは$1,909 USDです。H200は1台あたり約25kの費用なので、サーバは基本的に1年で元が取れます。
まだ不満な点
1つのvLLMレプリカが、あるウィンドウ内であまりにも多くのエラーを返すと、LiteLLMはそれをクールダウンします。その次のレプリカが全負荷を引き受け、倍のプレッシャーでエラーを出し始め、そしてまたクールダウンされます。その間に最初のレプリカは復帰しますが、今度はバーストを受けて再びエラーを出し始めます。結果として、プロキシ全体は実質的に容量が50%しか使えていない状態になります。にもかかわらず、両方のGPUは完全に正常です。私はcooldown_time、allowed_fails、num_retriesをいろいろ調整しましたが、このping pong効果なしで負荷をうまく分散できる設定を見つけられませんでした。
必要であれば、prometheus.yml、GrafanaのダッシュボードJSON、またはメトリクス収集スクリプトを共有できます。さらに、同程度の規模の構成を動かしている他の方々が、アドミッション制御やリトライ処理に何を使っているのかもとても気になります。そこが、私がまだ残している余地(headroom)を一番感じている部分だからです。