TL;DR
- Lenovo ThinkStation PGX(NVIDIA GB10、統合メモリ119GiB)を2台、ConnectX-7 200GbEで直結し、vLLMのマルチノードTP=2でDeepSeek-V4-Flash(FP8 ~150GB、MIT)を稼働させた
- コミュニティのdual-Spark 1Mレシピで コンテキスト1M(KVキャッシュ実測284万トークン) を実現
- LiteLLMのAnthropic互換エンドポイント経由で Claude Codeのバックエンド として使えるようにした
- Claude Code CLIは未知モデルを一律200Kコンテキスト扱いする。モデル名に
[1m]サフィックスを付けると1M扱いになる(実測28.6万トークンのリクエストで検証済み) - 実測値: デコード32.3 tok/s(単一ストリーム)、プリフィル~1,850 tok/s、NCCL間帯域110 Gbit/s
構成
各ノードは以下の構成です。
| 項目 | 値 |
|---|---|
| 筐体 | Lenovo ThinkStation PGX |
| SoC | NVIDIA GB10(Grace Blackwell、sm_121) |
| CPU | 20コア Arm |
| メモリ | 119GiB 統合メモリ(CPU/GPU共有、VRAM区別なし) |
| ストレージ | NVMe 3.6TB |
| OS | Ubuntu 24.04 arm64 |
| インターコネクト | ConnectX-7 200GbE ×2ポート(直結) |
GB10はDGX Sparkと同系のチップなので、DGX Spark 2台構成のレシピがほぼそのまま流用できます。本記事も以下の2つのコミュニティレシピをサイト事情に合わせて改変したものです。
- tonyd2wild氏のDeepSeek-V4-Flash dual-Sparkレシピ(基本構成)
- MiaAI-Labのdual-Spark 1Mコンテキストレシピ(1M化)
下準備
カーネル・ドライバの完全一致
マルチノードのvLLM(TP=2)では、2台のカーネルとドライバのバージョンをビット単位で一致させるのが前提条件です。今回は片方が 6.17.0-1021、もう片方が 1026 とズレていたため、先に両機を揃えました。
sudo apt full-upgrade && sudo reboot
# → 両機とも 6.17.0-1029-nvidia + driver 580.173.02 に統一
メモリの確保
統合メモリ機なので「GPUメモリ」はそのままシステムメモリと取り合いになります。既存で動かしていたサービス(embedding/reranker/評価用モデル)の --gpu-memory-utilization を削り、モデル本体+KVキャッシュの領地を確保しました。
- pgx01: 使用100GiB → 17GiB(embed/rerankを0.6Bに縮小、4B/8B級を停止)
- pgx02: 使用84GiB → 36GiB(常駐judgeモデルを停止)
200GbE直結リンクの設定
ConnectX-7は物理的に直結済み・RoCE ACTIVEでしたが、IPv4が未設定でした。NCCLをRoCEv2で使うにはケーブルが刺さっているポートにIPv4アドレスが必要です。netplanで固定します。
# /etc/netplan/60-cluster.yaml (pgx01側。pgx02は 10.10.0.2)
network:
version: 2
ethernets:
enp1s0f1np1:
addresses: [10.10.0.1/24]
mtu: 9000
ポイント:
- MTU 9000は両端で一致させる
- NCCLに渡すGIDは
NCCL_IB_GID_INDEX=3(RoCEv2/IPv4)。show_gidsで確認できる - レシピはf0ポート前提だったが、うちはf1側にケーブルが刺さっていたので
rocep1s0f1/enp1s0f1np1に読み替え
疎通確認の実測値:
| テスト | 結果 |
|---|---|
| iperf3(TCP×4本) | 92 Gbps |
| NCCL all_reduce(PyTorch distributed) | 110.5 Gbit/s |
TCPより速いのはRDMAが効いている証拠です。ここで帯域が1桁Gbpsしか出ない場合は、GID index(RoCEv1を掴んでいる)かMTU不一致を疑ってください。
vLLMマルチノード起動
2ノードTP=2の起動スクリプトです(長いので要点のみ。全文は末尾の補足に)。
#!/bin/bash
# ds4-launch.sh <node-rank> # 0 = head (pgx01), 1 = worker (pgx02)
RANK="${1:?usage: ds4-launch.sh <0|1>}"
MASTER=10.10.0.1
EXTRA=()
[ "$RANK" = "1" ] && EXTRA+=(--headless)
docker run --gpus all -d --privileged \
--network host --ipc host --shm-size 10g \
--ulimit memlock=-1 \
--device /dev/infiniband:/dev/infiniband \
-v /data/hf-cache:/cache/huggingface \
-e HF_HOME=/cache/huggingface -e HF_HUB_OFFLINE=1 \
-e NCCL_IB_DISABLE=0 \
-e NCCL_IB_HCA=rocep1s0f1 \
-e NCCL_IB_GID_INDEX=3 \
-e NCCL_SOCKET_IFNAME=enp1s0f1np1 \
-e GLOO_SOCKET_IFNAME=enp1s0f1np1 \
-e TP_SOCKET_IFNAME=enp1s0f1np1 \
-e NCCL_CUMEM_ENABLE=0 -e NCCL_CROSS_NIC=0 \
-e TORCH_CUDA_ARCH_LIST=12.1a \
-e VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 \
-e VLLM_USE_B12X_MOE=1 \
-e VLLM_SPARSE_INDEXER_MAX_LOGITS_MB=256 \
--name vllm-ds4 \
aidendle94/sparkrun-vllm-ds4-gb10:production-ready \
dsv4-vllm-entrypoint serve deepseek-ai/DeepSeek-V4-Flash \
--served-model-name deepseek-v4-flash \
--host 0.0.0.0 --port 8000 \
--tensor-parallel-size 2 \
--kv-cache-dtype fp8 \
--block-size 256 \
--max-model-len 1000000 \
--max-num-seqs 6 \
--max-num-batched-tokens 8192 \
--gpu-memory-utilization 0.83 \
--enable-prefix-caching \
--speculative-config '{"method":"mtp","num_speculative_tokens":2}' \
--tokenizer-mode deepseek_v4 \
--tool-call-parser deepseek_v4 --enable-auto-tool-choice \
--reasoning-parser deepseek_v4 \
--distributed-executor-backend mp \
--nnodes 2 --node-rank "$RANK" \
--master-addr "$MASTER" --master-port 29501 \
"${EXTRA[@]}"
最大のハマりどころ: 起動順序
workerを先に起動し、その後10分以内にheadを起動します。逆にすると、workerがrendezvous(--master-port 29501)に600秒で接続できず DistNetworkError で死にます。実際に一度この順序を間違えてworkerを看取りました。
# pgx02(worker)で先に
bash ~/ds4-launch.sh 1
# その後すぐpgx01(head)で
bash ~/ds4-launch.sh 0
# 起動完了まで約8分(torch.compileコンパイル込み)
1Mコンテキストが載る理由
最初は --max-model-len 300000 の保守的な構成で組みました。素朴なMLA KVキャッシュの計算では、重み~150GB(TP=2で各ノード~75GB)を引いた残りにKVが644Kトークン分しか入らず、1Mは物理的に不可能に見えたためです。
その後MiaAI-Labの1Mレシピに更新したところ、DeepSeek-V4-Flashのsparse attention(sparse indexer)によりKVの単価が激減し、起動ログで以下が確認できました。
GPU KV cache size: 2,844,499 tokens
Maximum concurrency for 1,000,000 tokens per request: 2.84x
1Mトークンのリクエストを2.84並列まで受けられる計算です。300K時代の644Kトークンから4倍以上の改善で、モデルのアーキテクチャ(sparse attention前提)にランタイム設定を合わせることの重要性を痛感しました。
性能実測
| 項目 | 実測値 |
|---|---|
| デコード(単一ストリーム、MTP 2トークン投機) | 32.3 tok/s |
| プリフィル(28.6万トークン入力時) | ~1,850 tok/s |
| 28.6万トークン入力→応答完了 | 156秒 |
LiteLLMでAnthropic互換にする
Claude Codeは ANTHROPIC_BASE_URL の差し替えに対応していますが、話す相手は Anthropic Messages API(/v1/messages) である必要があります。vLLMはOpenAI互換なので、間にLiteLLMを挟んで変換します。
# litellm-config.yaml(抜粋)
model_list:
- model_name: deepseek-v4-flash
litellm_params:
model: openai/deepseek-v4-flash
api_base: http://192.168.0.191:8000/v1
api_key: local
# 動作確認
curl http://192.168.0.191:4000/v1/models -H "Authorization: Bearer local"
Claude Code CLIから使う
ラッパースクリプトを1本書けば、普段の claude と共存できます。
#!/bin/bash
# ~/bin/claude-ds4
export ANTHROPIC_BASE_URL="http://192.168.0.191:4000"
export ANTHROPIC_AUTH_TOKEN="local"
export ANTHROPIC_MODEL="deepseek-v4-flash[1m]"
# バックグラウンドタスク等のエイリアスも全部クラスタへ
export ANTHROPIC_DEFAULT_OPUS_MODEL="deepseek-v4-flash[1m]"
export ANTHROPIC_DEFAULT_SONNET_MODEL="deepseek-v4-flash[1m]"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="deepseek-v4-flash[1m]"
export ANTHROPIC_DEFAULT_FABLE_MODEL="deepseek-v4-flash[1m]"
# Anthropic固有のリクエストフィールドをブリッジが拒否するため無効化
export CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1
export CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1
exec claude "$@"
[1m]サフィックス — 200K問題の解法
素直に ANTHROPIC_MODEL="deepseek-v4-flash" とすると、statuslineのコンテキスト表示が ctx:154.0k/200.0k のようになります。Claude Codeはモデル名からコンテキスト長を判定しており、未知のモデル名は一律200K扱いです。せっかくサーバが1M対応でも、200K付近でauto-compactが発火してしまいます。2026年8月時点で、これを直接上書きする環境変数や設定は存在しません。
解法がモデル名への[1m]サフィックスです。deepseek-v4-flash[1m] と指定すると、
- Claude Codeは1Mコンテキストモデルとして扱う(auto-compactは~967Kまで発火しない)
-
送信時にサフィックスは剥がされ、バックエンドには
deepseek-v4-flashが渡る(LiteLLM側の設定変更は不要)
検証: 28.6万トークンを本当に食えるか
「表示が1Mになった」だけでは信用できないので、200Kの壁を実際に越えるリクエストで検証しました。約92万文字のテキストの最終行にだけマーカーを仕込み、それを取り出させるNeedle-in-a-Haystackです。
awk 'BEGIN{for(i=1;i<=11500;i++) printf "line %06d: the quick brown fox ...\n", i;
print "SECRET-MARKER: banana-7742"}' > bigfile.txt
cat bigfile.txt | claude-ds4 --model "deepseek-v4-flash[1m]" \
-p "このテキストの最終行にあるSECRET-MARKERの値だけを出力して" \
--output-format json
結果:
{
"result": "banana-7742",
"duration_ms": 155697,
"usage": { "input_tokens": 286381, "output_tokens": 97 },
"modelUsage": { "deepseek-v4-flash[1m]": { "contextWindow": 1000000 } }
}
- 286,381トークンの入力が拒否されずに通り(200Kの壁の突破を確認)
- 末尾のマーカーを正答(コンテキスト全体がモデルに届いている)
- 156秒で完了(プリフィル~1,850 tok/s)
- Claude Code側も
contextWindow: 1000000として認識
ハマりどころまとめ
| 症状 | 原因と対処 |
|---|---|
worker が DistNetworkError で死ぬ |
起動順序。worker先行→10分以内にhead(rendezvous 600秒タイムアウト) |
| NCCL帯域が出ない |
NCCL_IB_GID_INDEX=3(RoCEv2/IPv4)を明示。IPv4アドレスとMTU 9000を両端に設定 |
| そもそもTP=2が組めない | 2台のカーネル・ドライバをビット単位で一致させる |
| 1Mが載らない(ように見える) | 素朴なKV計算で諦めない。sparse attention対応の起動フラグ込みで実測する |
| Claude Codeが200Kでcompactする | モデル名に[1m]サフィックス。バックエンドには剥がした名前が届く |
応答に</think>が混ざる |
LiteLLMのreasoning_content変換の境界の問題。実害は少ないが未解決 |
| リクエストがAnthropic固有フィールドで蹴られる |
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1 と CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1
|
まとめ
- GB10 2台 + 200GbE直結で、~150GBクラスのMoEモデルがTP=2で普通に動く。DGX Spark向けコミュニティレシピが流用できるのが強い
- sparse attention世代のモデルはKV単価の常識が変わっており、1Mコンテキストがローカル2台で現実に運用できる
- Claude Code CLI連携は「LiteLLMでAnthropic互換化」+「
[1m]サフィックス」の2点で完結する - デコード32 tok/sはクラウドのClaude比で体感1/5〜1/10。日常のペアプロ用途というより、巨大なコードベースやログをまるごと食わせる長コンテキスト特化の相棒として使うのが現実的
社内のクローズドな環境でも、コード資産を外に出さずに1Mコンテキストのエージェントが持てる時代になりました。同型機をお持ちの方の参考になれば幸いです。