지난 글에서 한 언어를 다른 언어로 변환하는 기계 번역을 살펴봤다. 번역도 사실 텍스트 생성의 특수한 형태다. 이번에는 더 일반적인 의미의 텍스트 생성 자체를 다룬다. 모델이 학습을 마치고 나면 파라미터는 더 이상 바뀌지 않는다. 그런데도 같은 프롬프트에 같은 모델이 매번 다른 글을 내놓고, 어떤 설정에서는 같은 말을 끝없이 되풀이하고, 어떤 설정에서는 문법이 무너진다. 그 차이를 만드는 것이 전부 디코딩이다 — 학습된 확률 분포에서 실제 토큰을 어떻게 골라낼 것인가를 정하는 절차다. 이 글은 그 절차를 손잡이 단위로 연다.
로짓에서 토큰까지
조건부 확률의 곱
현대 텍스트 생성 모델은 자동회귀 방식으로 동작한다. 이전에 생성한 모든 토큰을 조건으로 삼아 다음 토큰의 분포를 계산하고, 거기서 하나를 골라 입력 끝에 붙인 뒤 같은 일을 반복한다. 문장 하나의 확률은 그래서 토큰별 조건부 확률의 곱이 된다.
여기서 짚어 둘 것이 있다. 모델이 한 스텝에 내놓는 것은 문장도 단어도 아니라 어휘 전체에 매긴 점수 하나씩, 곧 로짓이다. 어휘가 5만이면 스텝마다 5만 개의 실수가 나오고 소프트맥스가 그것을 합이 1인 확률로 바꾼다. 학습이 정해 놓은 것은 여기까지다. 그 분포에서 무엇을 뽑을지는 학습이 아니라 디코딩이 정하고, 디코딩은 파라미터를 하나도 건드리지 않는다. 같은 체크포인트가 설정만 바꿔 전혀 다른 글을 쓰는 이유가 이것이다.
온도
온도는 소프트맥스에 들어가기 직전 로짓을 상수 로 나누는 조작이다. 나눗셈 하나지만 분포 모양을 통째로 바꾼다. 다음 토큰의 확률이 좋다 0.40 · 맑다 0.25 · 흐리다 0.12 · 춥다 0.08 · 덥다 0.05로 나왔다고 하자. 이 다섯만 놓고 다시 정규화해 비교하면 온도가 하는 일이 눈에 보인다.
| 토큰 | |||
|---|---|---|---|
| 좋다 | 0.44 | 0.65 | 0.36 |
| 맑다 | 0.28 | 0.25 | 0.26 |
| 흐리다 | 0.13 | 0.06 | 0.16 |
| 춥다 | 0.09 | 0.03 | 0.12 |
| 덥다 | 0.06 | 0.01 | 0.09 |
이면 1등과 나머지의 격차가 벌어져 분포가 뾰족해지고, 이면 격차가 좁혀져 평평해진다. 중요한 것은 순위가 절대 바뀌지 않는다는 점이다. 온도는 후보를 갈아 치우는 장치가 아니라 이미 정해진 순위에 얼마나 순종할지를 정하는 값이다. 의 극한이 곧 1등만 고르는 그리디 디코딩이고, 온도 0이라고 적힌 API 설정은 대개 그 극한을 가리킨다. 반대로 를 1.5 위로 올리면 위 표에 안 나오는 꼬리 토큰 수만 개가 함께 부풀어 오르기 때문에, 문장이 창의적이 되는 것이 아니라 그냥 무너진다.
top-k와 top-p
그래서 온도만으로는 부족하고, 뽑을 후보 집합 자체를 자르는 장치가 따로 붙는다. top-k는 확률 상위 개만 남기고 나머지를 버린 뒤 그 안에서 확률에 비례해 뽑는다. 위 예에서 이면 좋다·맑다·흐리다 셋만 남고 다시 정규화되어 0.52 · 0.32 · 0.16이 된다. 문제는 가 고정이라는 데 있다. 모델이 확신하는 자리에서는 2등 아래가 억지로 끌려 들어오고, 모델이 헤매는 자리에서는 그럴듯한 후보 열댓 개 중 셋만 남는다.
top-p는 개수 대신 누적 확률로 자른다. 확률이 높은 토큰부터 더해 가다가 합이 를 넘는 순간 멈추므로, 남는 후보 수가 분포 모양에 따라 저절로 달라진다. 위 예에서 면 0.40 + 0.25 + 0.12 + 0.08 + 0.05 = 0.90이라 다섯 개가 남고, 이면 네 개에서 끊긴다. 1등이 혼자 0.95를 차지하는 자리에서는 같은 가 후보를 하나만 남긴다. 분포가 뾰족하면 좁게, 평평하면 넓게 — 이 자동 조절이 top-p가 표준 설정으로 자리 잡은 이유다.
셋은 서로 배타적이지 않고 보통 함께 걸린다. 순서는 온도로 분포를 다시 그린 다음, 잘라 내고, 남은 것에서 뽑는 것이다.
output = model.generate(
input_ids,
max_new_tokens=200,
do_sample=True,
temperature=0.8,
top_p=0.9,
top_k=0, # 0이면 top-k를 끄고 top-p만 쓴다
)
빔 서치
빔 폭과 누적 점수
샘플링이 매 스텝 하나를 뽑고 되돌아보지 않는 것과 달리, 빔 서치는 후보 시퀀스 여러 개를 동시에 끌고 간다. 빔 폭이 4면 스텝마다 살아 있는 네 문장을 각각 확장해 나온 후보 전체를 누적 로그 확률로 줄 세우고 다시 상위 넷만 남긴다. 한 스텝에서 확률이 조금 낮은 토큰이 다음 스텝에서 크게 만회하는 경로를 살릴 수 있어서, 번역이나 요약처럼 정답이 하나로 좁혀지는 과제에서 그리디보다 꾸준히 낫다.
반대로 이야기를 지어내는 자리에서는 빔 서치가 오히려 나쁘다. 가장 그럴듯한 문장은 대개 가장 평범한 문장이라서, 빔 폭을 키울수록 결과가 무난해지고 서로 비슷해진다. 사람이 쓴 글의 토큰 확률은 높은 구간과 낮은 구간을 오르내리는데, 빔 서치의 출력은 그 오르내림이 없이 평탄하다.
비용도 폭에 그대로 비례한다. 빔 폭 4는 스텝마다 네 개의 문맥을 함께 끌고 가는 것이므로 연산량도 KV 캐시도 네 배다. 그리고 폭을 키운 만큼 품질이 계속 오르지도 않는다 — 번역에서 폭 4와 10의 차이는 폭 1과 4의 차이보다 훨씬 작고, 더 키우면 앞서 말한 평탄화가 먼저 나타나 도리어 떨어지는 구간이 온다. 폭을 정하는 기준은 「클수록 좋다」가 아니라 그 과제에서 이득이 멈추는 지점이다.
길이 편향
빔 서치의 점수는 로그 확률의 합이다. 로그 확률은 언제나 음수이므로 토큰을 하나 더 붙일 때마다 점수가 내려간다. 길이가 다른 후보를 이 합으로 비교하면 짧은 쪽이 구조적으로 유리하다. 8토큰 후보의 합이 이고 20토큰 후보의 합이 이면 8토큰짜리가 이기는데, 토큰당 평균을 내면 대 로 승부가 뒤집힌다. 번역기가 문장 뒷부분을 통째로 잘라 먹는 고전적인 고장이 대부분 여기서 나온다.
길이 보정 계수
그래서 합을 길이의 거듭제곱으로 나눈다.
가 length_penalty다. 0이면 나눗셈이 없어져 원래의 합, 곧 짧은 답 선호로 돌아가고, 1이면 토큰당 평균 로그 확률이 되어 길이가 점수에서 빠진다. 1보다 크면 긴 후보가 유리해진다. 요약처럼 답이 짧게 끊기는 것이 문제인 과제에서 1.22.0을 쓰고, 반대로 늘어지는 것이 문제면 0.60.8로 내린다. 값을 바꾸면 길이 분포가 통째로 움직이므로 한 번에 0.2씩만 옮기고 출력 길이의 중앙값을 함께 본다.
반복
가능도 함정
생성 텍스트의 가장 흔한 고장은 반복이다. 같은 구절이 나오고, 다시 나오고, 끝나지 않는다. 원인은 모델이 고장 난 것이 아니라 가능도 함정이다. 자동회귀 모델은 직전 문맥을 조건으로 다음 토큰을 예측하는데, 어떤 구절이 한 번 나오면 그 구절 자체가 문맥에 들어가 같은 구절의 확률을 올린다. 두 번 나오면 더 올라간다. 확률이 올라간 만큼 다시 뽑힐 가능성이 커지므로 이 되먹임은 저절로 강해지고, 그리디처럼 1등만 고르는 디코딩에서는 한 번 들어간 루프를 빠져나올 통로가 아예 없다.
그래서 반복은 온도를 올리면 조금 줄지만 근본적으로 없어지지는 않는다. 확률이 자기 강화되는 구조 자체를 건드려야 한다.
세 가지 패널티
이름이 비슷한 손잡이 셋이 각각 다른 것을 벌한다.
no_repeat_ngram_size=3— 이미 나온 3-gram이 다시 나오지 못하게 로짓을 로 만든다. 확률을 낮추는 것이 아니라 아예 막는 하드 제약이다.repetition_penalty=1.2— 이미 등장한 토큰의 로짓을 일괄로 깎는다. 몇 번 나왔는지는 보지 않고 나왔는지만 본다.frequency_penalty·presence_penalty— 앞의 것은 등장 횟수에 비례해 깎고, 뒤의 것은 한 번이라도 나왔으면 같은 크기로 깎는다. OpenAI 계열 API가 이 두 이름을 쓴다.
하드 제약이 제일 세 보이지만 부작용도 제일 크다. 「대한민국의 수도는」처럼 정당하게 두 번 나와야 하는 3-gram까지 막히므로, 같은 개체를 반복해 언급해야 하는 요약·표 생성에서 문장이 어색하게 비틀린다. repetition_penalty는 1.0이 무효이고 1.1~1.3이 실용 구간인데, 1.5를 넘기면 이미 쓴 조사와 어미까지 눌려 한국어 문장이 먼저 무너진다.
값을 정하는 순서
반복이 보이면 패널티부터 올리고 싶어지지만 순서를 반대로 잡는 편이 낫다. 먼저 온도와 top-p를 조금 올려 분포가 충분히 넓은지 보고, 그래도 루프가 남으면 repetition_penalty를 1.1에서 0.05씩 올린다. no_repeat_ngram_size는 마지막에, 그것도 반복 단위가 실제로 3~4토큰일 때만 켠다. 그리고 값을 하나 바꿀 때마다 반복만 보지 말고 문장이 성립하는지를 함께 읽어야 한다. 반복 지표는 좋아지는데 글이 읽히지 않는 구간이 반드시 있다.
그 구간을 눈으로만 찾지 않으려면 지표를 하나 정해 두는 편이 낫다. 출력에서 서로 다른 4-gram의 비율을 세면 반복의 정도가 숫자로 잡히고, 같은 프롬프트 50개를 같은 설정으로 돌려 그 비율의 중앙값을 비교하면 설정 둘 중 어느 쪽이 나은지가 한 줄로 정리된다. 다만 이 비율은 올리기가 너무 쉽다 — 패널티를 극단으로 올리면 문장이 무너지면서 비율이 1에 가까워진다. 그래서 반복 지표는 언제나 출력 몇 편을 실제로 읽는 일과 함께 본다.
새로운 샘플링 전략
contrastive search
top-p는 확률만 본다. contrastive search는 여기에 하나를 더 본다 — 후보 토큰을 넣었을 때의 은닉 표현이 앞서 만든 토큰들의 표현과 얼마나 닮았는지다. 상위 개 후보마다 확률에서 최대 코사인 유사도를 빼서 점수를 매기고, 그 점수의 1등을 결정적으로 고른다. 이미 쓴 것과 비슷한 말은 확률이 아무리 높아도 감점되므로 반복이 구조적으로 억제되고, 샘플링이 아니라서 같은 입력에 같은 출력이 나온다. 대신 스텝마다 후보들의 표현을 비교해야 해서 느리고, 억제 계수를 0.6 근처보다 올리면 이번에는 문맥과 동떨어진 단어를 골라 오기 시작한다.
min-p
min-p는 잘라 낼 기준선을 1등 확률에 비례해 잡는다. min_p=0.1이면 임계값이 1등 확률의 10분의 1이다. 1등이 0.9를 쥔 자리에서는 0.09 미만이 전부 잘려 후보가 한둘만 남고, 1등이 0.2뿐인 헤매는 자리에서는 0.02 위가 전부 살아남아 후보가 넓게 열린다. top-p가 누적 합으로 간접적으로 해내던 자동 조절을 더 직접적으로 하는 셈이다. 계산이 비교 한 번으로 끝나 온도를 1.0 위로 크게 올려 쓰는 설정과 궁합이 좋고, 실제로 온도를 높게 두는 창작용 설정에서 top-p 대신 쓰이는 경우가 늘었다.
typical sampling
typical sampling은 확률이 높은 토큰이 아니라 그 분포의 평균적인 정보량에 가까운 토큰을 남긴다. 사람이 쓴 글을 보면 토큰이 전부 예측 가능한 것도 아니고 전부 놀라운 것도 아니라, 놀라움의 크기가 그 자리의 엔트로피 근처를 오간다. 그래서 각 토큰의 와 분포 엔트로피의 차이를 재고 그 차이가 작은 것부터 누적 확률이 채워질 때까지 남긴다. 확률 1등이라도 그 자리에서 지나치게 뻔하면 빠질 수 있다는 점이 top-p와 결정적으로 다르다. 이야기 생성처럼 평탄함 자체가 결함인 과제에서 효과가 보이고, 사실을 또박또박 답해야 하는 자리에서는 쓸 이유가 없다.
제약 디코딩
토큰 마스킹
출력이 JSON이어야 하거나 정해진 목록 안의 값이어야 할 때, 프롬프트로 부탁하는 대신 디코딩 단계에서 막아 버릴 수 있다. 원리는 단순하다. 지금까지 만든 문자열을 상태 기계에 넣어 다음에 올 수 있는 문자 집합을 구하고, 그 집합과 맞지 않는 토큰의 로짓을 전부 로 눌러 소프트맥스 뒤 확률을 0으로 만든다. 모델은 허용된 토큰 중에서만 뽑으므로 형식이 깨지는 일이 원천적으로 없다. 재시도와 검증 루프를 없애 주기 때문에 실패율이 아니라 실패 자체를 지우는 방식이다.
문법과 스키마
막는 규칙은 세 층위로 쓴다. 정규식은 날짜·전화번호처럼 모양이 고정된 값에 쓰고, 문맥 자유 문법은 중첩이 있는 구조에 쓰고, JSON 스키마는 문법으로 컴파일된 뒤 같은 장치를 탄다. 어휘가 5만이면 상태마다 허용 토큰을 매번 훑는 비용이 만만치 않으므로, 실제 구현은 상태와 허용 토큰 집합의 대응표를 미리 만들어 두고 스텝마다 찾아 쓴다. 이 표를 만드는 시간이 첫 호출에 한 번 붙고 그 뒤로는 거의 공짜가 된다.
품질에 미치는 값
제약은 형식을 보장하지만 내용까지 보장하지는 않는다. 오히려 주의할 자리가 둘 있다. 하나는 스키마가 결론을 먼저 적게 강제하는 경우다. 모델이 근거를 적으며 답을 좁혀 가는 경로가 막히면 같은 모델이 같은 질문에 더 얕은 답을 낸다 — 그래서 추론이 필요한 과제의 스키마에는 결론 필드 앞에 자유 텍스트 필드를 하나 두는 편이 낫다. 다른 하나는 열거형이다. 허용 값을 다섯 개로 묶어 두면 모델은 그중 하나를 반드시 고르므로, 「해당 없음」을 넣지 않으면 애매한 입력이 조용히 오답으로 분류된다. 막는 장치는 모르겠다고 말할 자리를 남겨 두어야 한다.
생성 속도
KV 캐시
자동회귀 생성은 스텝마다 전체 문맥을 다시 넣는다. 아무 장치가 없으면 번째 토큰을 만들 때 앞의 개에 대한 키와 값을 처음부터 다시 계산해야 하고, 전체 비용이 길이의 제곱으로 커진다. KV 캐시는 한 번 계산한 키·값을 그대로 들고 있다가 새 토큰의 것만 덧붙인다. 스텝당 비용이 길이에 비례하는 수준으로 내려가고, 이것이 없으면 긴 답을 실시간으로 받아 볼 수 없다.
값은 메모리로 치른다. 32층에 KV 헤드 8개, 헤드 차원 128인 모델을 fp16으로 돌리면 토큰 하나가 키와 값 합쳐 바이트, 곧 128KB를 차지한다. 문맥 4,096토큰이면 한 요청에 512MB다. 동시 요청 열 개를 받는 순간 5GB가 가중치와 별도로 필요해진다는 뜻이고, 긴 문맥 서비스에서 GPU 메모리를 먼저 채우는 것이 가중치가 아니라 이 캐시인 이유가 여기 있다.
연속 배칭
여러 요청을 묶어 한 번에 처리하면 GPU 사용률이 올라간다. 그런데 요청마다 답 길이가 달라서, 배치를 통째로 묶어 놓으면 짧은 답이 이미 끝났는데도 가장 긴 답이 끝날 때까지 그 자리가 놀게 된다. 연속 배칭은 스텝 단위로 배치를 다시 짠다. 끝난 시퀀스를 그 자리에서 빼고 대기열의 새 요청을 바로 끼워 넣으므로, 빈자리가 다음 스텝까지 남지 않는다. 답 길이가 들쭉날쭉한 실제 트래픽일수록 이득이 크고, 길이가 고른 배치 실험에서는 차이가 거의 안 보인다.
추측 디코딩
한 토큰을 만들 때마다 큰 모델의 전체 순전파가 한 번씩 든다. 추측 디코딩은 작은 초안 모델에 앞으로 올 토큰 몇 개를 먼저 써 보게 하고, 큰 모델이 그 초안 전체를 한 번의 순전파로 검증한다. 맞은 접두사는 그대로 채택하고 처음 틀린 자리부터 버린다. 검증에 기각 샘플링을 쓰기 때문에 출력 분포가 큰 모델 단독으로 뽑았을 때와 같다는 것이 이 방법의 핵심이다 — 품질을 깎아 속도를 사는 거래가 아니다. 이득의 크기는 채택률이 정하고, 채택률은 두 모델이 얼마나 닮았는지와 문장이 얼마나 예측 가능한지에 달렸다. 코드나 정형 문서처럼 뻔한 구간이 많은 입력에서 잘 듣고, 자유로운 창작에서는 초안이 자주 기각되어 검증 비용만 더해질 수 있다.
평가
퍼플렉시티
언어 모델의 기본 지표는 퍼플렉시티다. 테스트 텍스트의 평균 음의 로그 확률을 지수로 되돌린 값이고, 모델이 그 텍스트를 볼 때 매 자리에서 몇 개 중 하나를 고르는 셈인지를 나타낸다. 낮을수록 좋다.
import math
import torch
def compute_perplexity(model, tokenizer, text: str) -> float:
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
loss = model(**inputs, labels=inputs["input_ids"]).loss
return math.exp(loss.item())
문제는 이 값이 모델을 재는 지표이지 출력을 재는 지표가 아니라는 점이다. 생성한 텍스트의 퍼플렉시티를 재면 그리디로 뽑은 글이 가장 낮게 나오는데, 그 글이 바로 앞에서 본 반복 루프다. 사람이 쓴 글의 퍼플렉시티는 오히려 그보다 높다. 그래서 생성 품질을 퍼플렉시티로 줄 세우면 가장 지루한 설정이 1등을 한다. 이 값은 같은 테스트 말뭉치에서 모델 A와 B를 견줄 때 쓰고, 디코딩 설정을 고르는 데는 쓰지 않는다.
MAUVE
그래서 한 문장씩 점수를 매기는 대신 분포끼리 견주는 지표가 나왔다. MAUVE는 모델이 쓴 글 뭉치와 사람이 쓴 글 뭉치를 같은 임베딩 공간에 올린 뒤, 두 분포가 서로를 얼마나 못 덮는지를 양쪽 방향으로 재서 하나의 값으로 묶는다. 모델이 안전한 말만 골라 좁은 영역에 몰려 있어도 감점되고, 아무 말이나 뱉어 사람 글이 없는 영역까지 퍼져도 감점된다. 값이 1에 가까울수록 두 분포가 겹친다. 디코딩 설정을 비교할 때 퍼플렉시티보다 이쪽이 맞는 도구인 이유는, 반복 루프와 문장 붕괴가 둘 다 벌점을 받는 유일한 구조이기 때문이다.
사람 쌍 비교
결국 마지막 판정은 사람이 한다. 방식은 절대 점수보다 쌍 비교가 안정적이다. 같은 프롬프트에 설정 둘의 출력을 나란히 놓고 어느 쪽이 나은지만 고르게 하면, 평가자마다 점수 기준이 다른 문제가 사라지고 승률 하나로 결과가 정리된다. 볼 것이 셋 있다. 프롬프트를 몇 개나 썼는지, 좌우 배치를 섞었는지, 같은 쌍을 둘 이상이 봤을 때 의견이 얼마나 갈렸는지다. 승률 55%라는 숫자는 프롬프트 30개로 잰 값과 300개로 잰 값이 전혀 다른 뜻이고, 평가자 간 일치율이 낮으면 그 과제에서는 애초에 우열이 없다는 뜻일 수 있다.
디코딩은 학습이 끝난 뒤에 붙는 얇은 층처럼 보이지만, 사용자가 실제로 읽는 글자를 고르는 것은 이 층이다. 다음 글에서는 생성된 텍스트를 이해하는 쪽으로 넘어가, 대명사가 가리키는 대상을 추적하는 지시 해소를 다룬다.
읽어주셔서 감사합니다. 😊

