2.4B 파라미터 한국어 모델로 캐릭터 일관성을 가진 AI NPC를 만든 과정.
HCX-SEED 실패 → Qwen2.5 실패 → EXAONE 성공 → Unity 연동까지.
Unity로 모바일 로그라이크 게임을 만들고 있다. NPC가 하나 있는데, 플레이어의 자유 입력에 반응해야 한다. 선택지 기반 대화로는 캐릭터의 개성을 표현하기 어려웠고, 대형 API(Claude, GPT-4o)는 비용과 레이턴시가 문제였다. 그래서 소형 LLM을 파인튜닝해서 로컬에서 돌리기로 했다.
결론부터 말하면, 3번 실패하고 4번째에 성공했다. 이 글은 그 과정에서 배운 것을 정리한 기록이다.
게임: "회귀자는 탑을 오른다" — 모바일 로그라이크
NPC: 마타이오스 — 기억을 잃은 소녀 전사. 호감도에 따라 말투가 달라지고, 스토리 진행에 따라 기억을 되찾는다.
NPC에게 요구되는 "행동 계약":
이걸 소형 모델 하나로 해결해야 했다.
네이버의 HCX-SEED 0.5B. 한국어 특화 모델이라 기대가 컸다.
결과: 완전 실패.
나중에 알게 된 건, 학습 자체가 안 됐을 가능성이 높다는 것이다. BPE 토큰화 문제로 DataCollatorForCompletionOnlyLM이 응답 경계를 찾지 못했고, 모든 라벨이 -100이 되어 사실상 학습 토큰이 0개였을 수 있다.
교훈: 모델을 고르기 전에 GGUF 변환 호환 여부부터 확인할 것. 그리고 훈련 후 반드시 라벨 검증을 할 것.
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 모델을 써야 한다.
DataCollatorForCompletionOnlyLM에 response_template을 문자열로 전달하면 사일런트 실패가 발생할 수 있다.
핵심은 BPE의 컨텍스트 민감성이다. 같은 문자열이라도 독립적으로 토큰화할 때와 긴 텍스트의 일부로 토큰화할 때 토큰 ID가 달라진다.
독립: "마타이오스: " → [..., 25, 220] (공백이 독립 토큰)
문맥: "마타이오스: …좋진 않아." → [..., 25, 4593] (공백이 다음 문자와 합쳐짐)
콜레이터는 독립 토큰화 결과로 문맥 내 시퀀스를 찾으니까 매칭이 실패하고, 모든 라벨이 -100이 되고, 모델은 아무것도 학습하지 않는다. loss가 0인데 에러가 안 나서 "왜 파인튜닝이 안 먹히지?"가 된다.
해결법: trailing space를 제거하고, 토큰 ID 리스트로 전달하고, 훈련 전에 active 라벨이 0이 아닌지 검증하는 셀을 추가했다.
이 문제는 Qwen2.5뿐 아니라 모든 BPE 모델에서 발생할 수 있다.
리서치를 다시 했다. 핵심 발견들:
EXAONE으로 교체하고, QLoRA rank를 16→32로 올리고, 데이터를 622→697건으로 증강했다.
v7 결과 (5세트 26턴):
다국어 누출이 0%가 된 건 모델 교체 덕분이 크다. 데이터 증강이나 rank 강화도 도움이 됐겠지만, 근본적으로는 한국어 prior가 강한 모델을 쓴 것이 결정적이었다.
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로 자동 감지한다.
두 가지 방식을 만들었다:
ILLMProvider 인터페이스에 맞춘 동기식 클라이언트. 서버 불통 시 결정론적 폴백 제공.Unity Inspector에서 LLMProviderMode를 Remote로 바꾸고 서버 URL을 입력하면 끝이다.
| 레이어 | 선택 | 비고 |
|---|---|---|
| 베이스 모델 | EXAONE 3.5 2.4B-Instruct | Korean-primary, trust_remote_code |
| 파인튜닝 | QLoRA r=32, alpha=64 | Colab A100, 697건 SFT |
| 추론 서버 | FastAPI + transformers | MPS/CUDA/CPU 자동 감지 |
| 게임 엔진 | Unity (C#) | RemoteLLMProvider + MataiosDialogueClient |
| 데이터 | 697건 JSONL | 13개 task, S0~S5, 호감도 -100~100 |
| # | 버전 | 에러 | 원인 | 해결 |
|---|---|---|---|---|
| E-01 | v5 | GGUF 변환 실패 | HCX-SEED 아키텍처 미지원 | 모델 교체 |
| E-02 | v6.0 | response_template 매칭 실패 | BPE trailing space | trailing space 제거 + 토큰 ID 리스트 |
| E-03 | v6.0 | 학습 안 됨 (loss=0) | 콜레이터 매칭 실패 → 라벨 전부 -100 | 토큰 ID 리스트 + 라벨 검증 셀 |
| E-04 | v6.2 | SyntaxError | f-string 이스케이프 충돌 | 변수에 먼저 할당 |
| E-05 | v6.2 | 다국어 누출 44% | Qwen2.5 다국어 prior 억제 불가 | 모델 교체 (→ EXAONE) |
| E-06 | v7.0 | ImportError 연쇄 | trust_remote_code 최신 커밋 비호환 | revision 핀 + transformers 버전 고정 |
| E-07 | v7.0 | pip install 실패 | 쉼표 누락 → 문자열 연결 | 쉼표 위치 수정 |
이 글은 개발 일지이며, 게임 스토리/대사 원본은 포함하지 않습니다.
모델 파일은 배포하지 않으며, 재현 방법만 기술합니다.