코드 월드 모델의 마법: Python으로 게임 규칙을 번역하는 법
들어가며: 이론에서 실제로
지난 글에서 우리는 기존 프롬프팅 방식의 한계와 코드 월드 모델의 필요성을 살펴봤습니다. 이제 본격적으로 코드 수준에서 들어가 봅시다. 어떻게 LLM이 "체스의 룩은 가로 또는 세로로만 움직입니다"라는 자연어 규칙을 정확한 Python 함수로 변환할 수 있을까요?
본 글에서는 Code World Models 논문이 제시하는 세 가지 핵심 구성요소를 코드와 함께 이해해봅니다.
코드 월드 모델의 세 기둥
논문에 따르면, LLM이 생성해야 하는 것은 단순한 게임 로직이 아니라, 완전하고 실행 가능한 게임 시뮬레이터입니다. 이는 세 가지 필수 함수로 구성됩니다:
1. 상태 전이 함수 (State Transition)
게임의 현재 상태와 선택한 행동을 받아, 다음 상태를 반환하는 함수입니다.
def apply_move(state, move, player):
"""
게임 상태에 플레이어의 수를 적용합니다.
Args:
state: 현재 게임 상태 (딕셔너리, 리스트 등)
move: 플레이어가 선택한 수
player: 현재 플레이어 식별자
Returns:
새로운 게임 상태
"""
new_state = deepcopy(state)
# 수를 적용하는 로직
return new_state
왜 중요한가?
이 함수는 게임의 "물리 법칙"입니다. 체스에서 말을 움직이면 보드가 어떻게 변하는지, 포커에서 카드를 낼 때 무슨 일이 일어나는지를 정확히 정의합니다.
2. 합법 수 열거 함수 (Legal Moves Enumeration)
현재 상태에서 가능한 모든 합법적인 수를 나열하는 함수입니다.
def get_legal_moves(state, player):
"""
현재 상태에서 플레이어가 선택할 수 있는 모든 합법적 수를 반환합니다.
Args:
state: 현재 게임 상태
player: 현재 플레이어
Returns:
합법적인 수들의 리스트
"""
legal_moves = []
# 규칙에 따라 합법적인 수를 찾는 로직
return legal_moves
핵심 가치
이 함수가 있으면 불법 수를 원천적으로 차단할 수 있습니다. LLM이 직접 수를 생성할 때의 가장 큰 문제였던 규칙 위반이 이제 구조적으로 불가능합니다.
3. 게임 종료 검증 함수 (Terminal State Check)
게임이 끝났는지, 그리고 결과가 무엇인지 판단하는 함수입니다.
def is_terminal(state):
"""
게임이 종료 상태인지 확인합니다.
Args:
state: 현재 게임 상태
Returns:
(is_ended, winner) 튜플
- is_ended: 게임 종료 여부 (bool)
- winner: 승자 식별자 (None이면 무승부)
"""
# 승리 조건, 무승부 조건 검증
return (False, None) # 또는 (True, winner)
왜 필요한가?
게임 종료 시점을 정확히 판단해야 AI가 목표를 향해 계획을 세울 수 있습니다. 또한 무한 루프를 방지합니다.
실제 예시: 오목(Gomoku) 구현
이론만으로는 부족하니, 실제 오목 게임을 예로 들어봅시다. LLM이 다음과 같은 자연어 규칙을 받았다고 가정합니다:
> "오목은 15x15 바둑판에서 플레이합니다. 두 명의 플레이어가 번갈아 돌을 놓으며, 먼저 가로, 세로, 또는 대각선으로 5개를 연속으로 놓은 플레이어가 승리합니다."
LLM은 이를 다음과 같은 코드로 변환합니다:
상태 표현
class GomokuState:
def __init__(self):
self.board = [[None for _ in range(15)] for _ in range(15)]
self.current_player = 'black'
self.move_history = []
def copy(self):
new_state = GomokuState()
new_state.board = [row[:] for row in self.board]
new_state.current_player = self.current_player
new_state.move_history = self.move_history[:]
return new_state
합법 수 찾기
def get_legal_moves(state):
"""빈 칸의 좌표를 모두 반환"""
moves = []
for row in range(15):
for col in range(15):
if state.board[row][col] is None:
moves.append((row, col))
return moves
간단하지만 정확합니다. 이미 돌이 놓인 곳에는 둘 수 없다는 규칙이 코드로 명확히 표현됐습니다.
수 적용하기
def apply_move(state, move):
"""수를 적용하고 플레이어를 바꿉니다"""
row, col = move
new_state = state.copy()
new_state.board[row][col] = state.current_player
new_state.move_history.append(move)
new_state.current_player = 'white' if state.current_player == 'black' else 'black'
return new_state
승리 조건 확인
def is_terminal(state):
"""5개 연속 확인"""
board = state.board
# 방향 벡터: 가로, 세로, 대각선 2개
directions = [(0, 1), (1, 0), (1, 1), (1, -1)]
for row in range(15):
for col in range(15):
stone = board[row][col]
if stone is None:
continue
for dr, dc in directions:
count = 1
# 한 방향으로 4칸 더 확인
for i in range(1, 5):
r, c = row + dr i, col + dc i
if 0 <= r < 15 and 0 <= c < 15 and board[r][c] == stone:
count += 1
else:
break
if count >= 5:
return (True, stone)
# 빈 칸이 없으면 무승부
if all(board[r][c] is not None for r in range(15) for c in range(15)):
return (True, None)
return (False, None)
불완전 정보 게임: 추론 함수
완전 정보 게임(체스, 오목)과 달리 포커나 브리지 같은 게임에서는 상대의 카드를 모릅니다. 논문은 이를 위한 네 번째 구성요소를 제시합니다:
4. 추론 함수 (Inference Function)
관측 가능한 정보로부터 숨겨진 상태를 추정합니다.
def infer_hidden_state(observations, player):
"""
관측 정보로부터 가능한 전체 상태를 추정합니다.
Args:
observations: 플레이어가 볼 수 있는 정보
player: 현재 플레이어
Returns:
가능한 상태들의 분포 또는 샘플
"""
# 베이지안 추론 또는 샘플링
possible_states = []
# ...
return possible_states
포커 예시
def infer_opponent_hand(visible_cards, community_cards, betting_history):
"""
상대의 가능한 패를 추정합니다.
- visible_cards: 내 카드
- community_cards: 공개된 커뮤니티 카드
- betting_history: 베팅 이력 (단서)
"""
deck = create_deck()
# 보이는 카드 제거
for card in visible_cards + community_cards:
deck.remove(card)
# 베팅 패턴에 따라 가중치 부여
possible_hands = []
for hand in itertools.combinations(deck, 2):
probability = estimate_probability(hand, betting_history)
possible_hands.append((hand, probability))
return possible_hands
휴리스틱 함수: MCTS를 더 똑똑하게
논문은 한 발 더 나아갑니다. LLM에게 게임 특화 휴리스틱 함수도 생성하도록 요청합니다.
def evaluate_position(state, player):
"""
현재 위치를 평가하여 점수를 반환합니다.
높을수록 player에게 유리합니다.
Args:
state: 평가할 게임 상태
player: 평가 기준 플레이어
Returns:
평가 점수 (float)
"""
score = 0.0
# 게임 특화 휴리스틱
return score
오목 휴리스틱 예시
def evaluate_gomoku_position(state, player):
"""연속된 돌의 개수에 따라 점수 부여"""
score = 0
opponent = 'white' if player == 'black' else 'black'
# 2연속: 10점, 3연속: 100점, 4연속: 10000점
weights = {2: 10, 3: 100, 4: 10000}
for length, weight in weights.items():
score += count_sequences(state.board, player, length) * weight
score -= count_sequences(state.board, opponent, length) * weight
return score
이 휴리스틱이 있으면 MCTS가 유망한 수를 우선 탐색하여 효율이 크게 향상됩니다.
MCTS와의 결합: 완전한 게임 AI
이제 월드 모델과 MCTS를 결합하면 강력한 게임 AI가 탄생합니다.
class MCTSNode:
def __init__(self, state, parent=None, move=None):
self.state = state
self.parent = parent
self.move = move
self.children = []
self.visits = 0
self.value = 0.0
def select(self):
"""UCB1으로 최선의 자식 선택"""
return max(self.children, key=lambda c: c.ucb_score())
def expand(self):
"""합법 수로 자식 노드 생성"""
legal_moves = get_legal_moves(self.state)
for move in legal_moves:
new_state = apply_move(self.state, move)
child = MCTSNode(new_state, parent=self, move=move)
self.children.append(child)
def simulate(self):
"""랜덤 플레이아웃 (휴리스틱으로 개선 가능)"""
state = self.state.copy()
while True:
is_ended, winner = is_terminal(state)
if is_ended:
return 1.0 if winner == 'black' else 0.0
# 휴리스틱 기반 수 선택
moves = get_legal_moves(state)
move = max(moves, key=lambda m: evaluate_position(apply_move(state, m), state.current_player))
state = apply_move(state, move)
def backpropagate(self, value):
"""결과를 부모로 전파"""
self.visits += 1
self.value += value
if self.parent:
self.parent.backpropagate(1.0 - value)
def mcts_search(root_state, num_simulations=1000):
"""MCTS로 최선의 수 찾기"""
root = MCTSNode(root_state)
root.expand()
for _ in range(num_simulations):
node = root
# Selection
while node.children and not is_terminal(node.state)[0]:
node = node.select()
# Expansion
if not is_terminal(node.state)[0]:
node.expand()
node = random.choice(node.children)
# Simulation
value = node.simulate()
# Backpropagation
node.backpropagate(value)
# 가장 많이 방문한 수 선택
best_child = max(root.children, key=lambda c: c.visits)
return best_child.move
검증의 중요성: 코드가 규칙을 정확히 구현했는가?
LLM이 생성한 코드를 그대로 믿을 수는 없습니다. 논문에서도 검증 단계를 강조합니다:
테스트 케이스 자동 생성
# LLM이 테스트도 생성할 수 있습니다
def test_gomoku_rules():
state = GomokuState()
# 테스트 1: 빈 보드에서 모든 칸이 합법적인가?
assert len(get_legal_moves(state)) == 225
# 테스트 2: 수를 두면 그 칸은 불법이 되는가?
state = apply_move(state, (7, 7))
assert (7, 7) not in get_legal_moves(state)
# 테스트 3: 5연속이 승리인가?
state = GomokuState()
for i in range(5):
state = apply_move(state, (0, i))
if i < 4:
state = apply_move(state, (1, i)) # 상대 수
is_ended, winner = is_terminal(state)
assert is_ended and winner == 'black'
print("All tests passed!")
대칭성 및 불변성 검증
def test_symmetry():
"""게임의 대칭성 확인"""
state1 = GomokuState()
state1 = apply_move(state1, (7, 7))
# 회전 대칭
state2 = rotate_90(state1)
assert evaluate_position(state1, 'black') == evaluate_position(state2, 'black')
LLM이 월드 모델을 생성하는 과정
실제 파이프라인은 다음과 같습니다:
이 과정을 여러 번 반복하면 점점 정확한 월드 모델이 완성됩니다.
장점 재확인: 왜 이 방법이 우수한가?
1. 재사용성
한 번 생성된 월드 모델은 수천 번의 게임 시뮬레이션에 사용됩니다. 프롬프팅 방식은 매 수마다 LLM을 호출해야 하지만, 코드 월드 모델은 생성 후 독립적으로 작동합니다.
2. 성능
Python 코드는 LLM 추론보다 수백 배 빠릅니다. MCTS가 초당 수천 번의 시뮬레이션을 돌릴 수 있습니다.
3. 투명성
코드는 읽을 수 있고, 디버그할 수 있으며, 수정할 수 있습니다. LLM의 "블랙박스" 판단과는 다릅니다.
4. 확장성
새로운 게임을 추가하려면? 규칙만 바꿔서 LLM에게 전달하면 됩니다. MCTS나 다른 알고리즘은 그대로 사용할 수 있습니다.
다음 편 예고: 실전 성능은 어떨까?
구현 방법을 알았으니, 이제 궁금한 건 실제 성능입니다:
다음 글에서는 논문의 실험 결과를 깊이 분석하고, 이 기술의 현실적 한계와 미래 가능성을 논의합니다.
마치며: 코드는 새로운 언어
이 연구의 핵심 통찰은 간단합니다: 코드는 의미를 표현하는 또 다른 언어입니다. LLM은 자연어뿐 아니라 프로그래밍 언어도 "이해"하고 "생성"할 수 있습니다.
차이는 코드가 실행 가능하고, 검증 가능하며, 형식적이라는 점입니다. LLM의 언어 능력을 코드 생성에 활용하면, 우리는 두 세계의 장점을 모두 얻을 수 있습니다.
다음 시간에는 이론과 구현을 넘어, 실전 성능과 실용적 응용을 살펴보겠습니다!
---
참고문헌
키워드: 코드 월드 모델, Python, MCTS, 게임 AI 구현, LLM 코드 생성, 휴리스틱 함수, 불완전 정보 게임