はじめに
ローカル AI 同士でオセロを対戦させるデモを作りました。特徴は Player B(VLM)に盤面データを一切渡さないことです。
- Player A: 従来型の探索 AI(bitboard + 反復深化 negamax/αβ、強さ5段階)
-
Player B: VLM(Nemotron 3 Nano Omni)。盤面は Chrome のスクリーンショット画像だけで受け取り、
{"move": "D3"}という JSON で指し手を返す
「人間と同じように“見て”指す」という制約が、このシステムの核心です。合法手の一覧は渡しません。
先に結論を書きます。
| 結果 | 数値 |
|---|---|
| VLM の盤面読み取り精度(マス単位) | 35.9% → 74.7% |
| VLM 自身の手が採用された割合(=ランダムフォールバックに落ちなかった割合) | 17% → 88% |
| そのために効いた施策 | プロンプトの1行、リトライ回数、temperature、マス内に座標を描く |
そして最も時間を溶かしたのは、オセロでも VLM でもなく llama.cpp が「一度壊れると以後ずっと空応答を返し続け、プロセス再起動でしか直らない」状態に落ちる問題でした。しかもエラーは一切出ません。前回の記事でも書きましたが、このスタックでは「落ちなかった=成功」という判定を一切してはいけません。
ソース一式は GitHub に置いてあります: https://github.com/kotetsuy/AIreversi
検証環境
| 項目 | 内容 |
|---|---|
| OS | Ubuntu 26.04 (kernel 7.0) |
| GPU | Ryzen AI MAX+ 395 / Radeon 8060S (gfx1151, VRAM 48GB) |
| ROCm | 7.14 |
| Python | 3.12(venv は uv で作成) |
| VLM | NVIDIA Nemotron-3-Nano-Omni-30B-A3B-Reasoning (Q4_K_XL, 約21GB) + mmproj-F16 |
| 推論 | llama.cpp b10364(llama-server) |
第1部: git clone から動かすまで
1.1 必要なもの
-
llama.cpp(HIP ビルド済み。
~/llama.cpp/build/bin/llama-serverを想定) - Nemotron 3 Nano Omni の GGUF と mmproj
- tmux(起動スクリプトが使います)
- Google Chrome(観戦用。撮影用の Chromium は Playwright が別途落とします)
Player B を使わず Player A 同士・人間 vs Player A で遊ぶだけなら、llama.cpp とモデルは不要です。
1.2 モデルの準備
pip install -U "huggingface_hub[cli]"
hf download \
unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF \
NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q4_K_XL.gguf \
--local-dir ~/nemotron-3
hf download \
unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF \
mmproj-F16.gguf \
--local-dir ~/nemotron-3
合計 24GB(本体 23GB + mmproj 1.5GB)。なお huggingface-cli は現在のバージョンでは
「deprecated and no longer works」となり、hf に置き換わっています。
置き場所を変えたい場合は環境変数 VLM_MODEL / VLM_MMPROJ / LLAMA_BIN で上書きできます。
1.3 セットアップ
git clone https://github.com/kotetsuy/AIreversi.git
cd AIreversi
uv venv --python 3.12
uv pip install -e ".[dev]"
# 撮影用の Chromium 本体(venv とは別に落とす必要があります)
.venv/bin/playwright install chromium
依存は FastAPI / uvicorn / httpx / playwright だけで、PyTorch も何も要りません。ルールエンジンも探索も純 Python です。
1.4 起動
./start_all.sh # llama-server + ゲームサーバ + Chrome
./stop_all.sh # まとめて停止
tmux セッション aireversi に2つのウィンドウ(vlm = llama-server、serve = ゲームサーバ)が立ち、両方の起動を待ってから Chrome が開きます。ログは tmux attach -t aireversi(Ctrl-b 0 / Ctrl-b 1 で切り替え、Ctrl-b d で戻る)。
| オプション | 用途 |
|---|---|
--no-vlm |
Player B を使わない(llama-server を起動しない) |
--no-browser |
Chrome を開かない |
--port 8100 |
ゲームサーバのポートを変える |
起動前に venv・依存パッケージ・Playwright の Chromium・tmux・ポートの空きを確認し、足りなければ具体的なコマンドを表示して止まります。個別に起動したい場合は次の2つです。
./scripts/start_vlm.sh --daemon
.venv/bin/python -m uvicorn server.app:app --host 127.0.0.1 --port 8000
1.5 画面
http://127.0.0.1:8000/ を開いて、対局モードと Player A の強さを選び「対局開始」。
| サイドパネル | 内容 |
|---|---|
| 対局モード | Player A vs Player B、人間 vs Player B、Player A 同士 など7通り |
| Player A の強さ | 初心者/中級者/上級者/世界チャンピオン/最強。対局開始前のみ変更可 |
| ウエイト | ON で Player A の着手を1秒待つ演出(「考えている感」用)。Player B は元々レイテンシがあるので対象外 |
| 合法手を Player B に伝える | 後述の「開示モード」。既定は OFF |
| Player A の思考 | 読み深さ・終盤を読み切ったか・探索した局面数 |
| Player B の応答 | 選んだ手、却下された回答とその理由、フォールバック発動 |
Player B の応答パネルはそのまま観戦の見どころになります。VLM が {"move": "D3"} と答えて「そこには置けません」と突き返され、温度を上げて再挑戦し、最終的に諦めてランダムに落ちる様子が全部出ます。
1.6 テストと計測
.venv/bin/python -m pytest -q # 491 件
.venv/bin/python -m scripts.bench # 各レベルの実効探索深さ
.venv/bin/python -m scripts.selfplay --games 12 --time-limit 0.3 # 強さの順序確認
.venv/bin/python -m scripts.vlm_probe --positions 6 # VLM の読み取り精度
.venv/bin/python -m scripts.vlm_bench --positions 16 --retries 3 6 # VLM の手が採用される割合
後半の2つが、この記事の主役です。「VLM が賢くなったか」を毎回この2つで測りながら調整しました。
第2部: 中身の話
2.1 全体構成
┌──────────────────────────────────────────────────────────┐
│ Game Server (FastAPI + uvicorn, :8000) │
│ ルールエンジン / Player A / Player B / 対局ログ │
└───────┬──────────────────────┬─────────────────┬──────────┘
│ WebSocket │ Playwright │ HTTP
│ (盤面状態) │ (element撮影) │ (画像+プロンプト)
▼ ▼ ▼
┌───────────────┐ ┌────────────────────┐ ┌──────────────────┐
│ Chrome │ │ Chromium │ │ llama-server │
│ (人間の観戦用) │ │ (headless・撮影用) │ │ Nemotron 3 :8081 │
└───────────────┘ └────────────────────┘ └──────────────────┘
撮影用ブラウザは人間用とは別に立てます。 Player B が見る画像は人間用と条件が違う(合法手ハイライトを消す、マス内に座標を描く)ため、同じページを別インスタンスで開いて URL パラメータで表示を切り替えています。
なお Player A の探索は CPU バウンドなので asyncio.to_thread に逃がしています。これをしないと探索中に WebSocket 配信が止まり、「思考中」表示すら出ません。
2.2 ルールエンジン: 参照実装を残す
盤面は 2 つの 64bit 整数。合法手生成は参照実装と展開版の二本立てにしました。
展開版は、相手石マスクから端の列・行をあらかじめ除くことで、シフトごとの回り込み対策を不要にしています。
_M_H = 0x7E7E7E7E7E7E7E7E # 横方向: A列・H列を除く
_M_V = 0x00FFFFFFFFFFFF00 # 縦方向: 1行目・8行目を除く
_M_D = 0x007E7E7E7E7E7E00 # 斜め方向: 外周を除く
これで手を取りこぼさないのは、裏返る石は盤の端には来ないからです(挟むための自石が盤外になる)。一方 A 列への着手は反対方向の計算で拾えます。
この手の最適化は盤の端だけが静かに壊れるのが怖いので、読みやすいループ版を消さずに残し、ランダム対局から作った400局面の合法手と、200局面 × 全空きマスの反転結果を突き合わせるテストを書きました。加えて初期局面からの perft(深さ1〜7 = 4 / 12 / 56 / 244 / 1396 / 8200 / 55092)が公知の値と一致することも確認しています。
2.3 Player A: プロファイルしたら無駄な計算が4.3倍あった
反復深化 negamax + αβ + 置換表 + ムーブオーダリング + 終盤の完全読み、という素直な構成です。
最初の実装をプロファイルしたら、1ノードあたり反転計算が 4.3 回呼ばれていました。子局面を先に全部展開してから並べ替えていたので、βカットで打ち切った分の着手適用が丸ごと無駄になっていたわけです。
そこで浅い局面では着手の適用を遅延させ(位置評価テーブルと置換表の最善手だけで並べ替える)、深い局面だけ全展開して相手モビリティで並べ替えるようにしました。これと展開版の導入で、実効速度は 約5万 → 約10万局面/秒になりました。
強さ5段階と、仕様に届かなかった部分
| レベル | 深さ | 評価関数 | 揺らぎ |
|---|---|---|---|
| 初心者 | 1手読み | 石数のみ | 30%でランダム |
| 中級者 | 4手 | 石数+モビリティ | なし |
| 上級者 | 7手 | 位置評価+モビリティ+確定石 | なし |
| 世界チャンピオン | 反復深化11手 | 上記+潜在モビリティ・辺の安定性・パリティ | なし |
| 最強 | 反復深化18手 | 同上 | なし |
自己対戦(全10ペア総当たり・12局ずつ)で、すべてのペアで下位レベルが上位レベルに負け越すことを確認しました。
一方、当初の仕様に書いた「最強=残り20マスで完全読み」は届きませんでした。純 Python での実測がこれです。
| 残り空きマス | 12 | 14 | 16 | 18 |
|---|---|---|---|---|
| 平均時間 | 0.6秒 | 4.4秒 | 33秒 | 148秒 |
| 平均ノード数 | 7.5万 | 51万 | 390万 | 1760万 |
残り20マスは1手10分規模です。反復深化+思考時間上限で頭打ちにする設計にしてあるので破綻はしませんが、実際に読み切れるのは残り12〜14マスからです。この手の「仕様に書いたが実測したら無理だった」項目は、README に差異表として残しました。
2.4 Player B: 撮って、聞いて、検証して、突き返す
1手の流れはこうです。
盤面が更新される
→ Playwright で #board-capture を撮影(合法手ハイライトは消す)
→ 画像+短い日本語プロンプトを llama-server へ
→ 厳密 JSON としてパース
→ サーバ側で合法手か検証
合法 → 採用
非合法/失敗 → 却下理由を添えて再プロンプト(上限6回)
上限到達 → ランダム合法手フォールバック
再プロンプトで伝えるのは「前回の答えがなぜ却下されたか」だけで、正解の候補は決して教えません。
撮影は仕様どおり Playwright の element screenshot です。OS レベルのスクショツールを使わないので Wayland/X11 の差異に依存しません。古いフレームを撮らないように、フロント側が描画済み盤面を document.body.dataset.board に書き出し、Playwright 側でそれが目的の局面と一致するまで待ってから撮っています。
さて、ここまでは順調でした。問題はここからです。
2.5 VLM が盤面をまったく読めない
最初の計測結果がこれです。
| 指標 | 値 |
|---|---|
| マス単位の読み取り正答率 | 35.9% |
| 盤面転写の JSON 成功率 | 33% |
| 着手が合法だった率 | 17% |
しかも決定的だったのは、プロンプト中の例示マスをそのまま返してくることでした。例を D3 にすると D3、F5 にすると F5 と答えます。盤面を見ていません。
参考までに Qwen3.6 35B-A3B も試しましたが、盤面を「全マス空」と読むため更に悪い結果でした(空きマスの数だけ正解するので、一見すると正答率54%に見えるという罠つき)。
効いた対策1: マス内に座標を描く
VLM は行と列を数えるのが苦手です。盤の外周に A〜H / 1〜8 のラベルがあっても、石の位置を取り違えます。
そこで各マスの中に座標(A1〜H8)を薄く描くモードを作りました。全マスに一様に描くので「どこが空きか」の情報は漏れず、合法手を渡さないという前提は保たれます。
あわせて、8×8グリッドの転写をやめて「石があるマスの座標を列挙」させる形式に変えました。グリッド形式は行がずれて破綻しますが、列挙形式ならラベルを直接読めます。
| 指標 | 変更前 | 変更後 |
|---|---|---|
| マス単位の読み取り正答率 | 35.9% | 74.7% |
| 転写の JSON 成功率 | 33% | 100% |
効いた対策2: 却下理由の65%は ```json だった
実対局のログを分解したところ、こうなっていました。
| 却下理由 | 割合 |
|---|---|
| 形式エラー(```json フェンス) | 65% |
| 非合法手 | 31% |
| (採用) | 4% |
オセロの能力以前に、2/3のチャンスを書式で捨てていました。基本プロンプトに「```は付けないでください」の1行を入れるだけで、初回パース成功率が 0% → 100% になりました。
効いた対策3: リトライは temperature を上げないと無意味
これは笑ってしまった発見です。同じ temperature で聞き直すと、モデルは一字一句同じ答えを返します。 つまりリトライ3回が、まったく同じ問い合わせを3回するだけの完全な無駄になっていました。
リトライごとに 0.1 → 0.4 → 0.6 → 0.7 と上げるようにしたところ、答えがばらけるようになりました。ただし 0.9 まで上げると今度は出力形式が崩れ、却下の半分が形式エラーに戻ります。頭打ちを 0.7 にしたのはそのためです。
まとめると
同じ16局面で「ランダムフォールバックに落ちなかった割合」を測った結果です。
| 設定 | 初回パース | VLM の手が採用 | 平均問い合わせ | 平均秒 |
|---|---|---|---|---|
| 初期実装(リトライ3回) | 0% | 17% | 2.8 | 2.9 |
| + フェンス禁止の1行 | 100% | 17% | 2.8 | 2.9 |
| + リトライ6回・温度スケジュール・却下の累積指摘 | 100% | 50〜62% | 4.4 | 6.8 |
| + 合法手を画像で開示 | 100% | 88% | 2.0 | 2.4 |
2.6 合法手を「画像で」開示する
ここまで来ても、VLM は盤面を7割読めるだけで合法手を選ぶ能力はほぼありません。そこで、仕様の前提を外して合法手を教えるモードをトグルで用意しました。
面白いのはその渡し方です。切り分けの実測がこれです。
| 方法 | 結果 |
|---|---|
| 基本プロンプト + 印なし画像 | 合法手 1/4 |
| 基本プロンプト + 印つき画像 | 合法手 3/4 |
| 「印のあるマスを選べ」と説明を足す | パース 0/4(```json フェンスが復活) |
| 候補の座標をテキストで列挙 | 応答が空になり llama-server が壊れる |
つまりこのモデルは、印を説明されなくても使えるのに、プロンプトをいじると壊れます。結論として、開示は「撮影する画像に黄色いリングを描く」だけで行い、プロンプトは1文字も変えていません。実対局でも VLM 自身の手の採用率が 24% → 61% に上がりました。
ただし念のため書いておくと、合法手を選べるようになっただけで、強さには結びついていません。ほぼ最初に見つけた印を選ぶので、Player A(中級者)に 62 対 2 で負けます。
2.7 本題: llama.cpp が「静かに」壊れる
ここからが、この記事で一番共有したい部分です。
環境は llama.cpp b10364 + Nemotron-3-Nano-Omni-30B-A3B-Reasoning (Q4_K_XL) + mmproj-F16。
症状
長い(特に英語の)プロンプトを与えると、モデルが <think> を開き、その中で生成されるトークンが1つも文字になりません。content は空文字、finish_reason は length。ストリーミングで見ても、content デルタは <think> の1個だけで、あとは何も届きません。
問題はここからです。一度この状態になると、以後すべてのリクエストが空応答になります。 画像なしのテキスト質問も、/completion の生プロンプトも空です。
再現手順(毎回再現します)
1. llama-server を再起動する
2. 短い日本語プロンプトで画像質問 → 正常に JSON が返る
3. 長い英語プロンプトで画像質問 → 空応答(finish_reason=length)
4. 以後、2 と同じリクエストを投げても空応答しか返らない
効かなかった対処
cache_prompt: false-
POST /slots/{id}?action=erase(--slot-save-path付きで起動しても不可) - 別の画像を送る
-
--parallel 1で単一スロットにする
プロセス再起動でしか直りません。
切り分け
GPU が壊れているのか、ビルドが悪いのか、モデルなのか。順に潰しました。
| 条件 | 結果 |
|---|---|
| 別プロジェクト(LLaVA-NPU)の設定・プロンプトをそのまま実行 | ✅ 3回とも日本語キャプションが返る |
| 同じ GPU で別モデル(llm-jp-4-8b) | ✅ 正常 |
Nemotron を CPU(-ngl 0)でテキスト生成 |
✅ 正常(約30 tok/s) |
| Nemotron + 長い英語プロンプト | ❌ 空応答 → 以後すべて空 |
GPU も llama.cpp ビルドも mmproj も正常でした。プロンプトの長さと言語が引き金です。
対策
- プロンプトを短い日本語に固定する。 実は既存プロジェクトで短い日本語プロンプトを使っていたのは、結果的にこの回避策になっていました。
-
max_tokensに余裕を持たせる(打ち切り=この状態への入口)。 - それでも落ちるので、検知して自動再起動する。
検知は画像なしの短いテキスト質問1回(約0.3秒)で十分です。
async def is_degraded(self) -> bool:
"""画像なしの短いテキスト質問1回で、壊れているかを判定する。"""
空応答を受け取ったらこれで確かめ、壊れていれば llama-server を再起動して同じ手を最初から聞き直します。実対局(60手・Player B は28手)で10回発動しました。 それだけ頻繁に落ちます。
おまけ: 「復旧したつもり」の罠
自動復旧を実装したのに数値が改善せず、しばらく悩みました。原因は起動スクリプトが既存プロセスを止めていなかったことです。
新しい llama-server はポートを掴めずに終了し、/health は壊れたままの古いプロセスが 200 を返す。結果、「復旧した」と判定して壊れたサーバに投げ続けていました。起動前に必ず停止する処理を入れて解決しています。
計測の教訓としても、壊れた状態のまま次の設定を測ると数値が完全に無意味になるので、A/B を取るときは変種ごとにサーバを再起動する必要があります。
その他の細かいハマりどころ
-
--reasoning offは必須。 これが無いとテンプレートが必ず<think>を開いて空応答になります。 -
--reasoning-formatは既定(auto)のまま。noneにすると空の<think></think>が content の先頭に残り、厳密 JSON のパースに失敗します。 -
response_formatの JSON スキーマ指定は使えません。{"type": "json_schema", ...}も{"type": "json_object", "schema": ...}もFailed to initialize samplersで 400 になります(pattern / enum / additionalProperties のいずれでも同じ)。スキーマ無しの{"type": "json_object"}は動作します。
2.8 対局ログ
各ターンのスクリーンショット・VLM の応答(全リトライの生出力)・着手を保存し、直近10対局分だけを残しています。
logs/games/20260812-142530-123456-a1b2/
game.json 棋譜・Player Aの探索内容・VLMの応答とリトライ・フォールバック発動
turn-003.png その手で VLM が実際に見た画像
ここで地味にハマったのがディレクトリ名の粒度です。当初ミリ秒までにしていたら、連続で対局を開始したときに名前が衝突し、ローテーション(名前順に古い方から削除)が UUID 部分の順序で決まってしまい、古い対局が残りました。マイクロ秒まで入れて解決しています。
まとめ
VLM に「見て」指させるデモとしては動きましたが、現時点のローカル VLM ではオセロは指せないというのが正直な結論です。盤面は7割読めても、合法手を1発で当てる力がありません。
一方で、工夫の効きどころははっきりしました。効いた順に並べます。
- 画像を読みやすくする(マス内に座標を描く)— 読み取り 35.9% → 74.7%
- 出力形式の取りこぼしを潰す(フェンス禁止の1行)— 初回パース 0% → 100%
- リトライを意味のあるものにする(temperature を上げる)— 同じ温度では完全な無駄
- 合法手を画像で開示する(プロンプトは変えない)— 採用率 88%
そして何より、エラーを出さずに壊れる相手とは、必ず「壊れているか」を検知する手段を用意してから戦うべきでした。今回は 0.3 秒のテキスト質問1回で判定できると分かってから、一気に前に進みました。
ソースはこちらです。実測に使ったスクリプト(scripts/bench.py / selfplay.py / vlm_probe.py / vlm_bench.py)も全部入っています。