Qwen, 당신은 이제 한국인 개발자입니다

처음에는 단순한 문제라고 생각했다.

로컬 LLM으로 코딩 에이전트를 구축하고, 적절한 모델을 선택한 뒤 한국어로만 답변하도록 시스템 프롬프트를 설정하면 될 것이라 믿었다. 하드웨어 한계를 계산하고, 그 안에서 가장 큰 모델과 가장 긴 컨텍스트를 확보하면 나머지는 자연스럽게 해결될 것처럼 보였다.

하지만 실제로 부딪힌 문제는 모델 크기나 속도만이 아니었다.

대화가 길어질수록 Qwen은 점차 한국어 출력 규칙을 벗어나기 시작했다. 처음에는 완벽하게 한국어로 답하던 모델이 어느 순간 중국어 문장을 섞었고, 더 긴 컨텍스트에서는 아예 중국어로 답변을 이어가는 현상까지 나타났다.

결국 문제는 단순히 프롬프트를 더 강하게 작성하는 것으로 해결되지 않았다.

이 글은 RTX 3060 12GB 환경에서 Qwen3-14B를 실행하기 위해 VRAM을 계산한 과정과, 장기 대화에서 출력 언어 정책이 무너지는 원인을 분석하고, 마지막에는 프롬프트가 아니라 샘플링 단계에서 중국어 토큰을 직접 차단한 과정을 정리한 기록이다.

RTX 3060의 현실적인 한계를 계산하다

인터넷의 추천 모델들은 대개 A100이나 RTX 4090 환경을 기준으로 한다. 하지만 내가 사용한 환경은 RTX 3060 12GB였다.

무조건 큰 모델을 선택하는 대신, 다음 목표를 먼저 정했다.

  • 모델: Qwen3-14B
  • 양자화: EXL3 4.0bpw
  • 목표 컨텍스트: 65,536 토큰
  • 가능한 한 높은 품질의 KV 캐시 사용
VRAM_{total} \approx W_{model} + W_{kv} + W_{workspace} + W_{buffer}

아래는 https://huggingface.co/Qwen/Qwen3-14B의 Model Overview 중 일부이다.

Training Stage: Pretraining & Post-training Number of Parameters: 14.8B
Number of Paramaters (Non-Embedding): 13.2B
Number of Layers: 40
Number of Attention Heads (GQA): 40 for Q and 8 for KV

Qwen3 14B 모델을 EXL3 4.0bpw로 사용하여 모델 전체가 평균 4.0비트로 저장된다고 단순 가정하면 모델 가중치 크기는 다음과 같다.

W_{model} = 14.8 \times 10^9 \times 4 / 8 = 7.4 \times 10^9 \text{ Byte} \approx 6.89\text{GiB}

가장 큰 변수인 KV Cache 또한 가용 자원 내에서 OOM을 피하기 위하여 수식으로 검증했다.

VRAM_{usable} = 12\text{ GiB}\\
W_{workspace} = 1\text{ GiB}\\\
\text{}\\
W_{budget}=VRAM_{usable}−(W_{model} + W_{workspace})\\
=12−(6.89 + 1)= 4.11\text{ GiB}

모든 레이어가 동일한 KV 헤드 수와 head dimension을 사용함을 가정하며, 캐시 구현을 위한 값은 무시한다.

_\text{레이어 수}\ L = 40\\\
_\text{목표 컨텍스트 크기}\ T=65536\\\
_\text{Attention Heads for KV}\ H_{kv} = 8\\
D_{head} = 128\\
\text{ }\\
b = \frac{W_{budget} \times 8 }{2 \times L \times T \times H_{kv} \times D_{head}} \\
\text{ }\\
= \frac{4.11 \times 1024^3 \times 8}{2 \times 40 \times 65536 \times 8 \times 128}\\
\text{ }\\
=\frac{35,304,631,173.12}{5,368,709,120}\\
\text{ }\\
= 6.576

이론 계산상 64K 컨텍스트에서는 KV 캐시가 약 6.58비트의 상한선으로 모델 가중치와 함께 VRAM에 들어간다. 실제 런타임 오버헤드와 안전 여유를 고려해 Q6 KV 캐시를 상한선에 가까운 후보로 선택했다. 놀랍게도 이 값은 RTX 3060이 실제 사용할 수 있는 VRAM과 거의 정확하게 일치했다. 최종적으로 선택된 Hugging Face 모델은 https://huggingface.co/TheMelonGod/Qwen3-14B-exl3/tree/6hb-4.0bpw 가 되었다.

