소형 LLM으로 게임 NPC 만들기 — 3전 4기 기록

이주형·2026년 5월 19일
post-thumbnail

소형 LLM으로 게임 NPC 만들기 — 실패 3번, 성공 1번의 기록

2.4B 파라미터 한국어 모델로 캐릭터 일관성을 가진 AI NPC를 만든 과정.
HCX-SEED 실패 → Qwen2.5 실패 → EXAONE 성공 → Unity 연동까지.


왜 이 글을 쓰는가

Unity로 모바일 로그라이크 게임을 만들고 있다. NPC가 하나 있는데, 플레이어의 자유 입력에 반응해야 한다. 선택지 기반 대화로는 캐릭터의 개성을 표현하기 어려웠고, 대형 API(Claude, GPT-4o)는 비용과 레이턴시가 문제였다. 그래서 소형 LLM을 파인튜닝해서 로컬에서 돌리기로 했다.

결론부터 말하면, 3번 실패하고 4번째에 성공했다. 이 글은 그 과정에서 배운 것을 정리한 기록이다.


1. 프로젝트 개요

게임: "회귀자는 탑을 오른다" — 모바일 로그라이크
NPC: 마타이오스 — 기억을 잃은 소녀 전사. 호감도에 따라 말투가 달라지고, 스토리 진행에 따라 기억을 되찾는다.

NPC에게 요구되는 "행동 계약":

  • 호감도 5단계(hostile/cold/neutral/warm/open)에 따른 어조 분리
  • 게임 세계관 밖 질문(날씨, AI 모델, 수학 질문 등) 차단 (BIW 규칙)
  • 스토리 진행(S0~S5)에 따른 기억 키워드 반응
  • S3 이후 정체성 혼란(fracture) 패턴
  • 항상 한국어 반말

이걸 소형 모델 하나로 해결해야 했다.


2. 첫 번째 시도: HCX-SEED 0.5B — GGUF 변환 자체가 안 됨

네이버의 HCX-SEED 0.5B. 한국어 특화 모델이라 기대가 컸다.

결과: 완전 실패.

  • GGUF 변환 시 가중치 매핑 오류 (hyperclovax 아키텍처가 llama.cpp 미지원)
  • Ollama에 올려봤더니 이름을 "마타리오스"로 출력하고, 성별을 남성으로 답하고, 마크다운 표를 그렸다
  • Colab에서 직접 추론해도 BIW 차단 실패, AI 자인 발생

나중에 알게 된 건, 학습 자체가 안 됐을 가능성이 높다는 것이다. BPE 토큰화 문제로 DataCollatorForCompletionOnlyLM이 응답 경계를 찾지 못했고, 모든 라벨이 -100이 되어 사실상 학습 토큰이 0개였을 수 있다.

교훈: 모델을 고르기 전에 GGUF 변환 호환 여부부터 확인할 것. 그리고 훈련 후 반드시 라벨 검증을 할 것.


3. 두 번째 시도: Qwen2.5-1.5B — 다국어 누출이 해결 안 됨

LLaMA 호환 아키텍처, ChatML 네이티브, GGUF 지원. 조건이 완벽해 보였다.

v6.0: BPE 토큰 경계 버그 발견 — "마타이오스: " trailing space가 문맥 내에서 다음 문자와 합쳐져 매칭 실패. 이건 수정했다.

v6.1: 훈련 성공, 추론 9건 테스트. 4건(44%)에서 영어/중국어 출력. "서울 날씨 어때?"에 영어로 답하고, "너 AI지?"에 "人工智能。"이라고 답했다.

v6.2: 시스템 프롬프트에 "반드시 한국어로만 응답한다" 추가 + BIW 거부 데이터 85건 증강 후 재훈련. 개선 없음 — 여전히 44%.

622건 SFT로는 Qwen2.5의 다국어 prior를 덮을 수 없었다. 다국어 모델의 base 지식이 너무 강해서, 학습 데이터에 없는 패턴(세계관 외부 질문)이 들어오면 영어/중국어 모드로 전환되는 현상이었다.

교훈: 다국어 모델에서 단일언어 SFT를 하려면 데이터가 훨씬 많거나, 아예 한국어 primary 모델을 써야 한다.


4. BPE 토큰화 버그 — 이건 따로 기록할 가치가 있다

DataCollatorForCompletionOnlyLM에 response_template을 문자열로 전달하면 사일런트 실패가 발생할 수 있다.

핵심은 BPE의 컨텍스트 민감성이다. 같은 문자열이라도 독립적으로 토큰화할 때와 긴 텍스트의 일부로 토큰화할 때 토큰 ID가 달라진다.

독립: "마타이오스: " → [..., 25, 220]     (공백이 독립 토큰)
문맥: "마타이오스: …좋진 않아." → [..., 25, 4593]  (공백이 다음 문자와 합쳐짐)

콜레이터는 독립 토큰화 결과로 문맥 내 시퀀스를 찾으니까 매칭이 실패하고, 모든 라벨이 -100이 되고, 모델은 아무것도 학습하지 않는다. loss가 0인데 에러가 안 나서 "왜 파인튜닝이 안 먹히지?"가 된다.

해결법: trailing space를 제거하고, 토큰 ID 리스트로 전달하고, 훈련 전에 active 라벨이 0이 아닌지 검증하는 셀을 추가했다.

이 문제는 Qwen2.5뿐 아니라 모든 BPE 모델에서 발생할 수 있다.


5. 세 번째 시도 (성공): EXAONE 3.5 2.4B — 한국어가 1순위인 모델

