VLM サーバー

VLM サーバーは Gemma 4 E4B(mlx 4bit)を mlx-vlm で読み込み、盤の画像とテキストを見て一手を選ばせます。囲碁の GO19 と同じ枠組みの OT88 プロトコルで、Qt アプリとWebのリクエストを受けます。プロンプトには合法手のリストも一緒に入れます。

読み込み失敗

サーバーのログにはサーバーを起動した記録が2回あり、2回とも同じエラーで失敗していました。

ValueError: Missing 54 parameters

欠けている重みは、言語モデルの24〜41層の self_attn k_proj、v_proj、k_norm です。今インストールされている mlx-vlm は 0.5.0 です。モデルファイルと mlx-vlm が期待する構造が合っていないようです。

失敗してもサーバーは起動する

サーバーはモデルを読み込めないと「ランダム合法手モード」に切り替わり、そのままポートを開きます。監視スクリプトはポートが応答するかどうかしか見ないので、正常と判断します。画面で VLM を選ぶと、合法手の一つがランダムに返ってきていました。

ログには接続と切断だけが3万回以上残っていて、どの接続が実際の対局リクエストだったのかは区別できません。1回目の記事の31局のうち何局が VLM 相手だったのか分からないのはそのためです。

ランダムに切り替わる経路

モデルを読み込んだ後でも、ランダムに切り替わる経路が三つあります。

  • 推論中にエラーが起きたとき
  • 打てる場所があるのにモデルがパスしたとき
  • モデルが打てない場所や禁止座標を選んだとき

Web画面は、VLM の応答が打てない場所ならローカルAIに切り替えます。今公開されているローカルAIは2回目の記事の一手AIです。どの場合も、画面には VLM が打ったように見えます。囲碁ではこっそり勝手な手に置き換えないことにしましたが、オセロは逆に作られていました。

この段階の実際のコード

モデルの読み込みに失敗すると model を空にして、サーバーを起動し続ける部分です。

vlm_server/server.pypython · 8行
model: Optional[_MlxVlmModel] = None
if load_model:
    model = _MlxVlmModel(model_id, max_new_tokens=max_new_tokens, temperature=temperature)
    try:
        model.start(wait_load=True)
    except BaseException as e:
        LOGGER.exception("모델 로드 실패 → 무작위 합법수 모드: %s", e)
        model = None

リクエストを受けて手を決める部分です。モデルがないか、答えが合法手でなければランダムに選びます。

vlm_server/server.pypython · 36行
model = self.server.model
row = col = None
is_pass = False
if model is not None:
    try:
        row, col, is_pass = model.predict_move(
            img, board, ai_side, legal, forbidden=list(bans), timeout_s=180.0
        )
    except Exception as e:
        LOGGER.warning("VLM 추론 실패, 합법수 무작위: %s", e)
        row = col = None
        is_pass = False

if is_pass:
    LOGGER.info("모델 PASS (합법수 있음) → 합법수 무작위")
    row = col = None

if row is None or col is None or (row, col) not in legal or (row, col) in bans:
    if row is not None and col is not None:
        LOGGER.info("불법/금지 (%s,%s) → 합법수 무작위", row, col)
    pick = _choose_legal(legal, bans)
    if pick is None:
        _send_pass(sock)
        continue
    row, col = pick

# ...

def _choose_legal(
    legal: list[tuple[int, int]], forbidden: set[tuple[int, int]]
) -> Optional[tuple[int, int]]:
    remain = [m for m in legal if m not in forbidden]
    pool = remain or legal
    if not pool:
        return None
    return random.choice(pool)

次の記事は「4. 囲碁・五目並べで学んだことをオセロに移す計画」です。