마법처럼 매끄럽게 완성되지 않은 코딩 에이전트

Qwen3-14B를 RTX 3060에 올리고 64K 컨텍스트까지 확보한 뒤에는 곧바로 코딩 에이전트로 사용할 수 있을 것이라 생각했다.

하지만 모델이 정상적으로 문장을 생성하는 것과 도구를 호출하는 것은 다른 해결법이 필요했다. 일반적인 대화에서는 모델이 자연스럽게 답변했지만, 파일을 읽거나 명령어를 실행해야 하는 상황에서도 실제 tool call을 생성하지 않았다.

겉으로 보기에는 도구를 사용하려는 것처럼 보이지만, API 서버와 에이전트는 이를 구조화된 tool call로 인식하지 못한다. 모델 자체가 능력을 학습했더라도, 서버가 도구 목록과 대화 메시지를 모델이 학습한 형식으로 직렬화해야만 동작하는 것이다.

이 문제를 해결하기 위해 https://huggingface.co/spaces/huggingfacejs/chat-template-playground?modelId=Qwen%2FQwen3-14B 채팅 템플릿을 다운로드하여 TabbyAPI 설정에 반영했다.

핵심은 가중치는 EXL3 양자화 모델을 사용하되, 대화 직렬화 규칙은 원본 Qwen 모델을 따르는 것이다.

시스템 프롬프트에 3개 국어를 박아넣었던 이유

처음 이 문제를 겪었을 때는 아주 단순하게 접근했다. 한국어로만 말하라는 한 문장으로는 모델이 부족함을 느낄까 봐, 아예 시스템 프롬프트에 영어, 중국어, 한국어를 섞어서 아주 강력하게 제약을 걸어버리면 어떨까 하는 생각이었다.

You must speak Korean only. 必须使用韩语。 반드시 한국어로만 답변하시오.

이렇게 3개 국어로 강제하면 모델이 내가 원하는 바를 확실하게 이해할 것이라 믿었다. 결과는 어땠을까? 놀랍게도 처음 몇 번의 대화는 정말 완벽했다. 모델은 충실하게 한국어로만 답변했다. 나는 이 방법이 통했다고 생각했다.

그러나 컨텍스트가 쌓이면 무너지는 규칙

문제는 대화가 길어지면서 발생했다. 대화의 턴이 늘어나고 컨텍스트 창이 차오를수록, 모델은 처음에 설정했던 시스템 프롬프트의 내용을 점점 망각하기 시작했다. 분명 한국어로 질문을 던졌는데, 모델이 갑자기 중국어로 답변을 쏟아내거나, 심지어는 중국어로 “사용자의 요청으로 한국어를 입력합니다” 같은 이상한 문장을 내뱉으며 한국어를 사용한 답변을 거부하는 현상이다.

시스템 프롬프트는 대화의 가장 처음에 위치하지만, 대화가 길어질수록 모델의 주의 집중은 바로 직전의 대화 내용이나 본래 학습 데이터의 비중이 높은 중국어로 강하게 쏠리는 듯했다. 결국 임계점을 넘어선 모델은 혼란을 일으키며 내가 걸어놓은 제약을 완전히 무시한다.

왜 우리의 명령은 대화가 쌓일수록 힘을 잃는 것일까? 그 원인을 크게 3가지 층위로 정리해 보았다.

장거리 지시 유지 실패

시스템 프롬프트는 대화의 가장 앞인 0번 토큰 근처에 배치되는데, 대화가 길어질수록 시스템 프롬프트와 현재 질문 사이의 물리적 거리는 점점 멀어진다. 모델이 시스템 메시지의 우선순위를 강하게 학습했더라도, 이를 모든 상황에서 완벽하게 유지하는 것은 아니다.

컨텍스트가 길어지면 사용자의 최근 질문과 이전에 생성한 답변, 코드와 오류 메시지, 도구 호출 결과 등의 정보가 누적되어 다음 토큰의 확률 분포에 영향을 준다. 모델이 장거리 지시를 안정적으로 유지하지 못하면, 시스템 프롬프트보다 최근에 반복된 언어 패턴이나 직전 답변의 형식이 더 강하게 반영될 수 있다.

