9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカル動作するJevっぽいものを調べてたら、なんとマルチモーダル対応もできてた!

9
Posted at

Jevが大流行してますね。

すごいなーと思いつつ、ローカルでもできるのかなーと思ってたら、すでに試されてる方がいました!
早い!!

ソースコード公開されているので、これをそのまま使えばいいのですが、せっかくなので、どういう仕組なのか調べてみました。

きしださんが公開されているソースそのものの解説ではなく、Fableに手取り足取り教えてもらった結果です。実装とは一致していないかもしれません。

環境

Macでやります。llama.cppを使用します。

brew install llama.cpp

インストールできたか確認します。

llama-server --version
version: 0.4.1 (build 10964, commit b29c606e2)
built with AppleClang 21.0.0.21000334 for Darwin arm64

モデルを取得

モデルはunsloth/Qwen3-VL-4B-Instruct-GGUF:Q8_0を使用します。(これを選んだ理由は後ほど)
ダウンロードします。

llama download -hf unsloth/Qwen3-VL-4B-Instruct-GGUF:Q8_0

ダウンロードしたモデルでサーバーを起動します。

llama-server -hf unsloth/Qwen3-VL-4B-Instruct-GGUF:Q8_0 --port 8081 -c 8192 --jinja

-cオプションでコンテキスト長を指定します。(指定しない場合はモデルの既定値(Qwen3-VL-4Bの場合は262144)が使用されます)
--jinjaというのはチャットテンプレートの指定です。

別のターミナルを起動して呼び出してみます。(jqでフォーマットしてます。)

curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"こんにちは"}]}' \
  | jq .
{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "こんにちは! 😊  \n何かお手伝いできることがあ れば、いつでもお気軽にお尋ねくださいね。  \nどうぞ、お気軽にご相談ください!"
      }
    }
  ],
  "created": 1789910380,
  "model": "unsloth/Qwen3-VL-4B-Instruct-GGUF:Q8_0",
  "system_fingerprint": "b10964-b29c606e2",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 37,
    "prompt_tokens": 9,
    "total_tokens": 46,
    "prompt_tokens_details": {
      "cached_tokens": 8
    }
  },
  "id": "chatcmpl-zSVvLz9YuZHR9SfhiGpR0z995vqgj9ck",
  "timings": {
    "cache_n": 8,
    "prompt_n": 1,
    "prompt_ms": 45.207,
    "prompt_per_token_ms": 45.207,
    "prompt_per_second": 22.12046806910434,
    "predicted_n": 37,
    "predicted_ms": 1237.889,
    "predicted_per_token_ms": 34.38580555555555,
    "predicted_per_second": 29.081767428258917
  }
}

Qwen3-VL-4BをJevっぽくする

さて、ここからいよいよ本題です。

Jevの特徴として以下のものがあります。

  • トークン生成ではなく「選択肢ごとの確率分布を1回の推論で返す」

これをきしださんの方法では、以下の制約で実現しています。

  • 1トークンだけ生成させる
  • 選択肢を1トークンで表せる記号(A〜Zなど)に割り当てて1文字で答えるよう指示する
  • 思考トークンは無効化する
  • 1トークン目に選ばれる確率をAは0.xx、Bは0.xxと全部取得する
  • これを選択肢の中で正規化して返却する

この投稿みてびっくりしました

https://x.com/kis/status/2101356104118944229?s=20

すごい。賢すぎる。

段階を追ってやっていきます。

出力を1トークンに制限する

例えば

プロンプト
ランダムで3桁の数値を生成して!数字だけ答えて。

と普通に投げると以下のように返却されます。

742

これの出力を1トークンに制限します。max_tokensに1を指定します。

curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"ランダムで3桁の数値を生成して!数字だけ答えて。"}],"max_tokens":1}' \
  | jq .
結果
{
  "choices": [
    {
      "finish_reason": "length",
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "7"
      }
    }
  ],
  "model": "unsloth/Qwen3-VL-4B-Instruct-GGUF:Q8_0",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 1,
    "prompt_tokens": 27,
    "total_tokens": 28
  },
  "timings": {
    "prompt_ms": 35.505,
    "predicted_n": 1,
    "predicted_ms": 0.001
  }
}

742の先頭の7だけが返ってきました。

そしてここが重要なのですが、1トークンしか生成しないので、めちゃくちゃ速いです。

1トークンしか生成しないので、めちゃくちゃ速いです!

大事なことなので2回言いました

数回測っただけですが、リクエストを投げてから返ってくるまで 約110ms でした。(同じ質問を普通に答えさせると、理由まで長々と説明しはじめて7〜12秒かかります。)

上位N件のトークンの候補と確率を出力する

