화면 전체를 읽고, 블록의 최종 위치를 결정하는 언어모델 기반 게임 에이전트

책 안에서 이 장이 맡는 역할

앞 장에서는 DQN으로 플레이 정책을 학습했다. 이 장에서는 LLM/VLM이 보드 상태를 읽고 블록 배치를 선택하는 구조를 살펴본다. 현재 블록, 다음 블록, 구멍, 높이와 표면의 굴곡을 함께 판단하되, 모델의 응답은 코드에서 검증한다.

학습 목표

  • 테트리스 보드를 LLM이 읽을 수 있는 표현으로 바꾸는 방법을 이해한다.
  • VLM에게 보드 이미지를 보여 주고 target_rot, target_x를 JSON으로 받는 구조를 만든다.
  • 텍스트 LLM에게는 전체 화면을 ASCII 보드와 후보 목록으로 제공해 환각을 줄인다.
  • LLM의 자유 답변을 그대로 믿지 않고, 후보 생성과 검증을 결합해 안정적인 게임 조작으로 바꾼다.
  • LLM 판단을 실제 Qt 테트리스 화면에 연결해 플레이 가능한 에이전트로 만든다.

문제 정의: 블록을 어디에 놓을 것인가

테트리스에서 모델이 매 순간 해야 할 결정은 매우 구체적이다. 현재 떨어지는 블록을 몇 번 회전하고, 어느 열에 놓을 것인가. 이 장에서는 그 결정을 두 값으로 표현한다.

{"target_rot": 1, "target_x": 4}

target_rot은 시계 방향 회전 횟수이고, target_x는 블록을 놓을 목표 열이다. LLM은 이 두 값을 결정하고, 프로그램은 그 값을 실제 키 입력으로 번역한다. 이렇게 하면 LLM이 매 프레임 왼쪽, 오른쪽, 회전을 직접 누르는 부담이 줄어든다.

T 블록의 target_rot 0~3 회전 모양, 4번 열에 놓일 자리를 표시한 보드, target_rot 1과 target_x 4로 이루어진 LLM 응답 예
그림. LLM의 답은 회전 횟수와 목표 열, 숫자 두 개로 충분하다

전체 설계

Qt Tetris screen/state
  -> grid extraction
  -> image or ASCII board
  -> LLM/VLM decision
  -> target_rot, target_x
  -> micro action translator
  -> Left/Right/Rotate/Drop command
파일이 장에서의 역할
vlm_macro_client.py보드 이미지를 VLM에게 보여 주고 목표 회전과 목표 열을 직접 받는 가장 직관적인 구현
llm_macro_client.py후보 배치를 계산한 뒤 LLM에게 Top-K 중 하나를 고르게 하는 안정형 구현
vlm_teacher.py관측 그리드를 컬러 이미지로 렌더링하고 VLM이 다음 행동과 점수를 반환하게 하는 교사 모듈
llm_teacher.py관측 그리드를 ASCII로 바꾸어 텍스트 LLM이 판단할 수 있게 하는 교사 모듈
tetris/src/tetris_engine.*규칙과 물리, 라인 삭제, 보드 상태를 제공하는 오래된 문제의 시뮬레이터
tetris/src/tetris_c_api.cppC++ 엔진을 Python에서 사용할 수 있게 연결하는 브릿지

1단계: 화면을 모델이 볼 수 있는 데이터로 바꾸기

LLM이 게임을 풀려면 먼저 화면을 읽어야 한다. 이 프로젝트에는 두 가지 표현 방식이 있다. 하나는 VLM용 이미지이고, 다른 하나는 텍스트 LLM용 ASCII 보드다.

같은 테트리스 판을 숫자 그리드, VLM용 컬러 이미지, 텍스트 LLM용 ASCII 보드로 나란히 표현한 그림
그림. 같은 관측 그리드를 VLM에는 컬러 이미지로, 텍스트 LLM에는 행 번호가 붙은 ASCII로 보여 준다

이미지 표현

vlm_teacher.py와 vlm_macro_client.py는 보드의 숫자 그리드를 색상 이미지로 바꾼다. 빈 칸은 어두운 색, I/O/T/S/Z/J/L 블록은 서로 다른 색으로 칠한다. 그 다음 각 칸을 cell_px 크기로 확대해 VLM이 보기 쉬운 이미지로 만든다.

