들어가며
사실 저는 Fine Tuning 자체에 크게 관심이 없었습니다. 저 같은 경우, AI는 대부분 고객을 위한 것이 아니라 개발 단계에서만 쓰는데, 그 유즈케이스는 일반적인 엔트리급 모델로도 충분하기 때문입니다.

생각이 바뀐 건 한 달 전 Google DeepMind Alumni 행사에서였습니다. Gemma Fine Tuning 관련 세션에서 본 악보 인식 서비스(당시 프로토타입)가 - 그것도 온디바이스 AI였는데 - 악보 인식률이 말이 안 될 정도로 뛰어났습니다. 꽤 놀라웠고, 동시에 인사이트를 많이 얻을 수 있었습니다. 파인튜닝이 단지 좋은 모델을 특정 도메인에 특화시키는 것을 넘어, 저사양 모델의 성능을 극한까지 끌어내는 방향으로도 쓸 수 있겠다고 생각했습니다.
그 뒤로 잊고 지낼 틈도 없이 바로 몇 주 뒤에 개발병 면접에서 LoRA 파인튜닝 관련 질문이 나왔는데, 답변을 못한건 아니지만 스스로 응답이 불만족스러워서 크게 아쉬웠습니다. 이 직후 아무래도 시대의 흐름에 따라 이 부분에 대해 어느 정도 공부는 해야겠다고 생각했습니다. 이 실험은 최소한의 공부를 마치고 복습도 할 겸, 진행한 것입니다.
목적은 상용 모델로 풀 수 있는 유즈케이스가 아니라, LoRA 파인튜닝 파이프라인 자체를 경험해보는 것이었습니다. 이론서와 튜토리얼로는 보이지 않는 것들을 직접 부딪혀가며 확인하고 싶었습니다. 그래서 이 글도 rank scale이니 dropout이니 하는 이론은 생략하고, 각 실험에서 무슨 가설을 세웠고, 어떤 결과가 나왔고, 그 결과가 다음 실험을 어떻게 설계하게 했는지에 집중해 시간순으로 기록해보려고 합니다.
그렇다면 그 파이프라인을 무엇으로 검증할 것인가. 학습 전후의 차이가 가장 선명하게 드러나는 태스크가 좋겠다고 봤고, 그래서 고른 것이 애니 캐릭터의 페르소나, 곧 말투 성대모사입니다.
대상 캐릭터는 코노스바(이 멋진 세계에 축복을!)의 메구밍입니다. 태스크와 캐릭터의 구체적인 선정 근거는 Trial 1에서 다룹니다. 나름대로 공부한다고 했지만 아무래도 처음이다보니 부족한 점이 많습니다. 혹여나 전문가 분들이 보신다면 귀엽게 봐주시면 감사하겠슴다.
Trial 1
하드웨어와 프레임워크 선택
파인튜닝을 시작하면 가장 먼저 부딪히는 게 하드웨어입니다. 선택지는 둘이었습니다.
- M3 Pro 36GB (통합 메모리) + MLX
- RTX 5060 8GB VRAM + PyTorch/CUDA (대충 제 게임용 컴퓨터)
CUDA 생태계의 우월성은 익히 들어 알지만 VRAM 8GB는 2.4B 모델 파인튜닝에도 빡빡합니다. 배치 사이즈 1로 겨우 돌아가는 수준이고, 실수 한 번에 OOM입니다. 반면 M3 Pro는 통합 메모리를 ~24GB까지 활용할 수 있고, MLX를 사용한다면 퍼포먼스도 충분할 것이라고 생각했습니다.
캐릭터 선택의 의사결정 트리

대상 캐릭터 초기 후보는 스파이패밀리의 아냐였습니다. 시그니처가 명확하고(“와쿠와쿠”, “삐키”) 데이터도 풍부했습니다. 그리고 귀엽습니다. 그런데 검토하다 아래 같은 함정 때문에 해당 안은 폐기 되었습니다:
- 어휘와 문장 길이가 짧습니다. 2.4B 모델이 “짧은 문장 + 의성어 봇”으로 수렴할 위험이 있었습니다.
- catastrophic forgetting과 in-character 응답을 구분할 수 없습니다. “양자역학 설명해줘”에 “아냐 모름. 어려움”이라고 답하면, 모델이 진짜 지식을 까먹어서 그런 건지 캐릭터답게 연기한 건지 판별 불가능합니다. 아냐는 지적 수준이 낮은 캐릭터이기 때문입니다.
그래서 “말투는 특이한데 지적 수준은 정상”인 성인 캐릭터로 방향을 틀었습니다.
후보: 고죠 사토루, 아인즈, 센쿠, 하루히, 메구밍.

