로컬 AI 모델이 테트리스를 플레이하도록 학습시킬 수 있을까?
요약
본 글은 로컬 AI 모델을 활용하여 테트리스와 같은 게임에 적용하는 방법을 탐구합니다. 특히, 복잡한 결정 과정을 모델에게 작은 임무(Move 선택)로 분해하여 부여하고, 그 결과를 바탕으로 게임 보드를 변화시키는 방식을 보여줍니다. 이는 LLM의 추론 능력을 시각적이고 구조화된 작업에 활용하는 새로운 접근법입니다.
핵심 포인트
- AI가 테트리스 플레이어로서 결정 과정을 수행합니다.
- 복잡한 계획을 'Move' 선택이라는 작은 임무로 분해했습니다.
- 모델은 키 입력 대신, 여러 후보 이동 설명 중 최적의 것을 선택합니다.
- Dart와 Flutter를 위한 로컬 AI 프레임워크(llamadart)를 소개합니다.
한 조각이 떨어지고 있습니다. 이 조각은 착지할 수 있는 몇 군데의 위치가 있습니다. 배경 어딘가에서 로컬 AI 모델이 그중 하나를 선택하고 있습니다.
만약 시간이 너무 오래 걸리면, 게임은 그것 없이 계속 진행됩니다.
실제 브라우저 실행에서 조정된 플레이어: WebGPU, Q8_0 및 혼합 후보를 사용하여 51줄 클리어. 이 일러스트레이션은 아래 기록된 벤치마크와는 별개입니다.
이것은 Dart와 Flutter를 위한 크로스 플랫폼 로컬 AI에 관한 5부작 시리즈인 llamadart와 함께 Beyond chat을 여는 것입니다. 우리는 결정, 임베딩(embeddings), 음성, 이미지, 그리고 이들을 연결하는 애플리케이션을 탐구할 것입니다.
테트리스는 실험에 눈에 보이는 결과를 제공합니다: 모델이 착지 위치를 선택하고, 보드가 바뀝니다.
게임 자체에 이미 유용한 휴리스틱(heuristic)이 있습니다. 결정 모델을 추가하는 것이 무엇을 가져다줄까요? 테트리스는 우리가 서로 다른 질문, 훈련, 그리고 답변을 기다리는 비용을 비교할 수 있게 해줍니다.
모델에게 더 작은 임무를 부여하기
플래너(planner)는 AI 없이 옵션을 구축합니다. 활성 조각의 현재 자세로부터 회전, 수평 이동, 하드 드롭을 시도하고 막힌 경로는 거부합니다.
각 후보는 Move입니다: 도달 가능한 착지 위치, 그곳에 도달하는 키 순서(key sequence), 그리고 결과적인 보드 측정값. 중복되는 착지 위치의 경우 가장 짧게 발견된 순서를 유지합니다.
게임은 클리어된 줄 수, 구멍(holes), 스택 높이(stack height), 울퉁불퉁함(bumpiness)을 세기 위해 각 착지를 시뮬레이션합니다. describeCompact()는 이러한 결과를 Laya로 전송되는 텍스트로 변환합니다.
모델은 계획된 이동에 대한 설명들 중에서 선택합니다. 모델이 키를 생성하지는 않습니다.

세 가지 예시 착지 장면 중 B가 예시 답변입니다. Laya는 보드 이미지가 아닌 설명을 받습니다. 게임 코드가 이동을 검증하고 실행합니다.
중력은 계속 작동합니다. 답변이 도착하기 전에 목표 지점이 도달 불가능해질 수 있으며, 게임은 이러한 실패를 기록합니다.
Jev의 역할
TypeSafe의 Jev는 이 접근 방식에 유용한 어휘를 제공합니다. TypeSafe는 이를 System One이라고 부르며, 상태(state)와 유형화된 질문(typed questions)을 제공한 다음 구조화된 답변을 코드에서 직접 사용한다고 설명합니다.
Laya는 Jev와 호환되는 인터페이스를 가진 별도의 오픈 가중치 모델입니다. Llamadart는 Jev의 어휘와 Laya의 요청 형식을 따르며, 이 실험은 Laya를 로컬에서 실행합니다.
여기서 얻은 결과는 Jev가 아닌 Laya와 Tetris에 맞게 튜닝된 헤드(head)의 성능을 측정합니다.
llamadart로 첫 번째 결정을 내리다
ModernBERT 인코더가 llama.cpp를 통해 실행되고, 결정 헤드(decision head)가 답변을 생성합니다. 각 질문은 산문(prose)을 생성하지 않고 인코더 패스(encoder pass)를 거칩니다.

