VLM 서버

VLM 서버는 Gemma 4 E4B(mlx 4bit)를 mlx-vlm으로 불러 판 이미지와 텍스트를 보고 한 수를 고르게 합니다. 바둑의 GO19와 같은 틀의 OT88 프로토콜로 Qt 앱과 웹의 요청을 받습니다. 프롬프트에는 합법수 목록을 함께 넣습니다.

로드 실패

서버 로그에는 서버를 띄운 기록이 두 번 있고, 두 번 모두 같은 오류로 실패했습니다.

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 상대였는지 알 수 없는 이유입니다.

무작위로 바꾸는 길

모델을 불러온 뒤에도 무작위로 바꾸는 길이 세 가지 있습니다.

  • 추론 중 오류가 날 때
  • 둘 곳이 있는데 모델이 패스할 때
  • 모델이 둘 수 없는 자리나 금지 좌표를 고를 때

웹 화면은 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. 바둑·오목에서 배운 것을 오델로로 옮기는 계획’입니다.