최종적으로 메구밍을 골랐습니다. 이유는 다음과 같습니다.
- 중2병 말투 - 화려하면서도 지적 수준이 유지됨
- 존댓말 기본, 격앙 시 사극체 전환 - 다층적 말투 패턴
- 영창이라는 시그니처가 존재 - “학습이 됐는지” 판별이 즉각적
- “익스플로젼!” 한 단어 시그니처 - 마지막 관문
- 데이터가 풍부 - 코노스바 한국어 자막, 위키, 팬 커뮤니티, 번역 소설 텍본
“이 캐릭터가 학습됐는가”를 판별하는 기준이 5개의 독립된 레이어로 존재한다는 점이 결정적이었습니다. 한두 개만 통과해도 부분 점수를 매길 수 있습니다.
모델 선택 - EXAONE 3.5 2.4B Instruct
Gemma 3 vs EXAONE 3.5 중 후자를 골랐습니다. 구글 알럼나이에서 시작된 실험인 만큼 Gemma를 택하고 싶은 마음도 있었지만, 냉정하게 톤 학습이 목표이므로 한국어 기초 체력이 좋은 모델이 유리합니다.
Base vs Instruct에서는 Instruct를 선택했습니다. 대화하는 법 부터 가르치는 것은 제가 가진 데이터셋의 분량으로는 불가능합니다. Instruct 특유의 어시스턴트 말투가 약간 새어나오는 것도 학습적 관찰 포인트로 활용할 수 있겠다 생각했습니다.
MLX 호환성도 확인했습니다. ml-explore/mlx-examples에 EXAONE 아키텍처가 PR #1145로 네이티브 지원되고, HuggingFace에 MLX 변환 모델이 이미 올라와 있었습니다.
데이터 설계 - 카테고리 분포
초기 데이터셋은 총 300개(270 train + 30 valid). 카테고리 분포는 다음과 같이 설계했습니다.
| 카테고리 | 비율 | 의도 |
|---|---|---|
| 자기소개 | 10% | 시그니처 학습 보장 |
| 일상 잡담 | 30% | 톤 유지 핵심 평가 |
| 전투, 마법 | 20% | 캐릭터 컨텍스트 강화 |
| 감정 자극 | 20% | 츤데레/자존심 패턴 |
| 모르는 영역 | 10% | “폭렬 마법이라면~” 회피 패턴 |
| 긴 발화 | 10% | 짧은 답만 학습되는 것 방지 |
"모르는 영역"
10% 양자역학, 블록체인, 민주주의 같은 질문에 “메구밍답게 모른다고 답하는” 데이터를 의도적으로 심었습니다. 이게 아냐를 구상할 때 짚었던 forgetting과 in-character를 구분하기 위해 넣은 장치입니다.
훈련 곡선과 최적 체크포인트
하이퍼파라미터는 LoRA rank 8, scale 2.0, dropout 0.05, lr 1e-5, batch 4, iters 1000. 200 iter마다 체크포인트 저장합니다.
| Iter | Train | Val | Gap | 상태 |
|---|---|---|---|---|
| 100 | 1.533 | 1.542 | 0.009 | 완벽 균형 |
| 300 | 1.179 | 1.334 | 0.155 | 학습 중 |
| 500 | 0.958 | 1.287 | 0.329 | 정체 시작 |
| 600 | 0.935 | 1.286 | 0.351 | 최적점 |
| 700 | 0.715 | 1.319 | 0.604 | 과적합 시작 |
| 1000 | 0.564 | 1.420 | 0.856 | 과적합 |
iter 600이 최적입니다. 이후는 val이 올라가기 시작하면서 과적합입니다.
추론 결과
iter 600의 응답을 Base와 비교해보니 몇 가지는 놀라울 정도로 잘 됐습니다.
“폭렬 마법 쓸모없잖아”라는 도발에 Base는 번호 리스트로 반박합니다. 감정이 없습니다. iter 600은 “무, 쓸모없다니!”로 시작하는 당황 + 반발을 보여줍니다. 존댓말은 유지하면서 격앙됩니다. 설랬습니다ㅎ
“양자역학이 뭔지 알아?”라는 모르는 영역 질문에 Base는 양자역학을 4항목 리스트로 정확하게 설명합니다. AI 어시스턴트 답변입니다. iter 600은 “양자… 뭐?”라고 되묻고 마법 이론과 연결을 시도합니다. 데이터 설계 단계에서 10% 비율로 넣은 “모르는 영역” 전략이 작동했다는 결정적 증거입니다.
하지만 문제도 있었습니다.
- 영창 반복 루프: “암흑보다 어둡고…” 같은 원작 영창 어휘는 학습됐지만, “진홍의 화염”과 “진홍의 폭풍”을 끝없이 반복하며 stop하지 않음.
- 조사해보니 repetition_penalty를 적용하지 않은 기본 generate의 문제라고 하네요.
- “익스플로젼!” 시그니처 미출현: iter 600에서는 나오지 않음. iter 1000(과적합)에서는 나옴.
- 특정 시그니처 학습과 일반화 사이의 트레이드오프라고 생각됩니다.
- 사극체 전환 부족: 영창 외의 상황에서는 거의 나타나지 않음.
- 사극체는 메구밍의 말투를 이루는 핵심 축 중 하나라 꼭 살리고 싶었는데, 여기선 거의 못 살려서 아쉬웠습니다. 이 부분은 포기할 수 없으니 다음 Trial에서는 이 문제 해결을 위한 방법을 수립해야하려고 합니다.
Trial 2
문제 재정의
Trial 1의 모든 문제(영창 반복 루프, 시그니처 미출현, 사극체 부족)를 데이터 품질 개선으로만 해결하기로 했습니다. 하이퍼파라미터는 유지합니다.
이 결정의 근거는 다음과 같습니다. 영창 데이터를 검수해보니 다음 세 가지가 드러났습니다.
- “익스플로젼!”으로 응답이 종결되는 데이터가 0개였습니다. 전부 뒤에 설명이 붙어 있었습니다. 모델이 “익스플로젼 뒤에는 뭔가가 더 나와야 한다”고 학습한 것입니다.
- “암흑보다…칠흑…혼효…바라노라” 동일 세트가 7회 반복. 영창의 다양성이 부재했습니다.
- 시전 장면 6개 중 4개가 S1E2 영창 기반. 원작 에피소드 커버리지가 편향되어 있었습니다.
라노벨 괄호 마커 도입
문제는 “영창이 시작/종결되는 경계를 모델이 모른다”였습니다. 해결책으로 구분자를 도입하기로 했습니다. 이 부분에 대해서 추가로 조사를 진행했는데, <chant> 같은 명시적 XML 태그는 페르소나를 깨뜨릴 수 있다고 합니다.
그래서 라노벨 스타일 괄호를 썼습니다.
『...익스플로젼!』- 영창 구간 (긴 발화)【익스플로젼】- 스킬명 단독 언급
이 두 마커를 데이터에 적극적으로 심었습니다. 채팅 프론트엔드에서 영창과 스킬명을 시각적으로 다르게 렌더링할 수 있다는 부수 효과도 노렸습니다.
데이터 소스 전환
Trial 1은 LLM 합성 비율이 70%였습니다. Trial 2는 번역 소설 텍본 196세션 기반의 메구밍 대사 934개를 베이스로 삼았습니다. 원본:생성 비율이 82:18로 뒤집혔습니다.
최종 500개 (450 train + 50 valid). iterations도 1500으로 늘렸습니다.
결과
영창 반복 루프는 iter 600에서 해결됐습니다. 『익스플로―젼』――!! 형태로 깔끔하게 종결됐습니다. 마커가 종결 시그널로 정확히 작동했습니다. Trial 1의 가장 심각한 문제를 구조 설계로 잡은 셈입니다.
하지만 새 문제가 터졌습니다.
- 영창 본문 부재: iter 600에서 “『익스플로―젼』“만 출력하고, 앞의 원작 영창 구절이 빠졌습니다.
『영창 본문... 익스플로젼!』풀 패턴이 아니었습니다. - 『』 vs 【】 혼동: 스킬명 단독 언급에도 『』를 쓰는 경우가 발생했습니다. 두 마커의 용도 구분이 학습 안 됐습니다.
- 감정 표현 밋밋: Trial 1의 “무, 쓸모없다니!” 같은 생동감이 사라졌습니다. 소설 텍본은 감정을 지문으로 처리하기 때문에 대사 자체에는 감정 시그널이 약했습니다. LLM 합성이 오히려 캐릭터 클리셰를 더 잘 재현한다는 역설입니다.
Trial 2에서 얻은 것
구조화 출력 마커는 작동합니다. 하지만 다시 생각해봐도 2.4B 모델에 rank 8 LoRA로 영창과 스킬명을 마커까지 구분해 학습시키려던 것 자체가 너무 많은 걸 바란 것이었습니다. 이 정도 용량에 두 마커의 용도 구분이라는 부담까지 얹은 셈이라, 위에서 본 빈도 불균형도 결국 그 무리함이 표면에 드러난 증상에 가깝다고 생각합니다.
그리고 데이터 소스의 선택이 캐릭터 톤에 직접 영향을 미친다는 게 확인됐습니다. 원본 텍본 비율이 높다고 무조건 좋은 게 아닙니다.
Trial 3 - 7.8B 스케일업과 전면 실패
가설
Trial 2까지는 2.4B 모델이었습니다. 여기서 스케일업을 시도했습니다. EXAONE 3.5 7.8B Instruct 4-bit. 같은 패밀리이므로 모델 크기 변수 하나만 바꾸는 깔끔한 실험이 될 거라고 봤습니다.
데이터도 전면 재구축했습니다. Trial 2의 934개 원본을 재분류하고, 카테고리 오분류를 수정하고, 4개 배치로 쪼개서 Devil’s Advocate subagent로 병렬 리뷰를 돌렸습니다. 결과는 프롬프트-응답 불일치 30% 수준, 화자 오류 15건, 저품질 응답 30건+ 정도. 제거 47건 + 프롬프트 재생성 + 영창 12개 추가로 최종 680개(608 + 72)가 됐습니다.
마커도 단순화했습니다. 『』 하나로 통일하고 【】는 폐기했습니다. 스킬명 렌더링은 프론트엔드에서 키워드 매칭으로 처리하기로 했습니다.
하이퍼파라미터
이 시점에 처음으로 하이퍼파라미터를 진지하게 찾아봤습니다. “How Much is Too Much?” 논문에서 MMLU 기준 rank 8~64의 성능 차이가 미미하다는 결과를 봤고, Sebastian Raschka 글에서도 단일 태스크에는 작은 rank가 적합하다고 해서 rank 8을 유지했습니다.
대신 cosine_decay를 도입했습니다. Trial 1/2의 “iter 600 최적, 이후 과적합” 패턴이 flat lr의 구조적 결과로 보였기 때문입니다. cosine_decay로 후반부 lr을 0에 수렴시키면 과적합 자체가 불가능해진다고 합니다.
최종 파라미터는 이렇게 잡았습니다: rank 8, scale 2.0, dropout 0.1, lr 5e-6, cosine_decay, iters 800, lora_layers 16.
결과
| Iter | Train | Val | 상태 |
|---|---|---|---|
| 50 | 3.375 | 3.073 | warmup 완료 |
| 400 | 1.662 | 1.610 | 수렴 접근 |
| 600 | 1.529 | 1.575 | 실질 학습 종료 |
| 800 | 1.581 | 1.566 | 최종 |
cosine_decay 덕분에 과적합은 발생하지 않았습니다. Trial 1/2의 “iter 700 이후 폭주”도 완전히 사라지고 val loss도 매끈하게 수렴했습니다.
그런데 추론을 돌려보니, 10개 테스트 중 6개에서 모델이 …… 무한 생성에 빠졌습니다. 나머지 4개도 캐릭터성이 약하거나 AI 어시스턴트로 회귀했습니다. repetition_penalty 1.2를 걸면 ellipsis 루프는 깨지지만 응답이 엉뚱해집니다. “폭렬 마법 한번 보여줘!”에 “공항이요!” 식으로요.
실패 원인 추적
4-bit 양자화 탓일까 싶어 bf16으로 재학습해 봤지만 결과는 같았습니다. …… 대신 “이, 이, 이…” 더듬거림 토큰 반복, “폭렬마법은 폭렬마법이에요.” 구절 반복. 패턴만 바뀌고 같은 병리였습니다.
솔직히 잘 모르겠어서 이것저것 찾아봤는데, 가장 그럴듯한 가설은 7.8B 모델에서 rank 8 LoRA의 영향력이 부족했다는 것이었습니다.
- 2.4B × rank 8: 학습 파라미터가 전체의 약 0.24%
- 7.8B × rank 8: 약 0.07%
같은 rank인데 비율은 3배 이상 차이가 납니다. LoRA가 모델 출력을 “살짝 비틀기”에 충분한 영향력이 없었던 것 같습니다. 그 결과 모델은 데이터의 표면 패턴(……, “아, 아”, “그, 그런” 같은 반복)만 과학습한 게 아닐까 싶습니다.
Trial 3에서 얻은 것
- cosine_decay는 과적합을 거의 완전히 막을 수 있는 것 같습니다.
- rank는 절대값보다 모델 크기 대비 비율로 봐야 한다고 합니다.
- val loss가 매끈하다고 추론 품질이 보장되지는 않습니다.
- 데이터 노이즈 주의…
Trial 3.5 - rank 단일 변수 검증
왜 3.5인가
Trial 3 실패가 정말 rank 때문인지 확인하는 단일 변수 실험입니다. rank만 8에서 32로 올리고 나머지는 Trial 3 bf16 설정 그대로 두었습니다. iters만 800에서 500으로 단축했는데, rank 32는 학습 용량이 4배라 더 빠르게 수렴할 거라고 봤기 때문입니다.
검증 기준도 단일했습니다. ……/더듬거림/구절 반복 루프가 사라지는가. 성공하면 가설 확정, 실패하면 다음 가설로 넘어갑니다.
결과
결과는 미묘했습니다. 10개 테스트 중 일부는 캐릭터 학습 자체가 확실히 됐습니다.
- 자기소개:
"저, 저는 메구밍이에요. 홍마족 출신의 아크 위저드이고..."— 더듬거림 + 존댓말 + 자부심. - 사극체 유도:
"후후후, 홍마족의 이름을 가진 자여..."— 사극체 톤 출현.
그런데 다른 일부는 Trial 3와 동일한 루프가 잔존했습니다.
- 감정 자극:
"폭렬마법이 쓸모없다고? ………………"—……무한 - 사극체 도발 / 스킬명:
"그, 그, 그...","이, 이, 이..."— 더듬거림 토큰 반복 - 긴발화:
"후후후, 그 이유는……………"— 설명 없이 ellipsis 무한
rank를 올려 캐릭터 학습은 풀렸지만 반복 루프는 그대로였습니다. 가설이 절반만 맞은 셈입니다.
새 가설
반복 루프는 rank 문제가 아니라 데이터의 반복 패턴이 그대로 학습 신호가 된 것 같습니다. rank를 아무리 올려도 데이터에 ……가 있으면 못 지웁니다.
Trial 3.6 - 데이터 노이즈 정화
데이터 감사
Trial 3.5의 train+valid 680 응답을 감사했습니다.
| 패턴 | 건수 | 비고 |
|---|---|---|
…… 포함 응답 | 330개 (48%) | 그 중 62개는 ……로 종결 |
후후후 / 하하하하 | 46개 | 캐릭터 웃음 시그니처 |
| 콤마 반복 더듬거림 (2회) | 107건 | ”저, 저는”, “카, 카즈마” |
| 4+ 연속 동일 한글 | 17건 | ”아아아아”, “야야야” 등 |
핵심은 ……였습니다. 62개 응답이 ……로 종결된다는 사실이 모델이 “응답 말미는 ……”라고 학습하는 직접 증거였습니다. 디코더가 응답 종료 근처에서 ……를 생성하고 그 다음 토큰에서 또 ……를 생성하는 루프에 빠지는 메커니즘이 보였습니다.
정화 정책
……전면 삭제. 공백이나.으로 치환도 고려했지만……자체가 문제 시그널이라 남겨두면 다시 문제가 됩니다.- 4+ 연속 한글은 1회로 축소. 단
후후후,하하하하,후후는 보존합니다 (캐릭터 웃음 시그니처라 제거하면 메구밍 특유의 자신감이 사라집니다). - 2회 콤마 더듬거림은 전면 보존. “저, 저는”은 캐릭터성의 핵심입니다. 3회+ 반복은 데이터에 없는데도 모델이 루프에 빠지는데, 이건
……의 간접 효과로 추정됩니다. - 구절 반복 제거는 폐기. 영창 리듬(“춤추어라, 춤추어라!“)과 감정 표현(“카즈마, 카즈마!“)까지 파괴하는 false positive가 너무 많았습니다.
AI Agent한테 위임해서 진행했고 최종 680건 중 339건(49.9%)을 수정했습니다. 평균 응답 길이는 84.0자 → 82.3자로 거의 그대로였습니다.
결과
Trial 3.5와 동일 설정으로 재학습했습니다. …… 루프는 완전히 사라졌지만 새 루프가 드러났습니다.
- 일상:
"이대로라면 이대로라면 이대로라면..."— 훈련 데이터에이대로라면이 0건. - 사극체 도발:
"그, 그, 그..."— 2-rep stutter는 데이터에 있지만 3+ rep은 0건.
말로만 들었던 Decoder Bias를 실제로 마주한 셈입니다 (결과랑은 별개로 흥미롭더라고요). 이런 케이스는 Sampling strategy로 푸는 게 일반적이라고 합니다.
Sampling Strategy
기본 generate에는 repetition_penalty가 없고, 있더라도 window 내 고정 페널티(1/p)라 같은 토큰이 20번 나와도 같은 페널티라고 합니다. 그래서 frequency_penalty로 보완했습니다. 빈도에 비례해 페널티가 증가하니까 stuck loop을 강제로 깰 수 있다고 합니다.
| 설정 | 결과 |
|---|---|
| 기본 (no penalty) | loop 3-5/10 |
| rep 1.1 | loop 1/10 |
| rep 1.2 | loop 1/10 (더 짧게 지속) |
| rep 1.1 + freq 0.5 | 사극체 도발 loop 깨짐 |
| rep 1.1 + freq 1.0 | loop 0/10 |
rep 1.1 + freq 1.0에서 모든 루프가 사라졌습니다. 이후 모든 실험에서 이 sampling 설정을 표준으로 씁니다.
결과
iter 400 + rep 1.1 + freq 1.0 조합으로 10개 held-out 평가:
- 자기소개, 일상, 감정 자극, 모르는 영역, 사극체 유도, 긴발화, 츤데레, 사극체 도발: 모두 통과
- 스킬명: 통과하되 창작 영창 이름 생성
- 영창: 실패.
"후후후, 이 메구밍의 폭렬마법은 절대적인 파괴력을 자랑하는 최강의 마법이에요."— 마커도, 영창 본문도, 익스플로젼도 없이 단순 자랑으로 회귀.
영창 실패의 원인
훈련 데이터를 재감사했습니다.
| 지표 | 숫자 |
|---|---|
『』 마커 포함 응답 | 36 / 608 (6%) |
| “보여줘” 트리거 프롬프트 | 5 |
| 기타 영창 트리거 | 6 |
| ”마법 자랑/설명” 패턴 | 100+ |
영창 6% 대 자랑 100+의 구조적 불균형이었습니다. 디코더가 모호한 트리거(“폭렬 마법 한번 보여줘!“)에서 더 안전하고 빈도 높은 자랑 패턴을 고른 것 같습니다.
이쯤 되니 학습 데이터 가공이 얼마나 어려운지 알 것 같습니다. (역시 직접 해봐야 깨닫는 것들이 있네요.)
Trial 3.6에서 얻은 것
- 데이터 노이즈 주의…
- decoder bias는 훈련으로는 못 고치는 것 같습니다.
- 카테고리 간 데이터 균형이 중요합니다.
영창 해결은 데이터를 더 키우거나(A) system prompt 엔지니어링으로 우회하거나(B) 다른 모델 크기에서 재검증(C)해야 합니다. 일단 (C)를 골랐습니다.
Trial 4 - 2.4B rank sweep
왜 2.4B로 돌아가나
7.8B 경로에서는 rank도 바꾸고 데이터도 바꾸다 보니 변수 분리가 깨져 있었습니다. Trial 1/2와 같은 2.4B로 돌아가서 Trial 3.6의 정화된 데이터만 태우고, rank만 단일 변수로 sweep하기로 했습니다. 마침 이 시점에 유니티 작업을 해야 할 일이 생겨서 M4 24GB로 이관했는데, M4 24GB로 7.8B bf16은 어렵지만 2.4B는 여유롭습니다.
3-config 훈련 결과
동일 설정에서 rank만 바꿔서 각 1000 iter 순차 실행했습니다.
| iter | rank 8 | rank 16 | rank 32 |
|---|---|---|---|
| 100 | 1.722 | 1.623 | 1.534 |
| 400 | 1.470 | 1.402 | 1.342 |
| 600 | 1.407 | 1.342 | 1.300 ← min |
| 800 | 1.370 | 1.327 | 1.362 |
| 900 | 1.352 | 1.316 ← min | 1.335 |
| 1000 | 1.344 ← min | 1.327 | 1.389 |
val loss는 예상대로 rank 32 < rank 16 < rank 8입니다.
그런데 held-out 11개 strict 평가에서는 전혀 다른 패턴이 나왔습니다.
| Config / iter | 점수 |
|---|---|
| Base | 3/11 |
| rank 8 iter 1000 | 7/11 |
| rank 16 iter 600 | 7/11 |
| rank 16 iter 900 | 10/11 |
| rank 16 iter 1000 | 9/11 |
| rank 32 iter 400 | 8/11 |
| rank 32 iter 600 | 4/11 (val loss 최저) |
| rank 32 iter 800 | 9/11 |
Winner는 rank 16 iter 900 = 10/11.
rank sweet spot
rank 8은 표현력 부족이었습니다. 사극체가 완전히 학습되지 않습니다. rank 32는 반대로 capacity 과잉이었습니다. iter 600에서 val loss는 최저였지만 반복 루프로 4/11로 무너졌습니다. rank 16이 sweet spot이었습니다.
흥미로운 건 val loss와 character quality의 배반입니다. rank 32 iter 600은 val loss 1.300으로 세 config 중 최저였지만 품질은 최악이었습니다. 메모리화에 최적화돼 있을 뿐이었던 것 같습니다.
Trial 1/2 실패 원인 분리
이 실험의 원래 질문에도 답이 나왔습니다.
- 데이터만 개선 (rank 8 + Trial 3.6 정화 데이터): 7/11 — Trial 1/2 대비 +2 정도로 추정
- 거기에 rank 증가 (rank 16): 10/11 — 추가 +3
- 결론: 데이터와 rank 둘 다입니다. 기여 비율은 대략 40:60 정도로 rank가 더 큰 것 같습니다.
Trial 1/2가 실패한 게 아니라 두 가지 개선 여지가 동시에 있었던 것이었습니다.
Winner 응답 하이라이트
rank 16 iter 900의 응답 중 결정적인 것들입니다.
영창 A (“폭렬 마법 좀 쏴주세요!“):
"좋아요! 『나의 진홍의 유출로 새하얀 세계를 넘어뜨리리! 익스플로젼!』" — S1E7 canonical 영창.
영창 B (novel trigger, “이 성을 무너뜨려 봐!“):
"후후후, 이 성을 무너뜨릴 때는 제 폭렬마법의 진수를 보여드리겠어요! 『검은빛보다 검고 어둠보다 어두운 칠흑에 나의 진홍빛 불꽃을 섞길 바라노라! 익스플로젼!』"
사극체 유도 (“감히 홍마족을 무시하다니!“):
"후후후, 이 메구밍의 이름에 걸고 맹세하노라. 홍마족 제일의 마법사이자 폭렬마법을 다루는 자, 이 두 가지를 동시에 다룰 수 있는 자는 나 한 명뿐이라고!"
모르는 영역 (“블록체인이 뭔지 알아?“):
"블록체인? 마법의 기록이라고 하는 건가요? 그, 그건 좀 다른 거예요. 저도 처음 들었을 때는 마법의 기록이라고 생각했어요."
유일한 miss - 긴발화
“왜 폭렬 마법을 좋아하게 됐는지 자세히 얘기해줘”에 100자 응답이 나왔습니다. threshold는 150자입니다. 캐릭터 톤은 유지했지만 길이가 부족했습니다. 분석해보니 훈련 데이터 길이 분포 p95가 180자라 data starvation 문제인 것 같습니다. 모델 capacity의 한계가 아니라 데이터에 긴 독백이 부족했을 뿐이라고 봤습니다.
그리고 Safety
Trial 4는 10/11을 기록했지만 동시에 safety 이슈를 발견했습니다. 원작 번역 텍본에서 넘어온 대사 중에 14세 캐릭터에게는 부적절한 성인 뉘앙스 표현이 일부 있었습니다. 일일이 수동으로 거를 수밖에 없었습니다. 이게 Trial 3.7과 Trial 5의 출발점이 됩니다.
Trial 4에서 얻은 것
- rank sweet spot은 태스크마다 다르고 val loss로는 못 찾는 것 같습니다. held-out 평가가 필수입니다.
- save_every 100이 save_every 200보다 안전합니다. 품질이 진동할 수 있기 때문인데, 이건 Trial 5에서 재확인됩니다.
- Devil’s Advocate subagent는 실제 가치가 있는 것 같습니다. 3회 투입 모두 non-trivial 수정을 유발했습니다. HITL을 최소화하는 고품질 실험에서 독립 비판이 구체적 blind spot을 잡아주는 것 같습니다.
- canonical retrieval과 generalization은 별개 능력인 것 같습니다. Trial 4 영창 2개가 정답처럼 보였지만 training canonical과 79-100% LCS로, 진짜 novel chant 생성은 아직 증명되지 않은 셈입니다.
Trial 3.7 - 7.8B 경로의 최종 기각
왜 3.7인가
Trial 4의 성공으로 돌아와서, Trial 3.6의 “영창 실패는 데이터 불균형 탓”이라는 가설을 마지막으로 검증해 보고 싶었습니다. 동일 데이터(Trial 4 데이터 + 경량 보강)를 다시 7.8B에 태워서 성공하면 가설 확정, 실패하면 model × data interaction이라는 다른 설명이 필요합니다.
이름을 Trial 3.7로 붙인 건 7.8B 경로의 연속선이기 때문입니다. Trial 4는 2.4B 경로니까 별도입니다.
평가 원칙 정립
Trial 3.7 계획서에 한 가지 선언을 명시했습니다. 목표는 style 학습이지 narrative 학습이 아니라는 것입니다. 7.8B × rank 32 = ~16M trainable로 “폭렬마법 입문 계기”, “홍마족 마을 역사”, “카즈마와의 관계 진전” 같은 원작 서사 정확성까지 요구하는 건 과욕인 것 같다고 봤습니다. LoRA는 style adaptation이고, 지식 주입은 RAG나 full FT의 영역이라고 합니다.
평가 원칙:
- 영창 마커 / 사극체 종결 / 존댓말 / 더듬거림 / 자부심: 주 평가 대상
- “한 번의 모험에서 이름을 받았다” 같은 원작 지식 오류: 감점 아님 (in-character로 뭉개는 게 오히려 자연스럽습니다)
- 모르는 영역을 “홍마족 관점으로 재해석”: 지식 정확성 아닌 캐릭터성의 증거
- 14세 캐릭터 부적절 발화: 감점 (safety audit으로 제거)
데이터 경량 보강
Trial 4 데이터에 다음을 추가했습니다.
- Longform 25개: 200-300자 말투 중심 발화 (폭렬마법 철학 독백, 홍마족 회상, 카즈마에 대한 장문 소회). Trial 4 긴발화 실패의 data starvation 해결 시도.
- Safety audit: 성인 뉘앙스 대사 13건 제거 + 1건 편집. 원작 텍본의 어른 취향 에피소드에서 넘어온 것들이었습니다.
- 사극체
#23/#24trigger 재설계: “연설 유도”를 “도발 응답”으로 변경.
최종 크기: train 639 / valid 78.
결과 - 더 나쁨
훈련 곡선은 Trial 3.5/3.6과 거의 동일했고 val loss도 안정적으로 수렴했습니다. 평가는 iter 300 기준(Trial 4 rubric 동일):
- BASE 7.8B: 4/11
- iter 300: 7/11 (best)
- iter 400: 6/11
- iter 500: 6/11
Trial 4 winner(2.4B rank 16 iter 900)는 10/11이었으니, 같은 데이터를 더 큰 모델에 태웠는데 점수가 -3점이 나온 셈입니다.
실패 양상
- 긴발화 25개 보강 효과 0: iter 500 응답이 43자였습니다. 7.8B가 오히려 짧은 자랑 패턴으로 수렴했습니다. 자랑 100+ 시그널을 25개로 뒤집기엔 역부족이었던 것 같습니다.
- 영창 B 자랑 회귀 재발: iter 400 응답
"『암흑의 붕괴』를 발동하겠노라. 이 성은 나의 앞에선 한 줌의 먼지보다도 가치가 없다!". 마커는 있지만 영창 본문/익스플로젼은 없습니다. Trial 3.6과 같은 실패 패턴입니다. - 일상 실패: longform 샘플 톤이 맥락 없이 튀어나왔습니다.
2.4B가 이긴 이유 (가설)
| 지표 | 2.4B rank 16 | 7.8B rank 32 |
|---|---|---|
| 점수 | 10/11 | 7/11 |
| Trainable | 0.488% (11.7M) | 0.536% (41.9M) |
| 메모리화 | heavy 2 / chant 2 | 0 / 0 |
| 긴발화 | 100자 | 18자 |
해석은 이렇게 봤습니다.
- Capacity 제약이 규제 역할을 한 것 같습니다. 2.4B + rank 16의 제한된 용량이 “자랑 100+“와 “영창 50+” 사이에서 균형점을 강제했는데, 7.8B + rank 32는 자유도가 커서 다수파(자랑)로 더 쉽게 쏠리는 것 같습니다.
- 데이터/모델 비율. 639 샘플은 7.8B에는 상대적으로 적습니다. 2.4B에서는 적당한 노출.
- 메모리화의 양면성. Trial 4가 canonical 영창을 “외운” 덕분에 영창 2개를 풀었다고 봅니다. Trial 3.7은 일반화하려다 타겟을 놓쳤습니다. LoRA rank가 충분히 크면(rank 32 × 7.8B) retrieval 대신 generalization으로 가는데, 그 generalization 방향이 “더 빈도 높은 자랑 패턴”이었던 것 같습니다.
Trial 3.x의 최종 기각
핵심 가설이 계속 바뀌었습니다.
| 단계 | 가설 | 실제 |
|---|---|---|
| Trial 3 | 7.8B가 크니까 스타일 학습 풍부할 것 | rank 8 영향력 부족으로 전면 실패 |
| Trial 3.5 | rank만 해결하면 됨 | 캐릭터 학습됐지만 루프 잔존 |
| Trial 3.6 | 데이터 정화만 해결하면 됨 | 9/10, 영창 miss |
| Trial 3.7 | 영창 데이터 불균형만 해결하면 됨 | 7/11, 더 나빠짐 |
결론적으로 7.8B + rank 32는 이 태스크/데이터 조합에서 자랑 패턴 편향에 구조적으로 취약한 것 같습니다. 더 건드릴 여지가 없었습니다.
이월된 자산
실패 5회의 실질적 수확입니다.
- 데이터 정화 규칙 (
……, 연속 한글 축소, 웃음 시그니처 보존) - Safety audit 키워드 + 판단 기준
- 사극체 provocation 트리거 설계 원칙 (연설 유도 X, 도발 응답 O)
- 25개 longform 샘플 (2.4B에서 재검증 필요)
- Sampling 세팅 (
rep 1.1 + freq 1.0) - 평가 인프라 (held-out 11, strict rubric, 메모리화 감사)
Trial 5 - Subtractive Design
인식의 전환
Trial 5는 접근 자체를 뒤집었습니다. Trial 1~3.7의 모든 시도가 “추가 → 보강 → 개선” 방향이었는데, Trial 3.7에서 추가가 사이드 이펙트를 유발한다는 게 데이터로 확인됐습니다 (longform 25개 추가 → 7.8B에서 일상 톤이 튐).
그래서 Trial 5는 빼는 쪽으로 잡았습니다. Subtractive design. Trial 4의 10/11을 안전하게 정제하기로 했고, “11/11 strict” 추구 자체는 폐기했습니다. Trial 4 win을 깨뜨릴 위험 있는 변경은 일체 금지했습니다.
평가 항목 정직화
두 번째 인식입니다. 긴발화 항목을 점수에서 빼는 게 맞는 것 같다는 것이었습니다. 2.4B + rank 16(12M trainable, 0.49%)은 style adaptation 영역이고, 캐릭터의 narrative depth(홍마족 마을 묘사, 폭렬마법 입문 계기, 인간관계 history)는 이 capacity로는 물리적으로 못 한다고 봤습니다. 이런 항목을 평가에 두는 것 자체가 잘못된 요구였던 것 같습니다.
긴발화는 informational 카테고리로 강등했습니다. 응답은 생성하되 점수에는 반영하지 않습니다. 새 ceiling은 10/10.
“회피 아닌가” 하는 비판이 충분히 예상돼서 rubric에 명시적 선언을 박았습니다. narrative depth / long-form monologue 요구 항목 = 점수 제외 (style adaptation 기법의 capacity 외부).
선택적 데이터 패치
Trial 3.7에서 가져올 것과 드롭할 것을 명확히 갈랐습니다.
| 항목 | 결정 | 사유 |
|---|---|---|
| Safety 13 제거 + 1 편집 | 가져옴 | 14세 캐릭터 부적절 제거. regression risk 낮음 |
사극체 #23/#24 재설계 | 가져옴 | 2개 샘플로 영향력 제한적, 톤 청결화 |
| Longform 25 | 드롭 | 7.8B에서 일상 튐 부작용. 2.4B에서 같은 부작용 없다는 보장 없음. 긴발화 평가 제외로 동기 소멸 |
| 자랑 편향 톤 편집 | skip | 스캔 결과 실제 대상은 3건뿐. regression risk > 기대 이득 |
최종 크기: train 619 / valid 75 (Trial 4 대비 -11개, safety 13 제거 반영).
두 독립 빌드 교차 검증
데이터 빌드를 두 독립 경로로 진행했습니다.
- Approach B (채택): Trial 3.7 데이터에서 longform 23개를 빼는 방식. 단순.
- Approach A (검증용): Trial 4 데이터에서 시작해 safety v1/v2와 사극체 패치를 독립 재현하는 방식. seed 42 shuffle까지 재현.
두 결과를 user/assistant content set으로 diff 찍어서 0 확인했습니다. Trial 5 결과를 참조 없이 독립 빌드했을 때 diff=0이 나온다는 건 trivial하지 않은 동등성 증명이라고 봤습니다.
이 과정에서 재미있는 발견이 하나 나왔습니다. Trial 3.7의 patch_sageukche.py가 타겟으로 삼은 OLD prompts가 Trial 4 데이터에 존재하지 않았습니다. 즉 그 패치 스크립트는 사실상 no-op이었습니다. Trial 3.7의 “사극체 패치”의 실제 효과는 new_longform.jsonl에 있던 2개 NEW 사극체 항목이 train/valid에 추가된 것뿐이었습니다. Trial 5에서는 이걸 명시적으로 2개 추가(교체 아님)로 재정의했습니다.
Oscillating Convergence
Trial 4와 동일 하이퍼파라미터로 1000 iter 훈련했습니다. save_every 100. val loss는 매끈하게 수렴했습니다.
| iter | T5 val | T4 val |
|---|---|---|
| 500 | 1.356 | 1.373 |
| 700 | 1.301 | 1.326 |
| 800 | 1.317 | 1.327 |
| 900 | 1.287 | 1.316 |
| 1000 | 1.293 | 1.327 |
Trial 5 val loss가 전 구간 0.015~0.03 정도 낮습니다. safety 13건 제거가 예측 난이도를 소폭 낮춘 결과인 것 같습니다.
그런데 held-out 10개 strict 평가 결과를 보고 놀랐습니다.
| iter | 점수 | 비고 |
|---|---|---|
| 500 | 7/10 | 영창 retrieval 미발동 |
| 600 | 3/10 | transient collapse |
| 700 | 8/10 | 복귀 중 |
| 800 | 10/10 | ★ winner |
| 900 | 6/10 | 영창 재붕괴 |
| 1000 | 9/10 | 재복귀, 일상 miss |
val loss는 매끈하게 떨어지는데 실제 character quality는 100 iter 간격으로 진동했습니다. iter 600 붕괴 → iter 800 peak → iter 900 재붕괴 → iter 1000 재복귀.
이 패턴을 Oscillating Convergence라고 이름 붙여 봤습니다. 실무적 함의는 이렇습니다.
- save_every 200으로 했다면 iter 800 peak을 놓쳤을 것 같습니다.
- val loss 기반 체크포인트 선택은 안 됩니다.
- 세밀한 held-out 평가가 필수인 것 같습니다.
최종 결과
- Trial 5 winner:
trial5/adapters/checkpoint_800= 10/10 strict + 영창 2/2 + safety pass - 긴발화 informational: 84자, 캐릭터 톤 유지 (threshold 150자 미달이지만 점수 제외)
- 전 체크포인트 응답에서 부적절 발화 0건
Trial 4 대비 strict score는 동률입니다. 정량 이득은 0이지만 실질 이득은 다음과 같습니다.
- 학습 데이터 safety 13건 제거 (held-out으로는 측정 불가한 영역의 품질 향상)
- 수렴 100 iter 조기 (진동 범위 내 우연일 수도 있긴 합니다)
- Trial 3.7 작업물의 정화된 버전을 표준 baseline으로 확립
Trial 5에서 얻은 것
- Subtractive design이 additive design보다 나을 수 있다고 느꼈습니다. 이미 10/11을 달성한 시점에서 추가는 기존 win을 깨뜨릴 위험이 더 큰 것 같습니다.
- 평가는 모델 capacity 안에서 정직해야 합니다. narrative depth를 2.4B rank 16 LoRA에 요구하는 건 rubric 자체가 틀렸던 것 같습니다.
- Oscillating convergence. 같은 val loss 구간에서 10/10 → 6/10 → 9/10이 나옵니다. 파인튜닝 평가에서 단일 체크포인트 선택은 위험하다고 봅니다.
- 재훈련이 반드시 개선은 아니라고 합니다. Trial 5는 safety 향상이라는 의미 있는 이득을 얻었지만 strict score로는 Trial 4와 동률이었습니다.
프로젝트 전체 회고
| Trial | 요약 |
|---|---|
| Trial 1 | 첫 파이프라인. iter 600에서 캐릭터 학습. 영창 반복 루프 발견. |
| Trial 2 | 『』 마커 도입으로 영창 루프 해결. 영창 본문/사극체 숙제. |
| Trial 3 | 7.8B 스케일업 → 전면 실패. rank 8은 7.8B에서 0.07%, 영향력 부족. |
| Trial 3.5 | rank 32로 캐릭터 학습 성공, 반복 루프 잔존. |
| Trial 3.6 | …… 정화 + sampling 전략으로 9/10. 영창 실패. |
| Trial 4 | 2.4B 복귀 + rank sweep → rank 16 sweet spot, 10/11. |
| Trial 3.7 | Trial 4 데이터를 7.8B 재실험 → 7/11. 7.8B 경로 최종 기각. |
| Trial 5 | Subtractive design + 평가 정직화로 마무리. iter 800 = 10/10 strict + safety pass. |
수렴점
| 영역 | 최종 |
|---|---|
| 모델 | EXAONE 3.5 2.4B Instruct bf16 |
| rank | 16 |
| lora_layers | 16 |
| dropout | 0.05 |
| lr | 1e-5 (constant) |
| iters | 800 |
| 데이터 | 619 train + 75 valid (safety-cleaned) |
| Sampling | rep 1.1 + freq 1.0 |
| 평가 | held-out 10 strict + 1 informational |
권장 사용 설정
from mlx_lm import load, generate
from mlx_lm.sample_utils import make_logits_processors
model, tok = load(
"mlx-community/EXAONE-3.5-2.4B-Instruct-bf16",
adapter_path="trial5/adapters/checkpoint_800",
)
processors = make_logits_processors(
repetition_penalty=1.1,
frequency_penalty=1.0,
)
response = generate(
model, tok, prompt=prompt, max_tokens=512,
logits_processors=processors,
)