"온도는 0.7 정도가 무난하다"는 말은 어디서나 보인다. 그런데 0.7이 무엇을 얼마나 자르는지를 숫자로 본 적은 드물다. 이 글은 0.5B 모델에서 실제 로짓을 꺼내 온도가 확률 분포를 어떻게 바꾸는지 직접 잰 기록이다.
온도·top-k·top-p를 어떻게 골라 쓰는지는 /articles/llm-temperature-top-k-top-p가 맡고, 샘플링 방법의 갈래는 샘플링 전략이 맡는다. 이 글은 분포 자체의 측정값만 다룬다.
재는 값은 셋이다. 엔트로피는 분포가 얼마나 퍼져 있는지를 나타내는 값으로,
로 계산하며 단위는 nats다. 한 토큰만 확률 1이면 0이고,
어휘 전체에 고르게 퍼지면 최대가 된다. 이 모델의 어휘가 151,936개이므로 최댓값은
이다. 유효 어휘 수는 확률을 큰 것부터 더해 90%를
채우는 데 몇 개가 필요한지를 센 값이다 — 이 글에서는 n90 으로 적는다. 실제로 뽑힐
후보가 몇 개인지를 가장 직관적으로 보여 준다. top1은 1등 토큰의 확률이다.
실험대
무엇으로 쟀는지, 어떻게 다시 돌리는지, 나온 출력이 무엇인지를 한자리에 둔다.
측정 환경
| 항목 | 값 |
|---|---|
| OS | Ubuntu 24.04.4 LTS (Linux 6.18.5, x86_64) |
| CPU | Intel Xeon @ 2.80GHz, 4코어 / 메모리 15GB |
| Python | 3.11.15 |
| torch | 2.13.0 / transformers 5.15.1 / numpy 2.4.6 |
| 모델 | Qwen/Qwen2.5-0.5B-Instruct (리비전 7ae557604adf67be50417f59c2c2f167def9a775) |
| 측정일 | 2026-08-20 |
앞 글과 같은 모델을 써서 가중치를 한 번만 내려받았다. 이 글은 어텐션 행렬이 필요
없으므로 attn_implementation을 건드리지 않았고, 대신 dtype=torch.float32는
그대로 지정했다 — transformers 5.x는 기본이 bfloat16이고 확률을 소수 넷째 자리까지
볼 글에서는 정밀도가 아깝다.
재현 블록
pip install torch transformers numpy
문장 8개(영어 4, 한국어 4)를 넣고 모든 위치의 로짓을 한 번에 모아 134개 지점에서 잰다. 위치마다 따로 forward를 돌릴 필요가 없다 — 인과 마스크 덕분에 한 번의 forward가 각 위치에서 "그다음 토큰"의 로짓을 전부 내놓기 때문이다.
import time, torch, numpy as np
from transformers import AutoTokenizer, AutoModelForCausalLM
M = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(M)
m = AutoModelForCausalLM.from_pretrained(M, dtype=torch.float32).eval()
TXT = """The capital of France is Paris, and the capital of Italy is Rome.
Machine learning models are trained on large collections of text data.
Water boils at one hundred degrees Celsius under standard pressure.
The algorithm sorts the list by repeatedly comparing adjacent items.
프랑스의 수도는 파리이고 이탈리아의 수도는 로마입니다.
기계 학습 모델은 대규모 텍스트 데이터로 학습됩니다.
물은 표준 기압에서 섭씨 백 도에 끓기 시작합니다.
이 알고리즘은 인접한 항목을 반복해 비교하며 목록을 정렬합니다.""".split("\n")
t0 = time.time()
LOGITS = []
for t in TXT: # 모든 위치의 로짓을 한 번만 모은다
with torch.no_grad():
LOGITS.append(m(**tok(t, return_tensors="pt")).logits[0])
L = torch.cat(LOGITS)
print(f"문장 {len(TXT)}개 / 위치 {L.shape[0]}개 / 어휘 {L.shape[1]} — forward {time.time()-t0:.1f}초")
def stats(v, T):
p = torch.softmax(v / T, -1)
sp, si = torch.sort(p, descending=True)
H = -(p * torch.log(p.clamp_min(1e-12))).sum().item()
n90 = int((torch.cumsum(sp, 0) < 0.90).sum().item()) + 1
return H, n90, sp[0].item(), sp[:50].sum().item(), si[:50]
print("\n[표1] 온도별 — 위치 {}개 집계".format(L.shape[0]))
print(" T | 엔트로피(nats) | n90 중앙값 | top1 평균 | top-k=50이 남기는 질량")
for T in [0.2, 0.5, 0.7, 1.0, 1.1, 1.2, 1.3, 1.5, 2.0]:
r = [stats(L[i], T) for i in range(L.shape[0])]
H = np.array([x[0] for x in r]); n = np.array([x[1] for x in r])
t1 = np.array([x[2] for x in r]); mass = np.array([x[3] for x in r])
print(f" {T:<4} | {H.mean():8.4f}±{H.std():.3f} | {int(np.median(n)):9d} | {t1.mean():8.4f} | {mass.mean():.4f}")
print("\n[표2] 같은 온도(T=1.0)에서 위치가 만드는 차이 — 첫 문장")
one = LOGITS[0]; ids = tok(TXT[0], return_tensors="pt").input_ids[0]
for pos in range(one.shape[0]):
H, n, t1, _, _ = stats(one[pos], 1.0)
print(f" {pos:2d} {tok.convert_ids_to_tokens([ids[pos]])[0]!r:12s} 다음 → H={H:6.4f} n90={n:5d} top1={t1:.4f}")
print("\n[표3] 온도를 바꾸면 top-k와 top-p가 무엇을 자르는가 (첫 문장 5번 위치)")
v = one[4]; base = set(stats(v, 0.2)[4].tolist())
for T in [0.2, 0.5, 0.7, 1.0, 1.5, 2.0]:
H, n, t1, mass, si = stats(v, T)
print(f" T={T:<4} top-p 0.9가 남기는 토큰 {n:6d}개 | top-k 50이 남기는 질량 {mass:.4f} | top-k 50 집합 {len(set(si.tolist()) & base)}/50 동일")
python3 temp.py
실제 출력
문장 8개 / 위치 134개 / 어휘 151936 — forward 3.0초
[표1] 온도별 — 위치 134개 집계
T | 엔트로피(nats) | n90 중앙값 | top1 평균 | top-k=50이 남기는 질량
0.2 | 0.2510±0.399 | 1 | 0.9134 | 1.0000
0.5 | 0.9042±0.862 | 2 | 0.7447 | 0.9945
0.7 | 1.5198±1.242 | 4 | 0.6266 | 0.9696
1.0 | 2.7301±1.898 | 16 | 0.4662 | 0.8721
1.1 | 3.2404±2.081 | 43 | 0.4171 | 0.8233
1.2 | 3.8274±2.224 | 139 | 0.3688 | 0.7657
1.3 | 4.4942±2.317 | 629 | 0.3208 | 0.6992
1.5 | 5.9959±2.320 | 8888 | 0.2283 | 0.5480
2.0 | 9.2609±1.331 | 56256 | 0.0676 | 0.2185
[표2] 같은 온도(T=1.0)에서 위치가 만드는 차이 — 첫 문장
0 'The' 다음 → H=4.4315 n90= 894 top1=0.4478
1 'Ġcapital' 다음 → H=1.4585 n90= 6 top1=0.7279
2 'Ġof' 다음 → H=4.5047 n90= 226 top1=0.1877
3 'ĠFrance' 다음 → H=1.2235 n90= 3 top1=0.6992
4 'Ġis' 다음 → H=3.0844 n90= 25 top1=0.3022
5 'ĠParis' 다음 → H=1.5307 n90= 5 top1=0.5171
6 ',' 다음 → H=3.1886 n90= 29 top1=0.2197
7 'Ġand' 다음 → H=2.7410 n90= 35 top1=0.4204
8 'Ġthe' 다음 → H=4.9062 n90= 417 top1=0.1836
9 'Ġcapital' 다음 → H=0.1072 n90= 1 top1=0.9859
10 'Ġof' 다음 → H=2.7128 n90= 15 top1=0.3562
11 'ĠItaly' 다음 → H=0.0471 n90= 1 top1=0.9949
12 'Ġis' 다음 → H=0.3413 n90= 1 top1=0.9490
13 'ĠRome' 다음 → H=0.8362 n90= 3 top1=0.7943
14 '.' 다음 → H=4.9834 n90= 246 top1=0.0846
[표3] 온도를 바꾸면 top-k와 top-p가 무엇을 자르는가 (첫 문장 5번 위치)
T=0.2 top-p 0.9가 남기는 토큰 1개 | top-k 50이 남기는 질량 1.0000 | top-k 50 집합 50/50 동일
T=0.5 top-p 0.9가 남기는 토큰 4개 | top-k 50이 남기는 질량 0.9999 | top-k 50 집합 50/50 동일
T=0.7 top-p 0.9가 남기는 토큰 8개 | top-k 50이 남기는 질량 0.9962 | top-k 50 집합 50/50 동일
T=1.0 top-p 0.9가 남기는 토큰 25개 | top-k 50이 남기는 질량 0.9446 | top-k 50 집합 50/50 동일
T=1.5 top-p 0.9가 남기는 토큰 4612개 | top-k 50이 남기는 질량 0.5841 | top-k 50 집합 50/50 동일
T=2.0 top-p 0.9가 남기는 토큰 41273개 | top-k 50이 남기는 질량 0.2190 | top-k 50 집합 50/50 동일
전체 실행이 30초이고 그중 forward가 3.0초다. 나머지는 위치 134개 × 온도 9개에 대해 어휘 151,936개를 정렬하는 비용이다.
깨끗한 venv에서 다시 돌려 위 블록과 대조했다. 엔트로피와 개수는 마지막 자리까지 전부 같았다. 다른 것은 forward 시간 한 줄뿐으로, 재실행에서는 2.5초가 나왔다.
유효 어휘
확률을 큰 것부터 더해 90%를 채우는 데 드는 토큰 수, 곧 n90 이 온도와 위치에
각각 얼마나 흔들리는지를 차례로 본다.
온도별 곡선
표1의 n90 열만 따로 본다.
| T | 0.2 | 0.5 | 0.7 | 1.0 | 1.1 | 1.2 | 1.3 | 1.5 | 2.0 |
|---|---|---|---|---|---|---|---|---|---|
| n90 중앙값 | 1 | 2 | 4 | 16 | 43 | 139 | 629 | 8,888 | 56,256 |
0.2에서 1.0까지는 완만하다. 1 → 2 → 4 → 16으로, 온도가 다섯 배 오르는 동안 후보가 16개까지 늘어난다. 그런데 1.0을 넘으면 0.1씩 올릴 때마다 세 배씩 뛴다. 1.1에서 43, 1.2에서 139, 1.3에서 629다. 1.5에서는 8,888개로 네 자리가 되고, 2.0에서는 56,256개 — 어휘 전체의 37%가 후보가 된다.
엔트로피로도 같은 것이 보인다. T=2.0의 9.2609 nats는 균등분포의 최댓값 11.93에 대해 78%다. 온도 2.0은 "조금 창의적"이 아니라 분포를 거의 균등에 가깝게 뭉갠 상태다. 표준편차가 T=1.5의 2.320에서 T=2.0의 1.331로 오히려 줄어드는 것도 같은 이야기다 — 위치마다 다르던 값들이 전부 천장에 붙어 버려 구별이 사라진다.
top1 열은 이것을 다른 각도로 확인해 준다. T=0.7에서 1등 토큰이 평균 62.66%를
가져가는데 T=1.5에서는 22.83%다. 1등이 3분의 1로 줄어든 몫이 아래로 흩어진 것이다.
위치별 편차
표2가 이 글에서 가장 실용적인 부분이다. 온도를 하나로 고정하고(T=1.0) 문장 안의 위치만 옮겨 가며 잰 값이다.
같은 온도인데 n90 이 1개에서 894개까지 갈린다. 세 자릿수 차이다. 표1에서
온도를 0.2부터 1.3까지 흔들어 얻은 폭(1 → 629)과 맞먹는 폭이, 온도를 전혀 건드리지
않고 위치만 바꿔서 나온다.
무엇이 이 차이를 만드는지도 표에 그대로 적혀 있다.
- 문장 첫머리(
'The'다음, 0번)는n90이 894다. 아직 아무 단서가 없으니 모델이 무슨 말이든 할 수 있다. 'France'다음(3번)은 3이다. "The capital of France is"까지 왔으면 다음은 뻔하다.- 가장 극적인 자리는 9번과 11번이다.
'capital'다음인데n90이 1이고 엔트로피가 0.1072다. 그런데 같은'capital'다음인 1번 자리는n90이 6이고 엔트로피가 1.4585다. 같은 단어, 같은 온도, 14배 차이나는 엔트로피다.
차이를 만든 것은 문맥이다. 1번은 문장에서 capital이 처음 나온 자리라 뒤에 무엇이
올지 폭이 있고, 9번은 앞에서 "the capital of France is Paris"라는 틀이 이미
세워진 뒤라 "of"밖에 올 것이 없다. 인컨텍스트 학습이 분포를 좁히는 것을 엔트로피
숫자로 직접 본 자리다.
실무 함의는 분명하다. "온도 0.7이면 안전하다"는 문장은 분포의 평균에 대한 진술이지 개별 토큰에 대한 진술이 아니다. 문장 첫머리에서는 0.7도 충분히 넓고, 패턴이 잡힌 자리에서는 1.0도 사실상 결정적이다. 생성이 이상한 자리에서 튀는 이유를 온도 하나로만 설명할 수 없는 까닭이 여기 있다.
top-k와 top-p
같은 표3을 이번에는 자르는 장치 쪽에서 읽는다. 개수로 자르느냐 질량으로 자르느냐에 따라 온도에 반응하는 방향이 갈린다.
순서 보존
표3은 짧지만 구조를 하나 못 박는다. top-k 50 집합이 모든 온도에서 50/50 동일하다.
당연한 결과이고, 당연한 이유가 중요하다. 온도로 나누는 것은 로짓의 순서를 바꾸지 않는 변환이다. 에서 이면 의 크기 순서가 그대로 의 순서가 된다. 그러니 상위 50개가 누구인지는 온도와 무관하다. 온도 2.0에서든 0.2에서든 top-k=50은 정확히 같은 50개 토큰을 남긴다.
질량과 개수
반대로 그 50개가 얼마를 쥐고 있는지는 온도에 크게 흔들린다. T=0.2에서 1.0000, T=1.0에서 0.9446, T=1.5에서 0.5841, T=2.0에서 0.2190이다. 온도 2.0에서 top-k=50은 확률 질량의 78%를 버리고 남은 22%를 50개에 재정규화해 나눠 준다.
top-p는 정확히 반대다. 남기는 질량은 정의상 항상 0.9로 고정이고, 대신 몇 개를 남기는지가 1개에서 41,273개까지 움직인다.
| 온도가 바뀌면 | 고정되는 것 | |
|---|---|---|
| top-k | 남기는 질량이 1.0000 → 0.2190으로 무너진다 | 남기는 집합(항상 같은 50개) |
| top-p | 남기는 개수가 1개 → 41,273개로 폭발한다 | 남기는 질량(항상 0.9) |
그래서 둘을 같이 쓸 때 무엇이 먼저 걸리는지가 온도에 따라 뒤집힌다. 낮은 온도에서는 top-p가 먼저 걸려 top-k=50이 하는 일이 없고(T=0.5에서 top-p는 이미 4개로 줄였다), 높은 온도에서는 top-k가 먼저 걸려 top-p 0.9가 하는 일이 없다(T=1.5에서 top-p는 4,612개를 허용하는데 top-k가 50개로 자른다). 온도를 올리면 실질적으로 top-k만 남는다.
결론의 범위
어디까지가 공짜이고 어디부터 손해인지를 먼저 적고, 그다음 이 숫자가 덮지 못하는 자리를 밝힌다.
꺾이는 지점
T=1.0까지 유효 어휘는 두 자리(중앙값 16)에 머물고 top-k=50이 확률 질량의 87%를 지킨다. 여기까지는 사실상 공짜다. T=1.2에서 유효 어휘가 139로 세 자리가 되고 top-k=50이 버리는 질량이 23%로 늘어난다. 여기서부터 손해다. 1.5는 8,888개와 45%로, 다른 물건이라고 보는 편이 낫다.
그리고 이 임계값 전체보다 큰 변수가 하나 더 있다. 같은 온도에서 위치가 만드는
폭(1개894개)이 온도를 0.2에서 1.3까지 흔든 폭(1개629개)보다 크다. 온도를
고르기 전에 그 자리의 분포가 이미 좁은지 넓은지를 봐야 한다.
실험대의 경계
- 모델 하나, 문장 여덟 개, 위치 134개다. 0.5B 모델의 분포는 큰 모델보다 넓은 경향이 있다고 알려져 있고, 그렇다면 여기서 잰 임계값(1.2)은 큰 모델에서 더 위로 올라갈 수 있다. 이 실험은 그 방향을 확인하지 못했다.
- 생성 품질을 재지 않았다. 이 글이 잰 것은 전부 분포의 모양이다. 유효 어휘가 139개라는 것과 그 온도에서 나온 글이 좋다/나쁘다는 것은 다른 이야기다. 품질을 재려면 실제로 문장을 뽑아 평가해야 하고 그건 이 실험대 밖이다.
- 한 번의 forward에서 잰 값이다. 각 위치의 로짓은 정답 문장이 주어진 상태에서 나온 것이라, 실제 생성처럼 모델이 자기 출력을 다시 먹으며 나아갈 때의 분포와는 다르다. 자기 출력을 먹으면 분포가 더 좁아지는지 넓어지는지는 재지 않았다.
- 한국어와 영어를 갈라 결론 내지 않았다. 두 언어를 섞어 134개 위치를 집계했다. 따로 재면 한국어 쪽 엔트로피가 높게 나왔지만(2.85 대 2.28), 한국어는 토큰이 더 잘게 쪼개져 위치 수 자체가 다르고 단어 중간 위치의 비율도 달라진다. 앞 글에서 길이 때문에 언어 차이처럼 보인 값에 한 번 속았으므로, 이 비교는 통제를 제대로 설계하기 전까지 결론으로 쓰지 않는다.
- top-k와 top-p를 각각 따로 봤다. 둘을 함께 걸었을 때 어느 쪽이 먼저 적용되는지는 구현마다 다를 수 있고, 이 실험은 그 순서를 확인하지 않았다. 위의 "무엇이 먼저 걸리는가"는 각각의 임계값을 비교해 읽은 것이지 실행 순서를 잰 것이 아니다.
계획과의 차이
RESEARCH-PLAN.md는 이 글의 예상 수치로 T=0.7에서 유효 어휘 12개, T=1.0에서 329개를
적어 두고 "온도 0.7과 1.0 사이에서 유효 어휘가 27배 뛰는 구간"을 결론 후보로 들었다.
그 구간은 이 코퍼스에서 재현되지 않았다. 같은 두 온도 사이에서 중앙값은 4개에서
16개로 4배 늘 뿐이고, 폭발은 그보다 위인 1.2~1.5에서 일어난다.
원인은 측정 기준이 다르기 때문으로 본다. 계획의 값은 한 위치에서 잰 것이고
이 글의 값은 134개 위치의 중앙값이다. 표2가 보여 주듯 위치에 따라 n90 이 1에서
894까지 갈리므로, 어느 한 위치를 고르느냐로 27배는 쉽게 만들어진다. 실제로 표3의
5번 위치 하나만 보면 T=1.0에서 25개, T=1.5에서 4,612개로 184배가 나온다.
단일 위치의 수치를 온도의 성질로 옮겨 적으면 안 된다는 것이 이 대조의 교훈이고,
그래서 표1을 중앙값으로 낸 것이다.
자기검사
첫째, 처음에는 표1에 n90 의 평균을 적었다. 그런데 같은 134개 위치를 T=1.0에서
재면 평균이 336.1인데 중앙값은 16이다. 스무 배 넘게 어긋난다. 분포가 극단적으로
한쪽에 쏠려 있어서다 — 위치 대부분은 n90 이 두 자리 아래인데 문장 첫머리 같은 몇
자리가 최대 7,655까지 찍어 평균을 혼자 끌어올린다. 평균을 쓰면 "보통 이 정도"라는
인상을 주는데 그 값에 해당하는 위치는 거의 없다. 중앙값으로 바꾸고 흩어진 모양은
표2에서 따로 보이는 쪽을 골랐다.
둘째, 엔트로피 계산에서 을 만났다. 온도가 낮으면 많은 토큰의 확률이
float32에서 그냥 0이 되고, log가 -inf를 내면 가 nan이
된다. 실제로 T=0.2의 첫 위치에서 어휘 151,936개 중 14,416개가 정확히 0이었고
clamp_min 없이 계산한 엔트로피는 nan으로 찍혔다. clamp_min(1e-12)로 막으면
0.0001이 나온다. nan은 눈에 띄어서 다행이지 nansum 같은 함수를 무심코 썼으면
조용히 틀린 값이 나왔을 자리다.
셋째, 초안에서 top-k 집합 불변을 "당연하니 재지 않는다"고 넘길 뻔했다. 순서 보존은 수식에서 바로 나오지만, 그렇다고 코드가 그렇게 동작한다는 보장은 없다 (정렬 안정성, 동점 처리, 수치 오차가 낄 자리가 있다). 실제로 재서 50/50이 나오는 것을 확인하고 표에 넣었다. 재는 데 든 비용은 한 줄이었다.
읽어주셔서 감사합니다. 😊

