한국어 서비스에 다국어 LLM을 붙여 쓰는 분이라면 이런 문장을 본 적이 있을 겁니다.

인기가极高的 해양 생물 테마파크
오후 6시前后的(경계) 시간대는
파일 버전号和 마지막 수정 시각을
일식居酒屋(주류집)는 소주와 함께

전부 Qwen3.8-27B가 한국어 프롬프트에 답하며 낸 출력입니다. 응답 전체가 중국어가 되는 게 아니라 한국어 문장 한복판에 두세 글자가 툭 끼어듭니다. 가장 흔한 침입자는 , , 로 중국어 조사와 존칭입니다.

이 글에서 얻어 가실 것은 세 가지입니다. 첫째, 이 문제를 재기 전에 생성 온도를 반드시 확인해야 합니다. 온도 하나가 오염률의 5배를 설명합니다. 둘째, 측정 도구부터 의심해야 합니다. 저희는 선호 학습의 효과를 재다가, 비교 자체가 성립하지 않았다는 걸 나중에 알았습니다. 셋째, 가중치에서 출력 경로를 막으면 됩니다. 다만 한국어 한자 병기를 죽이지 않으려면 무엇을 자를지 골라야 합니다.

쉽게 말하면

모델이 글자를 내놓는 마지막 단계는 자판에서 글쇠를 고르는 일과 같습니다. 저희가 한 일은 그 자판에서 중국어 단어 글쇠를 빼낸 것입니다. 낱글자 한자 글쇠는 남겨 두었습니다. 한국어 글에는 개항(開港)처럼 한자를 괄호로 병기하는 자리가 있기 때문입니다. 이 자판 비유를 글 끝까지 쓰겠습니다.

온도가 5배를 좌우합니다

측정을 시작하고 한동안 저희 숫자는 학계 보고치보다 40배 이상 높았습니다. 같은 계열 모델을 다룬 SASFT 논문이 Qwen3-8B 한국어 코드스위칭 비율을 0.25%로 보고하는데, 저희는 10%가 넘었습니다.

차이는 모델이 아니라 생성 설정이었습니다. 같은 모델, 같은 프롬프트 1,200건을 온도만 바꿔 돌린 결과입니다.

온도 오염률 실제 오류
1.0 10.42% 9.33%
0.0 2.67% 1.92%

여기서 ‘실제 오류’는 개항(開港)처럼 한국어에서 정상적으로 쓰는 한자 병기를 제외한 값입니다. 5배 차이가 온도 하나에서 나왔고, 논문과의 격차도 대부분 여기서 설명됩니다.

그래서 오염률을 인용할 때는 온도를 반드시 함께 적어야 합니다. 적지 않은 숫자는 비교할 수 없습니다. 이 글의 모든 수치는 temperature=0.0, max_tokens=400 기준입니다.

한 가지 더 필요한 게 노이즈 바닥입니다. 같은 모델을 같은 설정으로 두 번 돌리면 2.67%와 2.33%가 나옵니다. 런 사이에 0.33%p가 그냥 흔들립니다. 이보다 작은 차이는 어떤 처치의 효과로 읽으면 안 됩니다. 실제로 프롬프트 2,000건 중 569건에서 같은 모델이 자기 자신과 오염 여부가 뒤집혔습니다. 이 현상은 프롬프트의 성질이라기보다 샘플링의 성질에 가깝습니다.

DPO는 배웠지만, 전이는 판정하지 못했습니다

먼저 시도한 건 선호 학습이었습니다. 최소 쌍을 만들었습니다. 한자가 섞인 응답을 그대로 두고 그 한자 스팬만 한국어로 바꾼 것을 좋은 답으로 삼았습니다. 두 응답의 유사도 중앙값은 사실상 1이라(1과의 차이가 천분의 2), 모델이 배울 차이는 오직 한자 유무뿐입니다.

학습은 아주 깨끗하게 붙었습니다. 홀드아웃 선호 정확도가 97.4%까지 올라가고 손실은 0에 수렴했습니다.

그런데 생성 단계 검증은 무효가 됐습니다. 사후 점검에서, 저희가 쓰던 서빙 엔진이 이 모델 계열의 LoRA 어댑터를 에러 없이 통째로 무시한다는 사실이 드러났습니다. 온도 0에서 어댑터를 붙인 출력과 base 출력이 바이트 단위로 같았습니다. “DPO 적용”이라고 믿었던 열은 사실 base를 한 번 더 잰 것이었습니다. 그래서 이 글의 이전 판에 있던 전이 실패 표는 뺐고, 지금은 어댑터를 가중치에 직접 병합해 다시 재고 있습니다.