rgb = palette[g]
rgb = np.repeat(np.repeat(rgb, cell_px, axis=0), cell_px, axis=1)
image = Image.fromarray(rgb, mode="RGB")

텍스트 표현

텍스트 LLM은 이미지를 직접 보지 못하므로 보드를 문자열로 바꾼다. 빈 칸은 '.', 채워진 칸은 '#', 또는 조각 번호로 표시한다. 행 번호를 붙이면 모델이 위아래 위치를 더 안정적으로 읽을 수 있다.

00|..........|
01|..........|
18|..###.....|
19|.#####....|

2단계: VLM에게 렌더링한 보드 이미지를 보여 주기

가장 책 제목에 잘 맞는 방식은 VLM 방식이다. 모델에게 현재 보드 이미지를 보여 주고, 지금 블록의 최종 배치를 JSON으로 답하게 한다. 이 접근은 사람이 게임 화면을 보고 판단하는 방식과 가장 비슷하다.

prompt = (
    "You are an expert Tetris AI.\n"
    "Look at the current board state and decide the best final resting position.\n"
    "Return JSON: {\"target_rot\": 0..3, \"target_x\": 0..9}."
)

vlm_macro_client.py는 새 블록이 나타날 때마다 VLM을 호출한다. 응답에서 target_rot과 target_x를 뽑고, 실제 조작은 별도의 변환 함수가 맡는다. LLM이 '왼쪽으로 세 번 가고 회전해'처럼 절차를 말하게 하지 않고, 최종 목표만 말하게 하는 것이 핵심이다.

3단계: 목표 위치를 실제 조작으로 번역하기

게임 엔진은 여전히 한 번에 하나의 행동만 받는다. 그래서 LLM의 목표 배치를 실제 키 입력으로 바꾸는 작은 컨트롤러가 필요하다.

if cur_rot != target_rot:
    return RotateCW or RotateCCW
if cur_x < target_x:
    return Right
if cur_x > target_x:
    return Left
return HardDrop

이 번역기 덕분에 LLM은 고수준 판단만 담당하고, 세부 입력은 프로그램이 처리한다. 이것이 LLM 게임 에이전트를 만들 때 매우 중요한 패턴이다. 모델에게 모든 것을 시키는 대신, 모델이 잘하는 판단과 코드가 잘하는 실행을 나눈다.

VLM이 target_rot 1, target_x 6을 답하면 번역기가 RotateCW, Right 세 번, HardDrop으로 바꾸어 T 블록을 목표 자리에 놓는 과정
그림. 목표 회전과 목표 열이 실제 키 입력 순서로 번역되는 과정

4단계: 텍스트 LLM에는 후보를 고르게 하기

VLM 방식은 직관적이지만, 모델이 잘못된 좌표를 말하거나 보드를 착각할 수 있다. llm_macro_client.py는 더 안전한 방식을 사용한다. 프로그램이 가능한 모든 배치를 먼저 계산하고, 각 후보의 점수를 만든 다음, LLM에게 Top-K 후보 중 하나만 고르게 한다.

후보 지표의미
cleared이 배치로 지워지는 줄 수
new_holes새로 생기는 구멍 수
on_hole이미 구멍이 있는 열 위에 쌓는 블록 수
bumpiness표면이 얼마나 울퉁불퉁한지
max_h가장 높은 열의 높이
landing_height블록이 얼마나 높은 위치에 놓이는지
look현재 후보 점수에 다음 블록의 최선 점수를 더한 1수 앞보기 점수
Pre-evaluated candidate placements:
[0] rot=0 x=4 cleared=1 new_holes=0 bump=8 max_h=6 look=+2.40
[1] rot=1 x=7 cleared=0 new_holes=1 bump=12 max_h=8 look=-4.10

Reply EXACTLY as: {"choice": 0}

이 방식은 LLM의 창의성을 줄이는 대신 안정성을 얻는다. 모델은 좌표를 새로 발명하지 않고, 이미 검증된 후보 중 하나를 고른다. 이 패턴은 LLM을 의사결정자로, 코드를 검산자로 사용하는 구조다.

