Qwen3.8 27B をローカルで画像判定に当てて畳んだ — 導入手順と、MacBook Pro M5 Pro / 64GB に「LLMの仕事」がない話

  • #ローカルLLM
  • #Qwen3.8
  • #ollama
  • #VLM
  • #量子化
  • #評価設計
  • #画像生成

この記事について

生成した記事アイキャッチを、ローカルの視覚言語モデル Qwen3.8 27B で自動チェックする仕組みを組んだ。2日で本番投入し、対照実験を1本走らせて止めた。稼働は正味1日である。

実装ステータス: 停止済み(PoC)
稼働 正味1日 / 検査した画像 20枚(対象73枚のうち) / 通算の警告 1件(それが誤検知)
コードとスケジュール定義は残置、実行のみ解除。

ローカルLLMの記事は「使える」で終わっているものが多い。この記録は逆方向で、どこまでは使えて、どこから使えないのかを実測の線で引くことを目的にする。導入手順と、量子化違いで挙動が変わる罠は、結論と独立に再利用できるのでそのまま書く。

試した環境

項目
機体MacBook Pro(Apple M5 Pro / ユニファイドメモリ 64GB)/ macOS 26.6.1
推論ランタイムollama 0.32.14(Homebrew 導入)
採用モデルqwen3.8:27b-q4_K_M(27.3B・Q4_K_M・17GB)
比較したモデルqwen3.8:27b-mlx(27.8B・nvfp4・18GB)
視覚部CLIP プロジェクタ 460.73M(両者とも vision capability あり)
2段目の判定codex CLI(GPT-5.6 Luna・サブスクリプション枠)

数値はすべてこの環境での自社実測である。外部ベンチマークの引き写しではない。

導入手順

Homebrew で入れる。以前は公式インストーラを使っていたが、/usr/local/bin に root 所有のシンボリックリンクが残って新旧が競合したため、パッケージ管理側に寄せた。

brew install ollama
brew services start ollama          # 常駐(http://127.0.0.1:11434)
ollama --version                    # => ollama version is 0.32.14

モデルを引く。ここでタグを省略しない。 省略すると環境によって MLX 版が降ってくる場合があり、後述の罠を踏む。

ollama pull qwen3.8:27b-q4_K_M      # 17GB
ollama show qwen3.8:27b-q4_K_M      # quantization が Q4_K_M であることを目視で確認

画像を投げる最小形。構造化出力を使うので format に JSON Schema を渡す。

import base64, json, pathlib, urllib.request

SCHEMA = {
    "type": "object",
    "properties": {
        "verdict": {"type": "string", "enum": ["OK", "SUSPECT"]},
        "defects": {"type": "array", "items": {"type": "string"},
                    "minItems": 1, "maxItems": 4},   # 長さ固定は必須(後述)
    },
    "required": ["verdict", "defects"],
}

body = json.dumps({
    "model": "qwen3.8:27b-q4_K_M",
    "prompt": "この画像に描画の破綻がないか調べてください。",
    "images": [base64.b64encode(pathlib.Path("in.webp").read_bytes()).decode()],
    "stream": False,
    "think": False,
    "format": SCHEMA,
    "options": {"temperature": 0.1, "num_ctx": 8192, "keep_alive": "60s"},
}).encode()

req = urllib.request.Request("http://127.0.0.1:11434/api/generate",
                             data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=900) as f:
    print(json.loads(json.load(f)["response"]))

導入で踏んだ罠

「使える」記事が飛ばしがちな部分がここに集中していた。4つのうち最初の1つは、エラーを出さずに静かに壊れる。

症状原因対処
Schema を渡したのに自由文が返るMLX 版は format のスキーマを黙って無視するGGUF(q4_K_M)を使う
配列に要素を無限に吐いて打ち切られる長さ制約がないと繰り返しが止まらないminItems / maxItems を必ず付ける
メモリを25GB持っていかれるnum_ctx が使用量を支配する262144→25GB、8192→17GB。要る分だけ取る
バッチ後も18GB抱えたままkeep_alive の既定が5分明示的に短く指定して解放させる

同名で量子化違いのモデルを2つ並べていると、どちらを引いたかで構造化出力の効き方が変わる。しかも失敗側は例外を投げず、それらしい自由文を返してくる。スキーマが効いている前提でパースしていると、原因の切り分けに時間を取られる。

何に当てたか — 安いモデルで全件、高いモデルで容疑者だけ

構成は2段のカスケードにした。全件をローカルで走らせ、引っかかったものだけをサブスク枠の有料モデルに回す。

[1段目] Qwen3.8 27B(ローカル) ── 全画像を毎朝スキャン
            │ 疑わしいものだけ

        GitHub Issue(機械可読マーカー付き)


[2段目] codex CLI(GPT-5.6 Luna)── 容疑者のみ精査

   PASS → Issue を自動クローズ
   FAIL → コメントを残して開いたまま

1段目の実測は1枚あたり中央値20.6秒(最短10.8秒/最長188.0秒・n=20)。全件を毎朝流しても現実的な時間に収まる。カスケードも端から端まで一度は成立している——1段目が FAIL を出し、2段目が PASS と判定し、Issue が自動クローズされるところまで動いた。

配管は通った。問題は流れる中身のほうだった。

反証 — 壊れた画像を作って食わせる

1段目が本当に破綻を見つけられるのか、対照を作って確かめた。同じ画像から、意図的に壊したものを1枚生成する。 顔の帯を水平反転で貼り直し、体の一部を上下反転して別の場所に貼り付けた。

