OpenAI와 Anthropic에서 캐시된 입력 토큰이 일반 입력 토큰보다 훨씬 저렴하고 더 빠를 수 있는 이유를, 토크나이저·임베딩·어텐션(Transformer)과 KV 캐시 관점에서 설명합니다.
URL: https://ngrok.com/blog/prompt-caching/
이 글을 쓰는 시점 기준으로, OpenAI와 Anthropic의 API 모두에서 캐시된 입력 토큰(cached input tokens) 은 일반 입력 토큰(regular input tokens)보다 토큰당 달러 비용이 10배 저렴합니다.
Anthropic은 심지어 프롬프트 캐싱이 긴 프롬프트에 대해 “최대 85%까지 지연 시간을 줄일 수 있다” 고 주장합니다. 제 테스트에서도 충분히 긴 프롬프트라면 이 말이 사실임을 확인했습니다. Anthropic과 OpenAI에 수백 건의 요청을 보내 본 결과, 모든 입력 토큰이 캐시된 프롬프트에서는 time-to-first-token(첫 토큰까지의 시간) 지연이 크게 줄어드는 것을 관찰했습니다.
GPT-5
Sonnet 4.5
이제 화려한 그라데이션 텍스트와 예쁜 차트로 관심을 끌었으니, 이런 질문을 스스로 해본 적 있나요…
도대체 캐시된 토큰이란 뭘까요?
GPU의 광대한 바다 속에서 공급자들이 입력 토큰에 대해 10배 할인을 제공할 수 있게 해주는 건 대체 무엇일까요? 요청 간에 그들은 무엇을 저장하는 걸까요? 동일한 프롬프트를 다시 보냈을 때 응답을 저장해 재사용하는 방식이 아니라는 건 API로 쉽게 검증할 수 있습니다. 프롬프트를 작성해 열두 번 보내 보세요. usage 섹션이 캐시된 입력 토큰을 보여주더라도 매번 다른 응답을 받는 것을 확인할 수 있습니다.
벤더문서는 프롬프트 캐싱을 어떻게 사용하는지 설명은 훌륭하지만, 실제로 무엇이 캐시되는지는 교묘히 피해 가는 느낌이어서 만족하지 못했습니다. 그래서 저는 더 깊게 파고들기로 했습니다. LLM이 동작하는 방식을 토끼굴처럼 따라 들어가, 공급자가 정확히 어떤 데이터를 캐시하는지, 그것이 무엇에 쓰이는지, 그리고 왜 모두에게 더 빠르고 더 저렴해지는지 이해할 때까지 파고들었습니다.
핵심적으로 LLM은 거대한 수학 함수입니다. LLM은 숫자들의 시퀀스를 입력으로 받아 숫자 하나를 출력으로 내놓습니다. LLM 내부에는 입력 숫자를 출력 숫자로 바꾸는, 수십억 개의 정교하게 배치된 연산 그래프가 들어 있습니다.
이 거대한 연산 그래프는 대략 4개의 부분으로 나눌 수 있습니다.
그 다이어그램의 각 노드는 어떤 입력을 받아 출력을 만드는 함수라고 생각할 수 있습니다. 입력은 특수한 출력 값이 “멈춰라”라고 말할 때까지 루프를 돌며 LLM에 계속 들어갑니다. 의사코드로 쓰면 이렇게 보일 겁니다:
prompt = "What is the meaning of life?";
tokens = tokenizer(prompt);
while (true) {
embeddings = embed(tokens);
for ([attention, feedforward] of transformers) {
embeddings = attention(embeddings);
embeddings = feedforward(embeddings);
}
output_token = output(embeddings);
if (output_token === END_TOKEN) {
break;
}
tokens.push(output_token);
}
print(decode(tokens));
프롬프트 캐싱이 일어나는 곳은 트랜스포머의 “어텐션(attention)” 메커니즘입니다. 그곳에 도달하기 위해, LLM이 어떻게 동작하는지 순서대로 함께 걸어가 보겠습니다. 그러려면 먼저 토큰부터 이야기해야 합니다.
LLM이 여러분의 프롬프트로 무언가를 하기 전에, 먼저 다룰 수 있는 표현으로 변환해야 합니다. 이 과정은 토크나이저 단계와 임베딩 단계가 나눠 맡는 2단계 프로세스입니다. 왜 이런 과정이 필요한지는 임베딩까지 가야 명확해지니, 우선 토크나이저가 무엇을 하는지부터 차근차근 보겠습니다.
토크나이저는 프롬프트를 작은 조각으로 자르고, 각 고유 조각에 “토큰(token)”이라 불리는 정수 ID를 부여합니다. 예를 들어 GPT-5가 "Check out ngrok.ai" 프롬프트를 토큰화하는 방식은 다음과 같습니다:
4383
Check
842
out
1657
ng
17690
rok
75584
.ai
이 프롬프트는 배열 "Check", " out", " ng", "rok", ".ai" 로 분할되고, 토큰 [4383, 842, 1657, 17690, 75584] 로 변환됩니다. 같은 프롬프트는 언제나 같은 토큰을 생성합니다. 토큰은 대소문자도 구분하는데, 이는 대문자 사용이 단어에 대한 정보를 담고 있기 때문입니다. 예컨대 대문자 W로 시작하는 “Will”은 소문자 “will”보다 이름일 가능성이 더 높죠.
이건 의외로 큰 질문이며, 자세히 다루면 글 길이가 두 배가 될 수도 있습니다. 짧고(그리고 만족스럽지 못한) 답은 트레이드오프라는 것입니다. 더 깊게 보고 싶다면 Andrej Karpathy의 훌륭한 영상에서 토크나이저를 처음부터 만드는 과정을 확인할 수 있습니다. 프롬프트 캐싱 관점에서는, 토큰화가 “텍스트를 숫자로 바꾸는 작업”이라는 것만 알면 충분합니다.
토큰은 LLM의 입력과 출력의 기본 단위입니다. ChatGPT에 질문을 하면, 응답은 LLM의 각 반복(iteration)이 끝날 때마다 토큰 단위로 스트리밍되어 돌아옵니다. 공급자가 이렇게 하는 이유는 전체 응답을 생성하는 데 수십 초가 걸릴 수 있지만, 준비되는 대로 토큰을 보내 주면 과정이 더 인터랙티브하게 느껴지기 때문입니다.
이걸 직접 보기 위해, 전형적인 LLM 질문을 하나 던져 보겠습니다. 준비되면 아래 전송 버튼을 누르세요.
How many r's in the word 'st raw berry'?
프롬프트 토큰이 들어가고, ✨ AI가 일어나고 ✨, 출력 토큰이 나오고, 반복. 이 과정을 “추론(inference)”이라고 부릅니다. 그리고 중요한 점은 각 출력 토큰이 다음 반복의 입력 프롬프트에 덧붙여진다는 것입니다. LLM은 좋은 답변을 위해 모든 컨텍스트가 필요합니다. 프롬프트만 넣으면 답변의 첫 토큰만 계속 만들려고 할 것이고, 답변만 넣으면 질문을 즉시 잊어버릴 겁니다. 따라서 프롬프트 전체 + 지금까지의 답변 전체를 매 반복마다 LLM에 넣어야 합니다.
토크나이저에 대해 마지막으로: 토크나이저는 정말 많습니다! ChatGPT가 쓰는 토크나이저와 Claude가 쓰는 토크나이저는 다릅니다. OpenAI 내부에서도 모델마다 토크나이저가 다를 수 있습니다. 토크나이저마다 텍스트를 토큰으로 쪼개는 규칙이 다릅니다. 다양한 토크나이저가 텍스트를 어떻게 분할하는지 보고 싶다면 tiktokenizer를 확인해 보세요.
이제 토큰을 소개했으니, 임베딩으로 넘어가 봅시다.
토크나이저에서 나온 토큰은 이제 임베딩 단계로 들어갑니다. 임베딩을 이해하려면, 먼저 모델의 목표(goal) 가 무엇인지 이해하는 게 도움이 됩니다.
사람이 코드로 문제를 풀 때는, 입력을 받아 출력을 만드는 함수를 작성합니다. 예를 들어 화씨를 섭씨로 변환하는 함수는 이렇게 쓸 수 있죠.
function fahrenheitToCelsius(fahrenheit) {
return ((fahrenheit - 32) * 5) / 9;
}
fahrenheitToCelsius에 어떤 숫자를 넣어도 올바른 답을 얻을 수 있습니다. 그런데 만약 우리가 공식이 무엇인지 모르는 문제라면 어떨까요? 아래처럼 입력과 출력의 신비한 표만 있다면요?
| 입력 | 출력 |
|---|---|
| 21 | 73 |
| 2 | 3 |
| 10 | 29 |
| 206 | 1277 |
여기서 어떤 함수인지 알아맞히길 기대하진 않습니다. 다만 이 표를 스크린샷으로 찍어 ChatGPT에 붙여 넣으면, ChatGPT는 즉시 알아냅니다.
입력마다 기대 출력은 알지만 이를 만들어내는 함수를 모를 때, 우리는 모델이 그 함수를 학습하도록 “훈련(train)”할 수 있습니다. 이를 위해 모델에게 캔버스(거대한 수학 연산 그래프)를 제공하고, 올바른 함수를 찾아 수렴(converge)할 때까지 그 그래프를 수정합니다. 그래프를 업데이트할 때마다 입력을 통과시켜 정답 출력에 얼마나 가까운지 확인합니다. 만족할 만큼 충분히 가까워질 때까지 반복합니다. 이것이 훈련입니다.
그리고 모델이 올바른 텍스트를 출력하도록 훈련할 때, 두 문장이 서로 “비슷한지”를 인식할 수 있으면 도움이 됩니다. 하지만 무엇이 비슷하다는 걸까요? 슬픔의 정도가 비슷할 수도, 웃긴 정도가 비슷할 수도, 사색을 유도하는 정도가 비슷할 수도 있습니다. 길이, 리듬, 톤, 언어, 어휘, 구조가 비슷할 수도 있죠. 문장 간 유사성을 설명하는 차원(dimension) 은 무수히 많고, 어떤 차원에서는 비슷하지만 다른 차원에서는 다를 수도 있습니다.
토큰은 차원이 없습니다. 그냥 평범한 정수죠. 하지만 임베딩은 차원이 많습니다.
임베딩(embedding)은 길이 n의 배열로, n차원 공간에서의 한 위치를 나타냅니다. 만약 n이 3이라면 임베딩은 [10, 4, 2]처럼 생길 수 있고, 이는 3차원 공간에서 x=10, y=4, z=2 위치를 의미합니다. LLM을 훈련할 때 각 토큰은 이 공간에서 랜덤한 시작 위치를 부여받고, 훈련 과정이 토큰들을 조금씩 움직여 최적의 출력이 나오도록 가장 좋은 배치를 찾습니다.
임베딩 단계는 각 토큰의 임베딩을 조회하는 것으로 시작합니다. 의사코드는 이런 느낌입니다:
js// 훈련 중에 만들어지며, 추론 중에는 절대 변하지 않는다. const EMBEDDINGS = [...]; function embed(tokens) { return tokens.map(token => { return EMBEDDINGS[token]; }); }
즉 정수 배열인 tokens를 임베딩 배열의 배열로 바꿉니다. 배열의 배열, 즉 “행렬(matrix)”이 됩니다. 아래에서 토큰과 임베딩을 전환해 보며, 제가 머릿속에서 이 과정을 어떻게 그리는지 확인해 보세요.
Tokens
Embeddings
토큰 [75, 305, 284, 887]은 3차원 임베딩 행렬로 변환됩니다.
임베딩에 더 많은 차원을 부여할수록, 문장을 비교할 수 있는 차원도 늘어납니다. 우리는 3차원 임베딩으로 설명해 왔지만, 최신 모델은 수천 차원 임베딩을 사용합니다. 가장 큰 것들은 10,000차원을 넘습니다.
차원이 많아지는 가치(value)를 보여주기 위해, 아래에는 1차원 공간에서 시작하는 색깔 있는 도형 8그룹이 있습니다. 이들은 직선 위에 놓여 뒤죽박죽이라 이해하기 어렵습니다. 하지만 차원을 추가하면 8개의 뚜렷하고 서로 관련된 그룹이 있음을 알 수 있습니다. 2D와 3D 버튼을 눌러 확인해 보세요.
1D
2D
3D
드래그하여 회전
여기서 시각적으로 보여줄 수 있는 건 3차원이 한계라, 수천 차원에서 무엇이 가능한지는 상상력에 맡기겠습니다.
임베딩 단계가 하는 마지막 일이 하나 더 있습니다. 토큰 임베딩을 가져온 뒤, 프롬프트 내에서 그 토큰의 위치(position) 를 임베딩에 인코딩합니다. 저는 이것이 어떻게 동작하는지는 깊게 파고들진 않았습니다(프롬프트 캐싱에 미치는 영향이 크지 않다고 판단해서요). 다만 이것이 없으면 LLM이 프롬프트에서 토큰의 순서를 알 수 없습니다.
앞서의 의사코드를 업데이트해, encodePosition이라는 함수가 있다고 가정해 봅시다. 이 함수는 임베딩과 위치를 받아, 위치 정보가 인코딩된 새 임베딩을 반환합니다.
const EMBEDDINGS = [...];
// 입력: 토큰(정수)의 배열
function embed(tokens) {
// 출력: n차원 임베딩 배열들의 배열
return tokens.map((token, i) => {
const embeddings = EMBEDDINGS[token];
return encodePosition(embeddings, i);
});
}
정리하자면, 임베딩은 n차원 공간의 점이며, 그 텍스트가 나타내는 의미(semantic meaning) 로 생각할 수 있습니다. 훈련 중에 각 토큰은 이 공간에서 비슷한 토큰들과 가깝도록 이동합니다. 차원이 많을수록 LLM이 각 토큰을 표현하는 방식은 더 복잡하고 더 미묘해질 수 있습니다.
토크나이저와 임베딩 단계에서 한 모든 작업은 텍스트를 LLM이 다룰 수 있는 형태로 변환하기 위한 것이었습니다. 이제 트랜스포머 단계에서 그 “일”이 어떤 모습인지 살펴봅시다.
트랜스포머 단계는 임베딩을 입력으로 받아 n차원 공간에서 이리저리 “움직이는” 것에 관한 단계입니다. 이는 두 가지 방식으로 이뤄지며, 우리는 그중 첫 번째인 어텐션(attention) 에만 집중할 겁니다. 이 글에서는 “Feedforward”나 출력 단계(output stage)는 다루지 않겠습니다(이번 글에서는 👀).
어텐션 메커니즘의 역할은, 프롬프트의 각 토큰이 서로의 n차원 공간 위치에 영향을 주게 함으로써 토큰 간 관계를 이해하도록 돕는 것입니다. 이를 위해 프롬프트 토큰들의 임베딩을 가중치(weight)를 두고 결합합니다. 입력은 프롬프트 전체 임베딩이고, 출력은 모든 입력 임베딩을 가중 결합한 새 임베딩 하나입니다.
예를 들어 프롬프트가 “Mary had a little”이고 토큰이 Mary, had, a, little 네 개라면, 어텐션 메커니즘은 다음 토큰을 생성하기 위해 다음을 사용해야 한다고 판단할 수 있습니다:
Mary 임베딩의 63%had 임베딩의 16%a 임베딩의 12%little 임베딩의 9%그리고 각 임베딩을 해당 가중치로 스케일링한 뒤 합(sum)해 결합합니다. 이것이 LLM이 프롬프트에서 각 토큰에 얼마나 신경 써야 하는지, 즉 얼마나 “어텐드(attend)”해야 하는지를 아는 방식입니다.
지금까지 과정 중에서 이 부분이 가장 복잡하고 추상적입니다. 먼저 의사코드로 보여 준 뒤, 임베딩이 이를 통과하면서 어떻게 조작되는지 살펴보겠습니다. 이 섹션을 수학 덜 하게 만들고 싶었지만, 여기서는 어느 정도 수학을 피하기 어렵습니다. 할 수 있습니다. 믿습니다.
어텐션에서의 계산 대부분은 행렬 곱(matrix multiplication) 입니다. 이 글에서 행렬 곱에 대해 알아야 할 유일한 것은, 출력 행렬의 모양(shape)이 입력 행렬의 모양으로 결정된다는 점입니다. 출력은 항상 첫 번째 입력 행렬의 행(row) 개수와 같고, 두 번째 입력 행렬의 열(column) 개수와 같습니다.
2행 3열 행렬(“2x3”).
1.00 2.00 3.00 4.00 5.00 6.00
×
3행 1열 행렬(“3x1”).
6.00 7.00 8.00
=
2행 1열 행렬(“2x1”).
44.00 107.00
이를 염두에 두고, 단순화된 어텐션 메커니즘이 각 토큰에 부여할 가중치를 계산하는 방법을 봅시다. 아래 코드에서 *는 행렬 곱을 의미합니다.
js// 앞서의 의사코드에 나온 EMBEDDINGS와 비슷하게 // WQ와 WK 역시 훈련 중에 학습되며 // 추론 중에는 변하지 않는다. // // 둘 다 n*n 행렬이며, n은 임베딩 차원 수. // 위 예시에서는 n = 3. const WQ = [[...], [...], [...]]; const WK = [[...], [...], [...]]; // 입력 임베딩은 이렇게 생겼다: // [ // [-0.1, 0.1, -0.3], // Mary // [1.0, -0.5, -0.6], // had // [0.0, 0.8, 0.6], // a // [0.5, -0.7, 1.0] // little // ] function attentionWeights(embeddings) { const Q = embeddings * WQ; const K = embeddings * WK; const scores = Q * transpose(K); const masked = mask(scores); return softmax(masked); }
이제 임베딩이 이 함수 안을 흘러가면서 어떤 모습이 되는지 봅시다.
Q와 K를 얻기 위해 embeddings에 각각 WQ, WK를 곱합니다. WQ와 WK는 항상 행과 열의 크기가 임베딩 차원 수와 같습니다(여기서는 3). 아래 예시에서 WQ와 WK의 값은 제가 임의로 고른 것이고, 가독성을 위해 소수점 둘째 자리까지 반올림했습니다.
4행 3열 행렬(“embeddings”).
-0.06 0.13-0.30 0.95-0.49-0.64 -0.05 0.75 0.65 0.51-0.72 0.98
×
3행 3열 행렬(“WQ”).
-0.74 0.91-0.21 -0.51 0.43 0.18 -0.56-0.21 0.76
=
4행 3열 행렬(“Q”).
0.15 0.07-0.19 -0.10 0.79-0.78 -0.71 0.15 0.64 -0.56-0.06 0.51
결과로 나온 Q 행렬은 4행 3열입니다. 임베딩 행렬이 4행(토큰당 1행)이었기 때문에 4행이고, WQ가 3열(임베딩 차원당 1열)이기 때문에 3열입니다.
K 계산도 완전히 동일하며, WQ 대신 WK를 쓸 뿐입니다.
4행 3열 행렬(“embeddings”).
-0.06 0.13-0.30 0.95-0.49-0.64 -0.05 0.75 0.65 0.51-0.72 0.98
×
3행 3열 행렬(“WK”).
0.13-0.51-0.63 -0.79 0.54-0.04 0.33-0.10-0.87
=
4행 3열 행렬(“K”).
-0.21 0.13 0.29 0.30-0.68-0.03 -0.39 0.36-0.56 0.96-0.74-1.15
Q와 K는 입력 임베딩을 새로운 n차원 공간으로 “투영(projection)”한 것입니다. 원래 임베딩은 아니지만, 그로부터 유도된 값입니다.
그다음 Q와 K를 서로 곱합니다. 이때 K를 “전치(transpose)”하는데, 이는 대각선을 기준으로 뒤집는 것으로, 결과가 정사각 행렬이 되게 해 줍니다. 행과 열의 크기는 입력 프롬프트의 토큰 수와 같습니다.
4행 3열 행렬(“Q”).
0.15 0.07-0.19 -0.10 0.79-0.78 -0.71 0.15 0.64 -0.56-0.06 0.51
×
3행 4열 행렬(“transpose(K)”).
-0.21 0.30-0.39 0.96 0.13-0.68 0.36-0.74 0.29-0.03-0.56-1.15
=
4행 4열 행렬(“scores”).
-0.08 0.00 0.08 0.31 -0.10-0.54 0.76 0.21 0.36-0.33-0.04-1.53 0.26-0.15-0.09-1.08
이 scores는 다음에 생성될 토큰에 대해 각 토큰이 얼마나 중요한지를 나타냅니다. 좌상단 -0.08은 “Mary”가 “had”에 얼마나 중요한지입니다. 한 행 내려간 -0.10은 “Mary”가 “a”에 얼마나 중요한지입니다. 이 행렬 수학 뒤에 시각화를 보여 드리겠습니다. 이후 과정은 이 점수들을 임베딩을 섞는 데 쓸 수 있는 가중치로 바꾸는 과정입니다.
이 scores 행렬의 첫 번째 문제는, 미래 토큰이 과거에 영향을 줄 수 있게 되어 있다는 점입니다. 첫 번째 행에서는 우리가 아는 단어가 “Mary”뿐이므로, “had”를 생성하는 데 기여하는 단어도 “Mary”뿐이어야 합니다. 두 번째 행에서는 “Mary”와 “had”를 알고 있으니 “a” 생성에는 이 둘만 기여해야 합니다. 이런 식이죠.
이를 고치기 위해, 삼각형 마스크(triangular mask)를 적용해 미래 토큰을 0으로 만드는 대신 음의 무한대(-∞) 로 설정합니다. 왜냐하면 곧 설명하겠습니다.
mask(
4행 4열 행렬(“scores”).
-0.08 0.00 0.08 0.31 -0.10-0.54 0.76 0.21 0.36-0.33-0.04-1.53 0.26-0.15-0.09-1.08
)
=
4행 4열 행렬(“masked”).
-0.08-∞-∞-∞ -0.10-0.54-∞-∞ 0.36-0.33-0.04-∞ 0.26-0.15-0.09-1.08
두 번째 문제는 이 점수들이 임의의 숫자라는 겁니다. 각 행(row)의 합이 1이 되는 분포라면 훨씬 유용하겠죠. 이것이 바로 softmax 함수가 하는 일입니다. softmax가 정확히 어떻게 동작하는지는 중요하지 않습니다. 각 숫자를 행의 합으로 나누는 것보다 약간 더 복잡하지만, 결과는 동일합니다. 각 행의 합은 1이 되고, 각 값은 0과 1 사이가 됩니다.
softmax(
4행 4열 행렬(“masked”).
-0.08-∞-∞-∞ -0.10-0.54-∞-∞ 0.36-0.33-0.04-∞ 0.26-0.15-0.09-1.08
)
=
4행 4열 행렬(“weights”).
1.00 0.00 0.00 0.00 0.61 0.39 0.00 0.00 0.46 0.23 0.31 0.00 0.38 0.25 0.27 0.10
음의 무한대를 쓰는 이유를 설명하기 위해, softmax 구현을 코드로 보면:
function softmax(matrix) {
return matrix.map(row => {
const exps = row.map(x => Math.exp(x));
const sumExps = exps.reduce((a, b) => a + b, 0);
return exps.map(exp => exp / sumExps);
});
}
숫자를 단순히 합산한 뒤 나누는 게 아니라, 먼저 각 숫자에 Math.exp를 적용해 e^x로 바꿉니다. 만약 음의 무한대 대신 0을 썼다면 Math.exp(0) === 1이어서 0들도 가중치에 기여해 버립니다. 반면 Math.exp(-Infinity)는 0이 되므로, 우리가 원하는 대로 기여가 사라집니다.
아래 그리드는 “Mary had a little” 프롬프트의 어텐션 가중치 예시입니다. 그리드 셀에 호버하거나 클릭하면 각 토큰의 기여를 볼 수 있습니다. 이 가중치들은 위의 계산과 일치하지 않는데, 제가 환상적인 Transformer Explained 사이트에서 돌아가는 GPT-2 버전에서 가져왔기 때문입니다. 즉, 이건 실제(다만 오래된) 모델에서 나온 실제 가중치입니다.
Mary
had
a
little
Mary
had
a
little
1
.79
.21
.81
.13
.06
.63
.16
.12
.09
첫 번째 행에서는 “Mary”만 있으니 “had”에 대해 Mary가 100% 기여합니다. 두 번째 행에서는 “Mary”가 79%, “had”가 21% 기여해 “a”가 생성됩니다. 등등. 이 문장에서 LLM이 가장 중요하다고 생각하는 단어가 “Mary”라는 건(모든 행에서 Mary의 비중이 가장 큰 걸 보면) 놀랍지 않을 겁니다. “Jessica had a little”을 완성하라고 하면 “lamb”를 고르진 않겠죠.
남은 것은 토큰 임베딩을 실제로 섞는 작업인데, 다행히 가중치를 만드는 것보다는 훨씬 단순합니다.
js// 훈련 중에 학습되며 추론 중에는 변하지 않는다. // 이것도 n*n 행렬이며, n은 임베딩 차원 수. const WV = [[...], [...], ...]; function attention(embeddings) { const V = embeddings * WV; // 위 섹션의 attentionWeights 함수. // 이 attention 함수로 감싸고 있다. const weights = attentionWeights(embeddings); return weights * V; }
이전과 마찬가지로, 훈련 시점에 결정된 WV 행렬이 있습니다. 이를 사용해 토큰 임베딩에서 V 행렬을 얻습니다.
4행 3열 행렬(“embeddings”).
-0.06 0.13-0.30 0.95-0.49-0.64 -0.05 0.75 0.65 0.51-0.72 0.98
×
3행 3열 행렬(“WV”).
0.96 0.62-0.40 -0.90 0.06-0.55 -0.17-0.09 0.59
=
4행 3열 행렬(“V”).
-0.12-0.00-0.23 1.46 0.62-0.49 -0.83-0.04-0.01 0.97 0.18 0.78
그다음 우리가 만든 weights를 V에 곱하면, 새로운 임베딩 집합이 출력됩니다.
4행 4열 행렬(“weights”).
1.00 0.00 0.00 0.00 0.61 0.39 0.00 0.00 0.46 0.23 0.31 0.00 0.38 0.25 0.27 0.10
×
4행 3열 행렬(“V”).
-0.12-0.00-0.23 1.46 0.62-0.49 -0.83-0.04-0.01 0.97 0.18 0.78
=
4행 3열 행렬(“output”).
-0.12-0.00-0.23 0.50 0.24-0.33 0.02 0.13-0.22 0.20 0.16-0.14
어텐션 메커니즘의 최종 출력은 이 output 행렬의 마지막 행(last row)입니다. 이전 토큰들의 모든 컨텍스트 정보가 어텐션 과정을 통해 이 마지막 행에 섞여 들어갔습니다. 하지만 이를 위해 앞의 모든 행도 계산되어야 했습니다.
즉, 임베딩이 들어가 새 임베딩이 나옵니다. 어텐션 메커니즘은 훈련 중 학습한 WQ, WK, WV 행렬을 바탕으로 토큰들을 섬세한 수학 계산으로 섞어냅니다. 이것이 LLM이 컨텍스트 윈도우에서 무엇이 중요한지(왜 중요한지)를 아는 메커니즘입니다.
이제 드디어 캐싱에 대해 이야기할 준비가 끝났습니다.
제가 여기서 보여준 것은 프롬프트 캐싱에 중요한 것만 부각시키기 위해 단순화한(그렇습니다, 단순화한 겁니다) 어텐션 버전입니다. 실제로는 더 많은 요소가 있고, 더 깊게 파고들고 싶다면 3blue1brown의 어텐션 영상을 추천합니다.
이제 위의 그리드를 다시 보되, 이번에는 추론 루프에서 새 토큰이 생성될 때마다 어떻게 채워지는지 살펴봅시다. 재생 버튼을 눌러 애니메이션을 시작하세요.
새 토큰은 입력에 덧붙여지고, 전체가 다시 전부 재처리됩니다. 하지만 자세히 보세요. 애니메이션을 몇 번 반복해 보면 이전 가중치들은 전혀 바뀌지 않습니다. 2번째 행은 언제나 0.79와 0.21입니다. 3번째 행은 언제나 0.81, 0.13, 0.06입니다. 우리는 필요 없는 계산을 엄청나게 다시 하고 있습니다. “Mary had a little”에 대한 행렬 곱 대부분은, 바로 직전에 “Mary had a”를 처리한 상태라면 사실 필요하지 않습니다. 그리고 LLM의 추론 루프는 정확히 그런 식으로 동작합니다.
이 중복 계산을 피하려면 추론 루프에 두 가지 변경을 하면 됩니다:
K와 V 행렬을 캐시한다.이번에는 처음 4개 토큰의 K와 V 행렬이 캐시되어 있고, 우리는 토큰 하나의 임베딩만 넣는다고 가정하고 다시 행렬 곱을 훑어봅시다. 네, 또 행렬 수학입니다. 죄송합니다. 하지만 위와 대부분 같고, 빠르게 진행할 겁니다.
새 Q를 계산하면 출력은 한 행(row)만 나옵니다. WQ는 이전과 같습니다.
1행 3열 행렬(“embeddings”).
0.20-0.10 0.70
×
3행 3열 행렬(“WQ”).
-0.74 0.91-0.21 -0.51 0.43 0.18 -0.56-0.21 0.76
=
1행 3열 행렬(“Q(new)”).
-0.49-0.01 0.48
새 K를 계산해도 역시 한 행만 출력되고, WK도 동일합니다.
1행 3열 행렬(“embeddings”).
0.20-0.10 0.70
×
3행 3열 행렬(“WK”).
0.13-0.51-0.63 -0.79 0.54-0.04 0.33-0.10-0.87
=
1행 3열 행렬(“K(new)”).
0.34-0.23-0.74
그리고 이 새 행을 이전 반복에서 캐시해 둔 4개의 K 행에 덧붙입니다:
4행 3열 행렬(“cached K”).
-0.21 0.13 0.29 0.30-0.68-0.03 -0.39 0.36-0.56 0.96-0.74-1.15
append
1행 3열 행렬(“K(new)”).
0.34-0.23-0.74
=
5행 3열 행렬(“K”).
-0.21 0.13 0.29 0.30-0.68-0.03 -0.39 0.36-0.56 0.96-0.74-1.15 0.34-0.23-0.74
이제 프롬프트 전체 토큰에 대한 K 행렬이 생겼지만, 우리는 마지막 행만 계산했을 뿐입니다.
이 방식으로 새 scores를 구합니다:
1행 3열 행렬(“Q(new)”).
-0.49-0.01 0.48
×
3행 5열 행렬(“transpose(K)”).
-0.21 0.30-0.39 0.96 0.34 0.13-0.68 0.36-0.74-0.23 0.29-0.03-0.56-1.15-0.74
=
1행 5열 행렬(“scores(new)”).
0.24-0.16-0.08-1.01-0.52
그리고 새 weights:
softmax(
1행 5열 행렬(“scores(new)”).
0.24-0.16-0.08-1.01-0.52
)
=
1행 5열 행렬(“weights(new)”).
0.32 0.21 0.23 0.09 0.15
이 모든 과정에서 우리는 딱 필요한 것만 계산합니다. 이전 값은 전혀 재계산하지 않습니다. 이어서 새 V 행을 구합니다:
1행 3열 행렬(“embeddings”).
0.20-0.10 0.70
×
3행 3열 행렬(“WV”).
-0.74 0.91-0.21 -0.51 0.43 0.18 -0.56-0.21 0.76
=
1행 3열 행렬(“V(new)”).
-0.49-0.01 0.48
그리고 캐시해 둔 V에 이를 덧붙입니다:
4행 3열 행렬(“cached V”).
-0.21 0.13 0.29 0.30-0.68-0.03 -0.39 0.36-0.56 0.96-0.74-1.15
append
1행 3열 행렬(“V(new)”).
-0.49-0.01 0.48
=
5행 3열 행렬(“V”).
-0.21 0.13 0.29 0.30-0.68-0.03 -0.39 0.36-0.56 0.96-0.74-1.15 -0.49-0.01 0.48
마지막으로 새 weights와 새 V를 곱해 최종 새 embeddings를 얻습니다:
1행 5열 행렬(“weights(new)”).
0.32 0.21 0.23 0.09 0.15
×
5행 3열 행렬(“V”).
-0.21 0.13 0.29 0.30-0.68-0.03 -0.39 0.36-0.56 0.96-0.74-1.15 -0.49-0.01 0.48
=
1행 3열 행렬(“embeddings(new)”).
-0.08-0.09-0.08
우리가 필요했던 건 이 임베딩의 새 행 하나뿐이었습니다. 이전 토큰의 모든 컨텍스트 정보가, 캐시된 K와 V 덕분에 여기에 “구워져(baked)” 들어갔습니다.
캐시되는 데이터는 embeddings * WK와 embeddings * WV의 결과, 즉 K와 V입니다. 그래서 프롬프트 캐싱은 흔히 “KV 캐싱(KV caching)”이라고 불립니다.
캐시
5행 3열 행렬(“K”).
-0.21 0.13 0.29 0.30-0.68-0.03 -0.39 0.36-0.56 0.96-0.74-1.15 0.34-0.23-0.74
5행 3열 행렬(“V”).
-0.21 0.13 0.29 0.30-0.68-0.03 -0.39 0.36-0.56 0.96-0.74-1.15 -0.49-0.01 0.48
이게 전부입니다. 위의 K와 V 행렬이 바로 공급자들이 거대한 데이터센터에 저장해 두는 1과 0이며, 이를 통해 입력 토큰을 10배 저렴하게 제공하고 훨씬 빠른 응답을 만들어냅니다.
공급자들은 요청이 수행된 뒤 5~10분 동안 각 프롬프트의 이 행렬들을 유지하며, 같은 프롬프트로 시작하는 새 요청을 보내면 K와 V를 다시 계산하는 대신 캐시된 것을 재사용합니다. 정말 멋진 점은, 캐시 엔트리를 부분(prefix) 매칭해도 일치하는 부분만이라도 활용할 수 있다는 것입니다. 전체가 완전히 같을 필요가 없습니다.
아래 시각화는 비슷한 접두사를 가진 몇 개의 프롬프트를 라운드 로빈으로 돌며 캐시 엔트리가 어떻게 사용될 수 있는지를 보여줍니다. 그리고 가끔 캐시를 비워서 다시 차는 모습을 보여줍니다.
아직 캐시된 프롬프트가 없습니다.
OpenAI와 Anthropic은 서로 매우 다른 방식으로 캐싱을 합니다. OpenAI는 이를 전부 자동으로 처리하고, 가능하면 캐시 엔트리로 요청을 라우팅하려고 시도합니다. 제 실험에서는 요청을 보낸 뒤 즉시 다시 보내면 약 50% 정도의 적중률(hit rate)을 얻었습니다. 긴 컨텍스트 윈도우에서 time-to-first-byte가 커질 수 있음을 고려하면, 이는 성능의 일관성이 떨어질 수 있음을 의미합니다.
Anthropic은 더 많은 제어권을 제공합니다. 언제 캐시할지, 얼마나 오래 캐시할지 여러분이 결정할 수 있습니다. 이 특권에는 비용이 따르지만, 제 실험에서는 Anthropic은 프롬프트 캐싱을 요청했을 때 100% 캐시 엔트리로 라우팅해 주었습니다. 긴 컨텍스트 윈도우를 다루고 예측 가능한 지연 시간이 필요한 애플리케이션이라면 Anthropic이 더 적합한 선택일 수도 있습니다.
LLM 공급자들은 모델 출력의 랜덤성을 제어하기 위한 다양한 파라미터를 제공합니다. 대표적으로 temperature, top_p, top_k가 있습니다. 이 파라미터들은 모두 추론 루프의 마지막 단계, 즉 모델이 어휘(vocabulary)의 각 토큰에 할당한 확률을 기반으로 토큰을 선택하는 단계에 영향을 줍니다. 이는 어텐션 메커니즘이 최종 임베딩을 만들어낸 이후에 일어나므로, 프롬프트 캐싱은 이러한 파라미터의 영향을 받지 않습니다. 따라서 캐시된 프롬프트를 무효화할 걱정 없이 마음껏 변경해도 됩니다.
저는 이 글에서 소개한 모든 것을 배우는 과정이 정말 즐거웠습니다. LLM은 매혹적인 기술이고, 산업 전체적으로 우리가 할 수 있는 것의 표면만 긁은 수준이라고 생각합니다.
여기까지 읽었다면, 여러분은 우리의 신규 제품에 딱 맞는 완벽한 고객입니다: ngrok.ai. 하나의 통합 플랫폼으로, 클라우드든 로컬이든 어떤 LLM으로 향하는 트래픽이든 라우팅하고, 보안 적용하고, 관리하세요.
공지 포스트에서 더 알아보거나, 곧바로 문서로 들어가 보세요.
이 글을 쓰기 위해 필요한 것을 배우며 정말 많은 자료를 탐독했고, 그중 특히 도움이 된 것들은 다음과 같습니다:
이 글이 마음에 들었다면, 위 자료들도 분명히 즐길 수 있을 겁니다.