유용성 편향과의 충돌

시스템 프롬프트에서 중국어를 사용하지 마라고 강력하게 지시해도, 지시 튜닝과 선호도 최적화를 거친 모델은 일반적으로 현재 요청과 최근 문맥을 적극적으로 활용하도록 학습된다.

만약 대화 문맥 속에서 중국어 관련 데이터가 섞이거나 모델이 중국어 답변 패턴을 학습하게 되면, 모델은 지금 이 대화의 주제는 중국어와 관련이 있을 것이라고 잘못 추론한다. 시스템 프롬프트의 제약 조건은 통계적 가중치 싸움에서 후순위로 밀려나게 되는 것이다.

프롬프트의 통계적 붕괴

시스템 프롬프트는 하드코딩된 규칙이 아니라, 모델의 확률 분포를 미세하게 조정하는 조건자 역할을 한다. 그런데 사용자와 어시스턴트의 대화 토큰이 계속 쌓일수록, 이 조건자가 만들어내는 로그 확률 편향은 전체 시퀀스 길이에 의해 희석된다. 큰 바다에 잉크 한 방울을 떨어뜨려도 얼마 가지 않아 색이 사라지는 것처럼, 대화 데이터라는 거대한 통계적 바다 속에서 시스템 프롬프트의 영향력은 시간이 지날수록 상쇄될 수밖에 없다.

결국 우리가 겪은 시스템 프롬프트의 붕괴는 모델의 오류가 아니라 LLM 아키텍처 자체가 가진 구조적인 한계였다. 그렇기에 단순히 명령어를 정교하게 다듬는 것만으로는 한계가 명확하다. 시스템 프롬프트를 이용한 회피 전략은 사실 이 희석되는 주의력을 붙잡아 둘 명분이 부족한 것이다.

실전적 돌파구, LLM_Foreign_Block 사례를 보며

프롬프트만으로 모델을 제어하려는 시도는 한계에 부딪혔고, 모델의 출력 단계에서 강제로 차단하는 방법으로 변경했다.

이 프로젝트는 모델이 생성한 답변이 유니코드의 CJK 한중일 통합 한자 영역(U+4E00 ~ U+9FFF)을 침범하는지 실시간으로 감시한다. 모델이 중국어를 출력하려는 조짐이 보이면, 즉시 해당 문장을 차단하거나 생성을 중단시키는 후처리 로직을 적용한 것이다.

모델이 생성한 결과를 검증하는 계층이 하나 추가된 것이다. 모델은 자유롭게 토큰을 생성하도록 두되, 사용자에게 전달하기 직전에 출력이 정책을 만족하는지 검사하는 방식이다. 핵심은 잘 생성하도록 만드는 것이 아니라 잘못 생성된 결과는 통과시키지 않는 것이다.

아쉽게도 프로젝트를 TabbyAPI에 직접 적용할 수는 없었지만 단서를 얻은 것만으로 충분했다. 모델과 샘플러 일부를 수정하였다. 아래 코드는 유니코드의 CJK 한중일 통합 한자 영역(U+4E00 ~ U+9FFF)에 속하는 단어가 보이면 생성 경로를 통과하지 못하게 만든다.

#!/usr/bin/env bash
set -euo pipefail

cat > chinese-token-block.patch <<'PATCH'
diff --git a/backends/exllamav3/sampler.py b/backends/exllamav3/sampler.py
--- a/backends/exllamav3/sampler.py
+++ b/backends/exllamav3/sampler.py
@@ -1,7 +1,9 @@
 from dataclasses import dataclass, field
 from typing import List
