트랜스포머 아키텍처
트랜스포머는 순환신경망(RNN: Recurrent Neural Network)의 문제를 해결하기 위해 입력을 하나씩 순차적으로 처리하는 방식을 버리고 셀프 어텐션(Self-Attention)개념을 도입했다.
셀프 어텐션(Self-Attention) : 입력된 문장 내의 각 단어가 서로 어떤 관련이 있는지 계산하여 각 단어의 표현(representation)을 조정하는 역할을한다.
현재 대부분의 LLM은 트랜스포머 아키텍처를 활용하고 있다.
[장점]
- 확장성 : 더 깊은 모델을 만들어도 학습이 잘되며, 동일한 블록을 반복해 사용하기 때문에 확장이 용이하다.
- 효율성 : 학습할 때 병렬 연산이 가능하기 때문에 학습시간이 단축된다.
- 더 긴 입력 처리 : 입력이 길어져도 성능이 거의 떨어지지 않는다.
트랜스포머 아키텍처는 크게 인코더(Encoder)와 디코더(Decoder)로 구분되고, 공통적으로 입력을 임베딩(Embbedding) 층을 통해 숫자 집합인 임베딩으로 변환하고 위치 인코딩(Positional Encoding) 층에서 문장의 위치 정보를 더한다.
[인코더]
층 정규화(Layer normalization), 멀티 헤드 어텐션(Multi-head Attention), 피드포워드(feed forward)층을 거치며 문장을 이해하고 그 결과를 디코더로 전달한다.
[디코더]
인코더와 유사하게 층 졍규화(Layer normalization), 멀티 헤드 어텐션(Multi-head Attention) 연산을 수행하면서 크로스 어텐션(Cross Attention) 연산을 통해 인코더가 전달한 데이터를 출력과 함께 종합하여 피드 포워드 층을 거쳐 번역 결과를 생성.

텍스트를 임베딩으로 변환
컴퓨터는 텍스트를 그대로 계산할 수 없으므로, 숫자 형식의 데이터로 변경이 필요하다.
- 텍스트를 적절한 단위로 잘라 숫자형 아이디를 부여하는 토큰화(Tokenization)을 수행
- 토큰 아이디를 토큰 임베딩 층을 통해 여러 숫자 집합인 토큰 임베딩(Token Embbedding)으로 변환
- 위치 인코딩 층을 통해 토큰의 위치 정보를 담고 있는 위치 임베딩을 추가해 모델에 입력할 임베딩 생성

