지금 오목 AI의 구성

오목 AI는 세 부분으로 되어 있습니다.

  • Qt 앱: C++로 만든 오목 엔진과 로컬 휴리스틱 AI. 사람 대 AI, 사람 대 VLM, AI 대 VLM 대국을 둘 수 있습니다.
  • VLM 서버: Qwen3-VL이 판 이미지와 글자로 된 판을 보고 다음 수의 좌표를 답합니다.
  • 웹 대국: [omook.mariago.kr](https://omook.mariago.kr/play)에서 사람이 흑을 쥐고, 상대는 로컬 AI와 VLM 중에서 고릅니다.

사람과 둔 성적

끝까지 둔 판만 세면 사람이 43판 중 39판을 이겼습니다. 웹에서는 24판 중 22판, Qt 앱에서 VLM을 상대로는 19판 중 17판을 사람이 이겼습니다. 웹 대국 중 2판은 흑이 다섯 수를 두는 동안 AI가 한 번도 막지 않아 9수 만에 끝났습니다.

사람과 둔 대국. 웹 사람 22승 AI 2승, Qt 앱 사람 17승 VLM 2승, 합계 사람 39승 AI 4승
사람과 둔 대국. 웹 사람 22승 AI 2승, Qt 앱 사람 17승 VLM 2승, 합계 사람 39승 AI 4승

AI끼리 둔 학습 대국

일주일 동안 Qt 앱에서 로컬 AI(흑)와 VLM(백)을 자동으로 계속 두게 했습니다. 끝난 487판 중 백이 457판을 이겼습니다. 흑이 이긴 판은 29판, 무승부는 361수까지 간 1판입니다.

AI 대 VLM 학습 대국 하루별 승패. 백(VLM) 457승, 흑(로컬 AI) 29승, 무승부 1
AI 대 VLM 학습 대국 하루별 승패. 백(VLM) 457승, 흑(로컬 AI) 29승, 무승부 1

VLM은 좌표부터 틀린다

VLM이 고른 수 25,413개 중 7,089개(28%)는 이미 돌이 있는 칸처럼 둘 수 없는 자리였습니다. 이럴 때 서버는 다시 물어보고, 그래도 틀리면 가장 가까운 빈칸으로 옮겨 둡니다. 코드 주석에 적어 둔 것처럼 이 보정은 좌표를 살리는 장치일 뿐 좋은 수를 보장하지 않습니다.

VLM 좌표 보정 비율. 하루별 22~33%, 전체 28%
VLM 좌표 보정 비율. 하루별 22~33%, 전체 28%

자동 학습도 깨져 있었다

학습 대국이 끝날 때마다 이긴 쪽의 수로 LoRA를 다시 학습하게 해 두었습니다. 데이터는 12,080개까지 모였지만, 마지막 학습 기록을 보면 손실이 248,414에서 0으로 떨어지고 기울기는 NaN입니다. 학습이 제대로 되지 않았다는 뜻입니다. 지금 서버는 LoRA 없이 기본 모델인 Qwen3-VL-2B로 떠 있습니다.

남아 있는 학습 기록은 세 번입니다. 모두 자동 대국 다섯째 날에 돌았고, 한 번에 25스텝 동안 예제 200개만 썼습니다. 세 번 모두 10스텝에서 손실이 500을 넘었고 20스텝에서는 0이 됐습니다. 기울기는 여섯 번의 기록이 전부 NaN입니다. train_vlm.py는 손실이 100을 넘으면 발산으로 보는데, 세 번 모두 그 선을 넘었습니다.

모은 데이터도 한쪽으로 쏠려 있습니다. 12,080개 중 10,513개(87%)가 백의 수입니다. 학습 대국에서 백이 94%를 이겼기 때문인데, 이 백의 수는 VLM이 둔 것이 로컬 AI가 둔 것으로 기록된 것입니다. 로컬 AI가 흑으로 이긴 판의 수는 1,291개, 사람이 이긴 판의 수는 276개뿐입니다.

LoRA 자동 학습 3회의 손실과 학습 데이터 12,080개 구성
LoRA 자동 학습 3회의 손실과 학습 데이터 12,080개 구성

정리하면 지금 오목 AI는 사람에게 거의 다 지고, VLM은 아직 배운 것이 없는 상태입니다. 다음 글부터 규칙, 약점, 고칠 계획을 차례로 정리합니다.

이 단계의 실제 코드

VLM이 둘 수 없는 칸을 고르면 가까운 빈칸으로 옮기는 부분입니다.

vlm_pva_server.pypython · 25줄
if (
    not illegal_adj_fallback
    and board is not None
    and not _is_legal_vlm_move(board, r, c, size)
):
    if args.strict:
        raise RuntimeError(f"모델이 부적절 칸을 반환: ({r},{c})")
    why = _move_rejection_reason(board, r, c, size)
    if _is_opening_board(board, size):
        near = _opening_center(size)
    else:
        near = _nearest_empty(board, r, c, size=size)
    if near is None:
        raise RuntimeError("빈 칸 없음 (보드 가득 참)")
    logging.warning(
        "모델 좌표 (%d,%d) 부적절 — 사유: %s → 인접·근접 빈 칸 (%d,%d) 로 보정 "
        "(보정은 좌표 복구용 휴리스틱이라 좋은 수를 보장하지 않음)",
        r,
        c,
        why,
        *near,
    )
    r, c = near
    corrected = True
    correct_reason = why

다음 글은 ‘2. 렌주 규칙이 백을 무적으로 만들었다’입니다.