[CloudNeta] Hands-On LLM Serving 1주차 part 2 - LLM과 트랜스포머 기본

연재 안내

이 글은 1주차 연재의 두 번째 편입니다.

  1. part 1 - 모델과 모델 서빙의 개념
  2. part 2 - LLM 기초 (이 글)
  3. part 3 - LLM 서빙

들어가며

이번 편의 목표는 인공지능 전반의 개요와 LLM(Large Language Model) 기초를 잡는 것입니다. 단, 범위는 part 3(LLM 서빙)을 이해하는 데 필요한 최소한으로 한정합니다. 깊은 수학적 지식 없이, 엔지니어링 관점에서 간략히 살펴본다는 점을 염두에 두어주셨으면 좋겠습니다.

그렇다면 서빙할 대상인 LLM에 대해 살펴보겠습니다. 인공지능, 머신러닝, 딥러닝, 그리고 LLM이란 무엇인지 살펴봅니다.

인공지능, 머신러닝, 딥러닝, 그리고 LLM

우선 헷갈리는 네 가지 용어를 살펴봅시다.

---
title: 인공지능부터 LLM까지의 포함관계
---
flowchart TB
    subgraph AI["인공지능 (AI)"]
        direction TB
        rule["규칙 기반 시스템
(if-else로 사람이 규칙을 직접 작성)"] subgraph ML["머신러닝 (ML)"] direction TB classic["고전 ML
(결정트리, 회귀 등)"] subgraph DL["딥러닝 (DL)"] direction TB cnn["CNN 등
(이미지 인식)"] subgraph GenAI["생성형 AI (GenAI)"] direction TB llm["LLM
(언어)"] diff["이미지 생성 모델
(Diffusion 등)"] end end end end

보시다시피 네 가지 관계는 큰 개념과, 점차 작은 개념으로 내려가는 것입니다.

바깥으로 갈수록 넓은 개념입니다. 인공지능이라고 다 학습하는 건 아니고(규칙 기반), 학습한다고 다 신경망인 것도 아니며(고전 ML), 신경망이라고 다 무언가를 생성하는 것도 아닙니다(E.g., CNN 같은 인식 모델). 이번 스터디 전반에서는 딥러닝 기반의 생성형 AI, 그중에서도 언어를 다루는 LLM을 살펴본다는 점을 이해하시면 됩니다[1].

그러면 LLM을 이해하기 위해 살펴봅시다.

인공신경망: 뉴런에서 빌려온 아이디어

어디서 익숙한 내용이죠?

해당 유튜브 영상을 참조하였습니다.

인공신경망(neural network)은 뇌의 신경세포, 즉 뉴런의 동작을 본뜬 것입니다. 뉴런은 다른 뉴런에게서 입력을 받아(수상돌기), 처리한 뒤(세포체), 출력을 다음 뉴런으로 넘깁니다(축삭돌기). 이때 자극이 일정 수준을 넘지 못하면 다음 뉴런으로 신호가 전달되지 않는데, 이 최소한의 자극을 역치라고 부릅니다.

인공 뉴런도 같은 구조입니다. 각 입력값에 가중치(weight)를 곱해 더한 값이 기준을 넘으면 출력하고, 넘지 못하면 출력하지 않습니다. 이 출력 여부와 크기를 결정하는 함수를 활성화함수라고 부릅니다(시그모이드, 하이퍼볼릭 탄젠트, ReLU 등). 그리고 이런 인공 뉴런들이 망(network)을 이룬 것이 인공신경망입니다.

학습이란: 가중치 깎기

성인과 어린이를 구별하는 모델을 만든다고 해봅시다. 직관적으로는 "키 크고 무거우면 어른"이지만, 사람의 키와 몸무게는 확률적으로 분포하기 때문에 딱 떨어지는 규칙을 손으로 쓸 수 없습니다. 그래서 아래와 같은 판별식을 두고,

if (키 * 가중치1) + (몸무게 * 가중치2) < 기준값:
    return 아이
else:
    return 어른

수많은 데이터를 넣어보며 오차가 날 때마다 가중치를 조금씩 조정하고, 다시 값이 제대로 나오는지 확인하는 일을 반복합니다.

이것이 학습(training) 이고, 딥러닝은 결국 최적의 가중치를 찾는 과정입니다. 어떤 데이터를 넣어도 원하는 답이 나오도록 가중치를 다듬는 것이 핵심이니, 가중치는 모델의 두뇌인 셈입니다. part 1에서 state_dict로 열어봤던 그 숫자들이 바로 이 과정의 결과물입니다.

딥러닝: 층을 쌓기

그런데 세상 문제는 뉴런 하나와 가중치 한두 개로 풀리지 않습니다. 뉴런들을 층(layer)으로 겹겹이 쌓아서(deep) 복잡한 데이터를 처리하는 것이 딥러닝입니다. 데이터가 들어오는 입력층, 중간 계산이 일어나는 은닉층, 결과가 나오는 출력층으로 구성되며, 데이터는 입력층으로 들어와 출력층으로 나옵니다.

얕은 신경망과 깊은 신경망 - 왼쪽은 데이터를 분류하는 결정 경계 예시, 오른쪽은 은닉층을 하나만 가진 얕은 신경망(위)과 여러 층을 쌓은 깊은 신경망(아래) 구조

층이 깊어지면 "오차가 났을 때 어느 가중치를 고쳐야 하나"가 막막해지는데, 출력층에 가까운 가중치부터 거꾸로 보정해 나갑니다. 값을 앞으로 흘려보내며 계산하는 것이 순전파(forward propagation), 오차를 뒤로 흘려보내며 가중치를 보정하는 것이 역전파(backpropagation) 이고, 이것이 가장 일반적인 딥러닝 학습 알고리즘입니다. part 1에서 본 모델 클래스의 forward 메소드 이름이 바로 이 순전파의 forward입니다.

순전파·역전파를 전달 게임에 비유한 설명 - 각 층의 Forward/Backward 함수 쌍과, 마지막 층부터 첫 층까지 역순으로 책임(오차)을 거슬러 전달하는 호출 순서

추가: 이미지에서 언어로

이미지 인식은 이미지의 "특징"을 필터로 뽑아내 확률적으로 예측하는 방식으로 발전했습니다. part 1의 CNN 예제에 있던 conv 레이어가 바로 그 필터입니다.

반면 언어는 단어일 수도 문장일 수도 있어서 구조가 유연해야 합니다. 초기 언어 모델(RNN 계열)은 앞 단어를 처리한 정보를 다음 단어로 차례차례 넘겨주는 순차 처리 방식이었는데, 이 방식에는 치명적인 한계가 있었습니다. 한 스텝씩 순서대로 계산해야 하므로 병렬화가 안 되고, 대규모 병렬 연산에 최적화된 GPU를 제대로 활용할 수 없어 모델을 키우기 어려웠던 것이죠. 이 구조를 갈아엎은 것이 뒤에서 살펴볼 트랜스포머입니다. 이렇게 언어를 다루는 신경망을 대량의 텍스트로 학습시켜 파라미터가 수십억 개 규모로 커진 것이, 우리가 아는 그 라지(Large)한 언어 모델, LLM입니다.

사전 훈련과 미세 튜닝

LLM의 훈련은 두 단계로 나뉩니다[1:1].

  1. 사전 훈련(pretraining): 대량의 텍스트로 처음부터 학습하는 첫 번째 단계입니다. 이렇게 만들어진 모델을 베이스 모델(base model) 또는 파운데이션 모델(foundation model) 이라고 부릅니다.
  2. 미세 튜닝(fine-tuning): 베이스 모델을 특정 목적(대화, 코드, 분류 등)에 맞게 추가로 훈련하는 단계입니다.

part 1에서 본 서빙 대상 모델은 이 과정의 결과입니다. 허깅페이스에서 내려받는 가중치 파일은 누군가 사전 훈련(과 미세 튜닝)을 마친 결과물인 것이죠.

가중치를 둘러싼 용어 다잡기

토큰 이야기로 넘어가기 전에, 가중치와, 이와 연관된 단어를 살펴봅시다. 앞으로 이 용어들이 계속 나오기 때문이죠. 먼저 파라미터와 기울기라는 용어를 살펴보죠. 이후 양자화까지 살펴보겠습니다.

가중치(Weight)와 파라미터(Parameter)

먼저 파라미터는 모델이 학습으로 조정하는 숫자 전체를 가리키는 상위 개념입니다. 파라미터의 각 항목을 짧게 짚어보면 아래와 같습니다

Parameter (학습 가능한 숫자 전체)
├── 가중치(Weight)
├── 편향(Bias)
├── 임베딩(Embedding) 값
└── LayerNorm의 scale, bias

예를 한번 살펴봅시다. PyTorch에서는 model.named_parameters()로 학습 가능한 파라미터 전체를 확인할 수 있습니다. 트랜스포머 계열 모델이라면 대략 이런 이름들이 나옵니다. 아래 출력의 각 줄에 붙는 shape텐서의 모양, 즉 각 차원에 숫자가 몇 칸씩 들어 있는지를 나타낸 것입니다.

for name, parameter in model.named_parameters():
    print(name, parameter.shape)

# token_embedding.weight
# attention.query.weight
# attention.key.weight
# attention.value.weight
# ffn.linear1.weight
# ffn.linear1.bias
# layer_norm.weight
# layer_norm.bias
# ...

GPT-2의 예시에서는 (768, 2304)라는 예시가 있었죠. 이제 해석해봅시다.

shape의 숫자들을 전부 곱하면 그 텐서의 파라미터 개수가 됩니다. 이 곱셈은 단순해 보이지만, 앞으로 모델의 메모리 사용량을 셈할 때 계속 쓰이는 핵심 도구입니다.

기울기(Gradient)

기울기(gradient, 그래디언트) 는 "현재 파라미터를 어느 방향으로 얼마나 바꾸면 오차가 줄어드는지"를 알려주는 값입니다. 앞서 "오차가 날 때마다 가중치를 조금씩 조정한다"고 했죠. 그 조정의 방향과 크기를 정해주는 것이 역치값을 결정하는 그래프의 기울기입니다. 중요한 건 기울기는 지식이 아니라 임시 안내값이라는 점입니다. 지식은 파라미터에 저장되고, 기울기는 파라미터를 고치기 위해 매 학습 스텝마다 새로 계산됐다 버려집니다.

수정 공식은 단순합니다: 새 Weight = 현재 Weight - 학습률 × Gradient

현재 Weight = 0.50, Gradient = 0.20, 학습률 = 0.10
→ 새 Weight = 0.50 - 0.10 × 0.20 = 0.48   (기울기가 양수 → 가중치를 줄이는 방향)

현재 Weight = 0.50, Gradient = -0.20, 학습률 = 0.10
→ 새 Weight = 0.50 - 0.10 × (-0.20) = 0.52 (기울기가 음수 → 가중치를 키우는 방향)

지금까지 나온 용어를 한 표로 정리하면 다음과 같습니다.

용어 무엇인가? 학습 중 바뀌는가? 쉬운 비유
Token 입력 문장의 조각 아니요 문제지의 단어
Weight 입력 영향을 조절하는 숫자 판단 기준
Parameter 모델이 학습하는 숫자 전체 머릿속 설정 전체
Gradient 파라미터 수정 방향과 크기 매 스텝 새로 계산 수정 안내 화살표

가중치 공개 모델과 비공개 모델

모델 구조가 두뇌의 설계도라면, 가중치는 학습으로 채워진 실제 지식입니다. 구조만 있으면 빈 두뇌이고, 구조 + 학습된 가중치가 있어야 실제로 동작하는 모델입니다. 사실상 이 가중치 파일이 핵심이라고 할 수 있죠.

이번 연재는 전자의 케이스라는 것을 전제로 진행합니다.

가중치의 dtype

part 1의 GPT-2 실습에서 모든 텐서 옆에 torch.float32가 붙어 있었는데, 그것이 dtype(데이터 타입)입니다. 가중치와 계산 결과는 결국 숫자인데, dtype은 그 숫자 하나를 적는 메모지의 크기입니다.

주요한 타입은 아래와 같습니다:

부동소수점은 부호 + 지수(표현 범위) + 가수(정밀도)로 구성되는데, 세 dtype이 비트를 나누는 방식이 다릅니다.

dtype 총 비트 지수(범위) 가수(정밀도) 표현 가능 범위 유효숫자
float32 32bit (4byte) 8bit 23bit ±10⁻³⁸ ~ ±10³⁸ 약 7자리
float16 16bit (2byte) 5bit 10bit ±6×10⁻⁵ ~ 65504 약 3~4자리
bfloat16 16bit (2byte) 8bit 7bit float32와 동일한 범위 약 2~3자리

LLM 서빙에서 dtype이 중요한 이유는 세 가지입니다.

  1. 모델 크기 축소: 70B 파라미터 모델이 float32로는 약 280GB, float16으로는 약 140GB. GPU에 올라가느냐 마느냐가 결정되는 정도입니다.
  2. KV 캐시 축소: 캐시에서 읽어오는 데이터양이 절반이 되어 생성 속도가 실질적으로 빨라집니다 (자세한 건 part 3에서)
  3. 하드웨어 가속: NVIDIA GPU의 Tensor Core는 fp16/bf16 행렬곱에 특화되어 있어 float32보다 훨씬 빠릅니다[2:1]

추론 시에는 이미 학습된 가중치를 그대로 쓰므로 오버플로우 위험이 상대적으로 적어 float16을 무난하게 쓰고, 학습에는 bfloat16이나 혼합 정밀도(mixed precision)를 선호합니다[4].

더 줄이고 싶다면: 양자화(Quantization)

dtype 변환과 양자화는 다른 접근입니다. 정확도를 수정하는 것이 아니라 정수값으로 재처리한다고 할 수 있습니다.

dtype 변환

dtype 변환(E.g., fp32 → fp16/bf16)은 같은 문법(부동소수점)으로 적되 종이만 작은 걸 쓰는 것. 손실이 적고 거의 공짜로 적용됩니다.

양자화

양자화(E.g., fp → int8/int4)는 연속적인 실수값을 정해진 개수의 정수 칸에 끼워 맞추는 방식입니다. 사진을 물감 몇 가지 색으로만 다시 칠하는 것처럼, int8이라면 원래 값을 256개 정수 중 하나로 반올림해 저장하고, 되돌릴 때 쓸 환산표(scale/zero-point)를 따로 들고 다닙니다. "숫자 자체"가 아니라 "팔레트의 몇 번 색인지"를 저장하는 셈이죠.

int 가 우리가 아는 Integer인가요?

네 맞아요. 뒤의 숫자는 비트 수입니다. float와 달리 부호/지수/가수 구분 없이 정수만 표현하므로, 표현 가능한 값의 개수가 곧 입니다. 아래와 같은 셈이죠.

  • int8 = 2⁸ = 256개
  • int4 = 2⁴ = 16개

앞서 "팔레트의 색깔 수"로 비유한 게 이 개수입니다.

차이점 비교

둘의 차이점을 한번에 놓고 보겠습니다.

dtype 변환 (fp32→fp16/bf16) 양자화 (fp→int8/int4)
표현 방식 여전히 부동소수점 (연속적) 정수 + 환산표 (이산적, 팔레트 방식)
압축률 2배 4배(int8), 8배(int4) 이상
정밀도 손실 적고 안정적 더 큼. 보정(calibration) 없으면 성능 급락 가능
추가 작업 거의 없음 (캐스팅 한 줄) 보정 데이터셋 또는 양자화 인식 학습(QAT) 필요
대표 기법 dtype 캐스팅 GPTQ, AWQ, bitsandbytes(NF4), GGUF[5]
주 용도 학습·추론 모두 기본값 주로 추론 전용, 거대 모델을 저사양 GPU/엣지에 올릴 때
양자화 실전 과정?

통상의 70B 모델 기준으로 양자화를 진행하면 이정도로 작아집니다.

  • float32 280GB
  • float16 140GB(그래도 데이터센터 GPU 여러 장 필요)
  • int8 70GB(어지간한 빡센 GPU 한두 장으로 시도 가능)
  • int4 35GB(노트북 수준까지 내려옴).

다만 int4쯤 되면 팔레트 색이 16개 수준이라 정확도가 눈에 띄게 떨어질 수 있어, 어떤 가중치가 중요한지 분석해 오차를 최소화하는 GPTQ·AWQ 같은 알고리즘이 필요해집니다[5:1].

실무의 단계적 접근은 이렇습니다: 우선 float16/bfloat16으로 절반을 줄이고, 그래도 부족하면 양자화를 얹는다.

토큰, 토크나이저, 그리고 임베딩

그렇다면 본격적으로 모델이 문자를 처리하는 방식에 대해 살펴보겠습니다. LLM을 포함한 심층 신경망은 원시 텍스트를 바로 처리할 수 없습니다. 신경망이 다루는 건 결국 숫자(텐서)이기 때문입니다. 그래서 문자열은 토큰화 이후 임베딩을 거쳐 모델에 입력됩니다. 각 단계의 등장인물을 하나씩 보겠습니다.

토큰

토큰(token)은 모델이 텍스트를 다루는 최소 단위입니다. 단어와 비슷하지만 같지는 않습니다. 자주 쓰이는 단어는 통째로 토큰 하나가 되지만, 드물거나 긴 단어는 여러 조각(subword)으로 쪼개집니다. 예를 들어 "unbelievable"은 "un / believ / able"처럼 나뉠 수 있고, 한국어는 영어보다 더 잘게 쪼개지는 경향이 있습니다.

왜 단어 단위로 하지 않을까요? 어휘(vocabulary)를 고정된 크기로 묶어두면서도, 처음 보는 단어까지 조각의 조합으로 표현하기 위해서입니다. GPT-2의 어휘 크기가 50,257개인 것도 여기서 나옵니다. 기본 바이트 256개 + 학습으로 만든 병합 조각 50,000개 + 특수 종료 토큰 1개입니다[6]. part 1 실습에서 본 wte.weight의 행 수 50,257의 정체가 바로 이것이죠.

서빙 관점에서도 토큰은 기본 화폐 단위입니다. LLM API의 과금도, 컨텍스트 길이 제한도, part 3에서 다룰 처리량(tokens/s) 지표도 전부 토큰 단위로 계산됩니다.

토크나이저

토크나이저(tokenizer)는 텍스트와 토큰 ID를 양방향으로 변환하는 장치입니다. 입력 시에는 고정된 어휘를 기준으로 텍스트를 토큰으로 쪼개 각 토큰을 ID(어휘 내 번호)로 바꾸고, 출력 시에는 모델이 뱉은 토큰 ID를 다시 텍스트로 되돌립니다.

예를 들어 "Write a short introduction about the US capital city"라는 문장은 11개 토큰으로 쪼개지고, 각각 토큰 ID(숫자)가 되어 모델로 들어갑니다.

GPT 계열을 포함한 대부분의 최신 LLM은 BPE(Byte-Pair Encoding) 계열 토크나이저를 씁니다. 학습 데이터에서 자주 함께 등장하는 글자 조각을 반복적으로 병합해 어휘를 만들어가는 방식입니다[6:1]. 중요한 건 토크나이저가 모델마다 다르다는 점입니다. 토크나이저는 모델과 한 몸으로 만들어지기 때문에, part 1의 "모델 구성요소"에서 봤던 tokenizer.json, vocab.json 파일들이 가중치와 함께 배포되는 것이죠.

임베딩

임베딩(embedding)은 각 토큰 ID를 실수 벡터로 바꾸는 단계입니다. 이렇게 데이터를 벡터 형태로 변환하는 것을 임베딩이라고 부릅니다[1:3]. part 1 용어집에서 "텍스트를 모델이 계산할 수 있는 숫자 벡터로 바꿔놓은 표현"이라고 했던 그 임베딩이고, GPT-2 실습에서 본 wte.weight (50257, 768)이 바로 이 변환표입니다. 어휘 50,257개 각각에 768차원 벡터(벡터 한 줄의 길이가 768)가 하나씩 배정되어 있는 것이죠.

임베딩의 실체는 거창하지 않습니다. 그냥 큰 행렬(랭크 2 텐서) 하나이고, 조회는 행 인덱싱입니다. 토큰 id가 31373이면 행렬의 31373번째 행이 그 토큰의 벡터입니다. PyTorch의 nn.Embedding도 공식 문서 표현 그대로 "a simple lookup table"입니다[7]. 다만 두 가지가 중요합니다.

파라미터라고 했으니 크기도 계산해 볼까요? 앞에서 배운 "shape 숫자를 전부 곱하기"를 쓰면 50,257 × 768 ≈ 3,860만 개. GPT-2 전체 파라미터(약 1억 2,400만 개)의 3분의 1이 이 변환표 한 장에 들어 있는 셈입니다.

정리하면 텍스트는 아래 순서대로 트랜스포머 블록에 입력값으로 들어갑니다.

---
title: 텍스트가 트랜스포머 블록에 들어가는 과정
---
flowchart TD
    pt1["텍스트
"Write a short introduction ...""] pt2["토큰 ID들
(어휘 내 번호의 나열)"] pt3["벡터 행렬
shape [N, 768]"] pt4["트랜스포머 블록"] pt1 --> |"토크나이저: 어휘 기준으로 쪼개고 ID 부여"| pt2 pt2 --> |"임베딩 테이블: 행 인덱싱으로 벡터 조회"| pt3 pt3 --> |입력| pt4

다음 섹션에서 이 행렬을 받는 트랜스포머의 내부를 간단히 살펴봅시다.

트랜스포머 아키텍처 살펴보기 (맛보기로)

곧 트랜스포머 아키텍처는 밑바닥부터 만들면서 배우는 LLM 연재로 찾아뵙겠습니다!

드디어 현대 LLM 시대를 연 트랜스포머(Transformer) 를 맛보기로 살펴봅시다. 트랜스포머는 2017년 논문 Attention Is All You Need에서 소개된 심층 신경망 구조로, 원래는 번역 작업을 위해 고안되었습니다[1:4].

인코더와 디코더

원본 트랜스포머는 두 개의 서브모듈로 구성됩니다.

번역이라면 인코더가 원본 언어 텍스트를 벡터로 인코딩하고, 디코더가 그 벡터를 풀어 타깃 언어 텍스트를 생성하는 구조입니다.

셀프 어텐션: 트랜스포머의 심장

인코더와 디코더 모두 셀프 어텐션 메커니즘(self-attention mechanism) 으로 연결된 많은 층으로 구성됩니다. 셀프 어텐션은 시퀀스 안의 서로 다른 토큰들에 상대적인 가중치를 부여하는 장치입니다. 지금 처리 중인 토큰이 "문장의 어느 토큰을 얼마나 참고할지"를 계산하는 것이죠. 이 덕분에 모델이 멀리 떨어진 단어 사이의 의존성과 맥락 관계를 포착할 수 있어, 일관성 있고 맥락에 맞는 출력을 만들 수 있습니다.

앞서 "초기 언어 모델은 앞 단어의 정보를 순서대로 넘겨주는 방식"이라고 했는데, 셀프 어텐션이 바꾼 게 바로 이 지점입니다. 순서대로 전달하며 잊어버리는 대신, 모든 토큰이 모든 토큰을 직접 참조합니다. 순차 처리를 버렸으니 병렬화가 가능해져 GPU를 꽉 채워 쓸 수 있게 됐고, 덕분에 모델을 대규모로 키우는 길이 열렸습니다. part 3에서 다룰 서빙 문제(어텐션 비용, KV 캐시)가 전부 이 "직접 참조"의 대가입니다.

다만 순차 처리를 버리면 잃는 것이 하나 있는데요. 이는 단어의 순서 정보입니다. "개가 사람을 물었다"와 "사람이 개를 물었다"를 구분하려면 순서를 알아야 하는데, 모든 토큰을 동시에 보면 순서가 사라지죠. 그래서 트랜스포머는 각 위치(1번째, 2번째, ...)에도 벡터를 배정해 토큰 임베딩에 더해줍니다. 이것이 위치 인코딩(positional encoding) 입니다.

part 1 GPT-2 출력에서 봤던 wpe.weight (1024, 768)값을 의미합니다(word position embedding, 최대 1024개 위치 각각의 768차원 벡터). 이로써 part 1 실습에서 만난 텐서들의 정체가 모두 밝혀졌습니다.

블록 안을 조금만 열어보면: Query, Key, Value

쉽게 설명하자면[8] 셀프 어텐션 블록은 각 토큰의 벡터를 세 가지 역할로 변환합니다.

각 토큰의 Query를 모든 토큰의 Key와 대조해(행렬곱) "누구를 얼마나 참고할지" 점수를 매기고, 그 비율대로 각 토큰의 Value를 섞어 가져옵니다. 앞서 말한 "상대적인 가중치를 부여한다"의 실체가 이것입니다. part 1 GPT-2에서 본 c_attn.weight (768, 2304)가 바로 이 변환입니다. 768차원 입력을 각 768차원씩, 합쳐서 2304차원으로 투영하는 행렬이었던 거죠.

다만 여기까지는 Query, Key, Value 세 벡터만 갖고 어텐션의 "핵심 아이디어"를 설명한 것이고, 실제 트랜스포머 블록에는 이걸 뼈대로 감싸는 구조가 몇 겹 더 있습니다. 하나씩 채워보겠습니다.

멀티헤드: 이 대조 과정을 여러 벌 동시에 돌립니다

방금 설명한 대조 과정은 사실 헤드 하나 안에서 일어나는 일입니다. 실제로는 투영을 여러 개의 독립된 헤드로 쪼개서 병렬로 수행한 다음, 각 헤드의 결과를 이어붙이고(concat) 다시 한 번 선형 변환을 거칩니다. GPT-2는 이 헤드를 12개 씁니다[9]. 헤드마다 서로 다른 관계에 집중하도록 나뉘어 학습되기 때문에, 어떤 헤드는 문법적 관계를 잘 잡고 어떤 헤드는 의미적 관계를 잘 잡는 식으로 역할이 갈립니다.

서빙에서 멀티헤드가 중요한 이유

part 3에서 KV 캐시 크기를 "레이어 수 × hidden 차원"으로 계산했는데, 이 계산은 모든 헤드가 각자 독립된 Key, Value를 갖는 구조를 전제로 한 것입니다. 그런데 Llama-2 70B 이후 대부분의 오픈웨이트 모델은 GQA(Grouped-Query Attention)를 씁니다.

쿼리 헤드는 여러 개 두되, 소수의 Key, Value 헤드만 만들어서 그룹으로 묶어 공유하는 방식입니다. 이렇게 하면 KV 캐시 크기가 hidden 차원이 아니라 "공유되는 KV 헤드 수 × head 차원"에만 비례하게 되어, 캐시가 몇 분의 1로 줄어듭니다.

헤드가 여러 개라는 사실 자체가 곧 KV 캐시를 줄이는 튜닝 손잡이가 되는 셈입니다.

스케일링과 소프트맥스: 점수를 확률처럼 바꾸기

"대조해서 점수를 매긴다"는 표현을 수식으로 풀면 입니다[10]. 두 단계로 나눠보겠습니다.

  1. : Query와 Key를 행렬곱해서 토큰 쌍마다 점수를 매깁니다. 이 값을 (헤드 차원의 제곱근)로 나눠주는데, 이유는 head 차원이 커질수록 내적값이 과도하게 커지기 때문입니다. 값이 너무 커지면 다음 단계인 소프트맥스가 한쪽으로 쏠려버리고, 기울기가 거의 0인 구간에 갇혀 학습이 잘 안 되는 문제가 생깁니다.
  2. 소프트맥스: 점수들을 "다 더하면 1이 되는 비율"로 바꿉니다. 이 비율이 곧 "누구를 얼마나 참고할지"의 실제 가중치이고, 이 비율대로 Value를 섞습니다.

여기에 GPT 계열은 제약을 하나 추가합니다. 다음 단어를 예측하는 모델이 아직 나오지 않은 미래 토큰을 참조하면 반칙이므로, 각 토큰이 자기보다 뒤에 있는 토큰을 보지 못하게 가립니다. 이것이 마스크드 어텐션(masked attention) 입니다. part 1 체크포인트에서 봤던 정체불명의 h.*.attn.bias (1, 1, 1024, 1024) 버퍼가 바로 이 가림막(인과 마스크)입니다.

어텐션을 통과한 벡터는 FFN(Feed-Forward Network) 층에서 차원을 크게 키웠다가 다시 줄이는 변환을 거칩니다(GPT-2의 c_fc (768→3072)c_proj (3072→768)). 두 선형 변환 사이에는 GELU라는 활성화함수가 끼어 있어서, 단순히 선형 변환을 더한 게 아니라 비선형적인 표현력을 갖게 됩니다[11].

잔차 연결과 LayerNorm 위치: 블록이 깊어져도 무너지지 않는 이유

지금까지 설명한 어텐션과 FFN은 결과를 그대로 다음 층에 넘기지 않습니다. 각 서브레이어는 "입력 더하기 서브레이어(입력)" 형태로, 원래 입력을 결과에 다시 더해서 넘깁니다. 이것이 잔차 연결(residual connection) 입니다[12]. 이 덕분에 기울기가 블록을 12개, 96개씩 지나도 지름길로 흐를 수 있어서, 층이 아무리 깊어져도 학습이 무너지지 않습니다. 앞서 "입력과 출력의 텐서 크기가 같도록 설계되어 레고처럼 쌓을 수 있다"고 했던 부분이 실제로 가능해지는 이유가 바로 이 잔차 연결입니다.

그리고 part 1, part 2 초반부터 계속 나왔던 h.0.ln_1, h.0.ln_2 같은 LayerNorm 텐서가 블록 안 정확히 어디에 있는지도 짚고 넘어가겠습니다. GPT-2는 Pre-LN 구조를 씁니다. 어텐션에 들어가기 전에 ln_1으로, FFN에 들어가기 전에 ln_2로 먼저 정규화를 하는 방식입니다[13]. 반대로 원조 트랜스포머나 BERT는 서브레이어를 통과한 다음에 정규화하는 Post-LN을 씁니다. Pre-LN이 깊은 네트워크에서 기울기를 더 안정적으로 만들어주기 때문에 GPT 계열의 표준으로 자리잡았습니다.

블록 하나로 정리하면

여기까지 나온 조각을 모으면 GPT-2 블록 하나는 아래 흐름입니다.

---
title: GPT-2 트랜스포머 블록 내부
---
flowchart TD
    x["입력 x"] --> ln1["ln_1 (LayerNorm)"]
    ln1 --> mha["Multi-Head Causal Self-Attention
헤드 12개, 각각 softmax(QKᵀ/√d)V"] mha --> add1(("입력과 더하기")) x -.잔차 연결.-> add1 add1 --> ln2["ln_2 (LayerNorm)"] ln2 --> ffn["FFN: c_fc(768→3072) → GELU → c_proj(3072→768)"] ffn --> add2(("입력과 더하기")) add1 -.잔차 연결.-> add2 add2 --> out["출력, 다음 블록으로"]

이렇게 보면 part 1에서 GPT-2 state_dict로 봤던 h.0.ln_1, h.0.attn.c_attn, h.0.ln_2, h.0.mlp.c_fc, h.0.mlp.c_proj 텐서들이 전부 이 흐름 위의 자리를 하나씩 차지하고 있었던 셈입니다. 그리고 이 "어텐션 + FFN" 묶음이 트랜스포머 블록이고, 입력과 출력의 텐서 크기가 같도록 설계되어 있어서 레고처럼 겹겹이 쌓을 수 있습니다. GPT-2의 h.0부터 h.11까지가 정확히 같은 블록 12개를 쌓은 것입니다.

BERT와 GPT: 반쪽씩 가져간 두 갈래

원본 트랜스포머에서 어느 쪽을 가져갔느냐로 두 계열이 갈립니다[1:5].

우리가 서빙할 대상인 GPT 계열은 한 번에 한 단어(토큰)씩 예측하여 텍스트를 생성하며, 이전 출력을 다시 입력으로 사용해 다음을 예측하기 때문에 자기회귀(autoregressive) 모델의 한 유형입니다. part 1에서부터 예고해 온 그 자기회귀가 여기서 나옵니다. 이 생성 루프가 정확히 어떻게 도는지는 다음 섹션에서 봅니다.

(맛보기) LLM은 어떻게 텍스트를 생성하는가

그럼 여태까지의 개념을 토대로, 아래 문장을 어떻게 완성시키나 살펴봅시다.

"Life is short, you need ??"

까지 읽었다면 다음 토큰으로 "python"을 예측하는 과정을[14] 살펴보시죠.

  1. 입력 텍스트가 토큰화 후 임베딩을 거쳐 벡터 행렬이 됩니다
  2. 위 행렬은 트랜스포머 블록들(GPT-2라면 12개)을 통과하고, 결과로 문맥이 반영된 토큰 표현인 hidden states가 나옵니다. shape은 [N, d](N = 토큰 수, d = 은닉 차원, GPT-2는 768)로, 블록을 몇 개 지나도 크기가 유지됩니다
  3. 다음 토큰 예측에는 그중 마지막 토큰의 hidden state 하나만 사용합니다. LM 헤드(Language Modeling head) 가 이 벡터를 어휘 전체에 대한 확률 분포(logits)로 매핑합니다 (GPT-2라면 50,257개 토큰 각각의 확률) part 1의 아키텍처 콜아웃에서 "Output head"라고 불렀던 그 마지막 층입니다
  4. 그중 하나를 다음 토큰으로 선택합니다. 여기선 need 다음 python 이 나오겠죠.

그런데 이 한 번의 실행으로 나오는 건 토큰 딱 하나입니다. 문장을 만들려면 루프를 돌아야 합니다.

입력: "Life is short, you need"
실행 1 → "python" 선택 → 입력 끝에 붙임
입력: "Life is short, you need python"
실행 2 → "to" 선택 → 입력 끝에 붙임
...
종료 토큰(EOS)이 선택될 때까지 반복

이렇게 이전 출력을 입력으로 되먹이며 한 토큰씩 생성하는 루프가 자기회귀(autoregressive) 생성입니다. 앞에서 여러 번 예고한 그 개념의 실체가 이것입니다.

이 구조에서 두 가지 함의를 챙겨두면 part 3이 쉬워집니다.

다음 편 예고

이번 편에서는 인공 뉴런에서 출발해 가중치·파라미터·dtype 같은 기초 용어를 다잡고, 트랜스포머의 구조와 자기회귀 생성 루프까지 살펴봤습니다. part 3에서는 이 생성 루프를 실제 서비스로 만들 때 생기는 문제들을 본격적으로 다루겠습니다.

이전 편: 1주차 part 1 - 모델과 모델 서빙의 개념
다음 편: 1주차 part 3 - LLM 서빙


  1. 해당 내용은 밑바닥부터 만들면서 배우는 LLM (2025) 1장을 참고해 재구성했습니다. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. bfloat16과 Tensor Core에 대해서는 TensorFlow 공식 가이드: Mixed precision 참고. bfloat16은 float16 대비 가수 비트를 지수 비트와 맞바꾼 포맷으로, NVIDIA Ampere 세대부터 Tensor Core에서 네이티브 지원됩니다. ↩︎ ↩︎

  3. BFloat16: The secret to high performance on Cloud TPUs (Google Cloud 블로그) ↩︎

  4. What Every User Should Know About Mixed Precision Training in PyTorch (PyTorch 공식 블로그) ↩︎

  5. 양자화 기법 전반은 HuggingFace Transformers: Quantization concepts 참고. GPTQ는 가중치 행렬을 행 단위로 오차를 최소화하며 int4로 양자화하고, 추론 시 fused kernel에서 fp16으로 복원하는 방식입니다. ↩︎ ↩︎

  6. Byte-Pair Encoding tokenization (HuggingFace LLM Course). GPT-2는 바이트 수준 BPE를 사용하며, 어휘 50,257 = 기본 바이트 256 + 병합 50,000 + 특수 토큰(<|endoftext|>) 1로 구성됩니다. ↩︎ ↩︎

  7. torch.nn.Embedding (PyTorch 공식 문서): "A simple lookup table that stores embeddings of a fixed dictionary and size." ↩︎

  8. 수식없이 GPT(트랜스포머) 이해하기 1편 (SK DevOcean, Teus) ↩︎

  9. GPT-2(124M)는 12개 어텐션 헤드를 씁니다. 헤드별 결과는 concat 후 선형 투영을 거칩니다. Transformers Explained (Hakia) 참고. ↩︎

  10. 스케일드 닷프로덕트 어텐션 공식과 스케일링의 근거는 원 논문 Attention Is All You Need (arXiv)The Annotated Transformer (Harvard NLP)에 정리되어 있습니다. ↩︎

  11. GPT-2의 FFN(MLP) 블록 구성(선형 투영, GELU, 선형 투영 순서)은 Transformer block - Build an LLM from scratch with MAX 참고. ↩︎

  12. 잔차 연결이 깊은 네트워크의 기울기 소실 문제를 완화한다는 설명은 Transformers Explained (Hakia) 참고. ↩︎

  13. GPT-2가 Pre-LN(어텐션, FFN 앞에서 먼저 정규화) 구조를 쓴다는 내용은 Transformer block - Build an LLM from scratch with MAX 참고. ↩︎

  14. 쉽게 이해하는 GPT 1편: 다음단어 예측기, Base모델 (SK DevOcean, Teus) ↩︎

  15. 수식없이 GPT(트랜스포머) 이해하기 2편 (SK DevOcean, Teus) ↩︎