+from exllamav3.generator.sampler.custom import SS
 from exllamav3.generator.sampler import (
     CustomSampler,
+    SS_NoOp,
     SS_Temperature,
     SS_RepP,
     SS_PresFreqP,
@@ -16,6 +18,44 @@ from exllamav3.generator.sampler import (
 )
 
 
+class SS_BlockTokens(SS_Base):
+    """
+    Mask specific token IDs out of logits/probs.
+    """
+    def __init__(self, token_ids):
+        self.token_ids = sorted(set(int(x) for x in token_ids))
+
+    def run(self, state):
+        if not self.token_ids:
+            return
+
+        match state.state:
+            case SS.INIT:
+                state.logits = state.in_logits.float()
+                state.logits[..., self.token_ids] = -float("inf")
+                state.state = SS.LOGITS
+
+            case SS.LOGITS | SS.LOGITS_S:
+                state.logits[..., self.token_ids] = -float("inf")
+
+            case SS.PROBS | SS.PROBS_N:
+                state.probs[..., self.token_ids] = 0.0
+                state.state = SS.PROBS
+
+            case SS.PROBS_S | SS.PROBS_N_S:
+                state.probs[..., self.token_ids] = 0.0
+                state.state = SS.PROBS_S
+
+            case _:
+                raise ValueError("Sampling logic error")
+
+    def alt(self):
+        if not self.token_ids:
+            return SS_NoOp()
+        return None
+
+
 @dataclass
 class ExllamaV3SamplerBuilder:
@@ -25,6 +65,7 @@ class ExllamaV3SamplerBuilder:
     """
 
     stack: List[SS_Base] = field(default_factory=list)
+    blocked_token_ids: List[int] = field(default_factory=list)
 
     def penalties(self, rep_p, freq_p, pres_p, penalty_range, rep_decay):
         self.stack += [
@@ -51,9 +92,15 @@ class ExllamaV3SamplerBuilder:
     def adaptive_p(self, adaptive_target, adaptive_decay):
         self.stack.append(SS_AdaptiveP(adaptive_target, adaptive_decay))
 
+    def block_tokens(self, token_ids):
+        self.blocked_token_ids = token_ids
+
     def build(self, greedy):
         """Builds the final sampler from stack."""
 
+        if self.blocked_token_ids:
+            self.stack.insert(0, SS_BlockTokens(self.blocked_token_ids))
+
         # Adaptive-P does categorical sampling already
         if len(self.stack) and isinstance(self.stack[-1], SS_AdaptiveP):
             return CustomSampler(self.stack)
@@ -61,6 +108,8 @@ class ExllamaV3SamplerBuilder:
         # Use greedy if temp is 0
         if greedy:
+            if self.blocked_token_ids:
+                return CustomSampler([SS_BlockTokens(self.blocked_token_ids), SS_Argmax()])
             return CustomSampler([SS_Argmax()])
         else:
             self.stack.append(SS_Sample())
diff --git a/backends/exllamav3/model.py b/backends/exllamav3/model.py
--- a/backends/exllamav3/model.py
+++ b/backends/exllamav3/model.py
@@ -1039,6 +1039,13 @@
 
         sampler_builder = ExllamaV3SamplerBuilder()
 
+        # Optional token blocklist. One token id per line.
+        # Example use: block Chinese Han-character tokens for local coding models.
+        blocked_path = Path("blocked_chinese_token_ids.txt")
+        if blocked_path.exists():
+            blocked_ids = [int(x) for x in blocked_path.read_text().splitlines() if x.strip()]
+            sampler_builder.block_tokens(blocked_ids)
+
         # Apply penalties
         sampler_builder.penalties(
             params.repetition_penalty,
PATCH

git apply chinese-token-block.patch

echo "Patch applied successfully."

한계점으로 이 방식은 언어 자체를 스스로 이해해서 차단하는 것은 아니기 때문에 다음과 같은 문제가 발생할 수 있다.

  • 하나의 토큰에 한자와 다른 문자가 함께 포함될 수 있다.
  • 정상적인 코드나 문자열 토큰이 함께 차단될 수 있다.
  • 중국어가 여러 토큰으로 분리되어 우회될 수 있다.
  • CJK 확장 영역의 문자는 차단되지 않을 수 있다.
  • 중국어 구두점과 병음은 그대로 출력될 수 있다.

그러나 장점은 분명하다. 시스템 프롬프트 준수 여부에 의존하지 않으며 temperature가 0이어도 적용된다. 장기 컨텍스트에서도 동일한 정책을 유지하여 중국어를 마주할 확률을 크게 낮춘다. 또한 기존 모델을 다시 학습하거나 파인튜닝할 필요가 없다.

특히 코딩 에이전트처럼 출력 언어의 일관성이 중요한 환경에서는 매우 실용적이다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다