다음 토큰 고르기.
지난 편
3편에서 레이어를 여러 번 지나면 마지막 토큰 자리의 벡터에 지금까지의 문맥이 담기는 것을 봤다.
한 줄 결론
마지막 벡터로 어휘의 토큰마다 점수를 내고, 점수를 확률로 바꿔서 하나를 뽑는다.
어휘는 2편에서 본 토크나이저의 토큰 목록 전체를 말한다.
쉽게 보기
3편 끝에서 위치 15 <assistant>의 벡터는 마지막 레이어를 지나 시스템 지침과 질문의 문맥을 담게 됐다. 모델은 이 벡터 하나로 다음 토큰을 고른다. 과정은 세 단계다.
위치 15 <assistant>의 마지막 벡터 [ 0.57, -0.30, 0.09, ...] (3편의 가상 값)
|
| 1. 어휘의 토큰마다 점수를 하나씩 낸다
v
점수(로짓) 네 5.00 아니 4.15 토마토는 -1.20 <EOS> -4.00 ...
|
| 2. 점수를 확률로 바꾼다
v
확률 네 0.70 아니 0.30 나머지 거의 0
|
| 3. 확률대로 하나를 뽑는다
v
네 (토큰 ID 4512)
숫자는 모두 가상 값이고, 토큰 ID 4512는 2편 출력 표의 첫 토큰이다.
1. 점수 내기. 마지막 벡터에 학습된 숫자 표를 한 번 곱하면 어휘의 토큰마다 점수가 하나씩 나온다. 이 점수가 로짓이다. 공개 모델 Qwen2.5-7B라면 로짓이 152,064개 나온다(config.json의 vocab_size). 로짓이 높을수록 그 토큰이 다음 자리에 알맞다는 뜻이다. 끝 신호 <EOS>도 어휘의 한 항목이라서 똑같이 로짓을 받는다. 예시 답이라면 과일입니다. 다음 자리에서 <EOS>가 가장 높은 로짓을 받고, 이것이 뽑혀 반복이 멈춘다.
2. 확률로 바꾸기. 로짓은 음수도 되고 합계도 정해져 있지 않아서 그대로는 뽑기에 쓸 수 없다. 그래서 로짓을 확률로 바꾼다. 로짓이 높을수록 확률이 크고, 확률은 모두 0 이상이며 다 더하면 1이다. 3편의 어텐션이 비율을 만들 때와 같은 계산이다. 이렇게 토큰마다 붙은 확률 전체를 확률 분포라고 한다.
3. 하나 뽑기. 모델은 늘 확률이 가장 높은 토큰을 고르지는 않고, 확률에 따라 뽑는다. 이 예라면 열 번 중 일곱 번쯤은 네, 세 번쯤은 아니가 나온다. 같은 질문에 답이 달라지는 이유가 여기 있다. 첫 토큰이 아니가 되면 그 뒤 토큰은 아니를 입력에 붙인 채 고르므로 답 전체가 달라진다.
확률이 얼마나 한쪽으로 쏠리게 할지는 temperature로 정한다. temperature를 낮추면 로짓이 높은 토큰에 확률이 더 몰리고, 높이면 고르게 퍼진다. 0을 넣으면 보통 뽑지 않고 가장 높은 토큰을 고른다.
| temperature | 네 | 아니 |
|---|---|---|
| 0 | 1.00 | 0.00 |
| 0.5 | 0.85 | 0.15 |
| 1.0 | 0.70 | 0.30 |
| 2.0 | 0.60 | 0.40 |
나머지 토큰을 빼고 두 후보의 로짓만으로 계산한 가상 값이다. 1.0일 때가 로짓을 그대로 확률로 바꾼 값이다.
API를 쓸 때 알아 둘 점은 두 가지다.
- 같은 요청을 두 번 보내도 답이 다를 수 있다. 모델 출력 문자열을 그대로 비교하는 테스트는 깨지기 쉽다. temperature를 0으로 둬도 똑같은 답이 보장되지는 않는다(더 깊이 참고).
- Claude API 문서에서
temperature는 0.0~1.0이고 기본값은 1.0이다. 다만 같은 문서에 따르면 Claude Opus 4.6보다 나중에 나온 모델은temperature에 1.0이 아닌 값을 보내면 400 에러를 낸다. 이 필드는 문서에 Deprecated로 표시돼 있다. 값을 바꾸기 전에 지금 쓰는 모델이 Opus 4.6 이후에 나왔는지 확인한다.
새 용어
- 로짓: 마지막 벡터에서 계산한 점수로, 어휘의 토큰마다 하나씩 붙는다. 음수도 될 수 있고, 높을수록 그 토큰이 다음에 올 가능성이 크다.
- 확률 분포: 어휘의 토큰마다 0 이상의 확률을 하나씩 붙이고 다 더하면 1이 되게 한 것. 로짓을 바꿔서 만들고, 다음 토큰은 이 분포대로 뽑는다.
- temperature: 확률 분포가 얼마나 한쪽으로 쏠리게 할지 정하는 값. 낮을수록 로짓이 높은 토큰에 몰리고, 0이면 보통 가장 높은 토큰만 고른다.
더 깊이
로짓을 만드는 곱셈.
Hugging Face Transformers의 Qwen2 구현을 보면 마지막 레이어를 지난 벡터를 한 번 정규화한 뒤 lm_head라는 행렬을 곱한다. Qwen2.5-7B라면 lm_head는 숫자 3,584개(hidden_size)를 받아 152,064개(vocab_size)를 내놓는 크기다. 생성할 때는 맨 끝 위치의 로짓만 있으면 되므로 이 구현도 필요한 위치의 벡터만 골라 곱할 수 있게 돼 있다(logits_to_keep).
소프트맥스: 로짓을 확률로 바꾸는 함수.
쉽게 보기의 2단계 계산이 소프트맥스(softmax)다. 3편 더 깊이에서 어텐션 비율을 만들 때 쓴 함수와 같다. 로짓마다 e의 거듭제곱(Math.exp)을 구해 모두 0보다 크게 만든 뒤, 그 합으로 나눠 합을 1로 맞춘다. 거듭제곱이라서 로짓이 1 차이 나면 확률은 약 2.7배(e배) 차이 난다. 네와 아니의 로짓 차이 0.85는 확률 비율 약 2.3배(0.70 / 0.30)가 된다. 확률은 로짓의 차이로만 정해지므로 모든 로짓에서 같은 값을 빼도 결과가 같다. 구현에서는 가장 큰 로짓을 먼저 빼서 Math.exp가 너무 큰 수가 되지 않게 한다.
temperature는 소프트맥스 직전에 로짓을 나누는 값이다. Hugging Face Transformers의 TemperatureLogitsWarper도 로짓을 temperature로 나누기만 한다. 1보다 작은 값으로 나누면 로짓 차이가 커져서 높은 토큰에 확률이 더 몰리고, 1보다 큰 값이면 차이가 줄어 고르게 퍼진다. 0으로는 나눌 수 없어서, 0을 받는 서빙 엔진이나 API는 보통 가장 높은 로짓을 고르는 방식(greedy)으로 처리한다. Hugging Face Transformers는 0을 에러로 막고, greedy를 원하면 do_sample=False를 쓰라고 안내한다. 쉽게 보기의 표는 아래 함수로 계산했다.
function softmax(logits, temperature) {
const scaled = logits.map((z) => z / temperature);
// Math.max(...scaled)는 원소가 15만 개쯤이면 Node에서 콜 스택 초과 에러가 난다
const max = scaled.reduce((a, b) => Math.max(a, b), -Infinity);
const exps = scaled.map((z) => Math.exp(z - max));
const sum = exps.reduce((a, b) => a + b, 0);
return exps.map((e) => e / sum);
}
softmax([5.0, 4.15], 1.0); // [0.70..., 0.29...]
softmax([5.0, 4.15], 0.5); // [0.84..., 0.15...]
같은 temperature라도 허용 범위는 서비스마다 다르다. Claude가 이 값을 내부에서 어떻게 적용하는지는 공개되어 있지 않다.
top-p: 확률이 낮은 꼬리를 잘라 내고 뽑는다.
쉽게 보기에서는 나머지 토큰의 확률을 거의 0으로 봤지만, 소프트맥스를 거친 확률은 15만여 개 토큰 모두 0보다 크다. 하나하나는 작아도 합치면 무시하지 못할 크기가 될 수 있고, 그중 하나가 뽑히면 엉뚱한 토큰이 끼어든다. top-p는 확률이 높은 토큰부터 확률을 더해 가다가 합이 p 이상이 되는 지점까지만 남긴다. 예를 들어 확률이 0.50, 0.30, 0.15, 0.05인 토큰 넷이 있고 p가 0.9면, 앞 셋을 더한 0.95에서 멈추고 마지막 하나를 뺀다. 남은 셋의 확률을 다시 합이 1이 되게 맞춘 뒤 그 안에서 뽑는다(가상 값).
p가 같아도 남는 토큰 수는 자리마다 다르다. 확률이 한 토큰에 몰린 자리에서는 몇 개만 남고, 고르게 퍼진 자리에서는 많이 남는다. 이 방식은 The Curious Case of Neural Text Degeneration (Holtzman et al., 2019)이 Nucleus Sampling이라는 이름으로 제안했다. Hugging Face Transformers의 TopPLogitsWarper는 빠지는 토큰의 로짓을 음의 무한대로 바꾸는 방법으로 이 일을 한다. Claude API의 top_p도 문서에 같은 방식으로 설명돼 있고, Claude Opus 4.6보다 나중에 나온 모델은 0.99 이상만 받는다.
temperature를 0으로 둬도 같은 답이 보장되지 않는 이유.
temperature 0을 greedy로 처리하면 늘 가장 높은 로짓을 고르므로 같은 입력에는 같은 답이 나와야 할 것 같다. 그런데 Defeating Nondeterminism in LLM Inference (Thinking Machines, 2025)는 Qwen3-235B-A22B-Instruct-2507로 같은 프롬프트를 temperature 0에서 1,000번 생성해 서로 다른 답 80가지를 얻었다. 처음 달라진 곳은 103번째 토큰이었고, 992개는 Queens, New York, 8개는 New York City로 이어졌다.
흔한 설명은 GPU가 병렬로 계산하면서 부동소수점 덧셈 순서가 매번 바뀐다는 것이다. Thinking Machines 글은 이 설명이 완전히 틀리지는 않지만 원인의 전부는 아니라고 본다. 같은 데이터로 같은 행렬 곱을 되풀이하면 결과가 비트 단위까지 같고, 모델이 한 번 계산하는 데 쓰는 GPU 커널도 같은 입력에는 매번 같은 결과를 낸다.
Thinking Machines가 꼽는 원인은 배치 크기, 곧 서버가 한 번에 묶어 처리하는 요청 수다. 부동소수점 덧셈은 순서에 따라 결과가 다르다. 글의 예로 (0.1 + 1e20) - 1e20은 0이고 0.1 + (1e20 - 1e20)은 0.1이다. 행렬 곱, RMSNorm(벡터의 크기를 맞추는 계산), 어텐션처럼 많은 수를 더하는 계산은 배치 크기에 따라 덧셈을 나누는 방식을 바꾼다. 예를 들어 배치가 작아 GPU에 여유가 있으면 더할 구간을 여러 조각으로 나눠 동시에 더한다(split-K). 그러면 같은 요청이라도 배치 크기에 따라 덧셈 순서가 바뀌고 로짓이 아주 조금 달라진다. 1등과 2등의 로짓이 거의 같은 자리에서는 이 작은 차이로 1등이 바뀌고, 그 뒤로는 답 전체가 달라진다.
배치 크기는 그 순간 서버에 몰린 요청 수에 따라 정해진다. vLLM 같은 일반적인 서빙 엔진은 여러 사용자의 요청을 묶어 한 번에 계산한다. 다른 요청의 내용이 섞여 드는 것은 아니지만, 사용자 쪽에서 보면 같은 요청의 결과가 그때그때 달라진다.
Thinking Machines는 배치 크기와 상관없이 덧셈 순서를 고정한 커널(batch-invariant kernel)로 바꿔 1,000번 모두 같은 답을 얻었다. 대신 느려졌다. Qwen3-8B로 1,000개를 생성할 때 vLLM 기본 설정은 26초, 이 커널과 개선한 어텐션 커널을 함께 쓴 버전은 42초가 걸렸다.
Claude API 문서도 temperature가 0.0이어도 결과가 완전히 결정적이지는 않다고 밝힌다. 다만 원인은 밝히지 않으므로 위 설명이 Claude에 그대로 해당하는지는 알 수 없다.