1. 토큰화
텍스트를 적절한 단위로 나누고 숫자 아이디를 부여하는 것
(한글은 작게는 자음과 모음 단위부터 크게는 단어 단위로 나눌 수 있다)
토큰화를 할 때는 사전(Vocabulary)를 만들어 어떤 토큰이 어떤 숫자 아이디로 연결 됐는지 기록해야 한다.
- 큰 단위 기준으로 토큰화 : 텍스트 의미가 잘 유지되는 장점이 있지만, 사전의 크기가 크진다는 단점이 있다.
- 단어 기준으로 토큰화 : 단어 수만큼 토큰 아이디가 필요하기 때문에 사전이 커진다. 또한 이전에 본적없는 새로운 단어는 사전에 없기 때문에 처리하지 못하는 OOV(Out Of Vocabulary)문제가 자주 발생
- 작은 단위로 토큰화 : 사전의 크기가 작고 OOV 문제를 줄일 수 있지만, 텍스트의 이미가 유지되지 않는 단점이 존재
- 서브워드(Subword) 토큰화 방식 : 위 방식은 각각의 장단점이 뚜렷하므로 데이터에 등장하는 빈도에 따라 토큰화 단위를 결정
- 자주 나오는 단어는 단어 단위 그대로 유지
- 가끔 나오는 단어는 더 작은 단위로 텍스트 의미를 최대한 유지
- 사전의 크기는 작고 효율적으로 유지
2. 토큰 임베딩 변환
임베딩(Embedding) : 데이터를 의미를 담아 숫자 집합으로 변환하는 것
파이토치(PyTorch)가 제공하는 nn.Embedding 클래스를 활용하여 토큰 아이디를 토큰 임베딩으로 변환 가능
import torch
import torch.nn as nn
input_text =('나는 최근에 파리 여행을 다녀왔다.')
input_text_list = input_text.split()
str2idx = {word:idx for idx, word in enumerate(input_text_list)}
idx2str = {idx:word for idx, word in enumerate(input_text_list)}
# 텍스트를 숫자 ID 리스트로 변환
input_ids = [str2idx[word] for word in input_text_list]
embedding_dim = 16
embed_layer = nn.Embedding(len(str2idx), embedding_dim)
# input_ids를 LongTensor로 변환하여 임베딩 레이어에 전달
input_tensor = torch.tensor(input_ids)
input_embeddings = embed_layer(input_tensor)
# 배치 차원 추가
input_embeddings = input_embeddings.unsqueeze(0)
# 결과 텐서의 모양 출력
print(input_embeddings.shape)
3. 위치 인코딩
RNN과 트랜스포머의 가장 큰 차이는 입력을 순차적으로 처리하는지 여부인데, 트랜스포머는 순차적으로 처리하는 방식을 사용하지 않고, 모든 입력을 동시에 처리하며, 이 과정에서 순서 정보가 사라진다. 텍스트에서 순서는 매우 중요한 정보인데 위치 인코딩이 순서 정보 역할을 담당한다.
- 절대적 위치 인코딩(Absolute Position Encoding)
- 수식을 통해 위치 정보를 추가하는 방식이나 임베딩으로 위치 정보를 학습하는 방식 모두 결국 모델로 추론을 수행하는 시점에서는 입력 토큰의 위치에 따라 고정된 임베딩을 더해주는 것
- 구현방식은 간편하지만, 토큰과 토큰 사이의 상대적 위치 정보는 활용하지 못함
- 학습 데이터에서 긴 텍스트를 추론하는 경우 성능이 떨어짐
- 상대적 위치 인코딩(Relative Position Encoding)
import torch
import torch.nn as nn
input_text =('나는 최근에 파리 여행을 다녀왔다.')
input_text_list = input_text.split()
str2idx = {word:idx for idx, word in enumerate(input_text_list)}
idx2str = {idx:word for idx, word in enumerate(input_text_list)}
# 텍스트를 숫자 ID 리스트로 변환
input_ids = [str2idx[word] for word in input_text_list]
# 위치인코딩
max_position = 12
embed_layer = nn.Embedding(len(str2idx), embedding_dim)
position_embed_layer = nn.Embedding(max_position, embedding_dim)
position_ids = torch.arange(len(input_ids), dtype=torch.long).unsqueeze(0)
position_encodings = position_embed_layer(position_ids)
token_embeddings = embed_layer(torch.tensor(input_ids))
token_embeddings = token_embeddings.unsqueeze(0)
input_embeddings = token_embeddings + position_encodings
print(input_embeddings.shape)

어텐션(Attention)
트랜스포머 아키텍처의 핵심 논문의 제목 [Attention is All you need] 에서도 알수 있듯 ‘어텐션’이다.
텍스트를 처리하는 관점에서는 입력한 텍스트에서 어떤 단어가 서로 관련되었는지 ‘주의를 기울여’ 파악한다는 의미로 이해할 수 있다.
사람이 글을 읽는 방법과 어텐션
사람이 글을 읽을 때 보통 왼쪽에서 오른쪽으로 흐르듯 읽지만 복잡하고 어려운 글을 자주 멈추면서 어떤 단어가 어떤 단어와 연결되는지 문장 안에서 고민하고 찾아본다.
이처럼 어텐션은 사람이 단어 사이의 관계를 고민하는 과정을 딥러닝 모델이 수행할 수 있도록 모방한 연산이다.
- 어텐션 : 단어 사이의 관계를 고민하는 과정을 딥러닝 모델이 수행할 수 있도록 모방한 연산이다.
사람이 자연스럽게 관련이 있는 단어를 찾고 그 맥락을 반영해 단어를 재해석하는 것처럼 어텐션을 만들려면?
- 단어와 단어 사이의 관계를 계산해서 그 값에 따라 관련이 깊은 단어와 그렇지 않은 단어를 구분
- 관련이 깊은 단어는 더 많이, 관련이 적은 단어는 더 적게 맥락을 반영
이 과정을 해결하기위해 쿼리(Query), 키(Key), 값(Value)의 개념을 도입
- 쿼리(Query) : 입력한 검색어
- 키(Key) : 수 많은 자료 중 쿼리와 관련이 있는지 계산하기 위해 문서가 가진 특징
- 값(Value) : 쿼리와 관련이 깊은 키를 가진 문서를 찾아 관련도순으로 정렬하여 문서를 제공

