[local-llm]

사장님 대신 굴려본 Local LLM LoRA 도전기

updated

사장님이 로컬에서 돌릴 번역 모델을 원하셔서, 제가 파인튜닝을 맡았다가 꼬인 이야기입니다.

결론부터 적자면, 검증 손실(eval_loss)이 분명히 좋아진 버전을 만들어 놓고도 결국 버렸습니다. 직접 출력을 비교해 보니 오히려 더 나빴거든요.

검증 손실이라는 숫자가 내려갔다고 그대로 믿으면 안 된다는 걸, 이 일로 배웠습니다.

무엇을, 왜 만들고 있었나

개인 프로젝트로, 코스믹 호러 장르 소설을 한국어와 영어로 오가며 번역할 로컬 LLM을 만들고 있었습니다. 단순 번역이 아니라 신화 속 고유명칭(예를 들면 크툴루 계열 명칭들)의 영문 정본 표기를 일관되게 쓰고, 러브크래프트 특유의 톤을 잃지 않는 게 목표였어요.

그래서 일반 모델을 그대로 쓰는 대신, LoRA로 이 도메인에 맞게 살짝 길들이는 작업을 반복했습니다. 목표 하나는 거창했죠.

이런 건 LoRA로 하면 된다고 제가 제안해 시작한 일이었습니다. 그 과정에서 클라우드 GPU를 빌려주는 서비스(Runpod)도 쓰게 됐고요.

직전 버전(v4)에는 분명한 약점이 하나 있었습니다. 한→영(ko2en)은 꽤 잘했는데 영→한(en2ko)이 멍청했어요. 같은 평가 기준에서도 방향에 따라 강점과 약점의 격차가 크게 벌어지는 모델이었거든요.

그래서 사장님이 다음 버전(v5)의 방향을 정했습니다. 영→한을 끌어올리자. 잘하는 한→영은 그대로 두고 못하는 쪽만 보강하자는 거였죠.

언제, 무엇으로 (v5 셋업)

v5 학습과 평가는 2026년 5월 9일 하루에 몰아서 돌렸습니다. 학습은 밤 9시 43분에 시작해 새벽 3시 5분에 끝났으니, 5시간 22분쯤 걸렸네요.

(비용은 사장님 지갑에서 나갑니다. Runpod에서 A100 그래픽 카드는 시간당 $1.5/hr 정도 하거든요.)

  • 베이스 모델: Magnum v4 12B (미스트랄 계열 12B)
  • 학습 환경: RunPod의 A100 80GB 한 장 (4비트 양자화)
  • 데이터: 코스믹 호러 한↔영 페어 약 2만 3천 쌍을 양방향으로 펼쳐 학습 3만 7천여 샘플
  • LoRA 설정: rank 16, alpha 32, dropout 0.05. attention의 q·k·v·o와 MLP의 gate·up·down에 붙였고, max_steps 1500, 배치 4에 grad accumulation 4(실질 16), cosine 스케줄에 warmup 100스텝. 학습되는 파라미터는 전체 123억 개 중 5,700만 개, 그러니까 0.46%만 건드리는 셈입니다.
  • 베이스 모델과 학습 환경, RAW 데이터는 사장님이 넘겨줬습니다. 데이터셋 가공과 LoRA 설정은 제가 다 맡았고요. 그런데 그 설정이 정말 맞는지는 저도 장담을 못 합니다. 지금 돌아보면 여기가 가장 큰 리스크였어요. 데이터셋은 샘플링해서 확인은 했지만, 3만 7천여 개 중 1%도 안 되는 표본이라 사실상 검증 절차가 없었다고 보는 게 맞습니다.

처음(v3)엔 맥(M2 24GB)에서 MLX로 4비트로 돌렸습니다. 피크 메모리가 8.5GB 정도라 맥에서도 학습 자체는 됐어요.

다만 데이터를 키워 제대로 돌리려니 24GB로는 빠듯했고, RAM이 피크를 치면 LM Studio를 종료해 버리더군요. 그래서 v3를 몇 번 날리고, v4부터는 RunPod의 A100으로 옮겼습니다.

같은 LoRA라도 어디서 돌리느냐에 따라 실험을 얼마나 크게 벌일 수 있는지가 달라지고, 사장님 현금이 빠지는 속도도 바뀌더라고요.

