상대가 있는 게임에서 정책은 어떻게 강해지는가

왜 오목인가

오목은 규칙이 단순하다. 흑과 백이 번갈아 돌을 두고, 정확히 다섯 개를 먼저 연결하면 이긴다. 하지만 단순한 규칙과 달리 실제 판단은 매우 적대적이다. 내가 네 개를 만들면 상대는 막아야 하고, 상대가 활삼을 만들면 나는 다음 수를 예측해야 한다. 즉, 오목의 환경은 가만히 있는 물리 시스템이 아니라 나를 이기려는 상대다.

이 프로젝트는 Qt/C++ 오목 프로그램에 로컬 AI와 VLM 서버를 붙여, 사람 대 AI, AI 대 VLM, VLM 대 VLM 대국을 만들 수 있게 구성되어 있다. 특히 AI vs VLM 모드와 자동 재시작을 켜면 게임이 계속 쌓이고, 그 결과가 다시 학습 데이터가 된다.

적대적 강화학습이란

강화학습에서 에이전트는 상태를 보고 행동을 선택하고 보상을 받는다. 적대적 강화학습에서는 여기에 상대가 추가된다. 상대도 행동을 선택하고, 내 보상을 줄이거나 자신의 승률을 높이는 방향으로 움직인다.

규칙이 고정된 환경과 주고받는 일반 강화학습, 그리고 나와 상대가 같은 오목판에 번갈아 두며 서로의 승률을 깎는 적대적 강화학습 비교
그림. 일반 강화학습의 환경은 가만히 있지만, 적대적 학습의 상대는 맞서 움직인다
일반 강화학습적대적 강화학습
환경은 보통 규칙에 따라 반응한다.상대가 내 약점을 찌르며 반응한다.
좋은 행동은 비교적 안정적일 수 있다.좋은 행동은 상대 정책에 따라 달라진다.
목표는 보상 최대화다.목표는 상대보다 높은 승률이다.
데이터는 환경 경험에서 나온다.데이터는 대국, 승패, 상대 전략 변화에서 나온다.

프로젝트 구조

파일역할
src/boardmodel.*오목 규칙, 턴, 승패, 금수, 보드 크기를 관리한다.
src/aiplayer.cpp로컬 휴리스틱 AI. 즉시승, 즉시차단, 패턴 점수로 수를 고른다.
src/tcpvlmclient.cppQt 앱이 보드 이미지와 격자를 VLM 서버로 보내는 TCP 클라이언트다.
vlm_pva_server.py보드 PNG와 격자를 받아 VLM에게 다음 수를 묻고 좌표를 반환한다.
dataset_omook.py대국 로그를 SFT 학습 예제로 변환한다.
train_vlm.pyQwen3-VL 계열 모델에 LoRA SFT를 수행한다.
TRAINING.md데이터 수집, 변환, 학습, 자동 학습 루프를 설명한다.

오목 환경: 규칙이 곧 보상이다

오목에서 보상은 명확하다. 이기면 좋고, 지면 나쁘다. 하지만 한 수 한 수마다 보상을 주기는 어렵다. 그래서 이 프로젝트는 게임 결과를 이용한다. 완료된 게임에서 승리한 쪽의 수를 전문가 시연으로 간주하고, 그 수들을 VLM이 따라 배우게 한다.

BoardModel은 13, 15, 19 크기의 보드를 지원하고 정확히 5목을 승리로 판정한다. 이 프로젝트는 흑과 백 모두에게 장목과 삼삼을 금지하고 사사는 별도로 금지하지 않는 자체 규칙을 사용한다. 공식 렌주 규칙과는 다르다. 렌주의 삼삼·사사·장목 제한은 흑에 적용되며, 백은 장목으로도 이길 수 있다.

정확히 5목은 승리, 장목과 삼삼은 금수, 사사는 허용인 오목판 네 장
그림. 이 프로젝트가 흑·백 모두에 적용하는 승리 조건과 금수
규칙 요소코드에서의 의미
정확히 5목winsOnGrid가 길이 5만 승리로 인정한다.
장목 금수6목 이상은 hasOverline으로 금지한다.
삼삼 금수활삼 축이 2개 이상이면 거부한다.
보드 크기13, 15, 19를 런타임에 선택할 수 있다.
대국 모드HumanVsAi, AiVsVlm, VlmVsVlm 등 여러 상대 구성을 지원한다.

로컬 AI: 첫 번째 상대