임베딩 시 같은 단어끼리 임베딩이 동일하면 관련도가 크게 계산되어 주변 맥락을 충분히 반영하지 못하는 경우가 발생할 수 있다.
또한 토큰의 의미가 유사하거나, 반대되는 경우처럼 직접적인 관련성을 띨때는 잘 작동하지만 문법에 의거해 토큰이 이어지는 경우처럼 간접적인 관련성은 반영되기 어려울 수 있다.
트랜스포머는 이와같은 문제를 파히기 위해 토큰 임베딩을 반환하는 가중치 $W_Q, W_K$를 도입했다.
딥러닝에서 어떤 기능을 잘 하게 하고싶을 때 가중치를 도입하고 학습 단계에서 업데이트되게 한다.
트랜스포머에서는 $W_Q, W_K$가중치를 통해 토큰과 토큰 사이의 관계를 계산하는 능력을 학습 시킨것이다.

트랜스포머에서는 값(Value)도 토큰 임베딩 가중치$(W_V)$를 통해 변환한다. 이렇게 세 가지 가중치를 활용하여 토큰 간의 관계를 계산하고, 주변 맥락을 적절히 반영하는 방법을 학습한다. 최종적으로 쿼리와 키의 관계를 계산한 관련도 값과 값 가중치$(W_V)$로 변환한 값(Value)을 가중합하면 된다.
[쿼리, 키 값 벡터를 만드는 nn.Linear 층]
head_dim = 16
# 쿼리, 키 값을 계산하기 위한 변환
weight_q = nn.Linear(embedding_dim, head_dim)
weight_k = nn.Linear(embedding_dim, head_dim)
weight_v = nn.Linear(embedding_dim, head_dim)
## 변환 수행
querys = weight_q(input_embeddings)
keys = weight_k(input_embeddings)
values = weight_v(input_embeddings)
print(f"querys={querys}, keys={keys}, values={values}")
[스케일 점곱 방식 어텐션]
- 쿼리와 키를 곱한다. 이때 분산이 커지는 것을 방지하기 위해 ㅇ미베딩 차원 수(dim_k)의 제곱근으로 나눈다.
- 쿼리와 키를 곱해 스코어(score)를 합이 1이 되도록 소프트맥스softmax)를 취해 가중치(weights)로 바꾼다.
- 가중치와 값을 곱해 입력과 동일한 형태의 출력을 반환한다.
from math import sqrt
import torch.nn.functional as F
import torch
import torch.nn as nn
input_text =('나는 최근에 파리 여행을 다녀왔다.')
input_text_list = input_text.split()
str2idx = {word:idx for idx, word in enumerate(input_text_list)}
idx2str = {idx:word for idx, word in enumerate(input_text_list)}
# 텍스트를 숫자 ID 리스트로 변환
input_ids = [str2idx[word] for word in input_text_list]
embedding_dim = 16
embed_layer = nn.Embedding(len(str2idx), embedding_dim)
# input_ids를 LongTensor로 변환하여 임베딩 레이어에 전달
input_tensor = torch.tensor(input_ids)
input_embeddings = embed_layer(input_tensor)
# 배치 차원 추가
input_embeddings = input_embeddings.unsqueeze(0)
#############################################################
### 스케일 점곱 어텐션
head_dim = 16
# 쿼리, 키 값을 계산하기 위한 변환
weight_q = nn.Linear(embedding_dim, head_dim)
weight_k = nn.Linear(embedding_dim, head_dim)
weight_v = nn.Linear(embedding_dim, head_dim)
## 변환 수행
querys = weight_q(input_embeddings)
keys = weight_k(input_embeddings)
values = weight_v(input_embeddings)
print(f"querys={querys}, keys={keys}, values={values}")
def compute_attention(query, key, value):
dim_k = query.size(-1)
score = query @ key.transpose(-2, -1) / sqrt(dim_k)
weight = F.softmax(score, dim=-1)
return weight @ value
print(f"attention={compute_attention(querys, keys, values).shape}")
[가중치의 값을 곱한 후 더해서 주변 단어의 맥락을 반영한 하나의 값 임베딩으로 만든다.]