対照A:無加工の元画像
対照A — 無加工(公開済みのアイキャッチ)
対照B:顔と体を意図的にずらして貼り合わせた破壊版
対照B — 意図的に破壊(顔の帯を反転、体の一部を上下反転して移動)
入力所要判定モデル自身の記述
対照A(無加工)40.6秒OK「紙の文字が、ほとんど読めない記号や意味不明な文字」
対照B(破壊版)67.0秒OK「画像が水平方向に分割されており、キャラクターの顔と体が不自然にずれて結合している」

顔と体がずれて結合していると自分で書きながら、判定は OK を返している。

これは「見えていない」のではない。記述は正確で、加えた加工そのものを指している。対照Aの「読めない記号」も、実際にあの絵に混入している崩れた文字列のことで、当たっている。

見えていることと、それを可否に変換することは別の能力だった。

「使える」の解像度 — 出力形式ごとに結果が違う

同じモデル・同じ画像で、聞き方だけを変えると結果が割れる。

出力のさせ方結果
verdict: OK | SUSPECT を選ばせる❌ 破壊版でも OK。実質的に定数
「見えたものを書け」と描写させる✅ 加工箇所を正しく指す
手の本数を数えさせる❌ 5枚中4枚で「3」。数えているというより既定値

Qwen3.8 27B は描写役なら仕事になり、判定役に据えると壊れる。 「ローカルVLMが使える/使えない」という粒度では、この差は出てこない。

監査は分布を動かさない

仮に判定が完璧だったとして、それで画像の精度は上がるのか。上がらない。 ここが今回いちばん再利用できる部分だと考えている。

理由は配置にある。「監査」と「選抜」は別物である。

配置いつ走るか出口分布への効果
監査公開後気づいて作り直すなし
選抜生成時(N枚出して選ぶ)良いものを採るあり(判定精度に比例)

作っていたのは監査だった。監査が教えるのは「その絵は悪かった」であって、次の絵を良くはしない。出口が「同じモデルに同じプロンプトで振り直す」である以上、破綻率は元のままである。サイコロを振り直しても出目の分布は変わらない。

分布を実際に動かせる手は限られる。

効き性質
生成モデルを替える大きい分布そのものが変わる
プロンプト・ネガティブの調整分布が少し寄る
N枚生成して選抜する分布は変わらないが、裾を切れる

検知に価値があるのは3つ目だけで、しかも判定がランダムより良いことが条件になる。今回の実測はそれを満たさない。判定できないモデルを、分布を動かさない配置に置いていた——二重に外していた。

照合先のほうにも正本がなかった

判定以前の問題も出た。何と照合するのかが決まっていなかった。

キャラクターの外見定義(髪色・目の色・装飾)は社内規約として文書化してある。1段目はそれと画像を突き合わせていた。ところが規約どおりでない絵を運用者に見せたところ、それは許容範囲だという判断が返ってきた。衣装や小物は記事ごとに変わる前提で運用しており、毎回同じ格好のほうが不自然だという理屈である。

規約は書いてあったが、判定の正本ではなかった。字句どおり当てると、許容されるものが違反になる。誤検知を出し続ける構造で、続ければ確実に狼少年になる。

照合先が固定されていない対象に、照合型のゲートを置いてはいけない。 変わることが仕様である属性(衣装・小物)と、変わらない同一性のアンカー(髪型・種族)は分けるべきだった。ゲートを作る前に決める順序を逆にしていた。

機材のほうの決算

この機体——MacBook Pro / M5 Pro / ユニファイドメモリ64GB——はローカル推論のために導入した。ノートとしては明らかに過剰な構成で、その過剰分はまるごとローカル推論のためである。当初の目的で言えば、まだ回収できていない。

用途モデル現況
ローカルLLM(当初の目的)qwen3.8:27b ほか❌ 常用の持ち場がまだ無い
ローカル画像生成ComfyUI 系✅ 記事アイキャッチ生成が毎日稼働
ローカル動画生成MiniMax H3(MMH3・オープンウェイト)✅ ComfyUI で実行。音声付き動画を生成
ローカル音声・音楽生成同上(H3 の音声生成能力)✅ 参照音声からの声質再現まで確認済み

音声については補足がいる。H3 に独立した音楽生成モードがあるわけではない。 動画モデルの音声生成能力を使っており、音だけが欲しいときは映像側を極小サイズにして捨てるという回し方をしている。実測では参照音声ありの5秒生成で約2分かかった。

GPU は空いていない。 画像生成が常時使っており、ローカルLLMを走らせると取り合いになる。しかも競合相手のほうが直接的に成果物を出している。

ここから、ローカルモデルが割に合う条件を3つに整理した。量が多い・出力が機械に渡る・GPUが空いている。今回はいずれも外している。対象は73枚(量が少ない)、出力は公開可否の判断(質がそのまま結果に出る)、GPUは生成と取り合いになる。モデルの性能の話ではなく、当てる先を間違えていた。

コードとスケジュール定義は残し、実行だけを解除した。判定が要る場面が出てきたら、生成時の選抜として置き直す。ただしその前に生成モデル側を替えて破綻率を測るほうが順序として正しい。良いモデルなら選抜の必要性自体が下がる。

否定的な結論だが、qwen3.8:27b を腐すための記録ではない。描写は正確だった。 描写できるものを判定に使おうとした設計のほうが外れていた。