적대적 학습에는 상대가 필요하다. 이 프로젝트의 첫 상대는 src/aiplayer.cpp의 로컬 AI다. 이 AI는 신경망은 아니지만 중요한 역할을 한다. VLM이 처음부터 무작위로만 대국하면 좋은 수를 배우기 어렵기 때문에, 어느 정도 규칙을 아는 상대가 필요하다.

즉시 이길 자리, 상대의 즉시 승리 차단, 패턴 점수, 벽에 붙은 약한 3/4, 금수 위치 다섯 규칙 목록과 오목판 예시
그림. 로컬 AI 상대는 다섯 가지 규칙을 우선순위대로 확인한다
  • 내가 즉시 이길 수 있는 자리가 있으면 그곳에 둔다.
  • 상대가 다음 수에 이길 수 있으면 먼저 막는다.
  • 내 패턴 점수와 상대 패턴 점수를 함께 계산한다.
  • 벽에 붙은 약한 3/4 모양에는 큰 페널티를 준다.
  • 금수 위치에는 두지 않는다.

VLM 정책: 보드를 보고 좌표를 답하기

VLM 서버는 Qt 클라이언트에서 보드 PNG와 size*size 격자를 함께 받는다. 이미지는 모델이 판을 시각적으로 이해하는 데 쓰고, 격자는 좌표와 빈 칸 여부를 검증하는 데 쓴다. 서버는 모델에게 현재 보드와 자신의 색을 알려 주고, 다음 수를 두 정수로 답하게 한다.

Reply with EXACTLY two integers separated by a single space: "row col".
row and col are both in 0..(size-1). No other text.

모델이 이미 돌이 있는 칸이나 범위 밖 좌표를 답하면 서버는 재질문하거나 인접한 빈 칸으로 보정한다. 적대적 학습에서 이런 검증 장치는 매우 중요하다. 불법 수가 그대로 게임에 들어가면 학습 데이터 전체가 오염되기 때문이다.

Qt 앱이 보낸 판을 VLM 서버가 모델에 묻고, 답한 좌표를 범위와 빈 칸 여부로 검사해 통과하면 착수, 실패하면 재질문하거나 인접 빈 칸으로 보정하는 흐름
그림. VLM이 답한 좌표는 검사를 통과하거나 보정된 뒤에만 판과 로그에 들어간다

대국 로그: 경험을 데이터로 바꾸기

서버를 --log-jsonl 옵션으로 실행하면 대국 중 발생한 수가 JSONL 파일에 누적된다. 현재 로그 파일에는 61,423줄이 쌓여 있다. 각 기록에는 보드 상태, 둔 좌표, 원래 모델이 답한 좌표, 보정 여부, 모델 정보가 들어 있다.

{
  "size": 19,
  "ai_side": 2,
  "board": [0, 0, ...],
  "move_row": 10,
  "move_col": 9,
  "corrected": true
}

이 로그는 단순 기록이 아니라 다음 학습의 원료다. 적대적 학습에서는 상대와의 대국이 곧 데이터 수집 과정이 된다.

실제 로그(2026년 4월 30일~5월 6일)를 집계하면 VLM이 직접 답한 수는 25,289개이고, 그중 7,089개(28.0%)가 규칙 엔진에 의해 보정되었다. VLM이 처음 답한 좌표를 판 위에 쌓아 보면 7행 한 줄에만 44.8%가 몰려 있다. 판 상황을 읽기보다 익숙한 줄을 반복해서 답하는 편향이 있었다는 뜻이고, 이것이 뒤에서 좌표 학습을 따로 해야 했던 이유다.

VLM이 처음 답한 좌표 25,289개의 19×19 히트맵(7행에 집중)과 일별 VLM 답·보정 건수 막대그래프
그림. 오목 대국 로그 — VLM 원래 답의 19×19 분포와 일별 보정 건수

승자의 수만 골라 배우기

dataset_omook.py는 로그를 읽어 학습 데이터로 변환한다. 기본 필터는 완료된 게임만 사용하고, 그중 승리한 쪽의 수만 고른다. 패배한 쪽의 수는 기본적으로 제외한다. 승리한 수를 전문가 시연으로 가정하는 것이다.