[$v_1$은 가중치가 0.672로 가장 크기 때문에 가장 많은 비중으로 섞이고 $v_3$은 가중치가 0.013으로 가장 작기 때문에 가장 적은 비중으로 섞인다.

attention.py
import torch.nn as nn
import torch.nn.functional as F
from math import sqrt
def compute_attention(query, key, value):
dim_k = query.size(-1)
score = query @ key.transpose(-2, -1) / sqrt(dim_k)
weight = F.softmax(score, dim=-1)
return weight @ value
class AttentionHead(nn.Module):
def __init__(self, token_embed_dim, head_dim):
super().__init__()
self.weight_q = nn.Linear(token_embed_dim, head_dim)
self.weight_k = nn.Linear(token_embed_dim, head_dim)
self.weight_v = nn.Linear(token_embed_dim, head_dim)
def forward(self, queries, keys, values):
return compute_attention(
self.weight_q(queries)
, self.weight_k(keys)
, self.weight_v(values))
import attention
input_text =('나는 최근에 파리 여행을 다녀왔다.')
input_text_list = input_text.split()
embedding_dim = 16
str2idx = {word:idx for idx, word in enumerate(input_text_list)}
input_ids = [str2idx[word] for word in input_text_list]
input_tensor = torch.tensor(input_ids)
embed_layer = nn.Embedding(len(str2idx), embedding_dim)
input_embeddings = embed_layer(input_tensor)
input_embeddings = input_embeddings.unsqueeze(0)
####################################################################
attention_head = AttentionHead(embedding_dim, embedding_dim)
after_attention_embeddings = attention_head(input_embeddings, input_embeddings, input_embeddings)
print(f"after_attention_embeddings={after_attention_embeddings.shape}")
멀티 헤드 어텐션
여러 어텐션 연산을 동시에 적용하면 성능을 더 높일 수 있다.
즉, 토큰 사이의 관계를 한 가지 측면에서 이해하는 것보다 여러 측면을 동시에 고려할 때 언어나 문장에 대한 이해도가 높아지며 동시에 헤드의 수(n) 만큼 어텐션 연산을 수행하는 것을 멀티 헤드 어텐션이다.

- 헤드의 수 만큼 연산을 수행하기 위해 쿼리, 키, 값을 n_head개로 쪼갠다.
- 각각의 어텐션을 계산
- 입력과 같은 형태로 다시 변환
- 선형 층을 통과시키고 최종 결과를 반환
import torch.nn as nn
from attention import compute_attention
class MultiheadAttention(nn.Module):
def __init__(self, token_embed_dim, d_model, n_heads):
super().__init__()
self.n_heads = n_heads
self.weight_q = nn.Linear(token_embed_dim, d_model)
self.weight_k = nn.Linear(token_embed_dim, d_model)
self.weight_v = nn.Linear(token_embed_dim, d_model)
self.concat_linear = nn.Linear(d_model, d_model)
def forward(self, queries, keys, values):
B,T,C = queries.size()
queries = (self.weight_q(queries)
.view(B, T, self.n_heads, C // self.n_heads).transpose(1,2))
keys = (self.weight_k(keys)
.view(B, T, self.n_heads, C // self.n_heads).transpose(1, 2))
values = (self.weight_v(values)
.view(B, T, self.n_heads, C // self.n_heads).transpose(1, 2))
attention = compute_attention(queries, keys, values)
output = attention.transpose(1,2).contiguous().view(B, T, C)
output = self.concat_linear(output)
return output
from multihead_attention import MultiheadAttention
input_text =('나는 최근에 파리 여행을 다녀왔다.')
input_text_list = input_text.split()
embedding_dim = 16
str2idx = {word:idx for idx, word in enumerate(input_text_list)}
input_ids = [str2idx[word] for word in input_text_list]
input_tensor = torch.tensor(input_ids)
embed_layer = nn.Embedding(len(str2idx), embedding_dim)
input_embeddings = embed_layer(input_tensor)
input_embeddings = input_embeddings.unsqueeze(0)
n_head = 4
mh_attention = MultiheadAttention(embedding_dim, embedding_dim, n_head)
after_attention_embeddings = mh_attention(input_embeddings, input_embeddings, input_embeddings)
print(f"after
층 졍규화
정규화란 딥러닝 모델에서 입력이 일정한 분포를 갖도록 만들어 학습이 안정적이고 빨라질 수 있도록 하는 기법
과거에는 배치 입력 데이터 사이에 정규화를 수행하는 배치 정규화를 주로 사용
트랜스포머 아키텍처에서는 층 정규화를 사용
사전 정규화(post-norm) : 어텐션과 피드포워드 층 이후에 층 정규화를 하는 것
- 어텐션 → 합 → 층 정규화 → 피드포워드 층 → 합 정규화 …
사후 정규화(pre-norm) : 먼저 층 정규화를 적용하고 어텐션과 피드포워드 층을 통과하는 것
- 층 정규화 → 멀티 헤드 어텐션 → 합 → 층 정규화 → 피드포워드 층 → 합
피드포워드 층(feedforward layer)
데이터의 특징을 학습하는 완전 연결 층(fully connected layer)을 말한다.
멀테 헤드 어텐션이 단어 사이의 관계를 파악하는 역할이라면 피드 포워드 층은 입력 텍스트 전체를 이해하는 역할을 담당한다.
피드포워드는 선형 층, 드롭아웃 층 , 층 정규화, 활성함수로 구성되며, 임베딩의 차원을 동일하게 유지해야 쉽게 층을 쌓아 확장이 가능하기 때문에 입력과 출력의 형태가 동일하도록 맞춘다.
import torch.nn as nn
class PreLayerNormFeedForward(nn.Module):
def __init__(self, d_model, dim_feedforward, dropout):
super().__init__()
self.linear1 = nn.Linear(d_model, dim_feedforward) ## 선형 층 1
self.linear2 = nn.Linear(dim_feedforward, d_model) ## 선형 층 2
self.dropout1 = nn.Dropout(dropout) ## 드롭아웃 층 1
self.dropout2 = nn.Dropout(dropout) ## 드롭아웃 층 2
self.activation = nn.GELU() ## 활성 함수
self.norm = nn.LayerNorm(d_model) ## 층 정규화
def forward(self, x):
x = self.norm(x)
x = x + self.linear2(self.dropout1(self.activation(self.linear1(x))))
x = self.dropout2(x)
return x
인코더
트랜스포머 인코더는 멀티 헤드 어텐션, 층 정규화, 피드 포워드 층이 반복되는 형태이다.
이미지 상자 안을 보면 밖으로 뻗어나갔다가 다시 더해지는 화살표 2개가 있는데, 안정적인 학습이 가능하도록 도와주는 잔차 연결(residual connection)이다.
- 잔차연결: 화살표 모양 그대로 입력을 다시 더해서 더해주는 형태로 구현

트랜스포머 인코더는 인코더 블록을 $N_e$번 반복해서 쌓아서 만든다.
import torch.nn as nn
from feedforward import PreLayerNormFeedForward
from multihead_attention import MultiheadAttention
class TransformerEncoderLayer(nn.Module):
def __init__(self, d_model, n_head, dim_feedforward, dropout):
super().__init__()
self.attn = MultiheadAttention(d_model, d_model, n_head) ## 멀티 헤드 어텐션
self.norm1 = nn.LayerNorm(d_model) ## 층 정규화
self.dropout1 = nn.Dropout(dropout) ## 드롭아웃
self.feedForward = PreLayerNormFeedForward(d_model, dim_feedforward, dropout) ## 피드 포워드
def forward(self, src):
output = src
for mod in self.layers:
output = mod(output)
return output
디코더
인코더와 디코더를 비교할 때 두가지 부분 차이
- 마스크 멀티 헤드 어텐션을 사용
- 생성을 담당하는 부분으로, 사람이 글을 쓸 때 앞 단어부터 순차적으로 작성하는 것처럼 트랜스포머 모델도 앞에서 생성한 토큰을 기반으로 다음 토큰을 생성.
- 이를 인과적(causal) or 자기 회귀적(auto-regressive)이라 한다

실제 텍스트를 생성할 때 디코더는 이전까지 생성한 텍스트만 확인할 수 있다. 하지만 학습할 때는 인코더와 디코더 모두 완성된 텍스트를 입력으로 받는다.
따라서 어텐션을 그대로 활용하면 미래 시점에 작성해야 하는 텍스트를 미리 확인하게 되는 문제가 발생한다. 이를 방지하기 위해 특정 시점에는 그 이전에 생성된 토큰까지만 확인할 수 있도록 마스크를 추가한다.
def compute_attention(query, key, value, is_causal=False):
dim_k = query.size(-1)
score = query @ key.transpose(-2, -1) / sqrt(dim_k)
if is_causal:
query_length = query.size(-2)
key_length = key.size(-2)
temp_mask = torch.ones(query_length, key_length, dtype=torch.bool).tril(diagonal=0)
score = score.masked_fill(temp_mask == False, float("-inf"))
weight = F.softmax(score, dim=-1)
return weight @ value
is_causal 이 True이면 torch.ones로 모두 1인 행렬에 tril 함수를 취해 대각선 아래 부분만 1로 유지되고 나머지는 음의 무한대(-inf)로 변경해 마스크를 생성한다.

크로스 어텐션(Cross Attention)
크로스 어텐션(Cross Attention)은 트랜스포머 디코더에서 사용되는 메커니즘으로, 디코더가 인코더의 출력 정보를 참조할 수 있도록 한다.
셀프 어텐션과 달리, 크로스 어텐션에서는:
- Query는 디코더의 출력에서 가져온다
- Key와 Value는 인코더의 출력에서 가져온다
이를 통해 디코더는 생성 중인 텍스트와 입력 텍스트(인코더가 처리한 정보) 사이의 관계를 파악하여, 입력 문맥을 고려한 출력을 생성할 수 있다.
import torch.nn as nn
from feedforward import PreLayerNormFeedForward
from multihead_attention import MultiheadAttention
class TransformerDecoderLayer(nn.Module):
def __init__(self, token_embed_dim, d_model, n_heads, dim_feedforward=2048, dropout=0.1):
super().__init__()
self.self_attn = MultiheadAttention(d_model, d_model, n_heads)
self.multihead_attn = MultiheadAttention(d_model, d_model, n_heads)
self.feedForward = PreLayerNormFeedForward(d_model, dim_feedforward, dropout)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout1 = nn.Dropout(dropout)
self.dropout2 = nn.Dropout(dropout)
def forward(self, tgt, encoder_output, is_causal=True):
# 셀프 어텐션 계산
x = self.norm1(tgt)
x = x + self.dropout1(self.self_attn(x, x, x,
is_causal = True))
# 크로스 어텐션 연산
x = self.norm2(x)
x = x + self.dropout2(self.multihead_attn(x, encoder_output, encoder_output))
# 피드 포워드 연산
x = self.feed_forward(x)
return x
디코더는 인코더와 마찬가지로 디코더 층을 여러 번 쌓아 만든다.
import copy
def get_clones(module, N):
return nn.ModuleList([copy.deepcopy(module) for i in range(N)])
class TransformerDecoder(nn.Module):
def __init__(self, decoder_layer, num_layers):
super().__init__()
self.layers = get_clones(decoder_layer, num_layers)
self.num_layers = num_layers
def forward(self, tgt, src):
output = tgt
for mod in self.layers:
output = mod(output, src)
return output
참고
LLM을 활용한 실전 AI 애플리케이션 개발
'AI > LLM' 카테고리의 다른 글
| [AI LLM] 대규모 언어 모델 소개 (언어 AI 역사) (0) | 2026.02.17 |
|---|