“모델을 띄웠다”는 말 안에서 실제로 무슨 일이 벌어지는지 들여다보면, 생각보다 이른 시점에 KV 캐시를 만나게 된다. 성능 튜닝 항목처럼 소개되는 경우가 많지만, 실제로는 자기회귀 생성이 현실적인 속도로 돌아가기 위한 전제에 가깝다. 그리고 GPU 메모리를 가장 많이 먹는 주범이기도 하다.
이 글은 한 번의 요청 안에서 벌어지는 일만 다룬다. 여러 요청에 걸친 재사용(프리픽스 캐싱)과 그것이 과금·라우팅을 어떻게 바꾸는지는 프리필과 디코드는 다른 일이다에서 이어서 쓴다.
토큰을 하나씩 만든다는 것
LLM은 답변을 통째로 내놓지 않는다. 다음 토큰 하나를 예측하고, 그것을 입력 뒤에 붙인 다음, 다시 그 전체를 보고 또 다음 토큰 하나를 예측한다. 이 반복을 자기회귀(autoregressive) 생성이라고 한다.
flowchart LR P["프롬프트<br/>오늘 날씨"] --> S1["다음 토큰 예측<br/>→ 는"] S1 --> S2["오늘 날씨 는<br/>→ 맑"] S2 --> S3["오늘 날씨 는 맑<br/>→ 습니다"] S3 --> E["종료 토큰까지 반복"]
여기서 중요한 건, 매 단계의 입력이 직전 단계의 입력에 토큰 하나만 더 붙은 것이라는 점이다. 이 구조가 뒤에 나올 모든 이야기의 출발점이다.
Attention은 질문·이름표·알맹이로 움직인다
Transformer가 토큰 하나를 처리할 때, 그 토큰은 앞에 있는 모든 토큰을 참조한다. Attention Is All You Need가 정의한 방식은 각 토큰에서 세 종류의 벡터를 만드는 것이다.
| 벡터 | 역할 | 비유 |
|---|---|---|
| Query (Q) | 지금 처리 중인 토큰이 “나는 이런 걸 찾는다”고 던지는 질문 | 도서관에서 던지는 검색어 |
| Key (K) | 각 토큰이 달고 있는 “나는 이런 놈이다”라는 이름표 | 색인 카드의 표제어 |
| Value (V) | 매칭됐을 때 실제로 가져갈 내용 | 색인 카드에 적힌 내용 |
새 토큰의 Q를 앞선 모든 토큰의 K와 견줘 얼마나 관련 있는지 점수를 매기고, 그 점수대로 V를 섞어 가져온다. 즉 읽는 쪽(Q)은 매번 새로 생기지만, 읽히는 쪽(K·V)은 이미 있던 토큰들의 것이다.
캐시가 없으면 무슨 일이 벌어지나
여기서 자연스러운 질문이 하나 나온다. 이미 지나간 토큰의 K와 V는 다음 단계에서 달라질까?
달라지지 않는다. 디코더는 인과(causal) 마스크를 쓰기 때문에 각 토큰은 자기보다 앞선 토큰만 본다. 100번째 토큰이 새로 생겨도 3번째 토큰의 K·V는 그대로다. 3번째 토큰 입장에서 100번째는 “미래”라 애초에 보이지 않기 때문이다.
그런데 캐시를 두지 않으면 매 단계마다 이 값들을 처음부터 다시 만들게 된다.
flowchart TB
subgraph NO["캐시 없음"]
N1["1번째 토큰 생성<br/>K·V 1개 계산"] --> N2["2번째 토큰 생성<br/>K·V 2개 계산"] --> N3["3번째 토큰 생성<br/>K·V 3개 계산"] --> NN["n번째 토큰 생성<br/>K·V n개 계산"]
end
subgraph YES["KV 캐시 사용"]
Y1["1번째 토큰 생성<br/>K·V 1개 계산 후 보관"] --> Y2["2번째 토큰 생성<br/>새 토큰 1개만 계산"] --> Y3["3번째 토큰 생성<br/>새 토큰 1개만 계산"] --> YN["n번째 토큰 생성<br/>새 토큰 1개만 계산"]
end
답변 길이가 n일 때 누적 계산량이 1 + 2 + 3 + ... + n, 즉 n²에 비례해 늘어난다. 500토큰짜리 답변 하나를 만드는 데 12만 번어치의 K·V 계산이 들어가는 식이다. 캐시를 두면 이 값은 n에 비례하는 수준으로 떨어진다.
KV 캐시: 한 번 만든 이름표와 알맹이를 들고 있는다
그래서 추론 엔진은 각 토큰의 K와 V를 계산한 즉시 GPU 메모리에 쌓아 두고, 다음 단계에서는 새로 생긴 토큰 하나의 K·V만 계산해 뒤에 덧붙인다. 이것이 KV 캐시다. Hugging Face Transformers 문서의 KV cache 설명도 같은 구조를 전제로 쓰여 있다.
여기서 꼭 짚고 넘어갈 것이 있다. 캐시에 들어가는 건 문장이나 토큰 ID가 아니라, 그것을 모델에 통과시켜 얻은 텐서다. “대화 내용을 저장해 둔다”는 설명은 비유로는 통하지만 구현을 오해하게 만든다. 캐시는 모델 가중치와 함께 GPU 메모리에 올라가 있는 수 GB짜리 숫자 덩어리다.
그리고 Q는 캐시되지 않는다. Q는 “지금 이 토큰이 던지는 질문”이라 매 단계 새로 생기고 쓰고 버려진다. 재사용되는 쪽은 읽히는 쪽인 K·V뿐이라, 이름도 KV 캐시다.
공짜가 아니다 — 토큰 하나에 몇 바이트인가
캐시는 메모리를 쓴다. 그것도 많이 쓴다. 필요한 양은 모델 구조만 알면 바로 계산된다.
토큰 1개당 바이트
= 2 (K와 V)
× 레이어 수
× KV 헤드 수
× 헤드 차원
× 자료형 바이트 수
70B급 모델에서 흔한 구성(80 레이어, KV 헤드 8개, 헤드 차원 128, fp16)으로 넣어 보자.
2 × 80 × 8 × 128 × 2 바이트 = 327,680 바이트 ≈ 320 KiB / 토큰
| 상황 | KV 캐시 크기 |
|---|---|
| 토큰 1개 | 약 320 KiB |
| 4K 문맥 대화 1개 | 약 1.25 GiB |
| 32K 문맥 대화 1개 | 약 10 GiB |
모델 가중치가 이미 수십 GB를 차지한 상태에서, 대화 하나가 10GB를 더 요구한다. 동시에 받을 수 있는 요청 수의 상한을 모델 크기가 아니라 KV 캐시가 정하는 구간이 여기서 생긴다.
그래서 나온 것들
KV 헤드를 줄인다 — MQA와 GQA
위 공식에서 KV 헤드 수가 곱해지는 자리에 있다는 점이 중요하다. 원래 멀티헤드 어텐션(MHA)은 Q 헤드와 K·V 헤드 수가 같다. 위 예시에서 Q 헤드가 64개라면 K·V 헤드도 64개여야 한다.
MHA 가정: 2 × 80 × 64 × 128 × 2 = 2,621,440 바이트 ≈ 2.5 MiB / 토큰
32K 문맥 대화 1개 → 약 80 GiB
대화 하나가 H100 한 장(80GB)을 통째로 먹는다. 이 문제를 겨냥해 Fast Transformer Decoding: One Write-Head is All You Need는 K·V 헤드를 하나만 두는 MQA를, GQA는 Q 헤드 여러 개가 K·V 헤드 하나를 공유하는 절충안을 제시했다. 요즘 오픈 웨이트 모델이 대부분 GQA를 쓰는 이유가 품질이 아니라 이 메모리 계산 쪽에 있다.
조각난 메모리를 다시 모은다 — PagedAttention
크기를 줄여도 문제가 하나 더 남는다. 대화마다 길이가 다르고 언제 끝날지 모르니, 미리 최대 길이만큼 잡아 두면 대부분이 낭비된다. Efficient Memory Management for Large Language Model Serving with PagedAttention은 운영체제의 가상 메모리 페이징을 KV 캐시에 가져와, 캐시를 고정 크기 블록으로 쪼개고 블록 테이블로 관리하는 방식을 제안했다. vLLM이 이 논문의 구현체다.
flowchart LR
subgraph BEFORE["연속 할당"]
B1["대화 A: 최대 길이만큼 선점<br/>대부분 미사용"]
B2["대화 B: 자리가 없어 대기"]
end
subgraph AFTER["블록 단위 할당"]
A1["대화 A: 쓴 만큼만 블록 점유"]
A2["대화 B: 남은 블록에 바로 배치"]
A3["공통 앞부분: 블록 공유 가능"]
end
BEFORE --> AFTER
마지막 줄의 “공통 앞부분 블록 공유”가 다음 글의 주제다. 한 요청 안에서의 재사용이 아니라 요청과 요청 사이의 재사용이 가능해지는 지점이기 때문이다.
정리
- LLM은 토큰을 하나씩 만들고, 매 단계의 입력은 직전 입력에 토큰 하나가 더 붙은 형태다.
- 앞선 토큰의 K·V는 뒤에 뭐가 오든 변하지 않는다. 인과 마스크 덕분이다.
- 그래서 K·V를 보관해 두고 새 토큰 것만 계산한다. 이것이 KV 캐시이고, 없으면 계산량이 길이의 제곱으로 늘어난다.
- 캐시되는 건 텍스트가 아니라 GPU 메모리에 올라간 텐서다. 70B급 모델에서 32K 대화 하나가 약 10GiB를 쓴다.
- MQA·GQA는 이 크기를 줄이려는 구조 변경이고, PagedAttention은 낭비 없이 담으려는 메모리 관리 기법이다.
다음 글에서는 한 요청을 prefill과 decode 두 구간으로 나눠 보고, 요청 사이에 캐시를 재사용하는 프리픽스 캐싱이 왜 성능이 아니라 청구서 문제인지, 그리고 그것이 로드밸런싱 상식을 어떻게 뒤집는지 다룬다.
→ 프리필과 디코드는 다른 일이다: 프리픽스 캐시 적중률이 비용이 되는 이유 → LLM 게이트웨이는 왜 균등 분산을 포기하는가: 프리픽스 인지 라우팅