Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Qwen3

GPT 장의 GPT-2(2019)와 지금 쓰는 Llama·Qwen(2023~) 계열은 디코더 골격이 같습니다 — 토큰을 임베딩해 [정규화·인과 어텐션·잔차 / 정규화·MLP·잔차]를 여러 층 쌓고, 마지막에 어휘 로짓으로 투영합니다. 달라진 것은 그 부품 입니다. 이 장은 알리바바가 공개한 Qwen3-0.6B-Base(사후 훈련 전의 사전훈련 모델)를 Keras로 처음부터 조립하고 실제 가중치를 이식해, GPT-2에서 달라진 부품 하나하나를 짚습니다.

1GPT-2에서 무엇이 바뀌었나

GPT-2 (2019)Qwen3 (2025)
위치 정보학습형 위치 임베딩RoPE(회전 위치 임베딩)
정규화LayerNormRMSNorm
MLPGELUSwiGLU
어텐션멀티헤드(MHA)그룹 쿼리(GQA)
안정화QK-Norm
기타bias 있음bias 없음, 임베딩 공유

Qwen3-0.6B의 설정은 층 28, 임베딩 1024, 쿼리 헤드 16, 키·값 헤드 8(GQA), 헤드 차원 128, MLP 잠재 3,072, 어휘 151,936입니다.

import os
os.environ["KERAS_BACKEND"] = "torch"
import numpy as np
import keras
from keras import ops, layers

설정 = dict(어휘수=151936, 층수=28, 차원=1024, 헤드수=16, 키값헤드수=8, 헤드차원=128, 잠재차원=3072)
print("Keras:", keras.__version__)
Keras: 3.10.0

2RMSNorm — 더 단순한 정규화

LayerNorm은 평균을 빼고 분산으로 나눈 뒤 스케일·이동합니다. RMSNorm 은 평균 빼기와 이동(bias)을 모두 없애고, 제곱평균제곱근(RMS)으로만 나눕니다 — 더 싸고 실제로 잘 동작해 이후 모델 대부분이 채택했습니다.

RMSNorm(x)=x1dixi2+ϵg\text{RMSNorm}(x) = \frac{x}{\sqrt{\frac{1}{d}\sum_i x_i^2 + \epsilon}} \odot g
@keras.saving.register_keras_serializable(package="qwen3")
class RMSNorm(layers.Layer):
    """평균을 빼지 않고 RMS로만 정규화한다 (LayerNorm 대비 단순)."""
    def __init__(self, eps=1e-6, **kw):
        super().__init__(**kw)
        self.eps = eps

    def build(self, 입력형태):
        self.scale = self.add_weight(shape=(입력형태[-1],), initializer="ones", name="scale")

    def get_config(self):
        return {**super().get_config(), "eps": self.eps}

    def call(self, x):
        x32 = ops.cast(x, "float32")
        제곱평균 = ops.mean(x32 ** 2, axis=-1, keepdims=True)
        정규화 = x32 * ops.rsqrt(제곱평균 + self.eps)              # 평균 빼기 없음
        return ops.cast(정규화, x.dtype) * self.scale

3RoPE · QK-Norm · GQA — 어텐션의 변화

어텐션의 골격(스케일드 닷-프로덕트 + 인과 마스크)은 어텐션 장 그대로지만, 세 가지가 더해집니다.

RoPE(회전 위치 임베딩): GPT-2는 위치 임베딩을 입력에 더했지만, RoPE는 질의·색인 벡터를 위치 각도만큼 회전 시킵니다. 두 토큰의 내적이 자연히 상대 거리 에만 의존하게 되어, 학습하지 않은 긴 문맥에도 잘 일반화됩니다.

QK-Norm: 회전 전에 질의·색인을 헤드 차원 기준으로 RMSNorm해 어텐션 점수를 안정화합니다(Qwen3가 추가).

GQA(그룹 쿼리 어텐션): 키·값 헤드 수를 쿼리보다 적게 둬(16개를 8개로) 여러 쿼리 헤드가 키·값을 공유 합니다. 캐시 메모리와 추론 속도를 크게 줄여 최근 모델이 널리 씁니다.

@keras.saving.register_keras_serializable(package="qwen3")
class GroupedQueryAttention(layers.Layer):
    """RoPE 회전 + QK-Norm + 그룹 쿼리 어텐션 (Qwen3 어텐션)."""
    def __init__(self, 차원, 헤드수, 키값헤드수, 헤드차원, rope_theta=1000000.0, **kw):
        super().__init__(**kw)
        self.차원, self.헤드수, self.키값헤드수 = 차원, 헤드수, 키값헤드수
        self.헤드차원, self.rope_theta = 헤드차원, rope_theta
        self.q_proj = layers.Dense(헤드수 * 헤드차원, use_bias=False, name="q_proj")
        self.k_proj = layers.Dense(키값헤드수 * 헤드차원, use_bias=False, name="k_proj")
        self.v_proj = layers.Dense(키값헤드수 * 헤드차원, use_bias=False, name="v_proj")
        self.o_proj = layers.Dense(차원, use_bias=False, name="o_proj")
        self.q_norm = RMSNorm(name="q_norm")                      # 헤드 차원 기준 정규화
        self.k_norm = RMSNorm(name="k_norm")

    def get_config(self):
        return {**super().get_config(), "차원": self.차원, "헤드수": self.헤드수,
                "키값헤드수": self.키값헤드수, "헤드차원": self.헤드차원, "rope_theta": self.rope_theta}

    def 회전(self, x):                                            # (-x2, x1) 로 절반씩 회전
        x1, x2 = x[..., :self.헤드차원 // 2], x[..., self.헤드차원 // 2:]
        return ops.concatenate([-x2, x1], axis=-1)

    def 회전표(self, T):                                          # 위치별 cos·sin (RoPE)
        위치 = ops.arange(0, T, dtype="float32")
        역주파수 = 1.0 / (self.rope_theta ** (ops.arange(0, self.헤드차원, 2, "float32") / self.헤드차원))
        각도 = 위치[:, None] * 역주파수[None, :]
        각도 = ops.concatenate([각도, 각도], axis=-1)
        return ops.cos(각도)[None, None], ops.sin(각도)[None, None]

    def call(self, x):
        B, T = ops.shape(x)[0], ops.shape(x)[1]
        나누기 = lambda 텐서, 헤드: ops.reshape(텐서, (B, T, 헤드, self.헤드차원))
        q = self.q_norm(나누기(self.q_proj(x), self.헤드수))       # QK-Norm
        k = self.k_norm(나누기(self.k_proj(x), self.키값헤드수))
        v = 나누기(self.v_proj(x), self.키값헤드수)
        q = ops.transpose(q, (0, 2, 1, 3))                        # (B, 헤드, T, 헤드차원)
        k = ops.transpose(k, (0, 2, 1, 3))
        v = ops.transpose(v, (0, 2, 1, 3))

        cos, sin = self.회전표(T)
        q = q * cos + self.회전(q) * sin                          # RoPE 회전
        k = k * cos + self.회전(k) * sin

        반복 = self.헤드수 // self.키값헤드수
        k = ops.repeat(k, 반복, axis=1)                           # GQA: 키·값 헤드 공유
        v = ops.repeat(v, 반복, axis=1)

        점수 = ops.matmul(q, ops.transpose(k, (0, 1, 3, 2))) / ops.sqrt(float(self.헤드차원))
        마스크 = ops.triu(ops.full((T, T), float("-inf")), 1)     # 인과 마스크
        가중치 = ops.softmax(점수 + 마스크, axis=-1)
        y = ops.matmul(가중치, v)
        y = ops.reshape(ops.transpose(y, (0, 2, 1, 3)), (B, T, self.헤드수 * self.헤드차원))
        return self.o_proj(y)

4SwiGLU와 디코더 블록

GPT-2의 MLP는 Dense \to GELU \to Dense 였습니다. SwiGLU 는 입력을 두 갈래(gate·up)로 보내, 한쪽을 SiLU로 게이팅 한 뒤 곱합니다 — 표현력이 좋아 최근 모델이 채택했습니다. 수식만 있는 부품이 아니라 Dense 조합이므로, 블록 안에서 함수형으로 바로 조립합니다.

디코더 블록은 사전 정규화(pre-norm) — 부품 에 RMSNorm을 두고 잔차로 더합니다.

def 디코더_블록(차원, 헤드수, 키값헤드수, 헤드차원, 잠재차원, name):
    입력 = keras.Input(shape=(None, 차원))

    정규화1 = RMSNorm(name="input_layernorm")(입력)
    어텐션 = GroupedQueryAttention(차원, 헤드수, 키값헤드수, 헤드차원, name="attn")(정규화1)
    x = 입력 + 어텐션                                              # 잔차 1

    정규화2 = RMSNorm(name="post_attention_layernorm")(x)
    게이트 = layers.Dense(잠재차원, use_bias=False, name="gate_proj")(정규화2)
    업 = layers.Dense(잠재차원, use_bias=False, name="up_proj")(정규화2)
    은닉 = ops.silu(게이트) * 업                                   # SwiGLU
    은닉 = layers.Dense(차원, use_bias=False, name="down_proj")(은닉)
    x = x + 은닉                                                   # 잔차 2
    return keras.Model(입력, x, name=name)

임베딩·디코더·정규화·헤드를 함수형으로 이어 붙입니다. 출력 헤드는 GPT-2처럼 토큰 임베딩과 가중치를 공유 합니다.

def Qwen3(어휘수, 층수, 차원, 헤드수, 키값헤드수, 헤드차원, 잠재차원):
    토큰 = keras.Input(shape=(None,), dtype="int32")

    임베딩 = layers.Embedding(어휘수, 차원, name="embed")
    x = 임베딩(토큰)

    디코더 = keras.Sequential(
        [디코더_블록(차원, 헤드수, 키값헤드수, 헤드차원, 잠재차원, name=f"block_{i}") for i in range(층수)],
        name=f"decoder_x{층수}")
    x = 디코더(x)

    x = RMSNorm(name="norm")(x)
    로짓 = ops.matmul(x, ops.transpose(임베딩.embeddings))         # 임베딩 공유 헤드
    return keras.Model(토큰, 로짓, name="qwen3")


model = Qwen3(**설정)
model(np.zeros((1, 4), "int32"))                                  # 빌드(가중치 생성)
print("파라미터 수:", model.count_params())
파라미터 수: 596049920

5토크나이저 — 바이트 단위 BPE 재사용

토크나이저는 바이트 페어 인코딩 장의 GPT-2 방식과 같은 엔진 입니다 — 텍스트를 UTF-8 바이트로 바꿔 병합 순위대로 합칩니다. Qwen의 어휘(vocab.json)와 병합 규칙(merges.txt)만 받아 끼웁니다.

import urllib.request, json
import regex as re

기지 = "https://huggingface.co/Qwen/Qwen3-0.6B-Base/resolve/main/"
어휘 = json.load(urllib.request.urlopen(기지 + "vocab.json"))
역어휘 = {i: t for t, i in 어휘.items()}
병합 = urllib.request.urlopen(기지 + "merges.txt").read().decode("utf-8").split("\n")
순위 = {tuple(m.split()): i for i, m in enumerate(병합[1:]) if len(m.split()) == 2}

바이트목록 = list(range(33, 127)) + list(range(161, 173)) + list(range(174, 256))
유니목록, 다음 = 바이트목록[:], 0
for b in range(256):
    if b not in 바이트목록:
        바이트목록.append(b); 유니목록.append(256 + 다음); 다음 += 1
바이트유니 = {b: chr(c) for b, c in zip(바이트목록, 유니목록)}
유니바이트 = {c: b for b, c in 바이트유니.items()}
패턴 = re.compile(r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+""")

def 인접쌍(단어):
    return set(zip(단어[:-1], 단어[1:]))

def bpe(조각):
    단어 = tuple(조각); 쌍 = 인접쌍(단어)
    while 쌍:
        후보 = min(쌍, key=lambda 한쌍: 순위.get(한쌍, float("inf")))
        if 후보 not in 순위:
            break
        앞, 뒤 = 후보; 새단어 = []; i = 0
        while i < len(단어):
            try:
                j = 단어.index(앞, i); 새단어.extend(단어[i:j]); i = j
            except ValueError:
                새단어.extend(단어[i:]); break
            if 단어[i] == 앞 and i < len(단어) - 1 and 단어[i + 1] == 뒤:
                새단어.append(앞 + 뒤); i += 2
            else:
                새단어.append(단어[i]); i += 1
        단어 = tuple(새단어); 쌍 = 인접쌍(단어) if len(단어) > 1 else set()
    return 단어

def encode(text):
    ids = []
    for 조각 in 패턴.findall(text):
        조각 = "".join(바이트유니[b] for b in 조각.encode("utf-8"))
        ids.extend(어휘[t] for t in bpe(조각))
    return ids

def decode(ids):
    글자 = "".join(역어휘[i] for i in ids)
    return bytes(유니바이트[c] for c in 글자).decode("utf-8", errors="replace")

print("토큰:", [decode([i]) for i in encode("대한민국의 수도는")])
print("ID  :", encode("대한민국의 수도는"))
토큰: ['대', '한', '민', '국', '의', ' 수도', '는']
ID  : [66845, 23573, 125496, 124785, 20401, 134013, 16560]

6가중치 이식

Qwen3 가중치는 .safetensors 파일입니다 — safetensors 로 바로 읽습니다(transformers 불필요). GPT-2와 달리 선형층이 PyTorch nn.LinearBERT 장처럼 .T 로 전치합니다.

from safetensors.numpy import load_file

URL = "https://huggingface.co/Qwen/Qwen3-0.6B-Base/resolve/main/model.safetensors"
경로 = keras.utils.get_file("qwen3-0.6b-base.safetensors", URL)
가중치 = load_file(경로)

def 전치(이름): return 가중치[이름].T

def 이식(model, 가중치):
    model.get_layer("embed").set_weights([가중치["model.embed_tokens.weight"]])
    model.get_layer("norm").set_weights([가중치["model.norm.weight"]])
    for i, 블록 in enumerate(model.get_layer("decoder_x28").layers):
        p = f"model.layers.{i}."
        블록.get_layer("input_layernorm").set_weights([가중치[p + "input_layernorm.weight"]])
        블록.get_layer("post_attention_layernorm").set_weights([가중치[p + "post_attention_layernorm.weight"]])
        어텐션 = 블록.get_layer("attn")
        어텐션.q_proj.set_weights([전치(p + "self_attn.q_proj.weight")])
        어텐션.k_proj.set_weights([전치(p + "self_attn.k_proj.weight")])
        어텐션.v_proj.set_weights([전치(p + "self_attn.v_proj.weight")])
        어텐션.o_proj.set_weights([전치(p + "self_attn.o_proj.weight")])
        어텐션.q_norm.set_weights([가중치[p + "self_attn.q_norm.weight"]])
        어텐션.k_norm.set_weights([가중치[p + "self_attn.k_norm.weight"]])
        블록.get_layer("gate_proj").set_weights([전치(p + "mlp.gate_proj.weight")])
        블록.get_layer("up_proj").set_weights([전치(p + "mlp.up_proj.weight")])
        블록.get_layer("down_proj").set_weights([전치(p + "mlp.down_proj.weight")])

이식(model, 가중치)
print("이식 완료 — 이제 진짜 Qwen3-0.6B-Base입니다.")
이식 완료 — 이제 진짜 Qwen3-0.6B-Base입니다.

7검증 — transformers 없이

진짜 Qwen3에서 미리 기록해 둔 기준값과 맞춰 봅니다. RoPE·QK-Norm·GQA 가운데 하나라도 어긋나면 로짓이 틀어지므로, 일치한다면 모든 부품이 제자리에 들어갔다는 강한 증거입니다.

x = np.array([encode("대한민국의 수도는")], dtype="int32")
로짓 = model.predict(x, verbose=0)[0, -1]
print("다음 토큰:", repr(decode([int(로짓.argmax())])))
print("최대 로짓:", round(float(로짓.max()), 4))

assert decode([int(로짓.argmax())]) == " 어디"
assert abs(float(로짓.max()) - 24.8825) < 0.05
print("\n검증 통과 — 우리 Keras 모델은 진짜 Qwen3와 동일하게 동작합니다.")
다음 토큰: ' 어디'
최대 로짓: 24.8825

검증 통과 — 우리 Keras 모델은 진짜 Qwen3와 동일하게 동작합니다.

8생성

GPT 장과 같은 그리디 생성입니다 — 매번 가장 확률이 높은 토큰을 이어 붙입니다.

def 생성(model, encode, decode, 프롬프트, 길이):
    ids = encode(프롬프트)
    for _ in range(길이):
        로짓 = model.predict(np.array([ids], "int32"), verbose=0)[0, -1]
        ids.append(int(로짓.argmax()))
    return decode(ids)

print(생성(model, encode, decode, "딥러닝 언어 모델은", 20))
딥러닝 언어 모델은 주로 어떤 데이터베이스에서 사용되나요?

9저장과 로드

커스텀 레이어(RMSNorm·GroupedQueryAttention)에 register_keras_serializableget_config 가 있어 직렬화됩니다. 다만 0.6B 모델은 .keras(zip) 형식의 크기 한계를 넘으므로, 큰 모델은 가중치만 저장하고 같은 구조에 다시 불러옵니다.

model.save_weights("qwen3.weights.h5")            # 큰 모델: 가중치만 저장
model2 = Qwen3(**설정)
model2(np.zeros((1, 4), "int32"))                 # 같은 구조로 빌드 후
model2.load_weights("qwen3.weights.h5")

x = np.array([encode("안녕하세요")], dtype="int32")
assert np.allclose(model.predict(x, verbose=0), model2.predict(x, verbose=0), atol=1e-4)
print("로드한 모델 == 원본 모델")
로드한 모델 == 원본 모델

10정리