
Transformer는 왜 Q, K, V 셋으로 나눴을까
- 1LLM은 왜 다음 토큰 하나만 예측할까
- 2Transformer는 왜 Q, K, V 셋으로 나눴을까읽는 중
- 3토큰은 왜 단어가 아닐까
- 4LLM은 왜 가장 확률 높은 토큰을 고르지 않을까
- 5Positional Encoding에서 RoPE까지, 어텐션에 순서를 부여하는 법
ChatGPT 같은 모델에 “민수는 어제 빌린 책을”까지 넣으면 모델은 다음 단어(정확히는 토큰)를 고릅니다. “돌려줬다”쯤이 나오겠죠. 이 한 단어를 고르려면 앞 단어들을 봐야 합니다. 누가(민수), 무엇을(책), 언제(어제). 모델은 앞 단어들을 어떻게 “보는” 걸까요?
이 질문에 Transformer가 내놓은 답이 어텐션입니다. 어텐션의 중심에는 Q, K, V라는 세 벡터가 있습니다. 모델 카드나 추론 서버 설정에 head_dim이나 KV head 수 같은 값이 설명 없이 등장하는데 전부 이 셋에서 나온 말입니다. KV Cache가 K와 V를 저장한다는 것도 널리 알려져 있고요. 그런데 그 K와 V가 정확히 무엇인지, 왜 Q까지 셋으로 나뉘어야 했는지 물으면 의외로 답이 막힙니다.
문장 하나를 놓고 어텐션이 실제로 무슨 계산을 하는지 처음부터 따라갑니다. 그 길에서 왜 로 나누는지, causal mask가 무엇을 막는지, 그 마스크 덕분에 KV Cache가 왜 가능한지, 그리고 head와 KV head가 정확히 무엇인지까지 짚습니다.
왜 어텐션이어야 했나
Transformer 이전에 문장을 다루던 주역은 RNN이었습니다. RNN은 단어를 앞에서부터 하나씩 읽으면서 지금까지 읽은 내용을 고정된 크기의 숫자 묶음(벡터) 하나에 요약해 둡니다. 메모지 한 장이라고 생각하면 됩니다. 단어를 하나 읽을 때마다 메모지를 지우고 새로 쓰는데 메모지 크기는 늘지 않습니다.
이 방식에는 한계가 있습니다. 우선 정보가 희석됩니다. “민수는”을 읽고 쓴 메모는 “어제”, “빌린”, “책을”, “돌려줬다”를 거치며 네 번 고쳐 써집니다. 다섯 번째 단어에 도착했을 때 첫 단어의 정보가 얼마나 남아 있을지는 보장이 없고 문장이 길수록 심해집니다. 병렬화도 안 됩니다. 두 번째 메모를 쓰려면 첫 번째 메모가 끝나야 하고 세 번째는 두 번째가 끝나야 합니다. 단어가 1,000개면 1,000단계를 차례로 밟아야 합니다. GPU는 수천 개 연산을 동시에 돌리는데 그 장점을 살릴 수 없습니다.
2015년 Bahdanau 연구팀이 기계 번역에서 이 병목을 처음 돌파했습니다. 번역문을 쓰는 쪽을 디코더, 원문을 읽은 쪽을 인코더라고 부르는데 디코더가 인코더의 모든 위치를 직접 들여다보게 한 것이 어텐션의 시작이었습니다. 메모지 한 장을 전달받는 대신 원문 전체를 펼쳐 놓고 필요한 곳을 골라 보는 방식입니다.
2017년 “Attention Is All You Need”는 한 걸음 더 나가 인코더와 디코더 사이뿐 아니라 한 문장 안에서도 각 단어가 같은 문장의 다른 단어를 직접 참조하게 했습니다. 이 방식을 self-attention이라고 부릅니다. RNN 없이 self-attention만으로 세운 구조가 Transformer입니다. 메모지 전달과 직접 참조의 차이를 나란히 놓으면 이렇습니다.
Q, K, V라는 세 가지 역할
“돌려줬다” 자리에서 앞 단어들을 본다고 할 때 어텐션이 할 일은 두 가지입니다. 어느 단어를 얼마나 볼지 정하는 일과 고른 단어에서 무엇을 가져올지 정하는 일입니다.
먼저 단어는 모델 안에서 숫자 묶음, 즉 벡터로 들어 있습니다. “민수는”이 (0.2, −1.3, 0.8, …) 같은 수백 개의 숫자로 표현되는 식이고 이 벡터를 임베딩이라고 부릅니다. 두 벡터가 얼마나 닮았는지 재는 가장 단순한 방법은 내적입니다. 같은 자리의 숫자끼리 곱해서 전부 더합니다. (1, 2, 3)과 (4, 5, 6)의 내적은 1×4 + 2×5 + 3×6 = 32입니다. 두 벡터가 비슷한 방향을 가리키면 커지고 반대 방향이면 음수가 됩니다.
그러면 각 단어의 임베딩끼리 내적을 구해 값이 큰 단어를 많이 보는 설계가 가장 단순합니다. 그런데 벡터 하나로 고르고 가져오기까지 하면 엉키는 데가 생깁니다.
첫째, “고르는 기준”과 “가져올 내용”이 한 벡터에 묶입니다. “돌려줬다”가 “책을”을 골라야 하는 이유는 목적어가 필요해서인데 정작 “책을”에서 가져올 내용은 “빌린 물건이 책이다”라는 정보입니다. 고르는 기준과 가져올 내용이 다른데 벡터가 하나면 이 둘을 따로 학습할 수 없습니다.
둘째, 찾는 쪽과 찾아지는 쪽의 역할이 구분되지 않습니다. 내적은 “둘이 닮았는가”를 재는데 벡터 하나로 내적하면 “돌려줬다”가 “책을”을 보는 점수와 “책을”이 “돌려줬다”를 보는 점수가 항상 같은 값이 됩니다. 동사는 목적어가 필요하지만 목적어가 동사를 그만큼 필요로 하지는 않으니 이 비대칭을 적을 자리가 없습니다. 게다가 어떤 벡터든 자기 자신과의 내적이 대개 가장 커서 모든 단어가 자기 자신만 보게 되고요. “돌려줬다”와 “책을”은 뜻이 닮아서 연결되는 것이 아니라 한쪽에 다른 쪽이 필요로 하는 것이 있어서 연결됩니다.
그래서 Transformer는 각 위치의 벡터 하나에서 세 가지 역할의 벡터를 따로 뽑아냅니다. 벡터에 행렬을 곱하면 숫자들을 섞어 만든 새 벡터가 나옵니다. 512개짜리 에 512 × 64 행렬을 곱하면 64개짜리 벡터가 나오는 식이고 같은 라도 곱하는 행렬이 다르면 다른 벡터가 됩니다. 학습으로 값이 정해지는 행렬(파라미터) , , 를 각각 곱해 세 벡터를 만듭니다.
- Query(): 이 위치가 던지는 질문. “나는 어떤 정보가 필요한가”
- Key(): 이 위치가 내거는 간판. “나는 이런 질문에 답할 수 있다”
- Value(): 선택되었을 때 넘겨줄 내용물. “내가 갖고 있는 정보는 이것이다”
첫째 문제가 K와 V를 갈라놓는 이유이고, 둘째 문제가 Q와 K를 갈라놓는 이유입니다. 그래서 둘이 아니라 셋입니다. 와 가 따로 있으면 원래 임베딩은 닮지 않았어도 “돌려줬다의 질문”과 “책을의 간판”이 닮도록 학습할 수 있고 질문과 간판이 다른 행렬에서 나오니 점수도 한 방향이 됩니다.
도서관에 비유하면 Query는 검색창에 치는 검색어, Key는 책등에 붙은 제목과 색인, Value는 책의 본문입니다. 검색어와 제목을 맞춰 보고 책을 고른 다음 실제로 읽는 것은 본문이죠. 제목이 검색에 잘 걸리게 쓰이는 것과 본문이 내용을 담는 것은 별개의 일입니다. “책을” 한 단어로 놓으면 이렇습니다.
같은 에서 나왔지만 세 벡터는 하는 일이 다릅니다. “돌려줬다”의 질문은 “책을”의 간판과 맞춰 보고, 맞았다면 가져가는 것은 “책을”의 내용물입니다. , , 가 따로 있으니 모델은 이 세 역할을 각각 따로 학습합니다.
문장 하나로 따라가는 계산
“민수는 어제 빌린 책을 돌려줬다”에서 “돌려줬다” 위치의 계산을 따라가 봅시다. “돌려줬다”가 막 들어온 상태에서 이 위치가 자기 자신을 포함한 다섯 단어를 보는 계산이고 여기서 나온 출력이 그다음 단어를 고르는 데 쓰입니다. 세 단계입니다.
1단계, 점수. “돌려줬다”의 Q를 다섯 단어의 K와 각각 내적합니다. 자기 자신의 K도 포함합니다. 자기 정보도 필요하니까요. 숫자 다섯 개가 나옵니다. 예를 들어 민수는 2.1, 어제 1.0, 빌린 1.0, 책을 2.4, 돌려줬다 1.0처럼요. 값이 클수록 “돌려줬다”가 던진 질문에 그 단어의 간판이 잘 맞습니다. 이 점수가 내적에서 어떻게 나오는지 벡터 길이를 4로 줄여 보면 이렇습니다.
“책을”의 k는 q와 큰 값이 같은 자리에 몰려 있어 2.4가 나오고, “민수는”의 k는 엇갈려서 2.1에 그칩니다. 실제 모델은 이 벡터가 64개나 128개 길이이고 값도 학습으로 정해지지만 계산 방식은 이 격자 그대로입니다.
2단계, 가중치. 점수 다섯 개를 softmax에 넣습니다. softmax는 숫자 여러 개를 받아 전부 양수이고 합이 1인 비율로 바꾸는 함수입니다. 각 숫자 를 (는 약 2.718인 상수)로 바꾼 뒤 그 합으로 나눕니다.
위의 점수를 넣으면 , , 이고 다섯 개의 합은 약 27.3입니다. 각각을 27.3으로 나누면 민수는 0.3, 어제 0.1, 빌린 0.1, 책을 0.4, 돌려줬다 0.1이 나옵니다. 목적어인 “책을”과 주어인 “민수는”에 무게가 실렸습니다.
3단계, 합산. 이 비율대로 다섯 단어의 V를 섞습니다. V가 숫자 세 개짜리라고 하면 0.3×(0, 2, 0) + 0.1×(1, 1, 0) + 0.1×(1, 0, 1) + 0.4×(1, 0, 2) + 0.1×(0, 0, 1) = (0.6, 0.7, 1.0)처럼 칸별로 곱해서 더합니다. 이렇게 만든 벡터가 “돌려줬다” 위치의 어텐션 출력입니다. “돌려줬다”가 앞 단어들에서 필요한 정보를 비율대로 골라 담아 온 결과입니다.
같은 계산을 모든 위치에서 동시에 합니다. 문장 길이를 , Q와 K 벡터의 길이를 라고 하고 각 위치의 Q, K, V를 행으로 쌓아 행렬 , , 로 만들면 전체가 행렬 곱 몇 번으로 끝납니다. 는 의 행과 열을 바꾼 것인데 이렇게 해야 행렬 곱의 각 칸이 “어떤 위치의 Q 하나와 어떤 위치의 K 하나의 내적”이 됩니다.
| 기호 | 뜻 | 크기 |
|---|---|---|
| , , | 모든 위치의 q, k, v 벡터를 행으로 쌓은 행렬 | |
| 모든 위치 쌍의 점수. 행 열은 위치 의 Q와 위치 의 K의 내적 | ||
| 점수가 벡터 길이 때문에 지나치게 커지지 않게 맞추는 나눗셈 | 숫자 하나 | |
| softmax | 행마다 적용. 각 위치의 가중치 합이 1 | |
| 가중치 비율로 V를 합산 |
위 예시의 1단계가 의 “돌려줬다” 행이고 2단계가 그 행의 softmax, 3단계가 와의 곱입니다. 식에 새로 보이는 것은 로 나누는 부분 하나입니다.
√d_k로 나누는 이유
내적은 개의 곱을 더한 값입니다. 면 위 격자처럼 곱 네 개를 더하고 이면 128개를 더합니다.
더하는 항이 많아지면 합도 크게 흔들립니다. 흔들리는 폭을 재는 양이 분산이고 그 제곱근이 표준편차입니다. 학습을 막 시작한 시점에는 행렬이 무작위 초기값이라 Q와 K의 각 원소가 평균 0, 분산 1이고 서로 독립이라고 볼 수 있습니다. 이 가정 아래에서는 곱 하나 도 평균 0, 분산 1이고요. 독립인 값들을 더하면 분산은 그대로 더해지므로(주사위 두 개를 던져 더하면 하나보다 더 크게 흔들리는 것과 같은 이치입니다) 개를 더한 내적의 분산은 , 표준편차는 가 됩니다.
| 내적의 표준편차 | 로 나눈 뒤 | |
|---|---|---|
| 4 | 2 | 1 |
| 64 | 8 | 1 |
| 128 | 약 11.3 | 1 |
곱의 분산이 1인 이유
와 가 독립이고 각각 평균 0, 분산 1이면 이고, 입니다.
이면 점수가 ±11 정도로 흔들리고 위치 간 점수 차이는 수십까지 벌어질 수 있습니다. 앞 예시의 점수 2.1, 1.0, 1.0, 2.4, 1.0은 로 나눈 뒤의 값으로 보면 됩니다. 나누기 전이었다면 에서 약 23.8, 11.3, 11.3, 27.2, 11.3이 되고 같은 역할의 점수 차이 0.3이 3.4로 벌어집니다. 문제는 이 숫자들이 softmax로 들어갈 때 생깁니다.
softmax는 입력의 차이를 지수적으로 키웁니다. 숫자 두 개로 확인합니다.
| softmax 입력 | 출력 |
|---|---|
| [1, 2] | [0.27, 0.73] |
| [0, 3] | [0.05, 0.95] |
| [0, 10] | [0.00005, 0.99995] |
| [0, 30] | [10의 −13제곱 수준, 거의 1] |
차이가 1이면 둘 다 의미 있는 몫을 받지만 차이가 10만 돼도 한쪽이 사실상 전부를 가져갑니다. 30이면 다른 쪽 몫은 사실상 0입니다. 앞 예시로 보면 나누기 전 점수를 softmax에 넣었을 때 “책을”이 0.97을 가져가고 “민수는”에 0.03이 남으며 나머지 셋은 0입니다. 나눈 뒤에는 0.4, 0.3, 0.1, 0.1, 0.1로 여러 단어에 몫이 남습니다.
이게 왜 학습을 막는지는 학습이 무엇을 하는지부터 봐야 합니다. 학습은 출력의 오차를 줄이는 방향으로 파라미터를 조금씩 움직이는 일입니다. “이 값을 조금 키우면 오차가 얼마나 줄어드는가”를 알려 주는 값이 기울기(미분값)입니다. 기울기가 0이면 어느 쪽으로 고쳐야 할지 알 수 없죠. softmax의 출력 를 자기 입력으로 미분하면 입니다. 면 0.25로 넉넉하지만 면 약 0.000001입니다. 출력이 0이나 1에 붙어 버리면 기울기가 0에 가까워지고 “이 위치를 조금 더 볼까, 덜 볼까”를 조정할 신호가 흐르지 않습니다. 가중치를 한 곳에 몰아준 채로 학습이 멈춥니다. 이 상태를 softmax가 포화(saturation)됐다고 합니다.
그래서 를 그대로 쓰지 않고 로 나눕니다. 기준은 “점수의 퍼짐이 에 좌우되지 않게”입니다. 어떤 값 를 로 나누면 분산은 으로 나뉩니다. 내적의 분산이 이니 , 즉 일 때 분산이 정확히 1로 돌아옵니다. 로 나누면 어떨까요. 분산이 가 되어 차원이 클수록 작아지니 여전히 에 끌려다닙니다. 앞 예시의 나누기 전 점수를 128로 나누면 0.19, 0.09, 0.09, 0.21, 0.09가 되고 softmax는 다섯 단어에 0.19부터 0.22씩 거의 고르게 나눠 줍니다. 분산이 와 무관한 값으로 떨어지는 나눗셈은 하나뿐입니다.
스케일링은 “항상 여러 단어를 골고루 보라”는 장치가 아닙니다. 학습이 진행되면 Q와 K는 더 이상 독립이 아니라서 분산 1이 유지되지 않고 어떤 자리에서 정말 한 단어만 봐야 한다면 나눈 뒤의 점수가 [0.2, 0.3, 8.0, 0.1]처럼 나와서 여전히 한 곳에 집중합니다. 스케일링이 막는 것은 차원 수가 크다는 이유만으로 학습 초기부터 분포가 극단으로 가 버리는 일입니다. 필요할 때는 집중하되 때문에 억지로 집중하지는 않게 하는 장치입니다.
미래를 가리는 causal mask
식에는 아직 조건 하나가 빠져 있습니다. 는 모든 위치 쌍의 점수를 만듭니다. 3행 5열은 “위치 3의 Q가 위치 5의 K와 만난 점수”인데 이 칸을 그대로 두면 앞 단어가 뒤 단어를 볼 수 있습니다.
학습 중인 모델을 생각해 봅시다. 학습 때는 문장 전체를 한 번에 넣고 모든 위치에서 동시에 다음 단어를 맞히게 합니다. 학습 문장이 “민수는 어제 빌린 책을 돌려줬다”이고 위치 4 “책을”에서는 다음 단어 “돌려줬다”를 맞혀야 합니다. 그런데 위치 4의 계산에 위치 5의 정보가 섞여 들어오면 모델은 다음 단어가 “돌려줬다”라는 것을 이미 아는 상태에서 답을 씁니다. 시험 문제 옆에 정답이 적혀 있는 셈입니다. 모델은 앞 단어들로 다음을 추론하는 법 대신 옆의 답을 베끼는 법을 배웁니다. 실제로 문장을 생성할 때는 위치 5가 아직 없으니 베낄 것도 없고 그때 모델은 무너집니다.
그래서 규칙을 하나 둡니다. 각 위치는 자기 자신과 그 앞만 볼 수 있습니다. Query 위치를 , Key 위치를 라고 하면 인 칸만 허용하는 규칙입니다. 점수 행렬에서 대각선 위쪽, 즉 미래에 해당하는 칸을 softmax에 들어가기 전에 로 바꿉니다. 이것이 causal mask(인과 마스크)입니다. 아래 격자에서 위치 1부터 5는 차례로 민수는, 어제, 빌린, 책을, 돌려줬다입니다.
왜 0이 아니라 일까요? “못 보게 하려면 점수를 0으로 만들면 되지 않나”가 자연스러운 첫 생각인데 softmax 때문에 안 됩니다. softmax는 점수를 로 바꿔 비율을 냅니다. 점수가 0이면 이라 여전히 몫을 받습니다. 점수가 [2, 1]인 두 위치 뒤에 미래 위치 하나를 0으로 붙이면 softmax는 [0.67, 0.24, 0.09]가 되어 미래 위치가 9%를 가져갑니다.
반면 이니 를 넣으면 그 자리의 가중치가 정확히 0이 됩니다. [2, 1, −∞]의 softmax는 [0.73, 0.27, 0]이고요. 미래 위치를 완전히 지우는 장치가 입니다. 실제 구현에서는 무한대 대신 같은 아주 큰 음수를 더하거나, 가린 칸은 아예 계산하지 않는 GPU 프로그램(커널)을 쓰는데 효과는 같습니다.
이 마스크 을 식에 넣으면 디코더의 어텐션이 완성됩니다. 은 허용 칸이 0, 미래 칸이 인 행렬입니다. 원 논문의 인코더는 문장 전체를 양방향으로 보지만, GPT 계열 생성 모델은 인코더 없이 이 디코더만 쌓은 구조라서 모든 레이어의 어텐션에 이 마스크가 붙습니다.
추론 때도 마스크는 그대로 씁니다. 생성 중인 토큰은 뒤에 아무것도 없으니 가릴 것이 없어 보이지만 프롬프트 네 단어를 한꺼번에 넣는 첫 단계에서는 프롬프트 안의 뒤 단어가 이미 있습니다. 마스크가 없으면 “민수는”이 “책을”을 보게 되고 학습 때와 다른 표현이 나옵니다. 학습 때와 같은 규칙으로 계산해야 앞 위치의 표현이 학습 때와 같아집니다.
두 장치는 서로 다른 문제를 풉니다. 는 점수의 크기를 다루고 causal mask는 어느 칸을 볼 수 있는지를 다룹니다. 여기까지의 계산을 순서대로 늘어놓으면 이렇습니다.
앞 토큰의 K, V는 왜 바뀌지 않을까
causal mask는 학습용 규칙처럼 보이지만 모델을 실제로 돌려 답을 생성하는 서버 쪽(추론 엔진)이 기대는 성질을 하나 만들어 냅니다. 앞 토큰의 K와 V가 한 번 정해지면 그 뒤로 바뀌지 않는다는 성질입니다.
생성 과정을 따라가 봅시다. 프롬프트가 “민수는 어제 빌린 책을”이고 모델이 “돌려줬다”를 골랐습니다. 이제 “돌려줬다”가 다섯 번째 토큰으로 들어오고 모델은 여섯 번째 단어를 고르려고 다시 계산합니다. “민수는”의 K와 V를 다시 계산해야 할까요?
지금까지 본 어텐션 계산 한 번과 그 뒤에 붙는 작은 신경망을 묶어 레이어 하나라고 부릅니다. 한 번의 어텐션은 한 단계 관계만 잡습니다. 여러 단계 관계, 그러니까 “책을”이 “빌린”을 흡수한 뒤 “돌려줬다”가 그 “빌린 책”을 다시 보는 식의 관계는 레이어를 쌓아야 생깁니다. 그래서 Transformer는 레이어를 수십 개 쌓아 한 레이어의 출력을 다음 레이어의 입력으로 넘깁니다. 어텐션 뒤에 붙는 신경망은 위치마다 따로 돌아가서 다른 위치를 보지 않으므로 어느 위치가 어느 위치를 보는지는 어텐션만 따지면 됩니다.
첫 레이어부터 봅니다. 첫 레이어의 입력은 각 단어의 임베딩 입니다. causal mask 때문에 위치 1은 만 볼 수 있으니 위치 1의 출력 은 만으로 정해집니다. 위치 2의 출력 는 로, 위치 3은 로 정해집니다. 뒤에 “돌려줬다”의 가 새로 붙어도 부터 까지는 애초에 를 볼 수 없었으니 그대로입니다.
두 번째 레이어의 입력은 첫 레이어의 출력 입니다. 여기서도 마스크가 적용되므로 두 번째 레이어의 위치 1은 만 봅니다. 은 만으로 정해졌으니 결국 두 번째 레이어의 위치 1도 만으로 정해집니다. 위치 3도 마찬가지입니다. 두 번째 레이어의 위치 3은 만 보고 이 셋은 각각 부터 까지만으로 정해졌으니 결국 에만 의존합니다. 같은 논리가 세 번째, 네 번째 레이어에도 그대로 반복됩니다. 위치 의 벡터(표현)는 어느 레이어에서든 보다 뒤의 토큰에 의존하지 않습니다. K와 V는 그 표현에 , 를 곱한 것이니 마찬가지로 바뀌지 않습니다.
그래서 추론 엔진은 한 번 계산한 K, V를 저장해 둡니다. 이것이 KV Cache입니다. “돌려줬다”가 위치 5로 들어와 여섯 번째 토큰을 고르는 단계를 보면 캐시에는 와 가 이미 들어 있습니다. 새 토큰의 , , 만 계산하고 와 를 캐시 끝에 붙입니다. 그다음 를 캐시의 K 다섯 개와 내적해 가중치를 구하고 그 비율로 V 다섯 개를 합산합니다. 앞에서 본 0.3, 0.1, 0.1, 0.4, 0.1이 바로 이 다섯 개의 가중치입니다.
Q는 왜 저장하지 않을까요? 가 쓰이는 곳은 위치 5의 행 하나뿐입니다. 여섯 번째 토큰이 들어오는 다음 단계에서 필요한 것은 이지 가 아닙니다. 반면 K와 V는 다릅니다. 은 부터 까지 전부와 비교해야 하고 그 가중치로 부터 까지 전부를 합산해야 합니다.
| 벡터 | 생성 단계마다 필요한 것 | 저장 |
|---|---|---|
| Q | 현재 토큰의 것 하나 | 안 함 |
| K | 과거 토큰 전부 | 캐시 |
| V | 과거 토큰 전부 | 캐시 |
캐시가 없으면 어떻게 되는지도 세어 봅시다. “민수는” 다음에 “어제”가 들어오면 두 토큰의 K, V를, “빌린”이 들어오면 세 토큰의 K, V를, “돌려줬다”가 들어오면 다섯 토큰의 K, V를 처음부터 다시 계산합니다. 네 단계 동안 2 + 3 + 4 + 5 = 14토큰분인데 캐시를 쓰면 단계마다 새 토큰 하나씩 4토큰분입니다.
토큰 1,000개를 차례로 생성하면 차이가 커집니다. 캐시가 없으면 1,000번째 단계에서 토큰 1부터 1,000까지를 다시 계산해 전부 더하면 약 50만 토큰분이고, 캐시를 쓰면 1,000토큰분입니다. 여기서 센 것은 각 토큰의 Q, K, V를 만드는 계산입니다. 를 과거 K와 내적하는 횟수는 캐시가 있어도 단계마다 번이라 이 부분은 여전히 단계가 갈수록 늘어나는데 그 비용은 따로 봅니다.
KV Cache는 “예전에 계산했으니 저장해 두면 빠르다”는 단순한 최적화가 아닙니다. 저장한 값을 다시 써도 되는 근거는 causal mask입니다. 과거 위치의 K, V는 미래 토큰이 붙어도 흔들리지 않으니 그 값을 다시 써도 안전합니다. 마스크가 없는 구조라면 위치 1이 위치 5를 볼 수 있어서 토큰이 하나 붙을 때마다 위치 1의 표현이 바뀌고 , 을 다시 계산해야 합니다.
캐시는 모델이 맞게 동작하기 위한 필수 요소는 아닙니다. 캐시 없이 매번 처음부터 계산해도 결과는 같고 단지 생성 속도가 크게 떨어질 뿐입니다. 이 흐름은 한 줄로 이어집니다. 다음 토큰을 예측해야 하니 미래 토큰을 보면 안 되고, 그래서 causal mask를 씌우고, 그 결과 각 위치는 자기와 과거만 참조하고, 따라서 앞 위치의 표현은 토큰이 추가되어도 변하지 않고, 앞 위치의 K, V도 변하지 않고, 그래서 저장해도 안전하고, 그것이 KV Cache입니다.
헤드를 여러 개 두는 Multi-Head Attention
지금까지 본 어텐션은 한 벌입니다. “돌려줬다”가 Q 하나를 들고 모든 K와 비교하니 나오는 가중치 패턴도 하나뿐이죠. 그런데 언어에는 여러 종류의 관계가 한꺼번에 있습니다. “돌려줬다”는 “민수”와 주어 관계를, “책”과 목적어 관계를, “어제”와 시간 관계를 동시에 맺고 있고요. 가중치 패턴 하나로 이 세 관계를 모두 담으려면 0.3, 0.1, 0.1, 0.4, 0.1처럼 뭉뚱그린 비율이 되고 어느 관계도 또렷하게 잡히지 않습니다. 벡터 길이를 키워도 소용없습니다. softmax가 내놓는 비율은 여전히 한 줄이라 주어에 0.6을 주면서 동시에 목적어에도 0.6을 줄 수는 없으니까요.
Multi-Head Attention은 어텐션을 벌 두어 이 문제를 풉니다. 각 벌을 헤드(head), 벌 수 를 헤드 수(n_heads)라고 부르고 헤드마다 자기만의 , , 가 있습니다. 같은 입력에서 헤드마다 다른 Q, K, V가 나오니 가중치 패턴도 헤드마다 다릅니다. 예를 들어 이런 식으로 역할이 갈릴 수 있습니다.
실제 모델의 헤드는 이렇게 깔끔하게 나뉘지 않지만 헤드마다 서로 다른 패턴을 학습한다는 점은 같습니다.
헤드를 늘리면 계산이 배가 될 것 같지만 그렇지 않습니다. 각 헤드는 입력 전체를 받되 Q, K, V를 작게 만듭니다. 모델의 벡터 차원 이 512이고 헤드가 8개면 헤드마다 512 × 64 행렬로 투영해 64차원짜리 Q, K, V를 만듭니다. 이 크기를 head_dim이라고 부릅니다. 8개 헤드의 출력(64차원 × 8)을 옆으로 이어 붙이면(concatenate) 다시 512차원이 되고 여기에 행렬 를 곱해 헤드들의 결과를 섞어 줍니다.
Q, K, V를 만드는 투영의 파라미터 수는 한 벌일 때와 같습니다. 512차원 입력에서 512차원 Q를 만드는 도, 64차원 Q를 만드는 512 × 64 행렬 8개도 똑같이 262,144개의 숫자입니다. 같은 파라미터 예산으로 여러 관계를 동시에 잡는 구조이고 헤드 결과를 섞는 만 추가로 붙습니다.
이 구조에 붙은 용어를 정리하면 이렇습니다.
- 헤드(head): 자기만의 , , 를 가진 독립 어텐션 한 벌
- 헤드 차원(head_dim, ): 각 헤드 안에서 Q, K, V 벡터의 차원. 원 논문에서는 이고 요즘 모델은 따로 정하기도 합니다
- 헤드 수(n_heads, ): 나란히 실행되는 어텐션의 수
Llama 2 7B는 hidden size() 4096, 헤드 32개, 헤드 차원 128이라 4096 / 32 = 128로 원 논문의 관계가 그대로 맞습니다. Gemma 4처럼 레이어마다 헤드 차원을 256이나 512로 다르게 두는 모델도 있습니다.
KV head를 줄이는 MQA와 GQA
Multi-Head Attention 원형에서는 모든 헤드가 자기만의 K, V를 갖습니다. 헤드가 32개면 레이어당 K, V 쌍도 32개이고 KV Cache도 32벌을 저장해야 합니다. 토큰 하나마다, 레이어마다 그렇습니다. 이 레이어당 독립된 K, V 쌍의 수를 KV head(KV 헤드)라고 부릅니다. 원형에서는 헤드 수와 같습니다.
2019년 Shazeer가 제안한 MQA(Multi-Query Attention)는 이를 극단까지 줄입니다. Q만 헤드별로 따로 두고 K와 V는 모든 헤드가 한 벌을 나눠 씁니다. Q 헤드는 캐시하지 않으니 줄여도 메모리에 도움이 안 됩니다. 그래서 K, V만 줄이는 것입니다. KV Cache가 32분의 1로 줄어 메모리 부담이 크게 내려갑니다. 대신 같은 K, V로 32개의 서로 다른 질문에 답해야 합니다. 주어를 찾는 헤드와 목적어를 찾는 헤드가 “책을”의 간판 하나를 같이 봐야 하니 “나는 목적어다”와 “나는 주어가 아니다”를 한 간판에 다 적어야 하고 어느 질문에도 딱 맞지 않게 되어 표현력이 떨어질 수 있습니다.
GQA(Grouped Query Attention)는 그 중간입니다. Q 헤드를 몇 개의 그룹으로 묶고 같은 그룹 안의 Q 헤드들이 K, V 한 벌을 공유합니다. Llama 2 70B는 Q 헤드 64개를 8개씩 8그룹으로 묶어 KV head를 8개 둡니다. MHA의 표현력과 MQA의 효율 사이에서 적당한 점을 찾은 것이 GQA입니다. Llama 2 70B 이후 Llama 3, Mistral, Qwen, Gemma가 모두 이 방식을 써서 오픈 모델의 기본값이 됐습니다.
KV head 수는 KV Cache 크기 계산에 그대로 들어갑니다. 토큰 하나가 차지하는 캐시 크기는 모델 config만으로 바로 나옵니다.
토큰당 KV = 2(K, V) × 레이어 수 × KV head 수 × head_dim × dtype 바이트맨 앞의 2는 K와 V 각각 한 벌, KV head 수는 레이어당 독립 K, V 쌍의 수, head_dim은 각 헤드 안 벡터의 차원, dtype 바이트는 숫자 하나를 몇 바이트로 저장하는지(fp16이면 2)입니다. Llama 2 70B(레이어 80, head_dim 128)를 fp16으로 올리면 이렇습니다.
| 구성 | KV head | 토큰당 KV | 4,096토큰 요청 하나 |
|---|---|---|---|
| GQA (실제 Llama 2 70B) | 8 | 2 × 80 × 8 × 128 × 2 = 327,680바이트 ≈ 328KB | 약 1.3GB |
| MHA였다면 | 64 | 약 2.6MB | 약 10.7GB |
GQA가 줄이는 것은 KV head 수이고 토큰당 KV가 그만큼 비례해서 줄어듭니다. 8분의 1이면 캐시도 8분의 1입니다.
시퀀스 길이의 제곱이라는 대가
어텐션은 허용된 모든 위치 쌍의 점수를 계산합니다. 토큰이 개면 점수 행렬이 이라 다섯 단어면 25칸, 열 단어면 100칸입니다. 마스크로 절반을 건너뛰어도 칸 수는 에 비례하고 토큰이 2배가 되면 칸 수는 4배가 됩니다. 계산량과 메모리 모두 입니다. RNN은 토큰 수에 비례하는 이었고요. 순차 계산을 병렬 계산으로 바꾸고 참조도 간접에서 직접으로 옮긴 대가로 시퀀스 길이의 제곱이 따라온 셈입니다.
숫자로 놓으면 이렇습니다.
| 토큰 수 | 점수 쌍 (헤드당) |
|---|---|
| 1,024 | 약 105만 |
| 8,192 | 약 6,700만 |
| 131,072 (128K) | 약 172억 |
GPT-1과 BERT가 512토큰 문맥에 머문 데에는 이 비용이 크게 작용했고 문맥을 수만 토큰 이상으로 늘리는 데 아키텍처 수준의 손질이 필요했던 것도 이 때문입니다.
메모리도 마찬가지입니다. 128K 문맥의 점수 행렬을 헤드마다 저장하면 172억 쌍 × 32헤드 × 2바이트(fp16)로 레이어 하나만 해도 약 1.1TB입니다. FlashAttention은 이 행렬을 한꺼번에 만들지 않고 작은 타일 단위로 계산해 메모리 문제를 풀었는데 연산량 자체는 여전히 입니다.
위치는 따로 알려줘야 한다
RNN에는 없던 문제도 하나 따라옵니다. 어텐션 식 어디에도 토큰이 몇 번째인지는 들어가지 않습니다. RNN은 토큰을 순서대로 처리하니 위치가 저절로 반영됩니다. 어텐션의 내적과 가중합은 앞 토큰들의 K, V 묶음 위에서 하는 계산이라 “돌려줬다”는 앞에 “민수는”과 “책을”이 있다는 것만 알 뿐 어느 쪽이 먼저 왔는지는 모릅니다. “민수는 책을 돌려줬다”와 “책을 민수는 돌려줬다”에서 “돌려줬다”의 출력이 같아집니다. causal mask가 앞인지 뒤인지는 갈라 주지만 몇 칸 앞인지까지는 알려 주지 않습니다. 그래서 각 토큰이 몇 번째인지 알려주는 위치 인코딩(positional encoding)을 따로 더합니다. 원래 논문은 사인·코사인 함수를 썼고 지금 주류는 RoPE(Rotary Position Embedding)입니다.
2026년의 어텐션은 무엇이 달라졌나
2017년 “Attention Is All You Need”의 는 2026년 프론티어 모델에도 핵심 연산으로 남아 있습니다. 변한 것은 그 공식 주변이죠. 모든 헤드가 K, V를 따로 갖던 MHA가 대부분 GQA로 바뀌었고 점수 행렬을 통째로 올리던 방식이 FlashAttention의 타일 계산으로 바뀌었습니다. 일부 레이어에는 슬라이딩 윈도우 어텐션, 그러니까 전체 시퀀스 대신 주변 일정 범위만 보는 방식이 붙었고 위치 인코딩은 사인·코사인에서 RoPE로 넘어갔습니다. DeepSeek의 MLA(Multi-head Latent Attention)는 K, V 대신 저차원 잠재 벡터를 캐시해 KV Cache를 줄였는데 이것도 공식 자체가 아니라 K, V를 만드는 쪽을 손본 것이고요. Q, K, V로 투영하고 내적으로 점수를 구하는 뼈대는 그대로입니다.
공식 자체를 피하려는 시도도 있습니다. Mamba 같은 상태 공간 모델(SSM)과 선형 어텐션 계열은 자체를 피하려 합니다. 레이어 대부분을 선형 어텐션으로 바꾸고 softmax 어텐션을 몇 레이어에만 남긴 하이브리드 모델도 나왔습니다. 다만 softmax 어텐션을 완전히 뺀 프론티어 모델은 2026년 기준으로 아직 없습니다.
마치며
각 위치가 자기만의 질문을 던지고(Q), 다른 위치들이 그 질문에 맞는 간판을 내걸고(K), 선택된 위치가 내용물을 넘깁니다(V). 여기에 점수 크기를 맞추는 와 미래를 가리는 causal mask가 붙어 디코더 어텐션이 완성됩니다. 그리고 그 마스크가 만든 “앞 토큰의 K, V는 변하지 않는다”는 성질이 KV Cache의 근거이고 이 캐시를 어떻게 쌓고 나눌지가 서빙 최적화의 출발점입니다.
다음 글에서는 텍스트가 이 구조에 들어가기 전에 거치는 토큰화를 다룹니다. BPE가 왜 그 방식인지, 어휘 크기를 어떻게 정하는지 짚어봅니다.
함께 보면 좋은 글
- LLM은 왜 다음 토큰 하나만 예측할까
- 토큰은 왜 단어가 아닐까
- LLM은 왜 가장 확률 높은 토큰을 고르지 않을까
- KV Cache가 LLM 서빙을 바꾸는 방식
- Prefill과 Decode로 이해하는 LLM 추론 과정
참고자료
- Attention Is All You Need (Vaswani et al., NeurIPS 2017)
- Neural Machine Translation by Jointly Learning to Align and Translate (Bahdanau et al., ICLR 2015)
- Fast Transformer Decoding: One Write-Head is All You Need (Shazeer, 2019)
- GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints (Ainslie et al., 2023)
- The Illustrated Transformer (Jay Alammar)