
vision 非対応の DeepSeek V4 Flash に Qwen-MM-Plugins で目と耳を付けてみた
はじめに
こんにちは、クラスメソッド製造ビジネステクノロジー部の森茂です。
Qwen から Qwen-MM-Plugins が公開されました。「どんなエージェントハーネスでもマルチモーダルにする」というプラグイン群です。
自分がいま普段づかいにしている DeepSeek V4 Flash-0731 は、速くて安いのですが画像を受け付けません。スクリーンショットを貼ると 400 が返ってきます。かといって画像のためだけに主役のモデルを大きいものへ替えるのも、コストと速度の面で気が進まない……。そんなときにこのリリースを見て、これで穴を埋められるのではないかと思ったのが出発点です。
このモデルについては、DGX Spark に載せた話を 2 本書いています。
ちなみに、同じ穴をモデル側で埋めるアイデアも最近見かけるようになってきました。DeepSeek の前段に小さな VLM と shim を置き、画像をテキストに変換してから渡すことで、ハーネスからは「画像を受け付ける DeepSeek」に見せる構成です。
DeepSeek 自体をセルフホストしているなら、こちらも選択肢になりそうです。今回プラグインの方を選んだのは、主役のモデルを固定しない作りだからです。この先別のモデルに乗り換えても同じ構成が使えますし、後で触れるように vision 対応のモデルと組み合わせても活用の余地があります。
先に結論を書いておくと、穴は埋まりました。画像も音声も動画も通ります。ただしプラグインの目玉である「モデルに直接読ませる」系のツールはテキスト専用モデルでは動かず、使えるのは別系統のほうでした。そして目と耳は手元の DGX Spark で足ります。
目と耳として使うのは、以前このブログで動かした Nemotron 3 Nano Omni です。単体で動かしたときの話は前の記事にまとめています。
この記事では、テキスト専用のモデルを主役に据えたまま目と耳だけ外付けする構成の作り方と、実際にスクリーンショットを見せて UI のバグを直させるところまでを紹介します。ローカル LLM を実務で使いたいけれどマルチモーダル対応で悩んでいる人に刺さるといいなと思っています。
Qwen-MM-Plugins は skill と MCP でハーネスを拡張する
Qwen-MM-Plugins は、モデルそのものではなくエージェントの実行環境を拡張するプラグイン群です。Apache-2.0 で公開されていて、リポジトリ自体は 7 月 29 日に作られています。
構造はシンプルで、ひとつの機能が「skill」と「MCP サーバ」の組で配られます。skill がモデルに対して「こういうツールがあるよ」と教え、MCP サーバが実際の処理を担当する分担です。
機能は 7 つに分かれています。
| capability | 中身 |
|---|---|
core |
画像・動画・文書・3D モデルの読解、OCR、grounding、ASR、Web 検索 |
omni-av |
音声つき動画の理解。話者分離つき ASR、時刻を含むキャプション |
video-memory |
長時間動画の階層グラフ記憶と QA |
video-edit |
動画編集と、画像・動画・音声の生成 |
blender |
起動中の Blender を Python で操作する thin client |
freecad |
同じく FreeCAD を操作 |
edu-agent |
数学・理科の問題から解説動画を作る skill のみの機能 |
Claude Code、Codex、Qoder、OpenClaw、Qwen Code はプラグインマーケットプレイス経由で入れられます。opencode や Gemini CLI は設定ファイルに手で書く形です。自分は opencode を使っているので後者になります。
今回使うのは core と omni-av の 2 つです。
ツールは 2 系統に割れている
ここからが本題です。ドキュメントを読んでいて気になったのが、ツールの説明のしかたでした。core の skill には、ツールが 2 つのグループに分けて書かれています。抜粋するとこうです。
Native reading (feeds content directly to you):
- See a file (PDF, Office, CSV, code, notebook, 3D, ...) → visualize
- Read an image with dynamic resolution → read_image
- Read a video (extract frames) → read_video
External API calls (DashScope):
- Call an external VLM about images/videos → vision_chat
- Extract text from an image → ocr
- Detect/locate objects in an image → grounding
「feeds content directly to you」、つまり中身をあなた自身に直接渡す、と書かれています。気になって read_image の実装を見にいくと、最後の一行がこうでした。
return [text(summary), image(b64, mime)]
リサイズした画像そのものを返しているだけです。つまり native reading 系は、ハーネスの中で動いているモデルが画像を読めることが前提になっています。読めないモデルにとっては、受け取れない荷物が届くだけです。
一方で vision_chat や ocr は、別の vision モデルを API 経由で呼んで、返ってきたテキストを渡してくれます。こちらはテキストしか読めないモデルでも使えます。
この分かれ目を実際に確認しておきます。DeepSeek V4 Flash-0731 に画像を直接投げると、こうなります。
POST /v1/chat/completions + image_url
→ 400 {"message":"This model does not support image inputs"}
はっきり断られますね。プラグインを入れても、この事実は変わりません。動くのは外部 VLM 呼び出し系だけで、native reading 系は使えないままです。
| 系統 | 返すもの | テキスト専用の脳 | 画像を読める脳 |
|---|---|---|---|
native reading(read_image / read_video / visualize) |
画像そのもの | ✗ | ✅ |
外部 VLM 呼び出し(vision_chat / ocr / grounding) |
テキスト | ✅ | ✅ |
omni-av(omni_asr / omni_av_caption ほか) |
テキスト(JSON) | ✅ | ✅ |
segmentation |
マスク画像 + テキスト | △ | ✅ |
読めるモデルなら native reading はどうなるか
念のため反対側も確かめておきました。ルーターの向き先を vision 対応の minimax-m3 に変えて、同じ read_image を呼ばせます。指示は「read_image だけを使うこと」。
⚙ read_image(image_path=…/ppe-sample.jpg, budget="normal")
- 白いヘルメット(安全帽) — 左側の女性
- 黄色いヘルメット(安全帽) — 右側の男性
- 黄緑色の高視認性安全ベスト(反射ステッカー付き) — 左側の女性
- 濃紺の作業用カバーオール(つなぎ服) — 右側の男性
右側の男性は手にトランシーバーらしき機器を持っていますが、これは通信機器であり保護具ではありません。
安全靴や手袋は脚部・手元が木製フェンスや衣服に隠れて見えないため、画像からは確認できませんでした。
読めるモデルなら、当然ですが素直に動きます。しかも同じ画像に対して、後で vision_chat 経由で得られる答えより情報が多い。反射ステッカーの有無、トランシーバーは保護具ではないという判断、隠れていて確認できない箇所の断り。このあたりまで返ってきます。
理由ははっきりしていて、vision_chat は「質問を投げて答えをもらう」形なので、聞かなかったことは返ってきません。native reading は画像そのものがモデルの手元に届くので、モデルが自分の判断で好きなだけ見られます。
つまり native reading は無駄な機能ではなく、読めるモデルのための正攻法です。テキスト専用モデルから見ると使えないというだけの話でした。
read_image には解像度を選ぶ budget もあって、トークン量と引き換えに細部の見え方を調整できます。ただ万能ではないようで、手元で軽く試した範囲では、グラフの数値ラベルくらいの大きさなら一番小さい予算でも読めた一方、4K スクリーンショットの 11px の表は予算を上げても読めませんでした。切り出してから読ませたら今度は列を取り違えて誤答したので、素材との相性は見ておいたほうがよさそうです。
半分しか使えないと聞くと損した気分になりますが、実用上はそれほど困りません。「画像に何が写っているか知りたい」のであれば、答えがテキストで返ってくればいいわけですから。それに、画像がコンテキストに入らないぶん、主役のモデル側は身軽なままです。
目と耳を DGX Spark に置く
では外部 VLM 呼び出し系の「外部」に何を置くか。ドキュメントを読む限り Alibaba の DashScope を使う前提に見えます。ところがソースを読むと、shared/api_openai.py の冒頭にこう書いてありました。
Targets any OpenAI-compatible endpoint (DashScope's compatible-mode is only the default base_url).
OpenAI 互換ならどこでもいい、DashScope は既定値にすぎない、と明言されています。さらに API キーの解決部分にはこんな注釈もあります。
api_key falls back to "EMPTY" so local/self-hosted servers that ignore auth still work
自己ホストのサーバを想定した作りになっているわけですね。公式ドキュメントのほうには DASHSCOPE_BASE_URL が「プロキシやゲートウェイ用」としか書かれていないので、これはコードを読まないと気づけない部分でした。
というわけで、DGX Spark の上に目と耳を立てます。使うのは Nemotron 3 Nano Omni の FP8 版です。画像・音声・動画・テキストを 1 つのモデルで扱えるので、目と耳を別々に用意せずに済みます。
uv venv --python 3.12 && source .venv/bin/activate
uv pip install "vllm[audio]==0.20.0"
vllm serve nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8 \
--served-model-name nemotron-omni qwen3.7-plus qwen3.5-omni-plus \
--host 0.0.0.0 --port 8000 \
--gpu-memory-utilization 0.7 \
--max-model-len 131072 \
--media-io-kwargs '{"video":{"num_frames":256,"fps":2}}' \
--video-pruning-rate 0.5 \
--enable-auto-tool-choice --tool-call-parser qwen3_coder \
--reasoning-parser nemotron_v3 \
--trust-remote-code
--served-model-name にエイリアスを張る
プラグインは呼び出すモデル名を自分で決めています。vision_chat と ocr は qwen3.7-plus、omni-av 系は qwen3.5-omni-plus です。この名前を環境変数で変える口がないため、そのままでは 404 になります。
そこで vLLM 側で名前をエイリアスします。--served-model-name は複数指定できるので、プラグインが呼びたがっている名前を並べておけば、実体は Nemotron でも受け付けてくれます。これでプラグイン側は無改造、引数の追加もなしで通ります。
--max-model-len は大きめに取る
もうひとつは context 長です。omni-av はリクエストの max_tokens に既定で 65536 を入れて送ってくるので、サーバ側の --max-model-len がそれより小さいと max_tokens=65536 cannot be greater than max_model_len という 400 で弾かれます。送ったファイルの大きさとは関係ありません。入力ぶんの余裕も見て 131072 にしておくと、動画の要約まで通ります。
三つのモダリティを通してみる
環境変数は 2 つ渡すだけです。
DASHSCOPE_BASE_URL=http://<dgx-spark>:8000/v1
DASHSCOPE_API_KEY=EMPTY
この状態で MCP サーバ経由でツールを呼ぶと、画像・音声・動画がそれぞれ通りました。
| ツール | 入力 | 所要 | 返ってきたもの |
|---|---|---|---|
vision_chat |
工事現場の写真 | 3.10s | ヘルメット 2 つとベストを色つきで列挙 |
ocr |
同じ写真 | 2.86s | 画像内の文字を抽出 |
omni_asr |
16 秒の音声 | 11.0s | 文字起こし |
omni_av_caption |
10 秒の動画 | 44.22s | 時刻つきのストーリーライン |
動画のほうは、こんな形で返ってきます。
## Storyline
00:00.000 - 00:04.000
The video opens with a high-angle aerial shot sweeping over a steep, grassy mountainside …
音声だけは 1 行のパッチで通した
音声の omni_asr だけは素のままでは 400 が返ってきたので、プラグインが送っている中身を確認して、3 パターンを手で投げ比べてみました。
| 音声の渡し方 | 結果 |
|---|---|
data:;base64,…(プラグインが送る形) |
400 Incorrect padding |
data:audio/wav;base64,…(mime つき) |
400 Invalid or unsupported audio file |
生の base64(data: を付けない) |
✅ 200 / 3.92s |
生の base64 なら通ります。原因は包み方の食い違いで、プラグインは DashScope のドキュメントに合わせて音声を data: 付きの形に包むのですが、OpenAI の仕様では input_audio.data は base64 の文字列そのものです。vLLM は仕様どおりに実装しているので、包まれたまま渡されるとデコードで転びます。つまり仕様上は vLLM のほうが正しく、プラグインが DashScope の独自拡張に寄せている格好です。
直すのは、自己ホストのときだけ包装を外す実質 1 行です。手元でパッチを当てたら 11.0 秒で文字起こしが返ってきました。この修正は upstream に issue #8 と PR #9 で提案しています。
ルーターに載せて宛先を差し替えられるようにする
ここまでで MCP サーバが DGX Spark を直接叩く形になりました。動いてはいるのですが、宛先がプラグインの環境変数に直書きされている状態です。
自分の環境では、普段のモデル呼び出しは NeMo Switchyard というルーターを経由させています。用途に応じて安いモデルと賢いモデルを振り分ける仕組みで、これも以前記事にしました。
せっかくルーターがあるのだから、目と耳もここに載せてしまえば宛先の切り替えと記録が一箇所にまとまります。やってみました。
[llm_clients.local_omni]
format = "openai_chat"
base_url = "http://<dgx-spark>:8000/v1"
api_key_env = "FIREWORKS_API_KEY"
[targets.omni]
id = "nemotron-omni"
llm_client = "local_omni"
[routes.omni-vision]
id = "qwen3.7-plus"
type = "passthrough"
target = "omni"
[routes.omni-av]
id = "qwen3.5-omni-plus"
type = "passthrough"
target = "omni"
小さな工夫として、route の ID をプラグインが呼びたがっているモデル名そのものにしています。さきほど vLLM の --served-model-name でやったことを、今度はルーター側でやる形ですね。こうしておくと、プラグイン側は DASHSCOPE_BASE_URL をルーターに向けるだけで済みます。
ここまでで組み上がった全体像がこちらです。

ルーターを境に経路が 2 つに分かれる。左の青い線がテキスト専用の脳へ、右のオレンジがメディアを受ける目と耳へ。脳にはメディアが渡らず、目と耳が返したテキストだけが届く。
心配だったのは 2 点でした。ルーターがメディアを含むリクエストをそのまま通してくれるのか、そして通ったとしてどれだけ遅くなるのか。
前者は問題ありませんでした。画像・音声・動画のいずれも素通りします。ルーターは Docker コンテナで動いていて DGX Spark とは別マシンなので、そこの疎通も同時に確認できました。
後者は、直接叩いた場合と交互に 3 回ずつ測って比べました。
| 入力 | 直接叩く | ルーター経由 | 差 |
|---|---|---|---|
| 画像 | 2.21s | 2.47s | +0.26s(+12%) |
| 音声 | 1.10s | 1.16s | +0.06s(+5%) |
| 動画 | 1.44s | 1.47s | +0.03s(+2%) |
7.6MB の base64 を含む動画リクエストでも 0.03 秒しか増えません。最初に 1 回だけ測ったときは 1 秒以上遅く見えたのですが、これは単なるばらつきでした。数回測っておいてよかったところです。
記録のほうも期待どおりで、ルーターのログにメディア呼び出しがトークン数つきで残ります。
| 呼び出し | prompt tokens | completion tokens |
|---|---|---|
| テキスト | 21 | 30 |
| 画像 | 1,109 | 124 |
| 音声 | 224 | 46 |
| 動画 | 1,563 | 23 |
ついでに、これらの行では tier の欄が空になっていました。passthrough の route なので、どのモデルに振るかを判定する分類器を通っていないという意味です。画像や音声が分類器に流れ込むと余計なコストがかかるので、そこは避けられていることになります。
reasoning を切ったら 8 倍速くなった
構成が組み上がったので、実際のタスクで測ってみました。用意したのは正解が機械的に照合できる 2 つです。ひとつは工事現場の写真から保護具を挙げさせるもの、もうひとつはグラフから数値を 4 つ読み取らせるものです。後者はグラフに数字が印字されているので、読めたか読めなかったかしかありません。ごまかしが効かないぶん、目の性能を見るには向いています。
結果、正答率はどちらも満点でした。ところがレイテンシは 16〜19 秒と、目として使うには待たされすぎます。正体は Nemotron 系が既定で答えの前に吐く長い思考で、max_tokens を絞ると思考だけで枠を使い切って答えが空になることもありました。以前このブログで別の Nemotron を扱ったときは、chat_template_kwargs で思考を切って回避しています。
ただ、プラグインはそのパラメータを送ってくれません。ツールの引数にもありません。プラグイン側にパッチを当てるしかないかと思ったのですが、ルーターを挟んでいたことを思い出しました。target に 1 行足すだけで済みます。
[targets.omni]
id = "nemotron-omni"
llm_client = "local_omni"
extra_body = { chat_template_kwargs = { enable_thinking = false } }
これで全部の呼び出しに一括で効きます。測り直した結果がこちらです。
| タスク | 思考あり | 思考なし | 倍率 |
|---|---|---|---|
| 保護具の列挙 | 16.26s | 2.09s | 7.8 倍 |
| グラフの数値読み | 18.76s | 1.31s | 14.3 倍 |
正答率は中央値で満点を維持しています。厳密には思考なしの 3 回のうち 1 回だけ、保護具のベストを拾い損ねた回がありました。プラグインには一切手を触れていません。
ルーターを挟んでおくと宛先を切り替えられる、というのが当初の狙いでしたが、こういう「モデルごとの作法」を外から差し込む層としても働いてくれました。個人的にはこれが今回いちばん嬉しい発見だったかなと思っています。
クラウドの目で代替できるか
ここで気になるのが、そもそも目を手元に置く必要があるのか、という点です。自分ひとりなら DGX Spark がありますが、チームに配るとなると全員の机に置くわけにはいきません。
自分のチーム向けの環境では Fireworks 経由でオープンウェイトのモデルを使っています。この構成も記事にしました。
同じ Fireworks に vision 対応のモデルがあるなら、route の宛先を差し替えるだけで済むはずです。試してみました。
差し替え自体は 3 行でした。target を 1 つ足して、route の向き先を変えるだけです。MCP の設定にもプラグインにも触っていません。狙いどおりに動きます。
問題は、どのモデルが何を受け付けるかでした。自分のアカウントから使えるモデルを総当たりで叩いた結果がこちらです。
| モデル | 画像 | 音声 | 動画 |
|---|---|---|---|
minimax-m3 |
✅ | ✗ | △ |
qwen3p7-plus |
✅ | ✗ | ✗ |
kimi-k3 |
✅ | ✗ | ✗ |
minimax-m2p7 |
✗ | ✗ | ✗ |
glm-5p2 |
✗ | ✗ | ✗ |
nemotron-3-ultra-nvfp4 |
✗ | ✗ | ✗ |
deepseek-v4-flash-0731(主役の脳) |
✗ | ✗ | ✗ |
音声を受け付けるモデルは 1 つもありませんでした。画像は 3 つ通ります。
動画の minimax-m3 に △ を付けたのは、断られ方が他と違ったからです。他のモデルが動画非対応のエラーを返す中、これだけは「動画は HTTP URL で渡すこと、base64 の data URL は不可」という言い方でした。渡し方の問題だと言っているだけなので、公開 URL の動画で投げ直したら普通に読めました。ただしプラグインはローカルのファイルを base64 に変えて送るので、この経路には乗りません。乗せるには動画を外から見える場所に置く必要があって、社内の映像でそれをやるのは現実的ではないですね。
同じ Omni のホスト型はどうか
Fireworks に縛られる必要もありません。目に使っている Nemotron 3 Nano Omni そのものが、OpenRouter と NVIDIA の NIM API でホストされています。
どちらも route の宛先を変えるだけで試せるので、プラグインが送る形式のまま 3 モダリティを叩いてみました。
| 経路 | 画像 | 音声 | 動画 |
|---|---|---|---|
| ローカル vLLM(DGX Spark) | ✅ | ✅ | ✅ |
OpenRouter(:free) |
✅ | ✗ | ✅ |
NIM API(integrate.api.nvidia.com) |
✅ | ✗ | ✅ |
画像に加えて、動画が data URL のまま通ります。手元の動画ファイルを読めるホスト型はここで初めて出てきました。同じモデルなので当然といえばそうなのですが、Fireworks の総当たりで全滅だった穴がひとつ埋まります。
音声は、どちらのホスト型でも通りませんでした。しかもエラーではなく、200 で「音声ファイルをアップロードしてください」という答えが返ってきます。usage の prompt_tokens がテキストのぶんしかなく、音声パートが途中で黙って剥がされている形です。エラーが出ないぶん気づきにくいので、ここは覚えておいたほうがよさそうです。
無料枠の制約にも注意が要ります。NIM は検証中に並列数の上限で 503 を返してきましたし、OpenRouter の :free にもリクエスト数の制限があります。チームで常用するなら有料の枠が前提になりそうです。
速度も比べておきました。reasoning の章と同じ 2 タスクを、route の宛先だけ替えて測った中央値です。
| タスク | ローカル Omni(思考なし) | Fireworks minimax-m3 |
Fireworks qwen3p7-plus |
|---|---|---|---|
| 保護具の列挙 | 2.09s | 4.36s | 3.53s |
| グラフの数値読み | 1.31s | 1.38s | 2.32s |
DGX Spark 1 台の 30B が、serverless のクラウドをレイテンシで上回りました。正答率はどのアームも満点で差がつきません。目として使う限り、ローカルに置くことの不利はなさそうです。
ここから、配る範囲によって置き場所が決まります。
| 配りたい範囲 | 目と耳の置き場所 |
|---|---|
| 画像だけ | ホスト型で足りる。Fireworks でも OpenRouter でも route の宛先を変えるだけ |
| 手元の動画ファイル | ホスト型の Omni(OpenRouter / NIM)なら data URL のまま通る |
| 音声 | ローカルに立てる必要がある |
なお PDF については、プラグインの read_image では開けません。ただ、これは実際に試したときにモデルが自分で回り道を見つけました。save_view で 1 ページを PNG に書き出してから ocr に渡す、という順序です。テキスト層のある PDF なら pdftotext のほうが速くて正確なので、画像化が要るのはスキャン原稿と、図表そのものが答えになる場合でしょうか。
スクリーンショットを見て CSS を直させる
ここまでは「目が見えるか」の話でした。最後に、エージェントとして一周回るのかを試します。
題材は、よくある flex のバグです。カードの中に入力欄と送信ボタンを横並びにしたものの、入力欄に min-width: 280px が付いているせいでボタンがカードからはみ出しています。

カード幅 360px に対し、入力欄の最小幅とボタンの合計が収まらず、青い「登録する」ボタンが右枠を突き抜けている。
スクリーンショットは headless Chrome を叩く小さなシェルスクリプトを用意して、モデル自身に撮らせます。指示はこれだけです。
index.html の見た目が崩れています。./shot.sh でスクリーンショットを撮り、
原因を特定して CSS を修正してください。修正したら再度スクリーンショットを撮って、
直ったか目視で確認してください。
あなた自身は画像を読めないので、利用可能なツールを使って確認すること。
回ってほしいループはこういう形です。

4 つのステップをすべて同じモデルが実行する。1 周目の確認は vision_chat、締めの判定は grounding の座標比較。
動き出しました。まずスクリーンショットを撮り、vision_chat に質問を投げます。
⚙ vision_chat(images=[shot.png],
text="青い「登録する」ボタンは、白いカードの右端の枠線からはみ出していますか?
YES か NO で答え、その後に該当箇所の位置を 1 行で簡潔に説明してください。")
返ってきた答えをもとに、原因まで詰めていました。
原因が特定できました。
- 崩れの内容: 青い「登録する」ボタンが白いカードの右端枠線からはみ出している
- 原因: .field に flex: 1; min-width: 280px; が指定されている。
カードのコンテンツ幅は 360px - 24px×2 = 312px なのに、
min-width 280px + gap 12px + ボタン(nowrap, 約90px) で約 382px 必要となり、
flex 行がカード幅を超えて右にはみ出る。
数字まで合っています。修正は min-width: 0 の 1 行で、flex アイテムが縮まないときの定石どおりでした。
そのあとが面白いところです。再度スクリーンショットを撮って vision_chat に確認を投げたのですが、返ってきた答えを見てこう言い出しました。
VLM の確認が不安定なので、座標で厳密に検証します。
⚙ grounding(image_path=shot.png, prompt="the blue button with text 登録する")
⚙ grounding(image_path=shot.png, prompt="the white card container")
座標で検証できました(正規化座標 0-1000):
- カード右端: x=655
- ボタン範囲: x=567〜639
ボタン右端 639 < 655 で、カード内にきれいに収まっています。修正完了です。
自然文で「収まっていますか」と聞く代わりに、物体検出で座標を取って不等式で判定する方式に切り替えたわけですね。こちらから指示した内容ではありません。

min-width: 0 に変更後。入力欄が縮み、ボタンがカードの枠内に収まっている。
実際に直っています。
このループの間、主役のモデルは画像を一度も受け取っていません。受け取ったのは vision_chat が返した文章と、grounding が返した座標だけです。画像を直接投げれば 400 で突き返すモデルが、UI のバグを直したことになります。
ちなみに、プラグインの公式 skill を入れるかどうかも A/B で比べてみました。13 回ずつ試したところ、skill を入れると grounding を選ぶ割合が 15% から 46% に上がります。skill に「物体の検出や位置特定なら grounding」と書かれているので、「列挙してください」という指示がそちらに引っ張られたようです。自分で skill を書き直してみたりもしたのですが、目立った改善はありませんでした。ツールの選び方を安定させたいなら、skill の文面よりプロンプトで名前を挙げるほうが確実かなと思っています。
まとめ
テキスト専用のモデルに目と耳を後付けする話でした。結果としては、画像も音声も動画も通ります。ただし付け方は公式の想定とは違っていて、プラグインの目玉である native reading 系は使えないままです。動くのは外部の vision モデルを呼んでテキストを返してくれる系統だけでした。
肝心の「0731 を主役に据えたまま実務で回るのか」は、今回測った範囲では手応えありと見ています。目の応答は思考を切れば 1〜2 秒台でクラウドの serverless より速く、正答率も落ちず、ルーターの追加コストは数 % です。脳は安くて速いままで、スクリーンショットを見せて CSS を直させるループも 1 周で閉じました。観察の答えは短いテキストで返ってくるため、ローカル 30B の弱点である長文生成の遅さが表に出ない、というのがこの構成の効いているところです。もっとも、正答率が満点で並んだのはタスクが易しかったからでもあるので、細かい表や微妙な判断が要る素材での品質は、使いながら見ていくつもりです。
必要だった調整は 3 つで、そのうち 2 つはサーバの起動オプションだけで済みました。--max-model-len を大きめに取ることと、--served-model-name にプラグインが呼びたがる名前を並べておくこと。残る 1 つが音声の base64 の包み方で、こちらは 1 行のパッチが要ります。
やってみて印象が変わったのは、この構成が「vision 非対応モデルの穴埋め」ではないという点です。主役は安くて速いモデルに固定したまま、モダリティだけ専門のモデルに投げる。目が返してくるのはテキストと座標なので、主役のコンテキストを画像で圧迫することもありません。大きなマルチモーダルモデルを中心に据えなくてもよくなる、と考えると、けっこう応用が利きそうです。
Qwen-MM-Plugins そのものについても触れておくと、中を読んだ限り新しい発明が入っているわけではありませんでした。skill と MCP はどちらも既存の仕組みですし、vision_chat の実体は OpenAI 互換の API を叩くだけの短いコードです。価値があるのはむしろまとめ方のほうで、30 を超えるツールを 7 つの機能に整理して、複数のハーネスへ配れる形にしたところかなと思っています。そしてその素直さが、今回はそのまま効きました。独自のプロトコルで固められていたら、宛先をローカルに向けることも、ルーターの下に置くこともできなかったはずです。
次は、この構成を長い動画に向けたときに video-memory がどこまで使えるかも試してみたいところです。
参考リンク
- QwenLM/Qwen-MM-Plugins — Apache-2.0。skill と MCP サーバの組で配られる
- Qwen-MM-Plugins のインストール手順 — opencode / Gemini CLI など手動登録が必要なハーネスの設定例
- nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8
- OpenRouter の Nemotron 3 Nano Omni(free) — ホスト型。画像と動画は通るが音声は黙って落ちる(本文参照)
- NIM API の Nemotron 3 Nano Omni —
integrate.api.nvidia.comの OpenAI 互換 API で同モデルを提供 - Run Highly Efficient Multimodal Agentic AI with NVIDIA Nemotron 3 Nano Omni Using vLLM — vLLM 側の解説。DGX Spark もサポート対象に挙がっている
- Fireworks の Video & audio inputs ガイド — 音声・動画対応モデルは dedicated deployment が必要
- NVIDIA Nemotron 3 Nano Omni を DGX Spark で動かしてみた(2026-04-29 の記事です)
- 284B の DeepSeek V4 Flash-0731 を DGX Spark 2 台で動かしてみた
- NVIDIA の新しい LLM ルーティング基盤 NeMo Switchyard を試してみた
- オープンウェイトモデルで開発からビジネス利用まで回すチーム AI 環境