T 블록을 놓을 수 있는 모든 자리를 계산해 지표를 매기고, 1위·2위·꼴찌 후보 보드를 비교한 그림
그림. 코드가 모든 배치를 떨어뜨려 지표를 매기고, LLM은 Top-K 목록에서 번호만 고른다

5단계: 환각을 줄이는 프롬프트 설계

게임 에이전트에서 LLM 환각은 곧 잘못된 조작이다. 그래서 프롬프트는 자유로운 설명보다 제한된 출력 형식을 강하게 요구해야 한다.

LLM/VLM 응답이 JSON 파싱, 범위 검사, 후보 확인을 차례로 통과해야 키 입력으로 실행되고, 어느 단계든 실패하면 휴리스틱 1위 후보로 대체되는 흐름
그림. LLM의 답은 파싱과 검증을 모두 통과해야 실제 조작이 된다
  • 응답은 JSON 하나만 받는다.
  • 선택 가능한 행동 또는 후보 인덱스의 범위를 명시한다.
  • 구멍 생성 금지, 높이 낮추기, 표면 평탄화처럼 판단 기준의 우선순위를 적는다.
  • 모델이 직접 좌표를 만들게 하기보다 가능한 후보 목록을 주고 고르게 한다.
  • JSON 파싱 실패 시 휴리스틱 1위 후보로 되돌아가는 fallback을 둔다.

6단계: 오래된 게임과 최신 LLM의 만남

테트리스는 1980년대 게임이지만, LLM에게는 작지 않은 추론 문제다. 화면에는 숫자 계산, 공간 인식, 단기 계획, 위험 회피가 동시에 들어 있다. 그래서 이 장은 단순한 게임 자동화가 아니라, LLM을 시각적 의사결정 엔진으로 사용하는 예제가 된다.

실습 순서

  1. Qt 테트리스 실행: 테트리스 GUI를 실행하고 RL/agent 포트를 연다.
  2. VLM 클라이언트 실행: vlm_macro_client.py를 실행해 화면 이미지를 VLM에게 전달한다.
  3. JSON 응답 확인: target_rot과 target_x가 정상적으로 파싱되는지 로그를 확인한다.
  4. 조작 번역 확인: 목표 회전과 목표 열이 Left/Right/Rotate/Drop 명령으로 바뀌는 과정을 관찰한다.
  5. 텍스트 LLM 방식 비교: llm_macro_client.py로 후보 기반 선택을 실행해 VLM 방식과 안정성을 비교한다.
  6. 프롬프트 개선: 구멍 금지, 낮은 높이 유지, 라인 삭제 우선순위를 바꿔 플레이 스타일 변화를 본다.

실행 예시

./tetris/build/tetris_rl --rl-port 5555

python vlm_macro_client.py \
  --port 5555 \
  --model-dir llms/Qwen2.5-VL-3B-Instruct

python llm_macro_client.py \
  --port 5555 \
  --model-path llms/qwen2.5-3b-instruct-q4_k_m.gguf \
  --top-k 6

챕터 마무리

이 장에서 우리는 테트리스를 강화학습 점수 게임이 아니라 LLM 의사결정 문제로 다시 보았다. 숫자 격자를 색상 이미지나 ASCII 보드로 표현하고, 모델에게 최종 배치를 묻고, 코드는 그 결정을 실제 조작으로 바꾸었다.

핵심은 LLM에게 모든 책임을 넘기지 않는 것이다. 화면 인식과 판단은 LLM이 맡고, 후보 생성, 좌표 검증, JSON 파싱, fallback, 키 입력 변환은 코드가 맡는다. 이 분업 구조가 오래된 문제를 최신 LLM으로 풀 때 가장 안정적인 기본형이다.

연습 문제

  • VLM 프롬프트에서 'clear lines if possible'을 제거하면 플레이가 어떻게 달라질지 예상해 보자.
  • target_x를 직접 말하게 하는 방식과 후보 index만 고르게 하는 방식의 장단점을 비교해 보자.
  • ASCII 보드에 행 번호와 열 번호를 모두 추가하면 LLM의 선택 안정성이 좋아질지 실험해 보자.
  • JSON 파싱 실패가 반복될 때 사용할 fallback 정책을 직접 설계해 보자.