logprobstop_logprobsというオプションを使うことで、N件のトークン候補が取得できます。

例えば先ほどと同じプロンプトで上位5件を取ってみます。top_logprobslogprobsが有効なときだけ使えます。

curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"ランダムで3桁の数値を生成して!数字だけ答えて。"}],"max_tokens":1,"logprobs":true,"top_logprobs":5}' \
  | jq '[.choices[0].logprobs.content[0].top_logprobs[] | {token, logprob}]'
結果
[
  {
    "token": "7",
    "logprob": -0.09278639405965805
  },
  {
    "token": "4",
    "logprob": -2.517789363861084
  },
  {
    "token": "1",
    "logprob": -5.348824977874756
  },
  {
    "token": "5",
    "logprob": -6.274434566497803
  },
  {
    "token": "8",
    "logprob": -7.215909481048584
  }
]

7が返ってきた裏で、実は41も候補に挙がっていたことが分かります。logprobは確率の対数なので、expを取ると確率になります。

curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"ランダムで3桁の数値を生成して!数字だけ答えて。"}],"max_tokens":1,"logprobs":true,"top_logprobs":5}' \
  | jq '[.choices[0].logprobs.content[0].top_logprobs[] | {token, prob: (.logprob | exp)}]'
候補 logprob 確率
7 -0.093 91.1%
4 -2.518 8.1%
1 -5.349 0.5%
5 -6.274 0.2%
8 -7.216 0.1%

ランダムでお願いしたのに、1桁目が7の確率が91%もあるんですって。それはおいておいて、とりあえず、「次に来るトークンを複数取得して確率もわかる」ということです。

選択肢をプロンプトで渡す

プロンプトで選択肢と、選択肢ごとのラベルを指定します。

例えばこんな感じです。

以下の文章が書かれたときの気温として最も適切なのはどれ?アルファベット1文字で回答して。

今日は半袖でも心地良い気温でした。

A: 18度
B: 22度
C: 26度

このプロンプトをこれまでのオプションの状態で送信します。

curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"以下の文章が書かれたときの気温として最も適切なのはどれ?アルファベット1文字で回答して。\n\n今日は半袖でも心地良い気温でした。\n\nA: 18度\nB: 22度\nC: 26度"}],"max_tokens":1,"logprobs":true,"top_logprobs":5}' \
  | jq '[.choices[0].logprobs.content[0].top_logprobs[] | {token, prob: (.logprob | exp)}]'
結果
[
  {
    "token": "B",
    "prob": 0.8598889753956429
  },
  {
    "token": "C",
    "prob": 0.14005314941782393
  },
  {
    "token": "A",
    "prob": 5.437325003931644e-05
  },
  {
    "token": "**",
    "prob": 3.282940730038489e-06
  },
  {
    "token": "D",
    "prob": 1.0997108153218875e-07
  }
]

取得した値を正規化する

選択肢は3つしかないのに、5つ取得してるので、4番目が「**」、5番目が「D」となっています。この不要なものを除外しつつ、正規化します。

  1. expを取って確率にする
  2. 取り得る選択肢(今回はA、B、C)だけを抽出
  3. 3つの確率の合計で割り、合計が1.0になるようにする

そうすると結局こうなります

候補 生の確率 正規化後
B 0.8598889753956429 85.99%
C 0.1400531494178239 14.01%
A 0.0000543732500393 0.01%

Jevフォーマットで返却する

必要な値が算出できたので、Jevと同じ形式でレスポンスを返却します。

本家JevはPOST /v1/systemoneというエンドポイントを持っていて、こういうリクエストを受け取ります。

リクエスト
{
  "model": "jev-latest",
  "state": "今日は半袖でも心地良い気温でした。",
  "questions": {
    "temperature": {
      "type": "choice",
      "instructions": "この文章が書かれたときの気温として最も適切なのはどれ?",
      "criteria": {
        "18度": null,
        "22度": null,
        "26度": null
      }
    }
  }
}

レスポンスはこちら。

レスポンス
{
  "model": "jev-latest",
  "answers": {
    "temperature": {
      "type": "choice",
      "choice": "22度",
      "probabilities": {
        "18度": 0.0001,
        "22度": 0.8599,
        "26度": 0.1401
      },
      "confidence": 0.6308
    }
  },
  "usage": {
    "input_tokens": 117,
    "output_tokens": 0
  }
}

ここはいい感じにやりますw。
confidenceってのも計算して出してるんでしょう。

JevのAPIには「choice」「score」「noul」の3種類のtypeがありますが、やってることはおんなじ感じです。

Jevでは未対応のマルチモーダル入力も可能です!

ここまでで、なんちゃってJev APIが完成しました。

