[CloudNeta] Hands-On LLM Serving 1주차 part 2 - LLM과 트랜스포머 기본
이 글은 1주차 연재의 두 번째 편입니다.
- part 1 - 모델과 모델 서빙의 개념
- part 2 - LLM 기초 (이 글)
- part 3 - LLM 서빙
들어가며
이번 편의 목표는 인공지능 전반의 개요와 LLM(Large Language Model) 기초를 잡는 것입니다. 단, 범위는 part 3(LLM 서빙)을 이해하는 데 필요한 최소한으로 한정합니다. 깊은 수학적 지식 없이, 엔지니어링 관점에서 간략히 살펴본다는 점을 염두에 두어주셨으면 좋겠습니다.
그렇다면 서빙할 대상인 LLM에 대해 살펴보겠습니다. 인공지능, 머신러닝, 딥러닝, 그리고 LLM이란 무엇인지 살펴봅니다.
인공지능, 머신러닝, 딥러닝, 그리고 LLM
우선 헷갈리는 네 가지 용어를 살펴봅시다.
- 인공지능(Artificial Intelligence): 사람의 지적 작업을 기계로 수행
- 머신 러닝(Machine Learning): 규칙을 코딩하는 대신 데이터에서 학습
- 딥 러닝(Deep Learning): 신경망을 깊게 쌓아 학습
- 생성형 AI(Gen AI): 새로운 컨텐츠를 생성. 저희는 이 중 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입니다.

