初めに
最近、27B クラスのマルチモーダルモデルはローカルでも動かせるようになってきました。
ただ、普通の VLM とは違った使い方ができるモデルもあります。
「回答を生成する」のではなく、**「選択肢ごとの確率を 1 フォワードパスで返す」**判定モデルです。
今回は autotrust/JEV-27B-VL を、手元の 4×RTX 4090(PCIe のみ、WSL2)で vLLM を使ってデプロイして、デプロイ後のサービスを一通りテストします。
- モデル: autotrust/JEV-27B-VL (Qwen3.8-27B ベース, bf16 51.75 GiB)
- ハード: 4× NVIDIA GeForce RTX 4090 (24GB, PCIe のみ, WSL2)
- エンジン: vLLM v0.31.0 (公式 Docker イメージ)
- スクリプト: jev27b-vl-vllm.sh (TP=4, 256K コンテキスト)
対象読者は、コンシューマ GPU でマルチモーダル判定モデルをセルフホストしたい技術者です。
スクリプトの作り方、フラグの理由、起動ログ、実測のテスト結果まで書きます。
結論
先に結論を書くと、4×RTX 4090 のボックスでは次できました。
- bf16 重み (51.75 GiB) は TP=4 で余裕で載る
- 256K コンテキスト (--max-model-len 262144) がそのまま動く
- System 1 (POST /v1/decide) のテキスト / 画像判定は全部正解
(README のサンプル値と一致)
- 判定 1 件あたり 0.3〜0.8 秒
- 16 並行リクエストも 0.7 秒で完了
- 唯一の本物の罠は --max-num-seqs 8
公式の serve.sh は「80GB 以上の GPU 1 枚」を前提としています。
この記事は、それをコンシューマ GPU の 4 枚で走らせた記録です。
1. JEV-27B-VL とは
まずモデルを軽く紹介します。
autotrust/JEV-27B-VL は JEV-27B(テキスト判定モデル)のマルチモーダル版で、バックボーンは Qwen3.8-27B(Apache-2.0)です。
特徴は、1 つのエンジンに 2 つの使い方が入っているところです。
| 方式 | 入力 | 出力 | latency |
|---|---|---|---|
System 1 (POST /v1/decide) |
テキスト or 画像 (256K トークンまで) | 選択肢ごとの較正済み確率 | 1 フォワードパス |
System 2 (/v1/chat/completions) |
テキスト or 画像 | テキスト / 推論 | 生成時間に依存 |
System 1 の kind は 3 種類です。
| kind | 意味 |
|---|---|
noul |
yes/no。["false", "true"] への確率 |
choice |
2〜256 個の選択肢から選ぶ。16 個までが学習ラベル (A〜P) |
score |
0〜5 の評価 |
つまり「このクレームはどのチームが扱うべきか?」を 3 択 1 フォワードパスで、
「この画像は食べ物か?」を yes/no 1 フォワードパスで、確率付きで返せます。
System 1 のアダプタ (LoRA r=32) と decision head はテキストのみで学習されています。
ビジョンエンコーダは Qwen3.8-27B のもので無変更なので、画像判定は全部 zero-shot です。
評価面では、コミュニティ運営の Jev Decision Index 0.3 — Vision board で 20 モデル中 1 位(Full 69.82、ECE 0.019)。
参照である Qwen3.5-397B-A17B を、総パラメータの約 1/15 で +6.4 点上回っています。
ポイントだけ拾うと、次です。
- チャート / 文書系が特に強い (CharXiv, InfographicVQA 1 位)
- 未公開の画像 (private test) でも 1 位
- 確率が較正されている (ECE 0.019)。「80%」と書けば約 80% 正解
- VL-RewardBench 78.3% (2025 リーダーボード 1 位)
- AgentRewardBench 同一 recall での precision が全 16 判定モデル超え
- 動画のカバー画像からの zero-shot 推薦で AUC 0.727
詳細は README に全部あります。
この記事では、デプロイとサービステストに絞ります。
2. 環境
- 4× NVIDIA GeForce RTX 4090 (24GB, Ada / SM89)
- PCIe のみ (NVLink なし, GPU 間 P2P なし)
- Ubuntu + WSL2
- Docker
- vLLM イメージ: docker.1ms.run/vllm/vllm-openai:v0.31.0 (CUDA 13.0)
なぜ公式 vLLM イメージを使っているかというと、リポジトリ同梱の serve_decide.py(= vLLM の OpenAI サーバ + POST /v1/decide ルート)が、vLLM 内部の実装を 2 つ使っているからです。
1. logprob_token_ids (リクエストでトークン ID を指定して logprob を取れる)
2. vllm.entrypoints.launchers.api_server (この時期のサーバレイアウト)
このモデルがテストされているのは 2026 年 9 月の開発ビルドなので、
起動前にイメージの中で両方が使えるかを probe するのがスクリプトの最初の肝です。
docker run --rm --entrypoint python3 "${IMAGE}" -c '
import vllm, importlib
importlib.import_module("vllm.entrypoints.launchers.api_server")
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm import SamplingParams
fields = set(ChatCompletionRequest.model_fields) | set(getattr(SamplingParams, "__struct_fields__", ()))
assert "logprob_token_ids" in fields, "no logprob_token_ids in this build"
print("vllm", vllm.__version__, "- serve_decide.py deps OK")
'
ダメなイメージだと起動後何分も経ってから落ちるより、この 5 秒の probe で止まる方がマシです。
3. デプロイスクリプトの作り方 (jev27b-vl-vllm.sh)
スクリプトは 3 ブロックです。
1. Preflight (起動前チェック)
2. Launch (docker run)
3. Smoke tests (別の shell で実行)
3.1 Preflight
起動前にチェックする項目です。
- モデルディレクトリが存在する
- 必須ファイルが揃っている
(config.json, calibration.json, serve_decide.py,
adapter_vllm/adapter_config.json, adapter_vllm/decision_head.json, ...)
- 重みシャード 18 個が全部存在して、かつ空でない
- aria2 / .incomplete の半端なダウンロード残りが無い
- 同じ 4 枚の GPU を使う他コンテナが動いていない
- Docker イメージがローカルにある
- イメージの vLLM ビルドが serve_decide.py の依存を満たす (2 節の probe)
半端なダウンロード残りは「モデルが起動しない」原因の定番なので、ここで切り分けておくと後が楽です。
mapfile -t SHARDS < <(grep -o 'model-[0-9]*-of-[0-9]*\.safetensors'
"${MODEL_DIR}/model.safetensors.index.json" | sort -u)
for s in "${SHARDS[@]}"; do
[ -s "${MODEL_DIR}/${s}" ] || { echo "ERROR: missing or empty weight shard: ${s}"; exit 1; }
done
3.2 Launch: 重要フラグ
実際の引数です。
VLLM_ARGS=(
--model "${MODEL_MOUNT}"
--served-model-name "${SERVED_MODEL_NAME}"
--trust-remote-code
--tensor-parallel-size "${TP_SIZE}"
--enable-lora --max-lora-rank 32
--lora-modules "jev-decision=${MODEL_MOUNT}/adapter_vllm"
--logprobs-mode processed_logprobs
--max-model-len "${MAX_MODEL_LEN}"
--enable-prefix-caching --mamba-cache-mode align
--limit-mm-per-prompt "{\"image\": ${MAX_IMAGES}, \"video\": 0}"
--max-num-seqs "${MAX_NUM_SEQS}"
--max-num-batched-tokens "${MAX_NUM_BATCHED_TOKENS}"
--gpu-memory-utilization "${GPU_MEM_UTIL}"
--trust-request-chat-template
--disable-custom-all-reduce
--api-key "${API_KEY}"
--host 0.0.0.0
--port "${PORT}"
)
if [ "${ENABLE_TOOL_CALL}" = "true" ]; then
VLLM_ARGS+=(--reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder)
fi
フラグごとに理由を書きます。
| フラグ | 理由 |
|---|---|
--tensor-parallel-size 4 |
bf16 51.75 GiB は 1 枚の 24GB に載らないので、4 枚で分割 |
--enable-lora --max-lora-rank 32--lora-modules jev-decision=...
|
System 1 のアダプタ。jev-decision は OpenAI ルートからもモデル名として呼べる |
--logprobs-mode processed_logprobs |
generation config 適用後の logprob を返し、decision head が求める全オプションの確率を作る |
--max-model-len 262144 |
フル 256K。KV は 1 トークンあたり約 65KB なので、262K なら 1 リクエストで約 17GB。並行度を詰めたいなら 131072 を検討 |
--enable-prefix-caching--mamba-cache-mode align
|
prefix caching。/v1/decide の決まった prompt 先頭が効く(テスト中に 60% 台のヒット率) |
--limit-mm-per-prompt '{"image": 8, "video": 0}' |
1 リクエストあたり画像 8 枚まで、video は無効 |
--max-num-seqs 8 |
必須。理由は 3.3 節 |
--max-num-batched-tokens 8192 |
batch 上限。長い context でも安定する |
--gpu-memory-utilization 0.92 |
24GB × 0.92 ≒ 22.6GB/枚 |
--trust-request-chat-template |
System 1 は自分の raw template をクライアントから渡してレンダリングする |
--disable-custom-all-reduce |
この PCIe / WSL2 ボックスは GPU 間 P2P が無い |
--reasoning-parser qwen3--enable-auto-tool-choice--tool-call-parser qwen3_coder
|
System 2 用。thinking と tool call を有効にする(ENABLE_TOOL_CALL=false で切る) |
3.3 --max-num-seqs 8 は正しさを保つための上限
これが今回の一番の罠です。
README の serving notes にこうあります。
With more than 8 sequences in one batch, vLLM's LoRA path for this multimodal model class returns wrong System 1 probabilities
つまり、8 を超えると速くならないだけでなくて、System 1 の確率が壊れるということです。
上限を設定しておけば、クライアントの並行度が何であろうと結果は公式と同じになり、
8 件を超えるリクエストは単純にキューに入るだけ、という挙動になります。
3.4 WSL2 / PCIe ボックス固有の設定
docker run に載せているのは次です。
docker run --rm \
--name "${CONTAINER_NAME}" \
--gpus all \
-e CUDA_VISIBLE_DEVICES="${GPU_DEVICES}" \
--network host \
--ipc=host \
--shm-size 32g \
--ulimit memlock=-1 \
--ulimit stack=67108864 \
-v "${MODEL_DIR}:${MODEL_MOUNT}:ro" \
-e VLLM_WSL2_ENABLE_PIN_MEMORY=1 \
-e TRANSFORMERS_OFFLINE=1 \
-e HF_HUB_OFFLINE=1 \
-e NCCL_P2P_DISABLE=1 \
-e NCCL_IB_DISABLE=1 \
--entrypoint python3 \
"${IMAGE}" \
"${MODEL_MOUNT}/serve_decide.py" "${VLLM_ARGS[@]}"
WSL2 / PCIe 固有の部分です。
-e VLLM_WSL2_ENABLE_PIN_MEMORY=1 # WSL2 の pinned memory 経路を有効化
-e NCCL_P2P_DISABLE=1 # PCIe のみなので P2P を切らないと NCCL が詰まりやすい
-e NCCL_IB_DISABLE=1
-e TRANSFORMERS_OFFLINE=1 # モデルは完全にローカルなのでネットに依存しない
-e HF_HUB_OFFLINE=1
--shm-size 32g --ipc=host # TP 用の shared memory
--gpus all + CUDA_VISIBLE_DEVICES で GPU の選択を環境変数に頼るのが、
同じマシンで別モデル(同じ 4 枚を使う Qwen3.8 ボックスなど)を切り替えるときに助かります。
4. 実際の起動
bash /root/myvllm/jev27b-vl-vllm.sh
ログの抜粋です。
Checkpoint size: 51.75 GiB. Available RAM: 115.22 GiB.
Loading safetensors checkpoint shards: 100%| 18/18
Available KV cache memory: 7.02 GiB
GPU KV cache size: 450,487 tokens,
Maximum concurrency for 262,144 tokens per request: 1.72x
Application startup complete.
読み解くと、次です。
- 1 枚あたり: 重み ~13GB + KV 7.02 GiB + 活性化/バッファで 23GB / 24.5GB
- KV 合計 450,487 トークン
-> フル 262K で 1.72 倍の同時実行
-> 短い判定 (数百トークン) なら数十件同時に走る
- 起動 (18 シャード読み込み込み) は 3 分弱
nvidia-smi の様子です。
0, NVIDIA GeForce RTX 4090, 23243 MiB, 24564 MiB
1, NVIDIA GeForce RTX 4090, 22775 MiB, 24564 MiB
2, NVIDIA GeForce RTX 4090, 22752 MiB, 24564 MiB
3, NVIDIA GeForce RTX 4090, 22767 MiB, 24564 MiB
/v1/models には、served name と LoRA name の 2 件が出ます。
curl -s localhost:8000/v1/models -H 'Authorization: Bearer sk-dummy'
{"id": "claude-opus-5", "root": "/models/JEV-27B-VL", "max_model_len": 262144, ...}
{"id": "jev-decision", ...}
注意: スクリプトのデフォルト SERVED_MODEL_NAME はプレースホルダです。
運用する場合は実モデル名に上書きしてください。
SERVED_MODEL_NAME=autotrust/JEV-27B-VL bash jev27b-vl-vllm.sh
5. サービスのテスト
以下は全部、上の実機で 2026-10-12 に実行したものです。
5.1 System 1: テキスト choice (README と同じ例)
curl -s localhost:8000/v1/decide -H 'Authorization: Bearer sk-dummy' \
-H 'Content-Type: application/json' -d '{
"kind": "choice",
"state": "Customer: my card was charged twice for one coffee.",
"question": "Which team should handle this?",
"options": ["billing", "shipping", "tech support"]}'
結果(wall 0.71 秒):
{"options": ["billing", "shipping", "tech support"],
"probabilities": [0.9969, 0.0000, 0.0031],
"choice": "billing",
"adaptation": "native",
"protocol": "jev27-bare-v1",
"elapsed_seconds": 0.69}
billing 0.9969 は README に記載の 0.997 と一致です。
adaptation: native は、decision head が学習時に確認した 16 オプション以内のラベル (A〜P) で評価されたという意味です。
GET /v1/decide/info で上限と較正温度も見えます。
{"max_options": 256, "native_choice_options": 16,
"temperatures": {"noul": 1.0143, "choice": 1.0161, "score": 1.0036}, ...}
5.2 System 1: 画像判定
テスト画像は PIL で作ります。
from PIL import Image, ImageDraw
# 緑の正方形
img = Image.new("RGB", (512, 512), "white")
d = ImageDraw.Draw(img)
d.rectangle([116, 116, 396, 396], fill=(0, 160, 0))
img.save("green_square.png")
# 赤の三角形
img = Image.new("RGB", (512, 512), "white")
d = ImageDraw.Draw(img)
d.polygon([(256, 60), (76, 452), (436, 452)], fill=(220, 0, 0))
img.save("red_triangle.png")
呼び出しは README のとおり、state に画像 part を混ぜます。
import base64, requests
def image(path):
return {"image": "data:image/png;base64,"
+ base64.b64encode(open(path, "rb").read()).decode()}
def decide(kind, state, question, options=None):
body = {"kind": kind, "state": state, "question": question,
**({"options": options} if options else {})}
r = requests.post("http://localhost:8000/v1/decide", json=body,
headers={"Authorization": "Bearer sk-dummy"}).json()
return dict(zip(r["options"], r["probabilities"]))
noul (yes/no)
decide("noul", ["Photo: ", image("green_square.png")],
"Is this scenario one where: the image shows a green square?")
結果(0.38 秒):
{'false': 0.0016, 'true': 0.9984}
choice (4 択)
decide("choice", ["Image: ", image("red_triangle.png")],
"What shape and color is the main object?",
["red triangle", "blue circle", "green square", "yellow rectangle"])
結果(0.29 秒):
{'red triangle': 1.0, 'blue circle': 0.0,
'green square': 0.0, 'yellow rectangle': 0.0}
実画像 (README 同梱のチャート画像)
モデルリポジトリにはベンチマークのチャート画像が同梱されているので、それもテストします。
decide("choice", ["Image: ", image("multimodal_judge.png")],
"What kind of image is this?",
["a bar chart of benchmark scores", "a photograph of a person",
"a map of a city", "a photo of food"])
結果(0.78 秒):
{'a bar chart of benchmark scores': 1.0, 'a photograph of a person': 0.0,
'a map of a city': 0.0, 'a photo of food': 0.0}
合成画像も実チャートも zero-shot で正解でした。
5.3 System 1: 0〜5 点評価 (score)
{"kind": "score",
"state": "Review: The delivery took 3 weeks and the item arrived slightly
dented, but it works.",
"question": "How would you rate this purchase experience from 0 to 5?"}
結果(0.31 秒):
{'3': 0.771, '2': 0.221, '1': 0.006, '0': 0.0, '4': 0.001, '5': 0.0}
「まともだが文句あり」で 3 点、次点 2 点。妥当な分布です。
5.4 System 2: テキスト
curl -s localhost:8000/v1/chat/completions -H 'Authorization: Bearer sk-dummy' \
-H 'Content-Type: application/json' -d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "Say exactly: JEV is up and running."}],
"max_tokens": 64,
"chat_template_kwargs": {"enable_thinking": false}}'
結果(0.37 秒): JEV is up and running.
5.5 System 2: ビジョン + thinking
同じチャート画像をマルチモーダル message で渡します。
requests.post("http://localhost:8000/v1/chat/completions", json={
"model": "claude-opus-5",
"messages": [{"role": "user", "content": [
{"type": "image_url",
"image_url": {"url": "data:image/png;base64,..."}},
{"type": "text",
"text": "このチャートは何について何が示されているか、日本語で2文で要約してください。"}]}],
"max_tokens": 800})
結果(7.96 秒、thinking 有効):
このチャートは、マルチモーダル・ジャッジ(報酬モデル)として各種モデルの VL-RewardBench における総合精度(%)を比較したものである。その結果、JEV-27B-VL System 1 が 78.3% で最上位となり、Skywork-VL-Reward-7B の 73.3% に次いで高スコアを記録している。
自分の README に載っているチャートの要約を、値も正しく出しています。
緑の正方形画像に「画像の図形の形状と色を答えなさい」(thinking 有効、3.66 秒):
図形の形状: 正方形
色: 緑色
5.6 並行テスト
System 1 リクエストを 16 件並行で送ります。
16 concurrent System 1 requests: total 0.7s
per-req: [0.24, 0.47, 0.47, 0.47, 0.47, 0.47, 0.47, 0.46,
0.46, 0.69, 0.69, 0.69, 0.69, 0.69, 0.69, 0.69]
8 件ずつの 2 波で、2 波目が約 0.2 秒だけ伸びています。
README が言う「8 件超はキューに入るだけ」という挙動そのものです。
5.7 サーバ側ログ
GPU KV cache size: 450,487 tokens,
Maximum concurrency for 262,144 tokens per request: 1.72x
...
GPU KV cache usage: 0.0%, Prefix cache hit rate: 60.3%
prefix cache のヒット率が 60% 台まで出ているのは、
/v1/decide の判定 prompt は先頭が固定のフォーマットなので、同じ形式の連続リクエストで効いているためです。
5.8 テスト結果まとめ
| テスト | 結果 | latency |
|---|---|---|
| System 1 choice (テキスト, README と同じ例) | billing 0.9969(README 値と一致) | 0.71 秒 |
| System 1 noul (緑の正方形画像) | true 0.9984 | 0.38 秒 |
| System 1 choice (赤の三角形画像) | red triangle 1.0 | 0.29 秒 |
| System 1 choice (実チャート画像) | bar chart 1.0 | 0.78 秒 |
| System 1 score (レビュー評価) | 3 点 (0.771) | 0.31 秒 |
| System 2 テキスト (thinking off) | 指示どおりの出力 | 0.37 秒 |
| System 2 ビジョン (チャート要約, thinking on) | 値の正しい日本語要約 | 7.96 秒 |
| System 2 ビジョン (図形, thinking on) | 正方形 / 緑色 | 3.66 秒 |
| 16 件並行 System 1 | 2 波 × 8 件で完了、全件正解 | 合計 0.7 秒 |
6. 注意点
-
--max-num-seqs 8: 上げると速くならず、System 1 の確率が壊れます。このモデルでは上限 8 に固定しておけばよい -
chat template: 公式の Qwen3.8 テンプレートのままだと、System 2 の
enable_thinking: falseでクラッシュします。スクリプトはテンプレートをチェックして警告を出しています(修正テンプレートへの差し替え用) -
raw logprobs 経由で System 1 を自前実装する場合: OpenAI ルートを直接叩くなら
top_k: 0, top_p: 1.0を必ず渡します。generation config のデフォルト (top_k=20, top_p=0.95) が確率を切り捨てるためです。serve_decide.py経由ならこれは勝手にやってくれます - 画像の事前縮小: Qwen3.8 processor がリサイズしますが、大きな画像は先に小さく(例: 448px まで)しておくのが vision トークン数と latency にとって安いです
- speculative decoding は使わない: spec + LoRA + System 1 logprobs の組み合わせは未検証のため、スクリプトでは無効にしています
-
256K vs 128K: KV は 1 トークンあたり約 65KB。262144 で 1.72x、131072 で 3x 近い同時実行が期待できます。フル長文判定が要るか、並行判定が要るかで
MAX_MODEL_LENを選べます
まとめ
4×RTX 4090 のコンシューマ GPU ボックスで、256K コンテキストのマルチモーダル判定モデルを回せました。
- bf16 51.75 GiB は TP=4 で余裕で載る (1 枚 23GB/24.5GB)
- System 1 の 1 判定が 0.3〜0.8 秒 (テキスト / 画像 / 256K 共通)
- 画像判定は zero-shot だが、合成画像も実チャートも正解だった
- 16 並行は 8 件ずつ 2 波で、合計 0.7 秒
- 本物の罠は --max-num-seqs 8 のみ
- PCIe / WSL2 ボックスなら NCCL / P2P / pinned memory 系のフラグが必要
個人的には、「回答を生成する」VLM の使い方から、「選択肢ごとの較正済み確率を 1 フォワードパスで受け取る」使い方への切り替えが面白いと思っています。
確率が較正されているので、
確率 >= 0.80 -> 自動で処理
確率 < 0.80 -> System 2 か人間にエスカレーション
のように、確率をそのまま制御ループに使えるのが普通のチャット API との一番の違いです。
次は、System 1 を制御ループに組み込むロボットアーム / computer use の demo を、この 4×4090 ボックスで試してみたいです。