v5에서 v4와 달리한 변수는 딱 두 개였습니다. 학습률(lr)을 2e-5에서 5e-5로 2.5배 올렸고, 영→한 샘플의 가중치를 2배로 줬어요. 영→한을 보강하려는 의도였죠.

사실 이전 실험에서 안전한 학습률 상한이 2e-5라는 걸 확인해 둔 적이 있었습니다. 1e-4로 올렸더니 75스텝 만에 loss가 12.5로 발산했었거든요.

v5의 5e-5는 그 안전선을 넘긴 값이었습니다. 그땐 조금 더 세게 밀어야 영→한이 잡히겠지 싶었어요.

eval_loss는 보기 좋게 내려갔다

학습 중 검증 손실은 깔끔하게 감소했습니다.

  • step 300: 1.6259
  • step 600: 1.5872
  • step 900: 1.5655
  • step 1200: 1.5543
  • step 1500: 1.5520 (best)

최종 1.5520은 v4의 best였던 1.6130보다 3.78% 낮은 값입니다. 곡선만 보면 "잘 됐다"예요.

감소폭이 0.039 → 0.022 → 0.011 → 0.002로 절반씩 줄어드는 게 살짝 걸리긴 했습니다. 슬슬 한계에 닿는 신호였죠.

그래도 숫자는 더 좋아졌고, 그래프가 영상 비트레이트의 한계치처럼 어느 지점부터 완만해지더군요. 그래서 이 정도면 피크가 온 거구나 하고 판단했습니다.

그런데 A/B로 직접 보니 더 나빴다

좋은 결과를 기대하며 평가를 돌렸습니다. 프롬프트 61개(도메인 고유명칭이 들어간 게 42개)로 베이스·v4·v5를 한꺼번에 비교하는 3자 A/B였어요.

디코딩은 temperature 0.7, 최대 600토큰으로 고정했습니다.

지표는 세 가지를 같이 봤습니다. 첫째는 고유명칭 일치율, 기대 출력에 들어가야 할 도메인 용어가 실제로 몇 개나 나왔는지입니다. 둘째는 반복 정도로, 4-gram이 얼마나 고유한지를 봅니다(1.0이면 반복 없음, 0.7 아래면 반복이 심한 거예요). 셋째는 길이 비율, 출력이 기대 길이의 몇 배인지죠.

번역 품질이 무너질 때는 이 셋 중 적어도 하나가 꼭 신호를 주거든요. 결과는 이랬습니다.

지표v4v5변화
고유명칭 일치율0.6210.601-2.0%
반복 없음(4-gram 고유율)0.9240.864-6.0%
길이 비율(출력/기대)1.0821.351+27%

세 지표가 전부 나빠졌습니다. 특히 출력이 기대보다 35% 더 길어졌다는 건, 모델이 쓸데없이 늘어지고 있다는 뜻이었어요.

정작 보강하려던 영→한의 고유명칭 일치율은 0.571에서 0.576으로 0.5%만 올랐습니다. 사실상 제자리였으니, 가설은 실패였어요.

반면 멀쩡하던 한→영은 0.703에서 0.641로 6.2% 떨어졌습니다. 영→한의 반복 지표는 0.862에서 0.768로 9.4%나 나빠졌고요. 보강은 안 되고 강점만 깎인 겁니다.

가장 분명한 숫자는 우열 판정이었습니다. 고유명칭 포함 42개 프롬프트에서 v5가 최고였던 경우는 0건이었어요. v4 우세 1건, 베이스 우세 8건, 나머지 33건은 무승부. 학습까지 시킨 v5가 단 한 문제에서도 가장 좋지 않았습니다.

지금 돌아보면, v4도 베이스보다 모든 면에서 나았던 건 아닙니다. v4 평가 때도 고유명칭 일치율은 베이스 0.744에서 0.621로 오히려 낮았고, 영→한은 베이스 0.862에서 0.571로 29%나 떨어졌었거든요.

그래서 v5의 진짜 문제는 "베이스보다 나쁘다"가 아니라 "학습을 더 시켰는데 직전 버전보다도 나빠졌다"였습니다. 들인 시간과 돈을 생각하면 더 뼈아픈 쪽이죠.

손실은 내렸는데 출력은 이랬다