추가: 이미지에서 언어로
이미지 인식은 이미지의 "특징"을 필터로 뽑아내 확률적으로 예측하는 방식으로 발전했습니다. part 1의 CNN 예제에 있던 conv 레이어가 바로 그 필터입니다.
반면 언어는 단어일 수도 문장일 수도 있어서 구조가 유연해야 합니다. 초기 언어 모델(RNN 계열)은 앞 단어를 처리한 정보를 다음 단어로 차례차례 넘겨주는 순차 처리 방식이었는데, 이 방식에는 치명적인 한계가 있었습니다. 한 스텝씩 순서대로 계산해야 하므로 병렬화가 안 되고, 대규모 병렬 연산에 최적화된 GPU를 제대로 활용할 수 없어 모델을 키우기 어려웠던 것이죠. 이 구조를 갈아엎은 것이 뒤에서 살펴볼 트랜스포머입니다. 이렇게 언어를 다루는 신경망을 대량의 텍스트로 학습시켜 파라미터가 수십억 개 규모로 커진 것이, 우리가 아는 그 라지(Large)한 언어 모델, LLM입니다.
사전 훈련과 미세 튜닝
LLM의 훈련은 두 단계로 나뉩니다[1:1].
- 사전 훈련(pretraining): 대량의 텍스트로 처음부터 학습하는 첫 번째 단계입니다. 이렇게 만들어진 모델을 베이스 모델(base model) 또는 파운데이션 모델(foundation model) 이라고 부릅니다.
- 미세 튜닝(fine-tuning): 베이스 모델을 특정 목적(대화, 코드, 분류 등)에 맞게 추가로 훈련하는 단계입니다.
part 1에서 본 서빙 대상 모델은 이 과정의 결과입니다. 허깅페이스에서 내려받는 가중치 파일은 누군가 사전 훈련(과 미세 튜닝)을 마친 결과물인 것이죠.
모델 크기와 학습 데이터를 키울수록 성능이 크게 향상된다는 사실이 밝혀지면서, 파라미터 수는 폭발적으로 늘었습니다. GPT-1이 1억 1,700만 개, 2년 뒤 GPT-3가 1,750억 개, DeepSeek R1은 6,710억 개죠.
시간이 지난 요즘, 2026년 4월 공개된 DeepSeek-V4 시리즈는 V4-Pro가 총 1.6T(1조 6천억 개, 활성 49B), V4-Flash가 총 284B(활성 13B)에 이릅니다.
흥미로운 건 규모를 키우자 설계하지 않은 능력이 나타났다(창발, emergent ability)는 점입니다. 우리가 흔히 부르는 제로샷, 퓨샷 학습같은 능력을 할 수 있게 된 것이죠.
- Zero-shot: 예시를 하나도 안 주고 지시만 해도 새로운 태스크를 수행 ("이 문장을 프랑스어로 번역해줘")
- Few-shot: 프롬프트에 예시 몇 개만 넣어주면 패턴을 참고해 수행 ("사과 → apple, 바나나 → banana, 포도 → ?"). 이때 가중치는 업데이트되지 않고, 단지 프롬프트 문맥 안에서 예시를 참고할 뿐입니다.
기존 머신러닝에서는 새 태스크마다 별도 학습(파인튜닝)이 필요했는데, GPT-3급 모델은 학습 없이 프롬프트만으로 적응한다는 것 — 이것이 규모가 만든 질적 전환입니다.
T는 뭐고 활성은 뭐에요?
T와 B는 파라미터 개수의 단위입니다. M = Million(백만), B = Billion(10억), T = Trillion(1조). 즉 "GPT-2 124M"은 파라미터 1억 2,400만 개, "49B"는 490억 개, "1.6T"는 1조 6천억 개라는 뜻입니다. 흔히 모델 이름 뒤에 붙는 7B, 70B 같은 숫자가 전부 이 단위죠.
그럼 "활성"은 뭘까요? "총 1.6T, 활성 49B" 같은 표기는 MoE(Mixture of Experts, 전문가 혼합) 구조 때문입니다. 트랜스포머 블록의 FFN을 여러 "전문가"로 복제해 두고, 토큰마다 라우터가 그중 일부 전문가만 골라 계산하는 방식입니다. 즉 총 파라미터는 디스크·메모리에 저장된 전체, 활성 파라미터는 토큰 하나를 처리할 때 실제 연산에 참여하는 부분입니다. 서빙 관점에서는 "메모리는 1.6T짜리 모델인데 연산은 49B짜리 모델"이라는 뜻이라, GPU 메모리 요구량과 연산량이 분리된다 정도로 이해하면 되겠습니다.
가중치를 둘러싼 용어 다잡기
토큰 이야기로 넘어가기 전에, 가중치와, 이와 연관된 단어를 살펴봅시다. 앞으로 이 용어들이 계속 나오기 때문이죠. 먼저 파라미터와 기울기라는 용어를 살펴보죠. 이후 양자화까지 살펴보겠습니다.
가중치(Weight)와 파라미터(Parameter)
먼저 파라미터는 모델이 학습으로 조정하는 숫자 전체를 가리키는 상위 개념입니다. 파라미터의 각 항목을 짧게 짚어보면 아래와 같습니다
Parameter (학습 가능한 숫자 전체)
├── 가중치(Weight)
├── 편향(Bias)
├── 임베딩(Embedding) 값
└── LayerNorm의 scale, bias
- 가중치(weight): 입력의 영향력을 조절하는 숫자입니다.
- 편향(bias): 판단 기준선을 이동시키는 상수입니다. 다시말해 학습으로 조정되는 역치라 할 수 있습니다.
- 임베딩 값: 토큰마다 배정된 벡터입니다(뒤의 토큰/임베딩 섹션에서 자세히 다룹니다). 이는 학습으로 조정되는 값이므로 파라미터에 포함됩니다.
- LayerNorm의 scale, bias: 층이 깊어질 때 중간 계산값이 너무 커지거나 작아져 학습이 불안정해지지 않도록, 각 층의 출력을 정규화한 뒤 다시 곱(scale)하고 더해(bias) 조정하는 안정화 장치의 학습값입니다. 자세한 내용은 후술합니다.
넷 다 part 1의 GPT-2 출력에서 이미 본 텐서들입니다.
c_attn.weight(가중치)c_attn.bias(편향)wte.weight(임베딩)ln_1.weight/bias(LayerNorm)
예를 한번 살펴봅시다. 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)라는 예시가 있었죠. 이제 해석해봅시다.
(768,)은 숫자 768개가 한 줄로 늘어선 벡터(768, 2304)는 768행 × 2304열짜리 행렬이라는 뜻입니다.
shape의 숫자들을 전부 곱하면 그 텐서의 파라미터 개수가 됩니다. 이 곱셈은 단순해 보이지만, 앞으로 모델의 메모리 사용량을 셈할 때 계속 쓰이는 핵심 도구입니다.
위 출력의 shape를 읽는 법입니다. 텐서는 벡터와 행렬을 더 높은 차원으로 일반화한 수학적 개념이고, 차원 개수를 랭크(rank, 차수) 라고 부릅니다[1:2].
| 랭크 | 이름 | 예시 shape | part 1 GPT-2에서의 예 |
|---|---|---|---|
| 0 | 스칼라 (숫자 하나) | () |
. |
| 1 | 벡터 | (768,) |
h.0.ln_1.weight |
| 2 | 행렬 | (768, 2304) |
h.0.attn.c_attn.weight |
| 3+ | 그냥 "3D 텐서" 등으로 부름 | (1, 1, 1024, 1024) |
h.0.attn.bias |
계산 관점에서 텐서는 결국 다차원 데이터 컨테이너이고, 각 차원이 서로 다른 특성을 나타냅니다.
기울기(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 | 파라미터 수정 방향과 크기 | 매 스텝 새로 계산 | 수정 안내 화살표 |
GPU 여러 장으로 모델을 학습시킬 때는 같은 모델 복사본에 서로 다른 배치를 먹인 뒤, 각자 계산한 기울기를 네트워크로 교환·평균해서(All-Reduce) 모두가 같은 값으로 가중치를 업데이트합니다.
GPU-A: Batch-A로 forward/backward → gradient 계산 ┐
GPU-B: Batch-B로 forward/backward → gradient 계산 ┘
→ 네트워크로 gradient 교환 → 평균 → 같은 값으로 weight 업데이트
이 동기화는 매 스텝 일어나므로 높은 순간 대역폭과 낮은 지연시간이 필요하고, 가장 느린 GPU나 링크가 전체 스텝 시간을 결정합니다(동기식 장벽). part 1에서 "노드 간 통신이 병목"이라던 이야기가 가장 극적으로 드러나는 곳이 바로 학습입니다. 반대로 서빙(추론)의 수평 확장은 레플리카끼리 동기화할 것이 없어서 이 문제에서 자유롭습니다.
이런 차이는 학습용 인프라와 서빙용 인프라의 요구사항이 어떻게 달라지는 지를 시사하죠.
가중치 공개 모델과 비공개 모델
모델 구조가 두뇌의 설계도라면, 가중치는 학습으로 채워진 실제 지식입니다. 구조만 있으면 빈 두뇌이고, 구조 + 학습된 가중치가 있어야 실제로 동작하는 모델입니다. 사실상 이 가중치 파일이 핵심이라고 할 수 있죠.
- 가중치 공개(open-weight) 모델: 학습이 끝난 가중치 파일(
model.safetensors,pytorch_model.bin,*.gguf등)을 내려받아 내 서버나 PC에서 직접 실행할 수 있습니다. 다만 가중치 공개가 오픈소스인 것은 아닙니다. 사용 허가 범위는 모델 제공측의 라이선스를 별도로 확인해야 합니다. - 가중치 비공개(closed-weight) 모델: 모델 파일을 받을 수 없고, 제공 회사의 서버에서만 실행되며 사용자는 API로 요청만 보냅니다.
이번 연재는 전자의 케이스라는 것을 전제로 진행합니다.
가중치의 dtype
part 1의 GPT-2 실습에서 모든 텐서 옆에 torch.float32가 붙어 있었는데, 그것이 dtype(데이터 타입)입니다. 가중치와 계산 결과는 결국 숫자인데, dtype은 그 숫자 하나를 적는 메모지의 크기입니다.
주요한 타입은 아래와 같습니다:
- float32 = 큰 메모지(A4): 소수점 자리까지 정밀하게, 아주 크거나 작은 숫자도 넉넉하게 적을 수 있음
- float16 = 작은 메모지(포스트잇 반쪽): 공간이 절반이라 자릿수를 줄여 적어야 하고, 너무 크거나 작은 숫자는 아예 못 적음
부동소수점은 부호 + 지수(표현 범위) + 가수(정밀도)로 구성되는데, 세 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자리 |
- float16은 종이가 작아 둘 다 줄인 것: 65504를 넘으면
inf로 튀고(overflow), 아주 작은 값은 0으로 반올림됩니다(underflow) - bfloat16은 가수를 더 줄이는 대신 지수를 float32만큼 유지한 절충안입니다: 표현 범위는 그대로 두고 세밀함만 포기해서, 학습 시 오버플로우에 더 안전합니다[2]
- 이름의 bf는 Brain Float의 약자입니다. float16의 좁은 표현 범위가 딥러닝에 맞지 않다고 본 Google의 Brain 팀이 고안해서 붙은 이름입니다[3]
LLM 서빙에서 dtype이 중요한 이유는 세 가지입니다.
- 모델 크기 축소: 70B 파라미터 모델이 float32로는 약 280GB, float16으로는 약 140GB. GPU에 올라가느냐 마느냐가 결정되는 정도입니다.
- KV 캐시 축소: 캐시에서 읽어오는 데이터양이 절반이 되어 생성 속도가 실질적으로 빨라집니다 (자세한 건 part 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]. 다만 두 가지가 중요합니다.
- 이 벡터들은 해시 같은 고정 변환이 아니라 학습되는 파라미터입니다. 처음엔 랜덤 숫자였다가 학습을 거치며 조정됩니다(앞의 Parameter 트리에 "임베딩 값"이 들어 있던 이유).
- 학습의 결과로 비슷한 맥락에서 쓰이는 토큰들이 가까운 벡터를 갖게 됩니다. 즉 임베딩은 단순한 id의 숫자 변환이 아니라, 의미 공간에 좌표를 배정하는 작업에 가깝습니다.
파라미터라고 했으니 크기도 계산해 볼까요? 앞에서 배운 "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].
인코더와 디코더
원본 트랜스포머는 두 개의 서브모듈로 구성됩니다.
- 인코더(encoder): 입력 텍스트를 처리해서, 입력의 문맥 정보를 포착한 수치 표현(벡터)으로 인코딩합니다.
- 디코더(decoder): 인코딩된 벡터를 받아 출력 텍스트를 생성합니다.
번역이라면 인코더가 원본 언어 텍스트를 벡터로 인코딩하고, 디코더가 그 벡터를 풀어 타깃 언어 텍스트를 생성하는 구조입니다.
셀프 어텐션: 트랜스포머의 심장
인코더와 디코더 모두 셀프 어텐션 메커니즘(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(내용): "실제로 넘겨줄 정보"
각 토큰의 Query를 모든 토큰의 Key와 대조해(행렬곱) "누구를 얼마나 참고할지" 점수를 매기고, 그 비율대로 각 토큰의 Value를 섞어 가져옵니다. 앞서 말한 "상대적인 가중치를 부여한다"의 실체가 이것입니다. part 1 GPT-2에서 본 c_attn.weight (768, 2304)가 바로 이 변환입니다. 768차원 입력을
다만 여기까지는 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 캐시를 줄이는 튜닝 손잡이가 되는 셈입니다.
스케일링과 소프트맥스: 점수를 확률처럼 바꾸기
"대조해서 점수를 매긴다"는 표현을 수식으로 풀면
: Query와 Key를 행렬곱해서 토큰 쌍마다 점수를 매깁니다. 이 값을 (헤드 차원의 제곱근)로 나눠주는데, 이유는 head 차원이 커질수록 내적값이 과도하게 커지기 때문입니다. 값이 너무 커지면 다음 단계인 소프트맥스가 한쪽으로 쏠려버리고, 기울기가 거의 0인 구간에 갇혀 학습이 잘 안 되는 문제가 생깁니다. - 소프트맥스: 점수들을 "다 더하면 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].
- BERT 계열: 인코더 기반. 문장 전체를 읽고 이해하는 작업(분류, 검색 등)에 강합니다.
- GPT 계열: 디코더만 사용. 텍스트 생성이 필요한 작업을 위해 고안되었습니다.
우리가 서빙할 대상인 GPT 계열은 한 번에 한 단어(토큰)씩 예측하여 텍스트를 생성하며, 이전 출력을 다시 입력으로 사용해 다음을 예측하기 때문에 자기회귀(autoregressive) 모델의 한 유형입니다. part 1에서부터 예고해 온 그 자기회귀가 여기서 나옵니다. 이 생성 루프가 정확히 어떻게 도는지는 다음 섹션에서 봅니다.
(맛보기) LLM은 어떻게 텍스트를 생성하는가
그럼 여태까지의 개념을 토대로, 아래 문장을 어떻게 완성시키나 살펴봅시다.
"Life is short, you need ??"
까지 읽었다면 다음 토큰으로 "python"을 예측하는 과정을[14] 살펴보시죠.
- 입력 텍스트가 토큰화 후 임베딩을 거쳐 벡터 행렬이 됩니다
- 위 행렬은 트랜스포머 블록들(GPT-2라면 12개)을 통과하고, 결과로 문맥이 반영된 토큰 표현인 hidden states가 나옵니다. shape은
[N, d](N = 토큰 수, d = 은닉 차원, GPT-2는 768)로, 블록을 몇 개 지나도 크기가 유지됩니다 - 다음 토큰 예측에는 그중 마지막 토큰의 hidden state 하나만 사용합니다. LM 헤드(Language Modeling head) 가 이 벡터를 어휘 전체에 대한 확률 분포(logits)로 매핑합니다 (GPT-2라면 50,257개 토큰 각각의 확률) part 1의 아키텍처 콜아웃에서 "Output head"라고 불렀던 그 마지막 층입니다
- 그중 하나를 다음 토큰으로 선택합니다. 여기선 need 다음 python 이 나오겠죠.
그런데 이 한 번의 실행으로 나오는 건 토큰 딱 하나입니다. 문장을 만들려면 루프를 돌아야 합니다.
입력: "Life is short, you need"
실행 1 → "python" 선택 → 입력 끝에 붙임
입력: "Life is short, you need python"
실행 2 → "to" 선택 → 입력 끝에 붙임
...
종료 토큰(EOS)이 선택될 때까지 반복
이렇게 이전 출력을 입력으로 되먹이며 한 토큰씩 생성하는 루프가 자기회귀(autoregressive) 생성입니다. 앞에서 여러 번 예고한 그 개념의 실체가 이것입니다.
이 구조에서 두 가지 함의를 챙겨두면 part 3이 쉬워집니다.
- 토큰 100개짜리 답변 = 모델 실행 100번. 챗봇의 응답이 한 글자씩 흘러나오는(스트리밍) 이유이자, LLM 서빙 비용의 근원입니다.
- 매 실행마다 중복 계산이 발생합니다. 루프를 돌 때마다 지금까지의 시퀀스 전체를 다시 넣는데, 이전 토큰들의 Key/Value는 지난 실행에서 이미 계산했던 값 그대로입니다. 이걸 버리지 않고 저장해 재사용하자는 아이디어가 KV 캐시이고[15], "계산을 아끼는 대신 메모리를 쓴다"는 이 트레이드오프가 part 3의 주인공입니다.
다음 편 예고
이번 편에서는 인공 뉴런에서 출발해 가중치·파라미터·dtype 같은 기초 용어를 다잡고, 트랜스포머의 구조와 자기회귀 생성 루프까지 살펴봤습니다. part 3에서는 이 생성 루프를 실제 서비스로 만들 때 생기는 문제들을 본격적으로 다루겠습니다.
이전 편: 1주차 part 1 - 모델과 모델 서빙의 개념
다음 편: 1주차 part 3 - LLM 서빙
해당 내용은 밑바닥부터 만들면서 배우는 LLM (2025) 1장을 참고해 재구성했습니다. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
bfloat16과 Tensor Core에 대해서는 TensorFlow 공식 가이드: Mixed precision 참고. bfloat16은 float16 대비 가수 비트를 지수 비트와 맞바꾼 포맷으로, NVIDIA Ampere 세대부터 Tensor Core에서 네이티브 지원됩니다. ↩︎ ↩︎
BFloat16: The secret to high performance on Cloud TPUs (Google Cloud 블로그) ↩︎
What Every User Should Know About Mixed Precision Training in PyTorch (PyTorch 공식 블로그) ↩︎
양자화 기법 전반은 HuggingFace Transformers: Quantization concepts 참고. GPTQ는 가중치 행렬을 행 단위로 오차를 최소화하며 int4로 양자화하고, 추론 시 fused kernel에서 fp16으로 복원하는 방식입니다. ↩︎ ↩︎
Byte-Pair Encoding tokenization (HuggingFace LLM Course). GPT-2는 바이트 수준 BPE를 사용하며, 어휘 50,257 = 기본 바이트 256 + 병합 50,000 + 특수 토큰(
<|endoftext|>) 1로 구성됩니다. ↩︎ ↩︎torch.nn.Embedding (PyTorch 공식 문서): "A simple lookup table that stores embeddings of a fixed dictionary and size." ↩︎
GPT-2(124M)는 12개 어텐션 헤드를 씁니다. 헤드별 결과는 concat 후 선형 투영을 거칩니다. Transformers Explained (Hakia) 참고. ↩︎
스케일드 닷프로덕트 어텐션 공식과
스케일링의 근거는 원 논문 Attention Is All You Need (arXiv)와 The Annotated Transformer (Harvard NLP)에 정리되어 있습니다. ↩︎ GPT-2의 FFN(MLP) 블록 구성(선형 투영, GELU, 선형 투영 순서)은 Transformer block - Build an LLM from scratch with MAX 참고. ↩︎
잔차 연결이 깊은 네트워크의 기울기 소실 문제를 완화한다는 설명은 Transformers Explained (Hakia) 참고. ↩︎
GPT-2가 Pre-LN(어텐션, FFN 앞에서 먼저 정규화) 구조를 쓴다는 내용은 Transformer block - Build an LLM from scratch with MAX 참고. ↩︎