本家Jevはテキスト入力しか対応していませんが、Qwen3-VL-4Bは画像入力にも対応しています。
なので、プロンプトで「画像に写っているものは何? A:犬、B:猫、C:象」とやれば、同じ仕組みでマルチモーダルに対応します!

すごい!!

jq -n --arg img "$(base64 -i cat.png | tr -d '\n')" '{
  messages: [{role:"user", content:[
    {type:"image_url", image_url:{url:("data:image/png;base64," + $img)}},
    {type:"text", text:"画像に写っているものは何?アルファベット1文字で回答して。\nA: 犬\nB: 猫\nC: 象"}
  ]}],
  max_tokens:1, logprobs:true, top_logprobs:5
}' | curl -s http://127.0.0.1:8081/v1/chat/completions \
  -H "Content-Type: application/json" -d @- \
  | jq '[.choices[0].logprobs.content[0].top_logprobs[] | {token, prob: (.logprob | exp)}]'
結果
[
  {
    "token": "B",
    "prob": 1
  },
  {
    "token": "C",
    "prob": 5.0946682574252436e-08
  },
  {
    "token": "猫",
    "prob": 4.5441964347730576e-08
  },
  {
    "token": "B",
    "prob": 2.93381942924073e-08
  },
  {
    "token": "A",
    "prob": 1.6253244738724357e-09
  }
]

Qwen 3.5じゃなくてQwen 3の理由

本家JevのAPIでは、同時に複数の質問を送り、すべてを一度に返却する仕組みになっています。今回のJevもどきシステムでは、複数回モデルを呼び出して一気に回答を作成します。
(llama.cppは並列に呼び出すこともできるのですが、並列にするとキャッシュが効かなくなってかえって遅くなったので今回は採用せず)

llama.cppはデフォルトでプロンプトのキャッシュが有効になっているようで、同じ画像やテキストに対して質問を繰り返した際に、上手にキャッシュが効きます。

同じ画像に2回質問を投げてみます。1回目は「何が写ってる?」で2回目が「動物の色は?」とします。

1回目の結果
{"prompt_tokens":213,"cached":0,"processed":213,"ms":611}
2回目の結果(質問だけ変更)
{"prompt_tokens":214,"cached":175,"processed":39,"ms":100}

1回目は画像を初めて処理するので0.6秒ぐらいかかりますが、2回目は画像部分がキャッシュ済みなので0.1秒で応答が返却されます。

Qwen 3.5じゃなくてQwen 3の理由

で、Qwen 3.5だと、どうもキャッシュが効かないようです。

これはClaudeが色々説明してくれたのですが、あまり理解できませんでしたwClaudeの説明はこちら!

最初はもっと新しいQwen3.5-2Bを使っていました。こちらも画像に対応しています。

ところが、前の章でやったキャッシュがまったく効きませんでした。
同じ画像に質問だけ変えて投げても、cached_tokensがずっと0のままです。

実測するとこうなります。2回目、3回目のキャッシュ欄に注目してください。

  Qwen3-VL-4B / 画像   1回目 キャッシュ  0  2回目 キャッシュ174  3回目 キャッシュ174
  Qwen3.5-2B  / 画像   1回目 キャッシュ  0  2回目 キャッシュ  0  3回目 キャッシュ  0

サーバーのログを見たら、理由がはっきり書いてありました。

  forcing full prompt re-processing due to lack of cache data
  (likely due to SWA or hybrid/recurrent memory)

「キャッシュのデータが無いのでプロンプト全体を処理し直します」という意味です。

llama.cppは計算済みの部分に「しおり」を挟んでおいて、次に似たプロンプトが来たら
そこまで飛ばす、という作りになっています。

普通のモデルなら、しおりが無くても計算結果を途中で切り捨てて、共通部分だけ残せます。
ところがQwen3.5は「ハイブリッド型」と呼ばれる新しい構造で、
途中で切り捨てるということができません。保存したしおりの位置にしか戻れないのです。

そしてしおりは、前回のプロンプトの末尾にしか作られません。
質問を変えると、共通部分はその手前で終わってしまうので、しおりが使えません。
結果、毎回ゼロからやり直しになります。

  checking checkpoint with [79, 79] against 56...
  → しおりは79の位置、共通部分は56まで。使えないので最初から

テキストだけならまだマシなのですが、画像を渡すと差が開きます。
画像1枚で175トークンあるので、それを毎回エンコードし直すことになるからです。

Qwen3-VLは昔ながらの構造なので、画像を含む前半をまるごと使い回せます。
新しいほうが速いとは限らない、という話でした。

まとめ

Jevもそのうちマルチモーダル対応版が出てくるんだろうな〜!楽しみ!

9
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?