흑이 이긴 대국 로그에서 흑의 수만 학습에 쓰고, 7수째를 예로 방금 둔 돌을 뺀 판을 입력, 그 좌표를 정답으로 삼는 과정
그림. 이긴 쪽의 수만 고르고, 방금 둔 돌을 뺀 판(pre-move)을 입력으로 만든다
필터의미
완료된 게임만BlackWin 또는 WhiteWin으로 끝난 대국만 사용한다.
승리한 쪽의 수만이긴 플레이어의 행동을 좋은 예시로 본다.
human/local_ai 우선기본 설정에서는 VLM 자기 응답을 제외한다.
pre-move 복원post-move 보드에서 방금 둔 돌을 제거해 모델 입력을 만든다.
이미지 저장보드 렌더 이미지를 함께 저장해 VLM SFT에 사용한다.

현재 자동 데이터셋에는 12,080개의 예제가 있다. 각 예제는 보드 이미지, 텍스트 프롬프트, 정답 좌표로 구성된다.

그림. SFT 데이터로 저장된 오목 보드 이미지 예시

LoRA SFT: 정책을 조금씩 업데이트하기

train_vlm.py는 dataset_omook.py가 만든 train.jsonl을 사용해 Qwen3-VL 모델에 LoRA SFT를 수행한다. 전체 모델을 다시 학습하는 대신 작은 어댑터만 학습하므로, 개인 장비에서도 반복 실험이 가능하다.

python train_vlm.py \
  --dataset-jsonl sft_data/omook_sft_auto/train.jsonl \
  --base-model vlm_models/Qwen__Qwen3-VL-2B-Instruct \
  --output-dir vlm_models/lora_omook_auto \
  --epochs 1 --batch-size 1 --grad-accum 8

학습 스크립트는 user prompt 구간을 loss에서 제외하고 assistant 응답 좌표만 학습하도록 마스킹한다. 이 처리가 없으면 모델이 긴 보드 설명을 외우려 하다가 짧은 반복 토큰만 출력하는 collapse에 빠질 수 있다.

system·user 프롬프트 토큰은 loss에서 빼고 assistant의 좌표 토큰에만 loss를 거는 토큰 마스크 그림
그림. 프롬프트 구간은 loss에서 빼고, 모델이 답할 좌표 토큰만 학습한다

실제 실험에서는 승자의 수를 배우는 데이터와 별도로, 판에서 돌의 위치를 찾고(locate) 주어진 좌표의 돌을 읽는(lookup) 좌표 전용 데이터 6,000개(train 5,705 / val 295)를 만들어 Qwen3-VL-8B에 LoRA로 학습했다. loss는 0.83에서 시작해 약 2,950 iteration 뒤 0.026까지 내려갔다.

locate, locate_hard, lookup, lookup_dense, move 과제별 예제 수 막대와 0.83에서 0.026으로 내려가는 loss 곡선
그림. 오목 좌표 LoRA(r1)의 과제별 데이터 구성과 학습 loss

효과는 두 단계로 나타났다. 보드를 행·열 번호가 정렬된 표기로 바꾸기만 해도 합성 문제 정답률이 82.3%에서 84.4%로 올랐고, 좌표 LoRA를 얹으면 96.9%가 되었다. 실제 대국 판 150개에서 빈 칸을 답한 비율도 60.0% → 78.7% → 94.7%로 올라, 이미 돌이 있는 칸을 고르는 실수가 40%에서 5.3%로 줄었다.

기존 표기·정렬 표기·좌표 LoRA별 좌표 정답률 막대와 실제 판 150개에서 빈 칸을 답한 비율 60.0%, 78.7%, 94.7%
그림. 표기 정렬과 좌표 LoRA에 따른 좌표 정답률과 실제 판의 빈 칸 응답률

자기대국 루프

이 프로젝트의 핵심은 한 번 학습하고 끝내는 것이 아니라 반복하는 것이다. AI vs VLM 모드와 자동 재시작을 켜면 게임이 계속 진행되고, 서버는 게임이 끝날 때마다 데이터셋 변환과 LoRA 학습을 자동으로 실행할 수 있다.

대국, 로그 누적, 승자 수 추출, LoRA SFT, 새 정책 적용이 원형으로 이어지는 자기대국 루프
그림. 대국 → 로그 → 승자 수 추출 → LoRA SFT → 새 정책 적용으로 이어지는 자기대국 루프
대국 실행
  -> 로그 누적
  -> 승자 수 추출
  -> LoRA SFT
  -> 새 VLM 정책 적용
  -> 다시 대국

보상 대신 승패를 쓰는 이유

오목에서는 매 수마다 점수를 주기 어렵다. 어떤 수는 당장 좋아 보이지 않아도 5수 뒤 공격의 시작이 될 수 있고, 어떤 수는 현재 점수는 높지만 상대에게 더 큰 위협을 허용할 수도 있다. 그래서 이 프로젝트는 세밀한 즉시 보상 대신 게임 결과를 사용한다.

