왜 반복이 문제가 되는가

자기회귀(autoregressive) 언어 모델은 직전까지 생성한 토큰을 조건으로 다음 토큰의 확률을 예측한다. 이 구조에서 특정 토큰이나 구문이 한 번 높은 확률을 얻으면, 그 토큰이 다시 컨텍스트에 들어가 확률을 더 끌어올리는 양성 피드백이 생긴다. 결과적으로 "그리고 그리고 그리고"처럼 같은 단어가 반복되거나, 문장 단위로 같은 내용이 순환한다. 특히 greedy나 낮은 temperature 디코딩, 짧은 프롬프트, 파인튜닝이 부족한 소형 모델에서 두드러진다.

이 문제는 샘플링 파라미터만으로는 완전히 잡히지 않는다. temperature를 올리면 반복은 줄지만 사실성과 일관성이 함께 무너진다. 그래서 반복 자체를 겨냥한 페널티 계열 파라미터를 쓴다.

페널티의 종류와 수식

실무에서 마주치는 파라미터는 세 가지다. 동작 방식이 다르므로 혼동하면 안 된다.

파라미터적용 방식범위(관행)
repetition_penalty이미 등장한 토큰의 로짓을 곱셈으로 나눔1.0~1.3
frequency_penalty등장 횟수에 비례해 로짓을 뺄셈0.0~2.0
presence_penalty등장 여부(0/1)로 로짓을 뺄셈0.0~2.0

repetition_penalty는 HuggingFace/vLLM 계열에서 쓰이며 로짓 부호에 따라 나눗셈/곱셈을 다르게 적용한다. frequency/presence penalty는 OpenAI 호환 API에서 쓰인다. 핵심 차이는 곱셈(스케일) 대 뺄셈(오프셋), 그리고 빈도 반영 여부다.

직접 구현으로 이해하기

repetition_penalty의 표준 구현은 다음과 같다. 양수 로짓은 나눠서 낮추고, 음수 로짓은 곱해서 더 낮춘다.

import torch

def apply_repetition_penalty(logits, generated_ids, penalty=1.15):
    # logits: [vocab], generated_ids: 지금까지 생성된 토큰 텐서
    score = torch.gather(logits, 0, generated_ids)
    # 양수는 나누고 음수는 곱해서 일관되게 확률을 낮춘다
    score = torch.where(score > 0, score / penalty, score * penalty)
    logits.scatter_(0, generated_ids, score)
    return logits

penalty=1.0이면 아무 효과가 없고, 값이 커질수록 이미 나온 토큰이 강하게 억제된다. 단순 곱셈이라 로짓 스케일에 민감하다는 점이 뒤에서 다룰 함정의 원인이다.

서빙 스택별 설정 예시

vLLM은 OpenAI 호환 엔드포인트에서 repetition_penalty와 frequency/presence penalty를 모두 받는다. 요청 단위로 지정하는 예시다.

curl http://localhost:8000/v1/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "my-model",
    "prompt": "제품 설명을 작성해줘:",
    "max_tokens": 300,
    "temperature": 0.7,
    "repetition_penalty": 1.15,
    "frequency_penalty": 0.3,
    "presence_penalty": 0.0
  }'

배치 서빙 기본값은 설정 파일이나 게이트웨이 레벨에서 관리하되, 태스크별 오버라이드를 허용하도록 한다. 요약처럼 원문 어휘 재사용이 필요한 작업과 자유 생성은 요구되는 값이 다르기 때문이다.

튜닝 절차

값을 감으로 정하지 말고 측정 가능한 지표로 좁힌다. 실무에서 쓰는 순서는 다음과 같다.

  • 대표 프롬프트 50~100개를 고정 시드로 생성해 베이스라인을 만든다.
  • repetition_penalty를 1.0부터 0.05씩 올리며 반복 지표를 관찰한다.
  • 반복 지표와 품질 지표(사실성, 평가 점수)를 함께 본다. 한쪽만 보면 과튜닝된다.

반복 정량화는 n-gram 중복률이 실용적이다.

def distinct_n(text, n=3):
    tokens = text.split()
    ngrams = [tuple(tokens[i:i+n]) for i in range(len(tokens)-n+1)]
    if not ngrams:
        return 1.0
    return len(set(ngrams)) / len(ngrams)  # 1.0에 가까울수록 반복 적음

distinct-3가 0.8 밑으로 떨어지면 반복이 눈에 띄는 수준이다. 이 값을 기준으로 penalty를 조정하면 재현 가능한 튜닝이 된다.

흔한 함정

과한 penalty는 반복을 넘어 필수 어휘까지 억제한다. 코드 생성에서 중괄호나 들여쓰기 토큰, 표에서 구분자, 다국어에서 조사가 페널티를 받아 문법이 깨진다. repetition_penalty는 곱셈이라 로짓 분포가 넓은 모델에서 예측이 어려우니 1.2를 넘기기 전에 반드시 눈으로 확인한다.

또한 frequency_penalty와 repetition_penalty를 동시에 세게 걸면 상호작용으로 출력이 급격히 짧아지거나 조기 종료된다. 하나를 주력으로 삼고 다른 하나는 보조로 낮게 두는 편이 안정적이다. 반복의 근본 원인이 프롬프트 구조나 컨텍스트 부족일 때는 penalty로 덮지 말고 원인을 먼저 해결한다.

정리

repetition penalty는 디코딩 단계에서 반복을 억제하는 저비용 수단이지만, 곱셈/뺄셈 방식의 차이와 태스크별 민감도를 이해하지 못하면 품질을 갉아먹는다. 대표 프롬프트 세트와 distinct-n 같은 지표로 베이스라인을 잡고, 반복 지표와 품질 지표를 함께 추적하며 좁혀 가는 절차를 권한다. 값 하나를 극단으로 올리기보다 penalty·temperature·프롬프트를 함께 조정하는 접근이 실무에서 더 오래 버틴다.