はじめに
本記事は、判断特化AIのJev風APIをローカル環境のvLLM上で再現してみたという内容です。
Jevについて
Jev は TypeSafe AI が公開している「判断特化」のAIモデルです。
普通のLLMのように文章を生成するのではなく、型の決まった質問に対して値と確率分布だけを返します。
| 種類 | 質問の形 | 返ってくるもの |
|---|---|---|
| Choice | 選択肢から1つ選ぶ | 選んだ選択肢と各選択肢の確率 |
| Score | 基準に沿って段階評価 | 点数と各点数の確率 |
| Noul | この文は正しいか | Yesである確率(0〜1) |
Jevの仕組み(推測)
Jevの内部構造は公開されていませんが、入出力だけを見ると
判断に必要な情報を持った「1トークン目」だけを出力するLLM
に見えます。
プリフィル処理のみでデコードを行わないため、LLMのTTFT(1トークン出力までの時間)とほぼ同じ時間で処理が終わると考えられます。
- 選択肢を1トークンにする
- 「A」「B」「C」...などの記号
- 英語の基本単語等
- MAX_TOKENS=1に設定
- 候補トークン以外を選ばないようにする
- トークンの確率分布(Logit)を抽出
という設計をすれば、ローカルのLLMでも同じことができそうです。
というわけで、本記事ではローカルのVLM(Qwen3.5-4B)と vLLM で「Jevっぽいもの」を作ってみました。
本記事の実装は TypeSafe AI とは無関係の個人実装です。
Jev は独自の強化学習(RLCD)で確率が較正されている(出力される確率が実際の当たりやすさと合うように調整されている)とされていますが、
今回の実装は汎用モデルそのままで、その部分は再現していません。
作り方
必要なのは vLLM だけです。サーバーを自作しなくても、curl で Jev っぽい判定ができます。
1. vLLM を起動する
docker run -d --name vllm-jev \
--gpus all --ipc host \
--ulimit memlock=-1 --ulimit stack=67108864 \
-p 8000:8000 \
-v ~/.cache/huggingface:/root/.cache/huggingface \
--entrypoint "" \
vllm/vllm-openai:nightly \
vllm serve Qwen/Qwen3.5-4B \
--served-model-name qwen3.5-4b \
--quantization fp8 \
--max-model-len 8192 \
--gpu-memory-utilization 0.5 \
--limit-mm-per-prompt '{"image":1}' \
--max-num-seqs 64 \
--enable-prefix-caching \
--logprobs-mode processed_logprobs \
--compilation-config '{"cudagraph_mm_encoder":true}'
肝心なのは --logprobs-mode processed_logprobs だけです。
このオプションを付けると、候補の中だけで合計が1になる確率が返ってきます。
(--quantization fp8 と cudagraph_mm_encoder は速くするためのオプションで、無くても動きます)
2. 選択肢の記号のトークン ID を調べる
答えは「A」「B」…の記号1文字で出させます。まず、それぞれのトークン ID を調べます。
$ curl -s localhost:8000/tokenize -H 'Content-Type: application/json' \
-d '{"model":"qwen3.5-4b","prompt":"A","add_special_tokens":false}'
{"count":1,"max_model_len":8192,"tokens":[32],"token_strs":null}
"tokens" が要素1つだけになっていれば、その記号は1トークンです。
3. リクエスト
じゃんけんの手を判定するリクエストの例です。
#!/usr/bin/env bash
# 使い方: ./janken.sh 画像ファイル
set -euo pipefail
VLLM=${VLLM:-http://localhost:8000}
MODEL=${MODEL:-qwen3.5-4b}
IMAGE=${1:?使い方: $0 画像ファイル}
# 選択肢の記号 A〜D のトークン ID を調べる
IDS=$(for s in A B C D; do
curl -s "$VLLM/tokenize" -H 'Content-Type: application/json' \
-d "{\"model\":\"$MODEL\",\"prompt\":\"$s\",\"add_special_tokens\":false}" \
| jq '.tokens[0]'
done | jq -s -c .)
QUESTION='[質問]
画像に写っている手の形を、じゃんけんの手として判定してください。
グー: 指をすべて握っている(立っている指が0本)
チョキ: 指が2本だけ立っている
パー: 指が5本すべて開いている
判別できない: 手が写っていない、またはぶれていて判断できない
手のひら側か甲側か、手が縦向きか横向きかは問いません。立っている指の本数だけで判断してください。
A: グー
B: チョキ
C: パー
D: 判別できない
A〜Dのいずれか1文字で答えてください。'
# リクエストを先に組み立てておき、計測には含めない
REQUEST=$(jq -n \
--arg model "$MODEL" \
--arg image "data:image/jpeg;base64,$(base64 -w0 "$IMAGE")" \
--arg question "$QUESTION" \
--argjson ids "$IDS" \
'{
model: $model,
messages: [
{role: "system", content: "あなたは分類器です。回答は指定された選択肢の記号1文字のみで行います。"},
{role: "user", content: [
{type: "image_url", image_url: {url: $image}},
{type: "text", text: $question}
]}
],
max_tokens: 1,
temperature: 1.0,
logprobs: true,
top_logprobs: ($ids | length),
allowed_token_ids: $ids,
chat_template_kwargs: {enable_thinking: false}
}')
# 判定リクエストだけを計測する(最後の行に処理時間を付け足して受け取る)
RESPONSE=$(printf '%s' "$REQUEST" | curl -s "$VLLM/v1/chat/completions" \
-H 'Content-Type: application/json' --data-binary @- -w '\n%{time_total}')
ELAPSED=${RESPONSE##*$'\n'} # 最後の行 = 処理時間
BODY=${RESPONSE%$'\n'*} # それより前 = レスポンスの JSON
echo "$BODY" | jq -r '.choices[0].logprobs.content[0].top_logprobs[] | "\(.token)\t\(.logprob | exp * 1000 | round / 1000)"'
awk -v t="$ELAPSED" 'BEGIN { printf "%.1f ms\n", t * 1000 }'
| パラメータ | 役割 |
|---|---|
max_tokens: 1 |
1トークンだけ出して終わる |
temperature: 1.0 |
確率を温度でゆがめない |
logprobs |
候補ごとの確率を返させる |
top_logprobs |
候補の数 |
allowed_token_ids |
候補の記号以外をすべてマスクする |
enable_thinking: false |
thinking無効 |
選択肢の文字列(「グー」)ではなく記号(A〜D)で答えさせるのは、選択肢が何トークンになっても答えが必ず1トークンで決まるようにするためです。
4. レスポンス例
$ ./janken.sh ../images/janken/gu5.jpg
A 0.981
D 0.014
B 0.005
C 0
59.2 ms
$ ./janken.sh ../images/janken/cho5.jpg
B 0.979
C 0.012
A 0.007
D 0.001
57.9 ms
$ ./janken.sh ../images/janken/pa5.jpg
C 0.989
A 0.007
B 0.002
D 0.002
60.5 ms
top_logprobs の logprob を exp すれば確率です。
A〜D の確率を足すと1になります。本家のJevではこの確率が現実の確率と等しくなるように学習されたモデルを使っているとのことですが、ここでは通常モデルなのでキャリブレーションなしの確率(モデルの自信度)になります。
接待ジャンケンスタックチャン
- カメラ画像(320x240 jpg)をつけてラッパーAPIサーバーにリクエスト
- APIサーバーからvLLMに固定プロンプトをつけてリクエスト
- グー(A)/チョキ(B)/パー(C)の手の確率が閾値(0.6)を超えたら確定として負ける手をレスポンス
- レスポンスに応じてスタックチャンが演出
速度検証
環境
| 項目 | 内容 |
|---|---|
| クライアント | M5StackChan AIデスクトップロボット(ESP32-S3搭載) |
| API/LLMサーバー | AI TOP ATOM(DGX Spark互換) |
| 使用モデル | Qwen/Qwen3.5-4B(8bit) |
| LLMエンジン | vLLM 0.29.1rc1(nightly、2026年9月20日時点) |
ステップごとの処理時間
| ステップ | 処理時間 | 備考 |
|---|---|---|
| 撮影 | 約 0.01 ms | カメラを常時回し、最新フレームのバッファをコピーせずに借りるだけにした |
| JPEG エンコード | 約 23 ms | エンコーダを直接呼んでデュアルコアで処理し、エンコーダとバッファを使い回した |
| 通信(HTTP 往復 − サーバー判定) | 約 15 ms(下限 11 ms) | WiFi の省電力を切り、接続を使い回し、TCP 送信バッファを広げた JPEG は base64 に変換せず、そのまま送る |
| サーバー判定 | 60.5 ms | サーバー側の VLM の処理時間 |
| 合計 | 約 100 ms | 1 枚ずつ順番に処理した場合 |
| 判定の間隔 | 約 78 ms | 撮影・エンコードと通信を並行させた(パイプライン化) 約 13 判定/秒 |
金額見積もり
Jev公式は安いとはいえリアルタイム処理のためにリクエスト数が増えると高くなるのでは、と思いましたが、今回のジャンケンであれば
画像(320x240) : 80トークン ※Qwen3.5-4Bの場合。トークナイザーによって異なる
プロンプトテキスト : 180トークン
1リクエスト合計 : 260トークン
1プレイを10リクエストとした場合 : 2600トークン
Jev公式API値段設定 : 入力:$0.042 / 100万トークン 出力無料 ※jev-1.13.0 2026/09/22現在
Jev公式APIで1プレイした場合 : 約$0.00011(1ドル150円で約0.016円)
十分安いですね。
なお2026/09/22現在、Jev公式APIは画像入力には対応していません。
おわりに
Jev方式はVLMの精度、カスタマイズ性で高速判定ができるという点で、リアルタイムな画像認識に向いていると言えます。
メモリ帯域が重要なデコードが不要なため、「GPUコア性能が高くメモリ帯域が少ない」DGX Sparkに向いている処理になりそうです。
(とはいえプリフィルでのモデルの全重みを読み込む処理が時間の半分を占めているのでメモリ帯域が早いに越したことはない)
KVキャッシュの保持・管理、並列デコード効率化などの処理をすべて省いた、Jev方式用のプリフィル特化LLMエンジンを自作するのも面白そうですね。
Q&A
精度は?
自前のデータセットで98.5% (128/130) でした。
パーの指3本しか映っていなかった場合のみ失敗してました。
ジャンケン用途であれば4Bモデルで十分と言えます。
最初は57%でしたがプロンプト調整(指の本数や向きの指示)で改善できる余地があります。
モデルを軽くしたら速くなる?
1リクエスト自体は速くなります。
ただし精度が下がり誤認識が増えること、および確率(確信度)が下がるので確定までに時間がかかり、体感で遅くなりました。
| VLMモデル | 処理時間 | 精度 |
|---|---|---|
| Qwen3.5 4B(fp8) | 60.8 ms | 98.5% |
| Qwen3.5 4B(NVFP4) | 52.5 ms | 78.5% |
| Qwen3.5 2B(bf16) | 48.9 ms | 73.1% |
| Qwen3.5 2B(fp8) | 41.9 ms | 53.8% |