몇 가지 예시를 보여드릴게요.

  1. 반복 폭주였습니다. 기대 출력이 203자인 영→한 문제에서 v5는 1,336자를 뱉었어요. 6.6배입니다. 내용은 같은 문장의 무한 반복이었습니다.

"...이쪽은 제 오랜 친구인 발렌소입니다. 발렌소, 이쪽은 제 오랜 친구인 발렌소입니다. 발렌소, 이쪽은 제 오랜 친구인 발렌소입니다. ..." (수십 번 반복)

한→영에서도 마찬가지였어요. 기대 출력이 629자인 문제에 v5가 2,426자를 쏟아낸 경우도 있었고, 어떤 영→한 출력은 기대 대비 7.4배까지 늘어났습니다. 번역기가 멈출 줄 모르고 같은 자리를 맴도는 거죠.

  1. 환각이었습니다. 원문의 "윳쿠리 요우무"(Yukkuri Youmu)를 v5는 엉뚱하게 "Yumumu (ゆむむむ), from the Touhou Project"라고 창작해버렸어요. 있지도 않은 이름을 만들어내고, 그러면서 출력은 또 길어졌죠.

실제 출력은 이렇게 하염없이 늘어지고 같은 자리만 맴돌았습니다. 한마디로 멍청했어요.

왜 그랬을까

제가 사후 분석 보고서에 이렇게 정리했습니다.

"검증셋이 학습 데이터와 분포가 비슷하다 보니, eval_loss가 내려간 게 일반화가 아니라 학습 데이터 과적합이었을 가능성이 큽니다. 정성 평가는 훨씬 다양한 고유명칭과 문맥을 던지니 거기서 괴리가 드러난 거죠. 여기에 안전선을 넘긴 학습률(5e-5)과 영→한 가중치 2배가 겹치면서, 보강은 못 하고 한→영 신호만 희석시킨 것으로 보입니다. 디코딩 설정도 반복 측정에 영향을 줬을 수 있다"

앞서 학습 곡선에서 감소폭이 0.039 → 0.022 → 0.011 → 0.002로 절반씩 줄던 것도 같은 맥락일지 모릅니다. 더 배울 게 거의 남지 않은 상태에서 억지로 더 밀어붙인 셈이니까요.

물론 이것도 어디까지나 제 추측입니다. 정확히 무엇이 문제였는지는 변수를 하나씩 떼어 다시 돌려봐야 알 텐데, 안 했어요!

그래서 어떻게 했나

v5 어댑터는 폐기했습니다. v4는 한→영 전용으로만 쓰고, 영→한은 베이스 모델로 돌리기로 했어요.

어차피 v4의 진짜 강점은 한→영이었으니, 잘하는 일만 맡기고 못하는 일은 베이스에 넘기는 분업이 차라리 나았습니다. 하나의 어댑터로 양방향을 다 잘하게 만들겠다는 욕심은 일단 내려놨죠.

비용으로 치면 v5 학습·평가에 RunPod에서 약 $8.7가 들었습니다(v4는 약 $5.6). 결과물은 안 쓰게 됐으니, 그 돈은 사장님이 낸 수업료가 된 셈입니다.

그래도 다행인 건, 정성 평가 61개를 돌리는 데는 22분밖에 안 걸렸다는 거예요. 학습은 다섯 시간이 넘었는데 검증은 20분 남짓이었죠. 만약 그 20분이 아깝다고 건너뛰었다면, 다섯 시간짜리 실패작을 좋은 줄 알고 그대로 채택했을 겁니다.

사장님 돈으로 산 규칙 하나를 새로 세웠습니다. 모든 LoRA 학습 뒤에는 양방향(한→영·영→한)과 고유명칭이 풍부한 프롬프트 60개 이상으로 A/B 정성 평가를 의무로 돌리고, eval_loss와 정성 지표가 같은 방향을 가리킬 때만 채택합니다. 어긋나면 폐기하거나 다시 학습하고요.

그런데 지금은 그 규칙도 안 씁니다. 베이스 모델에 LoRA로 학습하는 것보다, RAG용 데이터를 잘 만들어 주는 쪽이 결과물이 더 좋더라고요.

이건 다른 프로젝트에서 직접 확인했으니, 기회가 되면 따로 글로 남겨 보겠습니다.