|
|
GPT 디코더 전용 구조 - 임베딩부터 토큰 생성까지
들어가기 전에
본문은 LLM중에서도 챗봇형, 정확히는 GPT의 디코더의 구조에 대해서 설명한다.
아래의 주된 흐름은 이렇다.
문장 하나가 GPT에 들어가서 "다음 단어 확률"이 나올 때까지, 그 사이에 있는 모든 블록을 하나씩 다 뜯어보는 것.
각 블록마다 이렇게 정리한다.
• 입력이 뭐고 어떤 형태인지
• 안에서 무슨 연산을 하는지
• 출력이 뭐고 어떤 형태로 나오는지
• 전문 설명 (정확한 용어와 수식 기준)
• 쉽게 다시 말하면 (비유 기준)
그리고 모델 크기는 GPT-2 small 기준으로 통일한다.
d_model = 768, 헤드 12개, 헤드당 d_k = 64, 블록 12개, vocab 50257개.
[구조도 ①: 전체 파이프라인 개요 - 텍스트에서 다음 토큰 확률까지]
═══════════════════════════════════════
1부. GPT 디코더 전용 구조
═══════════════════════════════════════
■ 0장. 시작하기 전에 - 원본 Transformer와 뭐가 다른가
원본 Transformer(Attention Is All You Need)는 인코더 스택과 디코더 스택이 둘 다 있었다. 디코더 블록 안에는 attention이 두 종류 들어있었다.
• Masked Self-Attention : 디코더 자기 자신 안에서, 미래 토큰 차단
• Cross-Attention : Query는 디코더에서, Key/Value는 인코더 출력에서
GPT류 "디코더 전용" 구조는 여기서 인코더 스택 전체와 Cross-Attention 블록이 통째로 사라진다.
남는 건 Masked Self-Attention 하나뿐이고, 이게 N번 반복된다. 참고할 "원문"이 따로 없으니까, 지금까지 자기가 만든 토큰들만 보고 다음 토큰을 예측하는 구조가 된다.
[구조도 ②: 인코더 양방향 attention vs 디코더 causal attention 비교]
───【핵심】───
GPT는 Transformer의 오른쪽 절반(디코더)에서 cross-attention까지 떼어낸, masked self-attention만 쌓아올린 구조다.
■ 1장. 텍스트를 숫자로 - 토크나이저(BPE)
▷ 입력 : 원문 텍스트 "나는 밥을 먹었다"
▷ 내부 연산 : 미리 만들어둔 병합 규칙에 따라 조각으로 분해
▷ 출력 : 토큰 조각들의 나열 [나는, 밥을, 먹, 었다]
전문 설명
BPE(Byte-Pair Encoding)는 GPT 학습보다 훨씬 먼저, 완전히 별도의 과정으로 미리 만들어진다. 역전파로 바뀌지 않는 고정된 규칙이다.
사전을 만드는 과정은 이렇다.
1) 모든 단어를 글자(또는 바이트) 단위로 완전히 분해하고, 단어 끝에 경계 표시 </w> 를 붙인다.
2) 데이터 전체에서 "인접한 글자 쌍"의 등장 빈도를 전부 센다. 이때 그 단어 자체의 등장 횟수를 가중치로 곱한다.
3) 가장 빈도가 높은 쌍을 하나로 병합해서 새 조각으로 사전에 추가하고, 데이터 전체에서 그 쌍을 병합된 형태로 치환한다.
4) 2~3번을 미리 정한 사전 크기에 도달할 때까지 반복한다.
여기서 방향을 헷갈리기 쉬운데, 사전 크기는 줄어드는 게 아니라 늘어난다. 병합할 때마다 새 조각이 하나씩 "추가"되기 때문에, 아주 작은 상태(글자 수준)에서 시작해서 상한선을 향해 올라가다가 도달하면 멈춘다. 그리고 병합 기준은 "의미적으로 관련이 깊은가"가 아니라 순전히 "얼마나 자주 붙어 나오는가"라는 빈도 하나뿐이다. BPE는 의미를 전혀 판단하지 않는다. </w> 표시가 왜 중요하냐면, 같은 글자 조합이라도 위치에 따라 완전히 다른 토큰으로 취급되기 때문이다.
• er : 단어 중간이나 시작에 있는 er (예: erase)
• er</w> : 단어가 er로 끝난다는 뜻 (예: lower)
영어에서 er이 단어 끝에 오면 보통 비교급이나 행위자 접미사인데, 중간에 오면 전혀 다른 의미다. 이 구분이 실제로 유의미해서 둘을 별개의 토큰으로 사전에 각각 등록한다.
쉽게 다시 말하면
레고 조각 규격을 정하는 단계라고 보면 된다. 2x1 블록 하나가 그 자체로 "자동차"나 "집"이라는 의미를 가지고 있지는 않다. 근데 부품 규격은 미리 정해놔야 나중에 뭘 조립하든 할 수 있다. 그래서 BPE로 쪼갠 조각 하나가 사람 눈에 "이게 무슨 의미지" 싶어도 전혀 문제가 안 된다. un + happi + iness 로 쪼개지면 iness 는 혼자선 아무 의미도 없지만, 셋이 함께 처리될 때 모델이 unhappiness라는 의미를 조립해낸다.
따라서 의미를 만드는 건 토크나이저가 아니라 그 다음 단계다.
───【핵심】───
BPE는 "자주 붙어 나오는 쌍을 계속 병합해서 사전을 키워가는" 빈도 기반 알고리즘이고, 의미 판단은 전혀 하지 않는다.
■ 2장. 조각을 벡터로 - 임베딩
▷ 입력 : 정수 ID 시퀀스, 형태 (배치수, 토큰수) 예: [1023, 887, 45, 220]
▷ 내부 연산 : 임베딩 테이블 조회 + 위치 임베딩 덧셈
▷ 출력 : (배치수, 토큰수, 768) 형태의 벡터 묶음
[구조도 ③: 텍스트에서 벡터까지 - 원문/토크나이저/정수ID/임베딩조회/위치임베딩]
전문 설명
먼저 토큰 조각들에 사전상의 번호를 매겨서 정수 ID로 바꾼다. 그 다음 (vocab크기 x 768) 크기의 임베딩 테이블에서, 각 ID에 해당하는 행을 꺼내온다. 수학적으로는 원핫 벡터와의 행렬곱이지만 실제 구현은 그냥 인덱싱이라 훨씬 빠르다. 여기에 위치 임베딩을 더한다. GPT-2는 원본 Transformer의 sin/cos 방식이 아니라 학습되는 절대 위치 임베딩을 쓴다. 위치 0, 1, 2, ... 각각에 대해 학습 가능한 벡터를 따로 두고
element-wise로 더하는 방식이다. attention 연산 자체는 순서 정보가 없는 연산이라, 이렇게 위치 정보를 따로 주입해주지 않으면 "나는 밥을 먹었다"와 "밥을 나는 먹었다"를 구분할 수 없다.
여기서 중요한 게 하나 있다. 임베딩 테이블은 처음에 완전히 랜덤한 숫자로 초기화된다. 학습 시작 전에는 "왕"이라는 단어와 그 벡터 사이에 아무 연관성도 없다. 역전파를 거치면서, 그 단어가 등장하는 문맥을 잘 예측하도록 해당 행의 숫자들이 계속 수정된다. 이때 등장한 단어의 행만 움직이고, 이번 배치에 안 나온 나머지 수만 개 행은 그대로다.
쉽게 다시 말하면
책장의 칸 번호와 그 칸에 꽂힌 책 내용의 관계다. "573번 칸 = 왕"이라는 위치 지정은 vocab 만들 때 정해지고 학습 내내 절대 안 바뀐다. 근데 그 칸에 처음엔 아무 의미 없는 낙서가 들어있다가, 학습이 진행되면서 그 낙서가 지워지고 "왕이 실제 언어에서 어떻게 쓰이는지"를 담은 내용으로 계속 다시 쓰여진다. 번호는 인덱스고 안 변하고, 안의 알맹이만 바뀌는 것.
참고 - king 빼기 man 더하기 woman 이 queen 이 된다는 이야기
유명한 얘기인데 팩트체크해보면 절반만 맞다. 원 논문에서도 결과를 찾을 때 입력 단어들(king, man, woman) 자체를 후보에서 제외하는데, 이걸 제외 안 하면 실제 결과는 queen이 아니라 그냥 king이 나온다. 최근에는 "일반화 안 되는, 조심스럽게 골라낸 예시"라는 비판도 많다.
다만 이런 선형적 유추 관계가 나타나는 현상 자체는 재현 가능하다. 중요한 건 이게 누가 그렇게 설계한 게 아니라는 점이다. "다음 토큰을 잘 맞히자"는 목표 하나로 학습하다 보니 부산물로 자연스럽게 생긴 통계적 성질이다.
그리고 word2vec과 다르게, GPT류는 임베딩 테이블을 별도로 먼저 학습하지 않는다. 전체 네트워크와 동시에, 같은 loss로 한 번에 역전파 학습된다.
───【핵심】───
번호(인덱스)는 고정, 알맹이(벡터값)만 역전파로 계속 갱신된다. 의미는 설계된 게 아니라 학습의 부산물로 생긴다.
■ 3장. 블록의 입구 - LayerNorm
▷ 입력 : 768차원 벡터 (토큰마다 하나씩)
▷ 내부 연산 : 표준화 후 학습 가능한 스케일/이동 적용
▷ 출력 : 똑같이 768차원 벡터 (모양 그대로, 값만 정리됨)
전문 설명
수식은 이렇다.
LayerNorm(x) = γ · (x - μ) / σ + β
μ : 그 벡터 안 숫자들의 평균
σ : 그 벡터 안 숫자들의 표준편차
γ, β : 학습 가능한 파라미터 (스케일, 이동)
여기서 (x - μ) / σ 부분은 고정된 계산이라 학습되지 않고, γ와 β만 학습된다. 완전히 딱딱한 고정 수식은 아니라는 뜻이다.
자주 헷갈리는 게 두 가지 있다.
첫째, 이건 0~1 범위로 압축하는 게 아니다. 표준화(평균 0, 분산 1)라서 음수도 나오고 절댓값이 3~4 이상 될 수도 있다. 0~1로 압축하는 건 LayerNorm이 아니라 나중에 나올 softmax의 역할이다.
둘째, 이건 편향을 없애는 게 아니다. 상대적 정보(순위, 격차)는 그대로 다 유지된다.
또 하나 중요한 게 위치다. GPT-2부터는 Pre-LN 구조를 쓴다.
• Post-LN (원본 Transformer) : LayerNorm(x + Sublayer(x))
• Pre-LN (GPT-2부터) : x + Sublayer(LayerNorm(x))
GPT-2 논문에 "layer normalization을 각 sub-block의 입력으로 옮겼다"고 명시되어 있다. 이렇게 하면 residual 경로에 원본 x가 LayerNorm을 거치지 않고 그대로 직행하는 길이 생겨서, gradient가 깊은 층까지 안정적으로 흐른다.
쉽게 다시 말하면
시험 점수 [60, 70, 90]을 평균 빼고 표준편차로 나누면 대략 [-0.7, -0.2, 0.9] 정도가 된다. 누가 제일 잘했고 누가 못했는지(순위, 격차)는 그대로 보존된다. 90점이 여전히 가장 큰 값이다. 바뀐 건 "60~90"이라는 스케일이 "-0.7~0.9"라는 표준화된 스케일로 옮겨진 것뿐이다.
정보를 지우는 게 아니라 자의 눈금만 통일하는 것.
───【핵심】───
경향(상대적 차이)은 유지하고 값의 크기만 표준 범위로 재조정. Pre-LN이라 본줄기는 LayerNorm을 안 거치고 그냥 지나간다.
■ 4장. 세 갈래로 나뉜다 - Q/K/V 생성
▷ 입력 : LayerNorm을 통과한 768차원 벡터
▷ 내부 연산 : 서로 다른 세 가중치 행렬로 각각 선형변환
▷ 출력 : Q, K, V 세 개의 벡터 (헤드당 각 64차원)
[구조도 ④: Masked Self-Attention 내부 세부 연산 전체]
전문 설명
같은 입력 벡터 x 하나를 서로 다른 세 행렬로 각각 변환한다.
Q = x · W_Q (Query, "내가 지금 뭘 찾고 있나")
K = x · W_K (Key, "각 토큰이 내미는 정보의 이름표")
V = x · W_V (Value, "그 이름표 뒤의 실제 내용물")
셋 다 출발은 완전히 같은 벡터인데, 다른 변환 규칙을 통과하니까 결과적으로 세 개의 다른 벡터가 나온다.
행렬 곱셈의 차원 규칙이 여기서 중요하다.
(토큰수 x 768) 크기 입력에 (768 x 64) 크기 행렬을 곱하면 (토큰수 x 64) 결과가 나온다.
가운데 차원(768)은 입력에 의해 강제로 고정되지만, 바깥쪽 차원(64)은 사람이 설계 시점에 자유롭게 정할 수 있다.
여기서 계속 반복되는 원칙이 하나 있다. 행렬의 모양(틀)은 사람이 정하는 하이퍼파라미터고, 그 안에 채워진 숫자(내용물)는 학습으로 결정된다. 앞서 임베딩 테이블의 "인덱스는 고정, 알맹이는 학습"과 정확히 같은 패턴이 여기서도 반복된다.
멀티헤드를 쓰기 때문에 헤드마다 독립적인 W_Q, W_K, W_V를 갖는다. 헤드가 12개고 헤드당 64차원이면 12 x 64 = 768로, 나중에 다시 이어붙였을 때 원래 크기로 돌아오게 설계된다.
쉽게 다시 말하면
똑같은 문서를 서로 다른 세 명이 각자 다른 관점에서 요약해서 내놓는 것과 비슷하다. 같은 원본에서 출발했지만 관점이 다르니까 결과물도 다르다. 그리고 각자가 "어떤 관점으로 볼지"는 처음부터 정해진 게 아니라 학습하면서 스스로 찾아간다.
───【핵심】───
같은 벡터를 세 행렬로 각각 변환해서 Q/K/V를 만든다. 행렬 크기는 사람이 설계, 행렬 내용은 학습으로 결정.
■ 5장. 관계 점수 계산 - QKᵀ 스케일링과 Causal Mask
▷ 입력 : Q와 K (V는 아직 안 쓰임)
▷ 내부 연산 : 내적 계산, √dₖ로 나눔, 마스크 덧셈
▷ 출력 : (토큰수 x 토큰수) 크기의 점수 표
전문 설명
모든 Query와 모든 Key를 짝지어 내적을 계산한다. 행렬로 하면 Q · Kᵀ 한 번에 끝난다. 토큰이 4개면 4x4 크기 표가 나온다.
이 표의 각 칸은 "이 단어가 저 단어와 얼마나 관련 있는가"를 나타내는 점수다.
여기서 주의할 게, 이 연산은 완전연결(Linear) 연산이 아니다. 학습되는 가중치가 전혀 없다. Q와 K는 둘 다 데이터지 파라미터가 아니기 때문이다. Q/K/V를 만들 때 쓴 W_Q, W_K는 학습 파라미터가 맞지만, 그걸로 만든 Q와 K를 서로 곱하는 이 단계는 그냥 유사도 계산이다.
그 다음 √dₖ 로 나눈다. dₖ가 크면 내적값의 분산이 커져서 softmax가 한쪽으로 극단적으로 쏠리고, 그러면 gradient가 거의 안 흐르게 된다. 이걸 막기 위한 스케일링이다. 스케일링이 처음 등장하는 지점이 여기라는 것도 짚어둘 만하다. Q/K/V를 만들 때는 스케일링이 없었다. 마지막으로 causal mask를 더한다. 4토큰 기준으로 이런 모양이다.
나는 밥을 먹었다 맛있게
나는 0 -inf -inf -inf
밥을 0 0 -inf -inf
먹었다 0 0 0 -inf
맛있게 0 0 0 0
상삼각 부분(미래 위치)에 -inf를 채운 행렬이다.
여기서 중요한 게, 이건 곱하는 게 아니라 더하는 거다. 곱하면 원래 점수의 부호에 따라 결과가 달라진다. 양수면 -inf가 되지만 음수면 +inf가 되어버린다. 반드시 더해야 부호와 무관하게 항상 -inf로 밀어낼 수 있다. 왜 하필 -inf냐면, softmax는 단조증가함수다. 입력이 크면 출력도 크고 작으면 작다. 그래서 출력을 이론상 가장 작은 값 (정확히 0)으로 만들려면 입력도 정의역의 극한값이어야 한다.
e^z 는 z가 유한한 값인 이상 절대 정확히 0이 될 수 없다. -100이나 -1000 같은 큰 음수를 넣어도 0에 아주 가까운 양수가 남을 뿐이다. -inf여야만 e^(-inf) = 0 으로 완전히 차단된다.
쉽게 다시 말하면
시험 채점표를 만드는데, 아직 안 배운 범위는 아예 채점 자체를 못 하게 막아버리는 거다. 그냥 0점을 주면 "0점을 받았다"는 정보로 남지만, 아예 채점 대상에서 빼버리면 그 항목은 최종 점수 계산에 조금도 영향을 못 준다. -inf를 더하는 게 딱 이 "아예 빼버리기"에 해당한다.
───【핵심】───
QKᵀ은 학습 파라미터가 없는 유사도 계산이고, 마스크는 곱셈이 아니라 덧셈이며, -inf여야만 완전히 0으로 차단된다.
■ 6장. 점수를 비율로 - Softmax와 가중합
▷ 입력 : 마스킹까지 끝난 (토큰수 x 토큰수) 점수 표, 그리고 V
▷ 내부 연산 : 행마다 softmax, 그 비율로 V를 가중합
▷ 출력 : 토큰마다 하나씩, dₖ차원(64) 벡터
전문 설명
점수 표의 각 행마다 softmax를 적용한다.
softmax(zᵢ) = e^(zᵢ) / Σⱼ e^(zⱼ)
-inf였던 자리는 자동으로 0이 되고, 나머지끼리 합이 1이 되도록 비율이 재분배된다. 이제 이 표의 각 칸은 진짜 확률(비율)이다. 0~1로 바뀌는 진짜 지점은 LayerNorm이 아니라 여기다.
토큰이 4개면 가중치 벡터도 4개 나오고, 각 벡터의 길이도 4다. 예를 들어 "먹었다"의 가중치 벡터는 이런 식이다.
[0.2, 0.3, 0.5, 0]
"맛있게"는 미래 토큰이라 causal mask 때문에 0이다. 이제 이 비율로 V를 섞는다.
새 벡터 = 0.2 x V(나는) + 0.3 x V(밥을) + 0.5 x V(먹었다) + 0 x V(맛있게)
여기서 헷갈리기 쉬운 게 결과의 형태다. 결과는 스칼라(숫자 하나)가 아니라 벡터다. 가중치(0.2 같은 숫자)는 차원이 없는 스칼라고, V는 64차원 벡터다. 스칼라 x 벡터 = 여전히 같은 차원의 벡터이고, 이런 벡터들을 더해도 차원은 그대로 유지된다.
정리하면, 차원은 비율 표가 아니라 V 쪽에서 들어온다. 비율 표는 "몇 대 몇으로 섞을지"만 정하고, 실제 섞이는 내용물의 차원은 전부 V가 결정한다. 그래서 결과는 dₖ차원(64) 벡터가 토큰 개수만큼 나온다. 그리고 이건 헤드 하나 기준이라, 헤드가 12개면 이 세트가 12개다.
쉽게 다시 말하면
여기가 바로 "문맥을 반영한다"는 게 실제로 일어나는 지점이다. 원래 "먹었다"라는 단어 혼자만 봤을 땐 그 자체 의미만 있었는데, 이 계산을 거치고 나면 "나는"과 "밥을"이라는 앞 문맥 정보가 섞여 들어간 새로운 벡터가 된다. 누가 먹었는지, 뭘 먹었는지가 그 하나의 벡터 안에 녹아든다.
───【핵심】───
softmax로 합이 1인 비율을 만들고, 그 비율로 V를 섞는다. 비율은 차원이 없고, 차원은 전부 V에서 온다.
■ 7장. 관점 합치기 - 헤드 병합과 출력 선형변환
▷ 입력 : 헤드 개수만큼(12개)의 (토큰수 x 64) 결과 묶음
▷ 내부 연산 : 옆으로 이어붙인 뒤 선형변환
▷ 출력 : 토큰마다 768차원 벡터 (원래 입력과 같은 모양)
전문 설명
각 토큰에 대해, 12개 헤드가 만든 64차원 벡터들을 옆으로 그냥 이어붙인다. 64 x 12 = 768 이 된다. 새로운 계산이 아니라 그냥 나란히 놓는 것뿐이다. 근데 이 상태는 문제가 있다. 헤드1이 만든 64개 숫자, 헤드2가 만든 64개 숫자가 그냥 각자 자기 구역에 따로 놓여있을 뿐, 서로 전혀 섞이지 않은 상태다.
그래서 W_O 라는 (768 x 768) 행렬로 한 번 더 선형변환한다. 이어붙인 768차원 벡터의 모든 숫자를 서로 섞어서, 여러 헤드의 정보를 하나로 융합하는 역할이다. W_O도 W_Q, W_K, W_V와 똑같이 학습되는 파라미터다. 헤드를 나눴다가(768 → 64x12) 다시 합쳐서(64x12 → 768) 원래 크기로 돌아오는 이유는, 다음에 올 residual 덧셈이 원래 입력과 같은 차원이어야만 더할 수 있기 때문이다.
쉽게 다시 말하면
여러 전문가에게 같은 문서를 각자 다른 관점(문법 전문가, 의미 전문가, 문맥 전문가)으로 분석하게 시킨 다음, 그 분석 결과들을 한 명의 편집자가 다 읽고 종합 보고서 하나로 정리하는 것과 비슷하다. concat이 "보고서들을 스테이플러로 찍어서 묶은 것"이라면, W_O는 "그 보고서들을 다 읽고 하나의 결론으로 정리하는 것"이다.
───【핵심】───
concat은 그냥 나열이고, W_O가 헤드별로 따로 놀던 정보를 실제로 섞어서 하나로 융합한다.
■ 8장. 본줄기에 얹기 - Residual 연결
▷ 입력 : 이 블록의 원본 입력, 그리고 W_O까지 끝난 attention 결과
▷ 내부 연산 : 두 벡터를 그냥 더함
▷ 출력 : 768차원 벡터 (다음 단계로)
전문 설명
x_new = x_original + Attention(LayerNorm(x_original))
여기서 x_original은 이 블록에 맨 처음 들어왔던 입력이다. LayerNorm을 거치기 전의 원본이다.
이게 Pre-LN 구조의 핵심이다. LayerNorm의 결과는 어디까지나 "Attention에 넣기 위한 임시 복사본"일 뿐이고, 본줄기인 원본 x는 정규화되지 않은 채로 그대로 보존되어 여기서 다시 쓰인다.
즉 본줄기(residual stream)는 처음부터 끝까지 단 한 번도 LayerNorm을 직접 거치지 않는다. 이 "깨끗한 우회로" 덕분에 gradient가 깊은 층까지 손실 없이 흐를 수 있다.
여기서 자연스럽게 나오는 의문이 있다. 계속 뭔가를 더하기만 하면 값이 점점 커지는 거 아닌가? 실제로 커진다. Pre-LN 구조에서 residual stream의 크기가 층이 깊어질수록 계속 증가하는 건 여러 논문에서 확인된 현상이다.
GPT-2는 이 문제에 대한 해법을 논문에 명시해뒀다. residual layer의 가중치를 초기화 시점에 1/√N 배로 줄이는 것이다. (여기서 N은 residual layer의 개수인데, 블록 하나당 residual 덧셈이 2번이니까 결과적으로 1/√(2 x 블록수)가 된다.)
대상은 딱 두 층이다. attention의 출력 선형변환 W_O, 그리고 FFN의 두 번째 Linear. residual에 직접 더해지는 그 두 층만 골라서 초기값을 작게 만든다. 원리는 분산의 덧셈 성질이다.
Var(x + f(x)) = Var(x) + Var(f(x))
residual을 계속 반복하면 총 분산이 층 개수에 비례해서 커진다. 그런데 각 f의 표준편차를 1/√(2N)배로 줄이면, 분산은 표준편차의 제곱이니까 각 기여분이 1/(2N)로 줄어든다. 이걸 2N번 다 더하면 N이 정확히 약분되어 사라진다.
다만 솔직하게 짚고 갈 부분이 있다. 이건 근본 해결이 아니라 완화(mitigation)에 가깝다. 초기화 시점에만 적용되는 값이라, 학습이 진행되면 이 가중치들도 자유롭게 커질 수 있어서 문제가 재발할 수 있다.
실제로 최근 연구에서는 이 스케일링을 끄는 게 오히려 성능이 낫다는 결과도 나왔고, Llama나 Mamba2 같은 최신 구현체들은 기본으로 꺼두기도 한다. NormFormer, DeepNet, GPAS, ProRes 같은 후속 연구들이 계속 다른 방식을 제안하는 중이다. 아직 완전히 해결됐다고 말할 수 있는 상태가 아니다.
쉽게 다시 말하면
본줄기를 "전체"라고 하고, 매 단계에서 새로 계산되는 걸 "조각"이라고 해보자.
전체를 정규화해서 → 그걸 가공해서 조각을 만들고 → 그 조각을 정규화 안 된 원래 전체에 더한다.
여기서 정규화는 "조각을 만들기 위한 임시 작업"일 뿐이고, 전체 자체는 계속 정규화 안 된 상태로 순수하게 쌓여만 간다.
───【핵심】───
본줄기는 절대 정규화되지 않고 계속 쌓이기만 한다. 그래서 값이 커지는 문제가 있고, 초기화 스케일링은 그 속도를 늦추는 완화책이지 완전한 해결은 아니다.
■ 9장. 넓혔다 좁히기 - FFN
▷ 입력 : LayerNorm을 통과한 768차원 벡터
▷ 내부 연산 : 4배 확장 → GELU → 원래 크기로 축소
▷ 출력 : 768차원 벡터
[구조도 ⑤: FFN 내부 - 확장/GELU/축소와 차원 변화]
전문 설명
FFN에 들어가기 전에 다시 LayerNorm을 한 번 거친다. Pre-LN 구조는 "모든 서브레이어 앞에 LayerNorm을 둔다"는 규칙이라, 블록 하나 안에 서브레이어가 둘(Attention, FFN)이면 LayerNorm도 두 번 나온다.
여기서 짚고 갈 게, 이때 LayerNorm이 받는 입력은 8장의 residual 덧셈 결과인데, 이건 정규화가 전혀 안 된 값이다. 정규화된 결과물(attention에 넣었던 것)을 정규화 안 된 원본에 더한 거니까 당연하다.
단계 1) Linear (768 → 3072)
첫 번째 선형변환으로 4배 넓은 공간으로 보낸다. 여기서 "부풀린다"는 표현은 좀 부정확하다. 기존 768개 숫자가 커지는 게 아니라, 768개를 재료 삼아 완전히 새로운 3072개의 숫자 조합을 만들어내는 것이다.
왜 넓히냐면, 바로 다음에 올 비선형 처리를 더 넓은 공간에서 해야 표현할 수 있는 패턴이 훨씬 풍부해지기 때문이다. 원본에 얽혀 있던 여러 패턴들을 넓은 공간에서 풀어헤쳐서 각각 더 명확하게 다룰 수 있게 만드는 셈이다. 여기서 나올 만한 걱정이 있다. 아무렇게나 섞으면 의미 있는 정보가 날아가는 거 아닌가?
초기화 시점엔 실제로 무의미하게 섞는 게 맞다. 근데 두 가지가 이걸 막아준다. 하나는 역전파로 "유용한 조합"이 되도록 계속 수정된다는 것이고, 다른 하나는 residual 덕분에 원본 정보가 항상 보존된다는 것이다. FFN이 별 도움 안 되는 조합을 내놔도, 본줄기 정보는 그대로 다음 층까지 간다.
단계 2) GELU
3072개 숫자 하나하나에 개별적으로 적용되는 비선형 함수다.
• 많이 양수면 → 거의 그대로 통과
• 많이 음수면 → 거의 0에 가깝게 억제
• 0 근처면 → 부드럽게 깎임 (최소값 약 -0.17 정도)
비선형이 왜 필수냐면, 선형변환은 아무리 여러 번 이어붙여도 결국 하나의 선형변환과 수학적으로 동일하다(결합법칙). 비선형이 없으면 층을 아무리 깊게 쌓아도 표현력이 단순한 하나의 직선 관계로 붕괴한다.
ReLU와 비교하면 이렇다. ReLU는 음수를 0으로 딱 잘라버려서 그 지점의 gradient가 정확히 0이 되고, 그 뉴런이 죽어서 다시는 학습 안 되는 문제(dead neuron)가 생길 수 있다. GELU는 완전히 끊지 않고 부드럽게 넘어가서 학습 신호가 계속 흐른다.
한 가지 짚어둘 게, "음수 = 쓸모없음"이 처음부터 정해진 규칙은 아니다. GELU라는 억제 함수가 있으니까, 역으로 앞단의 가중치가 "필요할 때 양수, 필요없을 때 음수를 내놓는 게 유리하다"는 걸 학습으로 스스로 알아내는 것이다.
단계 3) Linear (3072 → 768)
다시 원래 크기로 압축한다. 이 단계에서 "필요없는 걸 버린다"고 표현하기 쉬운데, 정확히는 버리는 게 아니라 재조합이다.
GELU가 이미 눌러놓은(0에 가까운) 값들은 아무리 다른 숫자와 섞여도 결과에 거의 기여를 못 한다. 0에 가까운 값에 가중치를 곱해도 여전히 0에 가깝기 때문이다. 그래서 결과적으로 "관련 높은 것 위주로 압축되는" 효과가 자연스럽게 나온다. 그리고 이 결과에 다시 residual 덧셈을 한다. 8장에서 나온 값(정규화 안 된 그 본줄기)에 지금 만든 조각을 더하는 것이다.
여기까지가 블록 하나의 전부다.
쉽게 다시 말하면
좁은 책상(768차원)에서 작업하는 것보다, 넓은 작업대(3072차원)에 재료를 다 펼쳐놓고 이것저것 조합해본 다음, 필요한 것만 골라서 다시 원래 서랍 크기로 정리해 담는 것과 비슷하다.
Attention이 "토큰들 사이의 관계"를 다루는 거라면, FFN은 "토큰 하나하나를 독립적으로 가공"하는 역할이다. FFN 안에서는 나는, 밥을, 먹었다가 서로를 전혀 보지 않는다. 각자 자기 벡터만 가지고 이 확장-억제-압축 과정을 거친다.
───【핵심】───
넓게 펼쳐서(확장) 필요한 신호만 남기고(GELU) 다시 압축한다(축소). FFN은 토큰끼리 상호작용하지 않고 각자 독립적으로 처리된다.
■ 10장. 반복 구조 - 블록이 N번 쌓인다
[구조도 ⑥: 블록 반복 루프 - 블록1에서 블록N까지 벡터가 전달되는 흐름]
전문 설명
지금까지 본 LayerNorm → Attention → Residual → LayerNorm → FFN → Residual, 이 전체가 "블록 하나"다. GPT-2 small이면 이게 12번 반복된다.
주의할 게 몇 가지 있다.
첫째, 임베딩(1~2장)은 반복 밖이다. 전체 과정에서 딱 한 번만 일어나고, 그 결과가 첫 블록의 입구로 들어간다.
둘째, 모든 블록이 똑같은 구조를 갖지만 가중치는 전부 다르다. 블록1의 W_Q와 블록2의 W_Q는 완전히 별개의 파라미터다.
셋째, 블록 사이를 넘어가는 건 residual 덧셈의 결과 벡터다. 형태는 항상 똑같이 (토큰수 x 768)이고, 값만 계속 갱신된다.
넷째, 마지막 블록도 똑같이 처음부터 끝까지 다 거친다. 다른 건 딱 하나, 그 결과가 다음 블록으로 안 가고 최종 출력 단계로 빠진다는 것뿐이다.
쉽게 다시 말하면
같은 공정을 12번 반복하는 컨베이어 벨트다. 각 공정의 설비 구조는 똑같은데, 설정값(가중치)은 다 다르다. 그리고 통과할 때마다 제품(벡터)이 조금씩 더 정교해진다.
───【핵심】───
임베딩은 1번, 블록은 N번, 최종 출력은 1번. 블록 사이를 넘어가는 건 모양이 같고 값만 갱신된 벡터다.
■ 11장. 확률 만들기 - 최종 출력 단계
▷ 입력 : 마지막 블록을 통과한 768차원 벡터
▷ 내부 연산 : 최종 LayerNorm → vocab 크기로 선형변환 → softmax
▷ 출력 : 토큰 위치마다 vocab 크기(50257)의 확률 분포
전문 설명
단계 1) 최종 LayerNorm
8장에서 본 것처럼 본줄기는 블록을 거칠 때마다 계속 쌓이기만 하고 한 번도 정규화되지 않았다. N개 블록을 다 거치면 스케일이 처음과 완전히 달라져 있다. 그래서 출력으로 내보내기 직전에 딱 한 번 표준 스케일로 정리한다. 본줄기 자체가 정규화되는 유일한 순간이다.
GPT-2 논문에도 "마지막 self-attention 블록 뒤에 layer normalization을 추가했다"고 명시되어 있다.
단계 2) Linear (768 → vocab 크기)
768차원 문맥 벡터를, vocab 전체(50257개) 각각에 대한 "얼마나 어울리는가" 점수로 펼친다. 이 점수를 로짓(logit)이라 한다.
여기서 GPT-2는 흥미로운 걸 한다. Weight Tying이라고 하는데, 이 출력 변환 행렬을 새로 만들지 않고 입력 임베딩 테이블을 전치해서 그대로 재사용한다.
입력 임베딩("이 토큰이면 어떤 벡터를 넣을까")과 출력 투영 ("이 벡터가 각 토큰에 얼마나 대응하나")이 사실상 같은 질문의 양면이라는 점에 착안한 것이다. GPT-2, BERT, RoBERTa, Gemma 등이 표준적으로 쓴다. 그래서 학습 중에 "왕"이 정답으로 등장하면, 그 순간 실제로는 입력 임베딩 테이블의 "왕" 벡터 자체가 수정되는 셈이다. 둘이 물리적으로 같은 숫자니까.
다만 최신 대형 모델들(DeepSeek-V3, Qwen3-32B, OLMo-2 등)은 다시 분리(untied)해서 쓰는 추세다. 모델이 커질수록 파라미터 절약 효과가 상대적으로 작아지고, 하나의 행렬이 입력/출력 두 목적을 동시에 만족해야 하는 제약이 부담될 수 있다는 연구가 있다.
단계 3) Softmax
50257개 로짓을 합이 1인 확률 분포로 바꾼다. attention에서 썼던 것과 완전히 같은 수식이다.
여기서 형태를 정확히 짚고 가야 한다. 확률 분포가 하나만 나오는 게 아니다. 토큰 위치마다 각각 하나씩, 총 (입력토큰수 x vocab크기) 만큼의 값이 나온다.
"나는" 위치 → 50257개 확률 (나는 다음에 뭐가 올지)
"밥을" 위치 → 50257개 확률
"먹었다" 위치 → 50257개 확률
"맛있게" 위치 → 50257개 확률
그리고 각 위치는 causal mask 덕분에 자신과 그 이전 토큰들의 정보만 반영해서 예측한다. "밥을" 위치의 예측은 "밥을"이라는 단어 하나만의 의미가 아니라, "나는 밥을"까지의 문맥 전체를 반영한 예측이다.
쉽게 다시 말하면
지금까지 이 벡터 하나가 문맥을 잔뜩 흡수했는데, 그걸 "그래서 다음에 올 단어는 뭘까"라는 5만여 개 후보에 대한 점수표로 펼쳐놓는 단계다. 그 다음 그 점수표를 "각 후보가 나올 확률 몇 퍼센트"로 환산하면 끝이다.
───【핵심】───
본줄기가 유일하게 정규화되는 지점이 최종 LayerNorm이고, 확률 분포는 하나가 아니라 토큰 위치마다 하나씩 나온다.
■ 12장. 그래서 이게 뭘 배운 건가 - 사전학습의 본질
전문 설명
여기까지 오면 자연스럽게 드는 의문이 있다. "정답이 다음 단어니까, 그냥 입력을 따라하는 앵무새 아닌가?"
아니다. 학습할 때 정답(다음 토큰)은 입력에 절대 안 보여준다. "나는"만 보고 "밥을"을 맞혀야 하는 구조라서, 애초에 복사가 불가능하다. 입력과 정답이 겹치지 않는 다른 토큰이다. 그리고 "다음 단어 맞히기"라는 단순한 목표를 아주 잘하려면 결과적으로 문법, 의미, 상식 구조를 다 내재화해야 한다.
"나는 어제 [ ]에 가서 밥을 먹었다" → 장소를 나타내는 단어가 와야 한다는 문법 구조를 알아야 함
"그는 화가 나서 문을 [ ]" → 감정과 행동의 인과관계를 알아야 함
앵무새와 다르다는 결정적 증거는 일반화다. 학습 때 한 번도 본 적 없는 새로운 문장 조합에도 그럴듯한 다음 단어를 예측한다. 특정 문장을 암기한 게 아니라 일반화된 통계적 규칙을 배운 것이다.
또 하나 중요한 구분이 있다. "입력에 대한 답변을 생성하는 거 아닌가?"라는 의문인데, 지금까지 배운 이 구조는 그런 걸 전혀 모른다.
이 구조가 하는 건 순수하게 "주어진 텍스트 다음에 뭐가 자연스러운가"를 예측하는 것뿐이다. 질문-답변이라는 개념 자체가 없다. base 모델에 "한국의 수도는 어디야?"를 넣으면, 답을 하는 게 아니라 그냥 그 문장 다음에 이어질 법한 텍스트를 예측할 뿐이다.
챗봇처럼 동작하려면 추가 단계가 필요하다.
1) 사전학습(Pretraining) : 지금까지 배운 전부. 결과물은 base 모델. 텍스트 이어쓰기만 잘함.
2) 지시 튜닝(SFT) : "질문: ~ 답변: ~" 형식 데이터로 추가 학습. 질문 다음엔 이런 형식으로 답하는 게 자연스럽다는 걸 학습.
3) RLHF 등 : 더 도움되고 선호되는 답변이 나오도록 추가 미세조정.
즉 지금까지 배운 건 엔진이고, 질문에 답하는 행동 방식은 그 엔진 위에 나중에 얹은 것이다.
쉽게 다시 말하면
이건 정보를 저장해뒀다가 질문에 맞는 걸 찾아 꺼내주는 검색 시스템이 아니다. 그냥 사람처럼 말하는 법을 가르치는 거다.
"한국의 수도는 서울이다"라는 문장을 무수히 봤으면 "한국의 수도는" 다음에 "서울"이 올 확률이 압도적으로 높아진다. 겉보기엔 "모델이 한국의 수도를 안다"처럼 보이지만, 실제로는 "이 문맥 다음엔 이 단어가 자연스럽다"는 패턴이 굳어진 것이다.
이게 할루시네이션의 근본 원인이기도 하다. 모델은 "사실인가"가 아니라 "언어적으로 자연스러운가"만 판단하기 때문에, 존재하지 않는 사실도 그럴듯하게 만들어낼 수 있다. 정확한 정보가 필요하면 웹 검색이나 RAG 같은 별도 도구를 붙여야 한다.
───【핵심】───
사전학습은 지식 데이터베이스를 만드는 게 아니라 언어가 어떻게 이어지는지에 대한 감각을 흡수하는 것이다.
═══════════════════════════════════════
2부. 토큰을 실제로 뽑는 방법
═══════════════════════════════════════
■ 13장. 왜 매번 답이 다른가
전문 설명
1부에서 확률 분포까지 만들었다. 이제 실제로 단어를 하나 골라내야 하는데, 여기서 학습과 추론이 갈린다.
• 학습 시 : 단어를 뽑지 않는다. 확률 분포를 정답과 비교해서 loss를 계산하고 역전파할 뿐이다. 토큰 4개면 loss도 4개를 한 번에 계산한다.(teacher forcing).
• 추론 시 : 마지막 위치의 확률 분포에서만 실제로 하나를 뽑는다. 나머지 위치의 분포는 그냥 버려진다.
중요한 건, forward pass 계산 자체는 학습이든 추론이든 완전히 동일하다는 것이다. 다른 건 그 결과를 어떻게 쓰느냐뿐이다. 그리고 여기서 자주 나오는 의문 하나. "같은 입력이면 같은 확률 분포가 나올 텐데, 그럼 매번 같은 단어가 나오는 거 아닌가?"
확률 분포까지는 맞다. 학습이 끝난 모델은 가중치가 고정이라 같은 입력에는 항상 같은 분포가 나온다. 여기엔 랜덤성이 전혀 없다. 랜덤성은 그 분포에서 단어를 뽑는 마지막 단계에서 생긴다.
───【핵심】───
확률 분포 계산은 완전히 결정적이고, 무작위성은 그 분포에서 단어를 뽑는 마지막 단계에서만 생긴다.
■ 14장. Temperature - 격차 조절
▷ 입력 : 로짓, 그리고 사람이 정한 T값
▷ 내부 연산 : 로짓을 T로 나눈 뒤 softmax
▷ 출력 : 뾰족하거나 평평해진 확률 분포 (합은 여전히 1)
전문 설명
P(tokenᵢ) = e^(zᵢ / T) / Σⱼ e^(zⱼ / T)
원래 softmax와 유일한 차이는 로짓을 T로 먼저 나눈다는 것이다. 로짓이 [4, 2, 1]인 세 후보로 예를 들어보자.
• T = 1 : 원래 그대로. e⁴, e², e¹
• T = 0.5 : 나누면 [8, 4, 2]. e⁸, e⁴, e² → 격차가 훨씬 벌어짐
• T = 2 : 나누면 [2, 1, 0.5]. e², e¹, e⁰·⁵ → 격차가 줄어 평평해짐
극단으로 가면 T가 0에 가까울 때 사실상 1등만 뽑히고(Greedy와 동일), T가 무한대로 가면 거의 균등분포가 된다.
원리는 5장에서 본 √dₖ 스케일링과 정확히 같은 나눗셈인데, 방향을 사람이 의도적으로 조절하는 것이다.
여기서 꼭 짚어야 할 게 하나 있다. Temperature는 로짓의 순위나 후보군 자체를 절대 바꾸지 않는다. 1등이 갑자기 3등이 되는 일은 없다. 원래 확률이 0에 가까웠던 "말이 안 되는" 단어에게 새로 기회를 만들어주는 장치도 아니다.
즉 무엇이 후보가 될 수 있는가(주제의 범위)는 학습된 로짓이 결정하고, Temperature는 그 범위 안에서 얼마나 보수적으로 또는 다양하게 고를지만 결정한다.
쉽게 다시 말하면
"얼마나 모험적으로 뽑을지"를 조절하는 다이얼이다. 낮추면 항상 제일 무난한 선택만 하고, 높이면 2등 3등도 꽤 뽑히게 된다. 근데 어느 쪽이든 애초에 후보 명단에 없던 엉뚱한 단어가 튀어나오지는 않는다.
───【핵심】───
Temperature는 후보들 사이의 격차만 조절하지, 후보군이나 순위 자체는 절대 건드리지 않는다.
■ 15장. Top-k - 개수로 자르기
▷ 입력 : 로짓 전체(50257개)와 사람이 정한 k
▷ 내부 연산 : 상위 k개만 남기고 나머지 제외, 재정규화
▷ 출력 : k개 후보에 대한 확률 분포
전문 설명
1) 로짓을 확률 높은 순으로 정렬한다
2) 상위 k개만 남기고, 나머지는 전부 후보에서 제외한다 (5장에서 본 causal mask처럼 -inf를 채워서 죽인다)
3) 남은 k개끼리만 다시 softmax로 확률을 재분배한다
4) 그 안에서만 뽑는다
예를 들어 "나는 밥을" 다음 로짓이 이렇다고 하자.
먹었다 4.2
먹는다 3.8
지었다 2.1
샀다 1.9
태웠다 0.3
(나머지 5만여 개는 대부분 -5 ~ -20 수준)
k=3이면 먹었다, 먹는다, 지었다까지만 남고 샀다부터는 원래 어느 정도 그럴듯했더라도 전부 제외된다.
왜 필요하냐면, Temperature만으로는 한계가 있기 때문이다. Temperature를 많이 높이면 순위는 안 바뀌어도 꼴찌 근처 후보들의 확률이 눈에 띄게 올라갈 수 있다. Top-k는 애초에 그런 후보를 명단에서 물리적으로 지워버려서 원천 차단한다.
주의할 건 판단 기준이 의미가 아니라는 점이다. "말이 안 되니까" 빼는 게 아니라 "k등 밖이니까" 기계적으로 빼는 것이다. 결과적으로 순위 낮은 게 대부분 말이 안 되는 단어라서 그런 효과가 나는 것뿐이다. 한계도 있다. k가 고정값이라, 정말 후보가 3~4개뿐인 상황에도 억지로 k개를 채우게 되고, 반대로 자연스러운 후보가 50개인 상황에서도 k개만 남기고 잘라내게 된다.
쉽게 다시 말하면
면접에서 서류 통과자를 딱 10명으로 고정해두는 것과 같다. 지원자가 아무리 많아도 10명, 아무리 적어도 10명이다. 확실히 이상한 지원자는 걸러지지만, 상황에 따라 너무 많이 자르거나 너무 적게 자를 수 있다.
───【핵심】───
Top-k는 후보 "개수"를 고정해서 자른다. 확실하지만 상황에 따라 과하거나 부족할 수 있다.
■ 16장. Top-p (Nucleus) - 누적 확률로 자르기
▷ 입력 : 확률 분포와 사람이 정한 p (예: 0.9)
▷ 내부 연산 : 누적 확률이 p에 도달할 때까지만 후보로 남김
▷ 출력 : 동적으로 정해진 개수의 후보에 대한 확률 분포
전문 설명
1) 확률을 높은 순으로 정렬한다
2) 1등부터 확률을 누적해서 더해간다
3) 누적 합이 임계값 p에 도달하는 순간까지만 후보로 남긴다
4) 그 이후는 전부 제외하고, 남은 후보끼리 재정규화한다
확신이 강한 경우 (p = 0.9)
먹었다 70%
먹는다 15% → 누적 85%
지었다 8% → 누적 93% ← 여기서 90% 넘음, 여기까지
샀다 3% → 제외
후보가 3개로 좁혀진다.
확신이 약한 경우 (p = 0.9)
밥을 12%
영화를 11% → 누적 23%
운동을 10% → 누적 33%
공부를 9% → 누적 42%
산책을 8% → 누적 50%
(비슷비슷하게 계속 이어짐)
90%를 채우려면 15~20개는 필요하다. 즉 Top-k가 "개수"를 고정하는 반면, Top-p는 "누적 확률 비율"을 고정한다. 그래서 후보 개수가 문맥의 확신 정도에 따라 자동으로 늘었다 줄었다 한다. Top-k의 고정값 한계를 자연스럽게 해결하는 방식이다.
쉽게 다시 말하면
면접 예시로 돌아가면, 이번엔 인원수를 고정하지 않고 "전체 실력의 90%를 커버할 만큼만 뽑는다"고 기준을 바꾼 거다. 압도적인 1등이 있으면 자연스럽게 소수만 남고, 다들 비슷비슷하면 많이 남는다.
───【핵심】───
Top-p는 개수가 아니라 누적 확률로 잘라서, 후보 수가 문맥에 따라 자동으로 조절된다.
■ 17장. Greedy와 실전 조합
전문 설명
Greedy는 가장 단순하다. 확률 무시하고 항상 1등만 뽑는다. 완전히 결정적이라 같은 입력에는 항상 같은 출력이 나온다. 코드 생성이나 사실 확인처럼 일관성이 핵심인 작업에 쓴다. sampling 관련 설정을 전부 끈 상태와 같다. 따라서 실전에서는 이것들을 하나만 쓰는 게 아니라 같이 쓴다. 예를 들어 Temperature 0.7 + Top-p 0.9 같은 식으로, 격차 조절과 후보 범위 제한을 이중으로 건다.
네 가지를 정리하면 이렇다. (표는 별도 이미지 참고)
• Temperature : 확률 격차를 조절, 후보 수는 변화 없음
• Top-k : 후보 개수를 자름, 항상 k개로 고정
• Top-p : 누적 확률로 자름, 문맥에 따라 개수가 동적으로 변함
• Greedy : 조절 없이 항상 1등 하나
[구조도 ⑦: 네 가지 생성 전략 비교 - 같은 분포에 각각 적용했을 때]
쉽게 다시 말하면
처음 들으면 "확률로 뽑는다니 되게 불안정한 거 아닌가" 싶을 수 있는데, 생각보다 훨씬 안정적이다.
왜냐면 그 확률 분포 자체가 이미 방대한 언어 패턴을 통해 형성된 거라서, "나는 밥을" 다음에 흔들려봐야 먹었다 / 먹는다 / 지었다 같은 말이 되는 후보들 사이에서 흔들리는 거지, "자동차했다" 같은 게 튀어나올 확률은 사실상 0에 가깝게 학습되어 있다.
즉 아주 좁고 이미 말이 되는 후보군 안에서의 흔들림이다. 그리고 Temperature, Top-k, Top-p는 그 흔들림의 폭을 용도에 맞게 조절하는 도구들이다.
───【핵심】───
완전 랜덤도 완전 확정도 아닌, 적당히 통제된 불안정함.
═══════════════════════════════════════
3부. 추론 최적화와 위치 인코딩
═══════════════════════════════════════
■ 18장. KV 캐시 - 같은 계산 반복하지 않기
▷ 입력 : 새로 추가된 토큰 하나, 그리고 지금까지의 K/V 캐시
▷ 내부 연산 : 새 토큰의 Q/K/V만 계산, K/V는 캐시에 이어붙임
▷ 출력 : 새 토큰 위치의 attention 결과
전문 설명
추론할 때는 토큰을 하나씩 만들면서, 매번 지금까지 생성된 전체 시퀀스를 다시 통째로 넣는다고 했다.
1번째 : [나는] → 밥을 생성
2번째 : [나는, 밥을] → 먹었다 생성
3번째 : [나는, 밥을, 먹었다] → 맛있게 생성
문제는 2번째에서 "나는"의 K와 V를 처음부터 다시 계산한다는 것이다. 1번째에서 이미 계산했던 건데 말이다.
여기서 중요한 관찰이 하나 있다. "나는"이라는 토큰의 벡터는 스텝이 지나도 절대 안 바뀐다. 임베딩도 고정이고, causal mask 때문에 "나는"의 attention 결과가 미래 토큰의 영향을 받지도 않는다. 그러니 K와 V도 매번 계산해봐야 항상 같은 값이 나온다.
역할별로 나눠보면 이렇다.
• Q : 매 스텝 "지금 새로 생성하려는 토큰"에 대해서만 필요하다.
과거 토큰의 Q는 다시 쓸 일이 없다(이미 그 예측은 끝났으니까).
그래서 캐시할 필요가 없고 매번 1개만 새로 계산한다.
• K, V : 과거 토큰들 것은 한 번 계산되면 다시는 안 바뀐다.
그래서 캐시에 저장해두고 재사용한다. 새 토큰의 K, V만
새로 계산해서 캐시에 이어붙인다.
계산은 이렇게 바뀐다.
softmax( Q(새토큰) · K(캐시전체)ᵀ / √dₖ ) · V(캐시전체)
새 토큰의 Q 하나가, 캐시에 쌓인 모든 K와 비교되고, 그 가중치로 모든 V를 가중합한다.
여기서 정확히 해둘 게 있다. 재사용되는 건 "과거의 attention 결과"가 아니라 "과거 K, V 벡터라는 재료"다. attention 계산 자체(새 Q와의 조합)는 매 스텝 새로 한다. "나는"과 "밥을" 사이의 예전 attention 관계는 이번 스텝에서 아예 다시 안 쓰인다. 효율은 이렇게 달라진다. 캐시가 없으면 n번째 토큰마다 1부터 n까지 전부 다시 계산해서 전체 계산량이 시퀀스 길이의 제곱에 비례한다. 캐시가 있으면 매 스텝 1개분만 계산하니 길이에 선형으로 비례한다.
물론 공짜는 아니다. 모든 과거 K, V를 메모리에 계속 들고 있어야 해서, 시퀀스가 길어질수록 메모리 사용량이 계속 늘어난다. 긴 문맥 LLM에서 메모리 병목의 주요 원인 중 하나다. 계산 속도와 메모리를 맞바꾼 셈이다. 그리고 학습 때는 이게 아예 필요 없다. 학습은 정답 시퀀스 전체를 한 번의 forward pass로 병렬 처리하니까, "순차 생성" 과정 자체가 없어서 재사용할 대상이 생기지 않는다. KV 캐시는 오직 추론 전용 최적화다.
쉽게 다시 말하면
매번 처음부터 다시 계산하지 말고, 안 바뀌는 건 저장해뒀다가 꺼내 쓰자는 아주 직관적인 아이디어다.
───【핵심】───
안 바뀌는 K, V만 저장해서 재사용하고, 새 토큰 것만 계산한다. 계산량은 줄지만 메모리는 계속 늘어난다.
■ 19장. 위치 정보의 진화 - 절대 위치에서 RoPE로
전문 설명
2장에서 본 GPT-2 방식(학습되는 절대 위치 임베딩)에는 한계가 두 가지 있다.
1) 최대 길이 제한
위치 임베딩 테이블 크기가 고정이라(GPT-2는 1024), 1025번째 위치가 필요하면 그 벡터가 아예 없다. 학습 때 안 나온 위치는 원천적으로 처리가 안 된다.
2) 상대적 거리 개념 없음
위치 1과 2의 임베딩은 각각 독립적으로 학습된 벡터라서, "이 둘이 1칸 떨어져 있다"는 관계를 명시적으로 담지 못한다. 근데 언어에서는 절대 위치보다 상대적 거리가 더 중요한 경우가 많다.
RoPE(Rotary Position Embedding)는 접근 자체가 다르다. 위치 정보를 더하는 게 아니라 회전으로 표현한다.
적용 지점도 다르다. 임베딩 단계가 아니라 Q와 K를 만든 직후에 적용된다(V에는 적용 안 함).
Q, K 벡터를 2차원씩 짝지어서, 각 짝을 그 토큰의 위치 m만큼 회전시킨다. 벡터를 평면 위의 점이라고 보면, "위치가 m번째"라는 정보를 "그 점을 각도 m·θ만큼 돌리는 것"으로 표현하는 셈이다.
qₘ = R(m·θ) · q
핵심은 여기서 나온다. attention은 결국 Q와 K의 내적인데, 각각 m·θ, n·θ만큼 회전시킨 두 벡터를 내적하면 결과가 절대 위치 m, n이 아니라 오직 차이 (m - n)에만 의존하게 된다. 즉 "나는"이 1번 "밥을"이 2번이든, 501번과 502번이든, 둘 사이 거리가 같으면 attention 계산 결과가 같아진다. 상대적 거리가 회전이라는 연산의 수학적 성질에서 자동으로 튀어나오는 것이다.
그리고 이건 학습되는 파라미터가 아니라 고정된 수학 함수다. 그래서 학습 때 본 적 없는 위치(2000번째든 뭐든)도 그냥 공식대로 계산하면 되니 길이 제한이 없다.
회전 속도는 이렇게 정해진다.
θᵢ = base^(-2i / d)
i : 몇 번째 차원 쌍인지
d : 전체 차원 수
base : 사람이 정하는 하이퍼파라미터 (보통 10000)
i=0(첫 쌍)일 때 가장 빠르게 돌고, i가 커질수록 지수적으로 느리게 돈다. 사람이 실제로 조절하는 건 base 값 하나뿐이고, 이 공식 하나가 전체 속도 분포를 결정한다.
여기서 짚어둘 게, 회전은 단어와 무관하다. 회전 속도 θᵢ는 차원 쌍마다 고정이고, 실제 회전 각도(m·θᵢ)는 그 토큰이 몇 번째 위치인지에 따라 달라진다. 같은 "먹었다"라도 3번째면 3θᵢ, 7번째면 7θᵢ만큼 돌아간다. 단어의 의미가 바뀌는 게 아니라 위치 정보만 덧입혀지는 것이다.
쉽게 다시 말하면
여러 개의 시계바늘이라고 생각하면 편하다. base는 "위치가 한 칸 갈 때마다 바늘이 얼마나 도는지", 즉 눈금 하나의 길이를 정하는 값이다.
• base가 크면 → 눈금이 촘촘해서 한 바퀴 도는 데 아주 많은 위치가 필요 → 멀리까지 구분 가능
• base가 작으면 → 눈금이 넓어서 금방 한 바퀴 → 금방 겹침
───【핵심】───
위치를 더하는 대신 회전시키면, 상대적 거리가 내적 계산에서 자동으로 나오고 길이 제한도 사라진다.
■ 20장. RoPE의 한계 - 한 바퀴 돌면 어떻게 되나
전문 설명
여기서 당연히 나올 의문이 있다. "360도 다 돌면 원점으로 돌아오는 거 아닌가?"
맞다. 이건 aliasing이라고 불리는 실제로 알려진 문제다.
만약 벡터 전체를 하나의 각도로만 통째로 회전시킨다면, 2π/θ 위치에서 정확히 한 바퀴를 돌아 원점으로 돌아온다.
그러면 위치 0과 그 위치가 완전히 똑같아 보여서 모델이 둘을 구분할 수 없게 된다. 그래서 앞서 본 것처럼 벡터를 여러 차원 쌍으로 나눠서 각 쌍마다 다른 속도로 회전시키는 것이다. 시계로 치면 초침만 있으면 1분마다 리셋되어 구분이 안 되지만, 초침 + 분침 + 시침을 같이 보면 초침이 원점에 돌아와도 나머지 바늘 위치가 달라서 전체적으로는 구분이 된다.
다만 이것도 완전한 해결은 아니다. 최근 연구들이 지적하는 한계가 두 가지 있다.
1) base 값이 사실상 최대 문맥 길이를 결정한다
회전 기준이 되는 base(보통 10000)가 처리 가능한 문맥 길이를 근본적으로 제한한다. base를 이론적 하한 아래로 설정하면 표면적인 지표는 괜찮아 보여도 실제 장거리 정보 검색 능력이 떨어진다.
2) 빨리 도는 차원이 긴 문맥에서 무용지물이 된다
고주파(빨리 도는) 차원들은 문맥이 조금만 길어져도 이미 수십~수백 바퀴를 돌아버린 상태라 의미 있는 위치 신호로서의 역할을 거의 잃는다. 실제로 이런 차원들을 잘라내도 성능이 나빠지지 않거나 오히려 좋아진다는 실험 결과가 있다. "dimension inefficiency"라고 부른다.
그럼 눈금을 최대한 줄이면(base를 아주 크게 하면) 되는 거 아닌가? 그것도 안 된다. 반대 방향의 문제가 생긴다. 눈금을 극단적으로 줄이면 위치 1과 2의 회전 차이도, 위치 1과 100의 차이도 둘 다 미미해진다. 결과적으로 "가까운 것과 조금 먼 것"의 차이를 거의 못 느끼게 된다.
근데 언어에서는 오히려 가까운 단어들 사이의 미묘한 거리 차이 (1칸인지 2칸인지 3칸인지)를 잘 구분하는 게 훨씬 중요한 경우가 많다. 바로 옆 문맥 파악이 attention의 핵심 역할이니까. 그래서 여러 속도의 바늘이 필요한 것이다.
• 빠른 바늘(넓은 눈금) : 근거리 세밀 구분 담당, 대신 원거리에서 금방 겹침
• 느린 바늘(좁은 눈금) : 원거리까지 구분 담당, 대신 근거리에서 둔감
하나의 적당한 눈금으로 둘 다 잘하는 건 불가능해서 여러 개를 섞어 쓰는 것이다.
쉽게 다시 말하면
RoPE는 "완전히 해결했다"기보다는 "여러 주기를 동시에 써서 실용적인 범위에서는 문제가 잘 안 드러나게 만든 정교한 완화책"에 가깝다.
앞서 8장에서 본 residual stream 증가 문제와 성격이 비슷하다. 잘 작동하지만 근본적으로 끝난 이야기는 아니고, 지금도 연구가 계속되는 주제다.
───【핵심】───
aliasing은 실제로 존재하는 문제고, 여러 회전 속도를 섞는 게 그 완화책이다. 눈금을 무작정 줄이면 근거리 구분력을 잃는다.
마무리
정리하면 이런 흐름이었다.
텍스트를 조각으로 쪼개고(BPE) → 조각을 벡터로 바꾸고(임베딩) → 정규화하고(LayerNorm) → 세 갈래로 나눠서(Q/K/V) → 관계 점수를 매기고(QKᵀ + 마스크) → 비율로 바꿔서 섞고(softmax + 가중합) → 관점을 합치고(concat + W_O) → 본줄기에 얹고(residual) →
넓혔다 좁히고(FFN) → 이걸 N번 반복 → 마지막에 정리해서 확률로 펼치고(최종 LayerNorm + Linear + softmax) → 그중 하나를 뽑는다(temperature/top-k/top-p/greedy).
계속 반복되는 패턴으로는 "행렬의 모양(틀)은 사람이 설계하고, 그 안의 숫자(내용물)는 학습으로 결정된다"는 원칙이 임베딩 테이블, Q/K/V 가중치, FFN 가중치, 출력 투영까지 전부 똑같이 적용된다.
마지막으로, 사전학습이 지식 데이터베이스를 만드는 게 아니라 언어의 흐름 감각을 흡수하는 과정이라는 점. 이게 왜 그렇게 잘 작동하는지, 그리고 왜 가끔 그럴듯한 거짓말을 하는지를 동시에 설명해준다.
|
|