로컬 파이프라인을 통한 하나의 질문 처리 과정입니다. DecisionEngine이 토큰을 준비하고 모델의 점수(score)를 디코딩합니다. 결과를 기다리는 동안 게임은 계속 실행됩니다.
ModernBERT는 질문, 옵션 및 상태를 함께 읽습니다. 헤드는 각 옵션을 표시하는 위치에 점수를 매기고, 디코더가 이 점수들을 확률로 변환합니다. 튜닝 과정에서는 인코더는 고정된(frozen) 상태를 유지하고 헤드만 변경됩니다.
모델을 로드하고 이동 요청하기
Dart 프로젝트에 llamadart를 추가하려면 dart pub add llamadart를 사용하고, 다음 내용을 bin/decision_walkthrough.dart로 저장한 후 지원되는 네이티브 타겟에서 dart run bin/decision_walkthrough.dart를 실행하세요. 문서화된 의사 결정 경로는 macOS에서 검증되었으며, 다른 타겟은 지원 매트릭스를 참조하세요. 첫 번째 실행 시 약 421 MB의 인코더와 106 MB의 헤드가 다운로드됩니다. 이 독립형 예제는 하나의 결정만 출력하며, 테트리스 게임을 시작하지는 않습니다.
import 'package:llamadart/llamadart.dart';
Future<void> main(List<String> args) async {
...
DecisionModel은 인코더와 헤드를 식별합니다. load는 이들의 파일을 준비하고 두 리소스를 소유합니다. systemOne은 하나의 상태에 대한 명명된 질문에 답변하며, criteria는 각 후보 레이블을 그 설명에 매핑합니다.
타이핑된 답변 읽기
result.choices['move']는 ChoiceAnswer를 반환합니다. 여기서 choice는 선택된 레이블이며, probabilities에는 모든 옵션의 확률이 포함됩니다. 다음은 해당 필드들을 설명하는 예시 렌더링으로, 실제 출력 결과가 아닙니다. 실행 시 다른 옵션을 선택할 수 있습니다.
{
"choice": "B",
"probabilities": {
...
예제는 candidates[answer.choice]를 사용하여 선택된 설명을 복구합니다. 게임에서는 동일한 조회(lookup)가 해당 착지 지점과 계획된 키를 가진 Move로 이어집니다. 튜닝된 플레이어는 확률 자체에서 선택하므로 동률일 경우 무작위로 깨뜨릴 수 있습니다. ChoiceAnswer.choice는 가장 높은 확률의 첫 번째 옵션을 사용합니다. 0.75의 확률이 성공에 대한 75% 보장은 아닙니다.
B에서 버튼 누름까지
매핑은 게임 메모리에 유지됩니다:
B → 원본 후보 → Move {
landing,
planned keys,
...
아무것도 누르기 전에, 게임은 여전히 같은 조각을 제어하고 있는지 확인합니다. 추론(inference) 동안 중력이 계속되었으므로 플래너를 다시 실행하여 현재 자세(pose)에서 B의 착지 지점까지의 경로를 찾습니다. 이 경로를 큐에 넣고 설정된 간격으로 하나의 동작을 누릅니다.
만약 조각이 이미 고정되었다면, 그 답변은 폐기됩니다. 착지 지점이 막힌 경우, 게임은 실패를 기록하고 하드 드롭합니다. 실행 코드가 이러한 경우를 처리합니다.
API는 세 가지 종류의 질문을 지원합니다:
| 종류 | 결과 | 예시 |
|---|---|---|
| Choice | 선택된 옵션과 옵션 확률 | 어떤 착지 지점을 사용해야 할까요? |
| ... | ||
| “Noul”은 기존 Jev 및 Laya 어휘를 유지합니다. 점수는 레벨 사이에 떨어질 수 있으며, 애플리케이션이 규칙을 적용할 때까지 확률은 부울(boolean) 값이 아닙니다. |
챗 모델도 움직임을 선택할 수 있습니다. 여기에는 일치하는 챗 모델 벤치마크가 없으므로, 이 실험으로는 LLM에 대한 우위를 확립할 수 없습니다.
분리하여 유지해야 할 두 가지 설정
| 설정 | 변경하는 것 | 옵션 |
|---|---|---|
| Player | 움직임이 선택되는 방식 | Human, heuristic, random, Laya judge, checklist, base choice, tuned choice |
| Candidates | 고려되는 계획된 움직임 | 3 best + 3 random, 6 best, 6 random, 모든 생성 가능한 합법적 움직임 |
휴리스틱(heuristic) 플레이어는 모든 생성된 합법적 움직임을 고려합니다. 기본 선택(base choice)은 최대 여섯 개를 수용하므로, “모두(all)”는 해당 플레이어의 혼합된 짧은 목록으로 되돌아갑니다. 튜닝된 선택(tuned choice)은 녹아웃 라운드를 통해 더 큰 세트를 처리합니다. 아래 비교는 별도로 언급되지 않는 한 혼합된 짧은 목록을 사용합니다.
첫 번째 결과: 한 줄
기본 헤드에 여섯 개의 후보를 제공하고 선택하도록 요청하면, 기록된 단기 벤치마크에서 해당 플레이어는 평균 한 줄을 지웁니다. 랜덤 선택도 한 줄을 지웁니다.
각 후보에 대해 “이것이 좋은 움직임인가요?”라고 질문하면 평균은 18줄로 높아집니다.
더 좁은 체크리스트(checklist)는 35줄에 도달합니다: 움직임이 줄을 지우는지, 그리고 구멍을 만드는지 여부를 물어본 다음, 두 번째 확률에서 첫 번째 확률을 뺍니다.
게임은 이미 어떤 움직임이 라인을 지우는지, 혹은 구멍을 만드는지 알고 있습니다. 체크리스트는 Laya가 텍스트에서 이러한 사실들을 얼마나 잘 해석하는지를 테스트하며, 실제 애플리케이션에서는 계산된 값들을 직접 읽어올 수 있습니다. 여섯 개의 후보군은 열두 개의 모델 질문을 의미할 수 있습니다.
이러한 개선에는 노력이 필요합니다. 이 구현에서는 질문들을 호출로 배치(batching)한다고 해서 그것들이 하나의 인코더 패스(encoder pass)가 되는 것은 아닙니다.
세 가지 모두 섞인 짧은 목록(shuffled shortlist)을 받습니다: 휴리스틱이 찾은 세 가지 최고의 움직임과 무작위로 선택된 나머지 세 가지 움직임입니다. 단일 선택 접근 방식도 학습을 통해 유용하게 만들 수 있을까요?
휴리스틱을 선생님으로 삼기
의사 결정 헤드(decision head)만 튜닝하고, 인코더는 고정(frozen) 상태로 유지합니다. dart run bin/decision_walkthrough.dart --tuned를 실행하여 동일한 인코더와 함께 고정된 테트리스 헤드를 시도해 보세요. 하나의 샘플 결정은 간단한 점검이며, 게임플레이 비교는 나중에 이루어집니다.
데이터셋에는 다른 게임에서 가져온 16,000개의 학습 질문과 2,000개의 검증 질문이 있습니다. 레이블은 휴리스틱으로부터 오며, 동률인 최고의 움직임들은 목표 확률을 공유합니다.
별도로 분리된 질문들에서 기본 헤드(base head)는 30.5%의 정확도를 기록했습니다. 무작위 선택은 27.5%였으며, 이 작업은 여러 개의 동등하게 최고의 옵션을 가질 수 있으므로 단순히 여섯 개 중 하나를 고르는 것만은 아닙니다.
게시된 헤드는 **75.7%**에 도달합니다. 문서화된 12 에포크(epoch) 실행 결과는 75.5%에서 78.5% 사이였습니다. 학습 레시피에 전체 결과가 포함되어 있습니다.
이것은 최적의 플레이가 아닌 휴리스틱 모방을 측정합니다. MPS 학습은 비트 단위로 재현 가능하지 않았습니다.
유용한 통제(control) 사례: 기본 헤드는 튜닝된 질문 형식을 받았을 때 여전히 대략 무작위로 플레이했습니다. 새로운 문구만으로는 개선을 설명할 수 없었습니다.
자신만의 헤드를 학습시키기
학습은 Python 노트북에서 진행됩니다. DecisionEngine이 결과를 로드하고 실행하는 헤드를 사용합니다. 준비된 example/laya_tetris 프로젝트에서 시드된 데이터셋을 생성하세요:
dart run bin/make_dataset.dart dataset
그런 다음 문서화된 Python 환경을 사용하여 training/laya_head_tuning.ipynb를 실행합니다. 이 노트북은 인코더(encoder)를 고정하고, 헤드(head)를 학습시키며, 보류된 질문을 평가하고, laya-head-tetris.safetensors 파일을 작성합니다. 내장된 설정 덕분에 llamadart는 별도의 설정 파일 없이 이를 로드할 수 있습니다.
compare_heads.dart에서 tunedHead: 소스를 ModelSource.path('training/laya-head-tetris.safetensors')로 교체하고, 경로를 내보낸 파일에 맞게 조정하세요. 첫 번째 워크스루의 경우, DecisionModel.head와 동일한 소스를 사용합니다. 일치하는 인코더를 유지하세요. Dart API는 Python 학습 후 헤드를 실행합니다.
하나의 인코더에서 두 개의 헤드 비교하기
기본 플레이어(base)와 튜닝된 플레이어(tuned) 간에 전환하는 앱의 경우, 인코더를 한 번 로드하고 두 헤드를 모두 연결하세요. 필수 호출은 아래와 같습니다. 완전한 실행 가능한 비교 코드는 고정된 모델 소스, 요청(request), main() 호출 및 부분적인 로딩 실패를 포함한 정리(cleanup)까지 포함합니다. 이를 동일한 Dart 프로젝트의 bin/compare_heads.dart로 저장하고 dart run bin/compare_heads.dart를 실행하세요.
// compare_heads.dart에서 발췌; 설정 및 정리는 파일에 있습니다.
final engine = await LlamaEngine.load(
LlamaModel(encoder),
...
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