리서치를 다시 했다. 핵심 발견들:

  • 단일언어 SFT가 해당 언어에서 더 높은 성능을 보인다는 논문 (EACL 2024)
  • 소형 모델 + 페르소나 고정 + 모듈형 메모리가 NPC 대화에 적합하다는 연구
  • EXAONE 3.5 2.4B는 한국어가 1순위 학습 언어 (Korean-primary)

EXAONE으로 교체하고, QLoRA rank를 16→32로 올리고, 데이터를 622→697건으로 증강했다.

v7 결과 (5세트 26턴):

  • 한국어 유지율: 100% (v6.1의 56%에서 완전 해결)
  • BIW 차단: 100%
  • 호감도 어조 분리: 정상
  • Fracture 패턴: 정상

다국어 누출이 0%가 된 건 모델 교체 덕분이 크다. 데이터 증강이나 rank 강화도 도움이 됐겠지만, 근본적으로는 한국어 prior가 강한 모델을 쓴 것이 결정적이었다.


6. Ollama가 안 돼서 FastAPI로 갈아탄 이야기

EXAONE 모델은 GGUF로 변환은 된다. llama.cpp의 convert_hf_to_gguf.py가 EXAONE 아키텍처를 지원하기 때문이다.

그런데 추론이 안 된다. GGUF "변환기"와 "추론 엔진"은 별개인데, llama.cpp 추론 경로에 EXAONE의 attention 패턴이 구현되어 있지 않았다.

처음에는 이걸 몰라서 한참 삽질했다. q8_0 양자화까지 성공하고, ollama create까지 됐는데, ollama run에서 추론 오류가 나서야 알게 됐다.

대안으로 FastAPI + transformers 직접 추론 서버를 만들었다. Python에서 transformers로 모델을 로드하고, HTTP API를 열어서 Unity에서 호출하는 구조다.

Unity (C#) ──HTTP POST──▶ FastAPI (Python) ──▶ transformers + EXAONE 2.4B

Apple Silicon Mac에서는 MPS, NVIDIA GPU가 있으면 CUDA, 없으면 CPU로 자동 감지한다.


7. Unity 연동

두 가지 방식을 만들었다:

  1. RemoteLLMProvider: 기존 Unity 프로젝트의 ILLMProvider 인터페이스에 맞춘 동기식 클라이언트. 서버 불통 시 결정론적 폴백 제공.
  2. MataiosDialogueClient: 비동기 UnityWebRequest 코루틴 기반. 실제 게임에서 사용할 것. UI가 안 멈춘다.

Unity Inspector에서 LLMProviderMode를 Remote로 바꾸고 서버 URL을 입력하면 끝이다.


8. 기술 스택 요약

레이어선택비고
베이스 모델EXAONE 3.5 2.4B-InstructKorean-primary, trust_remote_code
파인튜닝QLoRA r=32, alpha=64Colab A100, 697건 SFT
추론 서버FastAPI + transformersMPS/CUDA/CPU 자동 감지
게임 엔진Unity (C#)RemoteLLMProvider + MataiosDialogueClient
데이터697건 JSONL13개 task, S0~S5, 호감도 -100~100

9. 실패에서 배운 것

  1. 모델 선택이 데이터 품질보다 중요할 수 있다: 622건으로 Qwen2.5의 다국어 prior를 못 덮었지만, EXAONE에서는 697건으로 충분했다.
  2. BPE 사일런트 실패는 진짜 무섭다: loss가 0인데 에러가 안 나면 아무도 눈치 못 챈다. 라벨 검증 셀은 필수다.
  3. GGUF 변환 ≠ 추론 가능: 변환기와 추론 엔진이 별개라는 걸 알기 전에 많은 시간을 날렸다.
  4. trust_remote_code는 반드시 revision 핀: HF 모델 팀이 최신 커밋을 업데이트하면 어떤 transformers 버전을 깔아도 호환이 안 될 수 있다.
  5. Ollama가 안 되면 FastAPI로 우회할 수 있다: transformers 직접 추론은 느리지만, 로컬 서버 용도로는 충분하다.

10. 다음 단계

  • 모바일 빌드 테스트 (FastAPI 서버가 같은 네트워크에 있으면 됨)
  • MLC-LLM 온디바이스 추론 재검토 (EXAONE 지원 여부)
  • 대화 히스토리 관리 + 요약 파이프라인
  • 유저 테스트 — 실제 플레이어 반응 수집

부록: 에러 로그 (전체)

#버전에러원인해결
E-01v5GGUF 변환 실패HCX-SEED 아키텍처 미지원모델 교체
E-02v6.0response_template 매칭 실패BPE trailing spacetrailing space 제거 + 토큰 ID 리스트
E-03v6.0학습 안 됨 (loss=0)콜레이터 매칭 실패 → 라벨 전부 -100토큰 ID 리스트 + 라벨 검증 셀
E-04v6.2SyntaxErrorf-string 이스케이프 충돌변수에 먼저 할당
E-05v6.2다국어 누출 44%Qwen2.5 다국어 prior 억제 불가모델 교체 (→ EXAONE)
E-06v7.0ImportError 연쇄trust_remote_code 최신 커밋 비호환revision 핀 + transformers 버전 고정
E-07v7.0pip install 실패쉼표 누락 → 문자열 연결쉼표 위치 수정

이 글은 개발 일지이며, 게임 스토리/대사 원본은 포함하지 않습니다.
모델 파일은 배포하지 않으며, 재현 방법만 기술합니다.

profile
작가, PM 출신 비개발자의 개발새발공부

0개의 댓글