가설은 남아 있습니다. DPO는 완성된 두 응답의 상대 확률을 조정하는데, 오염은 임의 위치에서 터지는 단일 토큰 사건입니다. TLPO 논문도 시퀀스 수준 미세조정의 같은 한계를 지적하며 토큰 수준 개입을 제안합니다. 다만 이번 실험이 그 가설을 검증한 것은 아닙니다.

덧붙이면 데이터 규모도 부족했습니다. 저희는 346쌍이었는데, 같은 증상을 SFT로 잡은 SASFT는 11만~21만 샘플을 썼습니다. 다만 규모를 키운다고 층위 불일치가 사라지는 건 아닙니다.

그래서 출력 경로를 막았습니다

lm_head는 은닉 상태를 어휘 크기의 로짓으로 바꾸는 행렬입니다. 특정 토큰의 행을 손대면 그 토큰이 뽑힐 확률을 직접 통제할 수 있습니다.

주의할 게 하나 있습니다. 행을 0으로 만들면 안 됩니다. Qwen의 lm_head에는 bias가 없어서 로짓이 h · W_i인데, 행을 0으로 만들면 로짓이 0이 될 뿐 음의 무한대가 아닙니다. 다른 후보가 전부 음수인 순간 0이 최댓값이 됩니다.

그래서 은닉 상태 평균 방향의 큰 음수 배수로 덮었습니다. 자판 비유로는 글쇠를 뽑아내는 대신, 눌러도 절대 안 먹히게 잠근 것입니다.

W_i := -alpha * mu_h / ||mu_h||^2     (alpha = 200)

mu_h는 한국어 문장들의 최종 은닉 상태 평균으로, 가정하지 않고 직접 측정했습니다. 두 번의 독립 측정에서 ||mu_h||²가 약 9,846과 9,887로 0.4% 안에서 재현됐습니다.

토크나이저와 입력 임베딩은 건드리지 않았습니다. 즉 이 모델은 한자를 생성하지 못하지만 읽을 수는 있습니다. 어휘 크기가 그대로라 표준 방식으로 로드됩니다.

무엇을 자를지가 진짜 문제입니다

전부 자르면 간단하지만 한국어 한자 병기까지 죽습니다. 그래서 세 후보를 만들어 곡선을 그렸습니다.

문자 단위로 보면 오염의 72.3%가 한국어에서도 쓰는 번체·공용 한자입니다. 간체만 자르면 4분의 1밖에 못 잡습니다. 그런데 토큰 단위로 보면 그림이 다릅니다. 오염 토큰 498회 중 355회가 이런 것들이었습니다.

您的(12)  贵公司(8)  具体时间(7)  为您(5)  本次会议(3)

중국어 단어가 통째로 한 토큰입니다. 한국어는 저런 걸 한 덩어리로 뱉지 않습니다. 반면 한국어 한자 병기는 낱글자로 쪼개집니다. 개항(開港) 두 토큰이고, 채권(債權)입니다.

여기서 방향이 나옵니다. 가나와 간체 전용 글자, 그리고 두 글자 이상 순수 한자 토큰을 자르고 단일 한자는 남깁니다. 54,902개 토큰이 대상이 됩니다.

결과

프롬프트 3,369건을 같은 엔진에서 짝지어 측정했습니다.

  오염률 실제 오류
base 2.55% 1.81%
가지치기 적용 0.68% 0.18%

실제 오류 기준 82% 감소이고, 노이즈 바닥 0.33%p 대비 5.7배라 처치에 귀속할 수 있습니다. 통계 검정(McNemar)으로도 우연이라 보기 어렵습니다(p 값이 1만분의 1 미만).

한자 병기는 살아 있습니다. 한자를 명시적으로 요구한 12개 프롬프트에서 base와 똑같이 12개 모두 정상 생성합니다. HumanEval 코딩 정확도도 92.19%로 base와 동일합니다. 다만 n=64에서 최소 검출차가 13.29%p라, 이건 “회귀가 없다”가 아니라 “검출하지 못했다”로 읽어야 정확합니다.

전면 마스킹은 어떨까요. 오염률은 0.06%까지 내려가지만 대가가 큽니다. 한자 요구 프롬프트 12건 중 3건만 한자를 내고, 그 3건마저 틀린 한자입니다.

債權 → 倖         새옹지마 → 壺齋         大 → 尙