승리한 쪽의 수를 모아 학습하는 방식은 엄밀히 말해 value-based RL은 아니지만, 적대적 환경에서 경험을 수집하고 정책을 개선한다는 점에서 자기대국 강화 루프와 닮아 있다. 이 차이를 분명히 짚는 것이 좋다. 독자는 '강화학습'이라는 이름 아래에도 Q-learning, policy gradient, imitation, self-play fine-tuning처럼 여러 구현 방식이 있다는 것을 이해하게 된다.

실행 흐름

  1. 오목 앱 실행: Qt 오목 프로그램을 실행하고 AI vs VLM 또는 VLM vs VLM 모드를 선택한다.
  2. VLM 서버 실행: vlm_pva_server.py를 --log-jsonl 옵션과 함께 실행한다.
  3. 대국 수집: 자동 재시작을 켜서 여러 판의 승패와 수순을 누적한다.
  4. 데이터셋 변환: dataset_omook.py로 승자의 수를 SFT 예제로 변환한다.
  5. LoRA 학습: train_vlm.py로 VLM 정책을 미세조정한다.
  6. 재평가: 새 LoRA를 서버에 얹고 다시 같은 상대와 대국시켜 승률 변화를 본다.

실행 예시

python vlm_pva_server.py \
  --host 127.0.0.1 --port 9299 --device mps \
  --model-id vlm_models/Qwen__Qwen3-VL-2B-Instruct \
  --log-jsonl logs/omook_vlm.jsonl \
  --lora-dir vlm_models/lora_omook_auto \
  --auto-train \
  --auto-train-max-samples 200

이 장에서 강조할 개념

개념오목 프로젝트에서의 모습
상대 정책로컬 AI, VLM, 사람 플레이어가 모두 상대가 될 수 있다.
비정상 환경상대가 학습하면 환경의 반응도 계속 바뀐다.
자기대국AI vs VLM 또는 VLM vs VLM으로 데이터를 계속 만든다.
정책 개선승자의 수를 LoRA SFT로 학습해 다음 정책에 반영한다.
검증과 보정불법 좌표, 점유 칸, collapse 출력을 감지하고 막는다.
평가같은 상대와 반복 대국해 승률과 오류율을 확인한다.

주의할 점

  • 승자의 모든 수가 항상 좋은 수는 아니다. 상대 실수 덕분에 이긴 수가 섞일 수 있다.
  • VLM 자기 응답을 그대로 학습하면 모델의 나쁜 습관이 강화될 수 있다.
  • 데이터가 한 상대에게만 치우치면 그 상대에게만 강한 정책이 될 수 있다.
  • LoRA 학습률이 너무 크면 출력이 collapse될 수 있어 손실과 출력 검사가 필요하다.
  • 좌표 형식과 보드 좌표계가 흔들리면 좋은 정책도 실제 게임에서 실패한다.

마무리

오목 프로그램은 적대적 강화학습의 핵심을 잘 보여 준다. 에이전트는 혼자 환경을 탐험하는 것이 아니라, 자신을 이기려는 상대와 계속 부딪힌다. 상대가 바뀌면 좋은 수의 의미도 바뀐다. 그래서 오목에서 학습은 단순한 정답 암기가 아니라, 상대의 위협을 읽고 자신의 위협을 만드는 정책 개선 과정이다.

이 프로젝트의 학습 방식은 전통적인 강화학습 알고리즘과 완전히 같지는 않다. 하지만 대국으로 데이터를 만들고, 승리한 행동을 모아 정책을 업데이트하고, 다시 강해진 정책을 상대와 붙이는 반복 루프는 적대적 학습의 감각을 매우 잘 전달한다. 이 장의 목표는 바로 그 구조를 독자가 손에 잡히게 이해하도록 만드는 것이다.

연습 문제

  • 패배한 쪽의 수까지 학습 데이터에 포함하면 어떤 장단점이 생길지 설명해 보자.
  • VLM vs VLM 데이터만으로 학습하면 모델의 약점이 어떻게 고착될 수 있을까?
  • 승자의 모든 수를 같은 가중치로 학습하는 대신, 승리 직전 5수에 더 큰 가중치를 주면 어떤 변화가 생길까?
  • 불법 좌표 보정을 무작위 빈 칸이 아니라 휴리스틱 AI 후보 중 하나로 바꾸면 어떤 효과가 있을까?