Qwen3
GPT 장의 GPT-2(2019)와 지금 쓰는 Llama·Qwen(2023~) 계열은 디코더 골격이 같습니다 — 토큰을 임베딩해 [정규화·인과 어텐션·잔차 / 정규화·MLP·잔차]를 여러 층 쌓고, 마지막에 어휘 로짓으로 투영합니다. 달라진 것은 그 부품 입니다. 이 장은 알리바바가 공개한 Qwen3-0.6B-Base(사후 훈련 전의 사전훈련 모델)를 Keras로 처음부터 조립하고 실제 가중치를 이식해, GPT-2에서 달라진 부품 하나하나를 짚습니다.
1GPT-2에서 무엇이 바뀌었나¶
| GPT-2 (2019) | Qwen3 (2025) | |
|---|---|---|
| 위치 정보 | 학습형 위치 임베딩 | RoPE(회전 위치 임베딩) |
| 정규화 | LayerNorm | RMSNorm |
| MLP | GELU | SwiGLU |
| 어텐션 | 멀티헤드(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.02RMSNorm — 더 단순한 정규화¶
LayerNorm은 평균을 빼고 분산으로 나눈 뒤 스케일·이동합니다. RMSNorm 은 평균 빼기와 이동(bias)을 모두 없애고, 제곱평균제곱근(RMS)으로만 나눕니다 — 더 싸고 실제로 잘 동작해 이후 모델 대부분이 채택했습니다.
@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.scale3RoPE · 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 GELU 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())파라미터 수: 5960499205토크나이저 — 바이트 단위 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.Linear 라 BERT 장처럼 .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_serializable 과 get_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정리¶
Qwen3는 GPT-2와 디코더 골격이 같고 부품만 바뀌었습니다 — 위치 임베딩 RoPE, LayerNorm RMSNorm, GELU MLP SwiGLU, 멀티헤드 GQA, 그리고 QK-Norm.
수식을 드러내는 부품(RMSNorm·RoPE/QK-Norm/GQA 어텐션)만 커스텀 레이어로, 나머지(SwiGLU·블록·헤드)는 함수형으로 조립했습니다.
transformers없이.safetensors를 직접 읽어 이식하고, 진짜 Qwen3와 로짓이 일치(14.3523)함을 검증했습니다.같은 골격이므로, Llama 같은 다른 모델도 가중치만 바꿔 끼우면 됩니다(차이는 작습니다 — Llama는 QK-Norm이 없고 임베딩을 공유하지 않는 정도).