맞는 글자가 막히자 바이트 조각으로 엉뚱한 한자를 조립해 자신 있게 내놓습니다. 한자를 아예 안 쓰는 것보다 나쁩니다.

이 방법의 한계 두 가지

첫째, 완전한 0은 구조적으로 불가능합니다. 전면 마스킹에서도 3,369건 중 2건이 남았고, 정체는 이었습니다. 토크나이저를 열어 보면 답이 나옵니다.

的 → 1토큰 [95726]          차단됨
鄕 → 2토큰 [98248, 243]     차단 안 됨

희귀 한자는 어휘에 단독 글쇠가 없어 바이트 조각으로 조립됩니다. 자판 비유로는, 글쇠를 잠가도 특수문자 조합 입력으로 그 글자를 쳐내는 길이 남아 있는 셈입니다. 그 바이트 글쇠는 자를 수 없습니다. 자르면 임의의 UTF-8 처리가 깨집니다. 어휘 가지치기의 하한은 0이 아닙니다.

둘째, 행 덮어쓰기는 근사입니다. 같은 방법을 0.8B 모델에 적용했더니 프록시 측정 0.20%가 실제 가중치에서 1.33%로 벌어졌습니다. 새어 나온 20건 중 15건이 차단 목록에 들어 있는 한 글자였고, 열어 보니 전부 이런 자리였습니다.

systemd-run・user     my・pod     request・user_id     ・perf

한국어 문맥이 아니라 코드와 CLI 문맥에서 하이픈이 와야 할 자리입니다. 잠가 둔 글쇠가 어떤 상황에서는 눌려 버린 것입니다.

여기서 저희 검증 방식의 구멍이 드러났습니다. 마스킹 로짓 마진을 200개 문맥에서 쟀고 0.8B도 최악 마진 103으로 안전 판정이 나왔는데, 그 프로브는 프롬프트 마지막 토큰 위치만 봅니다. 누출은 생성 도중에 일어나고 그때의 은닉 상태는 다릅니다. 안전 판정은 “내가 잰 위치에서 안전”이지 “모든 위치에서 안전”이 아니었습니다.

27B는 같은 계열 문맥(하이픈과 언더스코어가 잦은 코드·CLI·YAML) 프롬프트에서도 오염이 0건이었고, 한국어 3,369건에서도 차단 대상 토큰 누출이 0건입니다. 27B의 근거는 마진 지표가 아니라 이 실측입니다.

0.8B만 뚫리고 27B는 안 뚫리는 이유는 아직 모릅니다. 용량 차이로 설명하고 싶은 유혹이 있지만 근거가 없습니다. 확실한 건 행을 덮어쓰는 방식이 보장을 주지 못한다는 것이고, 진짜 어휘 가지치기가 행을 제거하는 이유가 여기 있습니다. bias 없는 선형 층에서 특정 토큰을 확실히 막으려면 행을 없애는 수밖에 없습니다.

정리하면

한국어 응답의 한자 혼입을 다룰 때 순서는 이렇습니다. 먼저 생성 온도를 확인하세요. 그것만으로 5배가 갈립니다. 그다음 선호 학습에 시간을 쓰기 전에 문제의 층위를 보세요. 단일 토큰 사건에 시퀀스 수준 신호는 닿지 않습니다. 가중치를 손댄다면 무엇을 자를지가 결과를 정합니다. 한국어 한자를 쓰는 도메인이라면 낱글자를 살리는 게 실용적입니다.

접근 자체는 새롭지 않습니다. dnotitia/smoothie-qwen이 같은 방향으로 Qwen 계열 사전 조정 체크포인트를 이미 배포하고 있습니다. 저희가 더한 것은 한국어 한자 보존을 설계축으로 삼은 곡선과, 위에 적은 두 관측입니다.

마지막으로 분명히 해둘 게 있습니다. 이건 위생 처리이지 문체 개선이 아닙니다. 같은 프로젝트의 문체 개선 시도는 아직 판정하지 못했습니다. 위와 같은 서빙 결함으로 비교 측정이 무효가 되어 재측정 중입니다. 확인된 것은 당시 학습 타깃이 base 출력과 거의 같았다는 점입니다. 한자가 안 섞인다고 글이 자연스러워지지는 않습니다. 그건 다른 문제고, 다른 데이터가 필요합니다.

참고

태그: code-switching, dpo, korean, language-confusion, measurement, qwen, vocabulary-pruning

카테고리:

업데이트: