대형 언어 모델의 추론이 느린 근본 원인은 자기회귀(autoregressive) 생성에 있습니다. 토큰을 하나 뽑으려면 모델 전체를 한 번 순전파(forward)해야 하고, 다음 토큰은 앞 토큰이 확정돼야 시작할 수 있습니다. 100토큰을 생성하려면 거대한 모델을 100번 순차 통과시켜야 하는 셈입니다. 이 과정은 연산량보다 메모리 대역폭에 묶여 있는데, 매 스텝마다 수십 GB의 가중치를 GPU 메모리에서 읽어 오는 것이 병목이기 때문입니다.

스페큘러티브 디코딩(speculative decoding)은 이 순차성의 벽을 우회하는 기법입니다. 작고 빠른 초안 모델(draft model)이 여러 토큰을 미리 추측하고, 크고 정확한 타깃 모델(target model)이 그 추측을 한 번의 순전파로 병렬 검증합니다. 핵심은 통과된 토큰들의 확률 분포가 타깃 모델 단독 생성과 수학적으로 동일하다는 점입니다. 즉 품질을 조금도 희생하지 않고 속도만 올립니다. 이 글에서는 그 원리와 실무 적용, 흔히 놓치는 함정을 정리합니다.

왜 빠른가: 메모리 대역폭과 병렬 검증

한 번의 순전파가 소비하는 시간은 대부분 가중치를 읽어 오는 데 쓰입니다. 그런데 이 순전파는 입력 토큰 하나든 열 개든 거의 같은 시간이 걸립니다. 가중치는 한 번만 읽으면 되고, 토큰이 몇 개 더 붙는다고 대역폭 비용이 비례해 늘지 않기 때문입니다. 배치 크기 1 생성이 GPU를 놀립니다.

스페큘러티브 디코딩은 이 “남는 여유”를 활용합니다. 초안 모델이 k개 토큰을 추측하면, 타깃 모델은 그 k개를 한 번의 순전파에 나란히 넣어 각 위치의 확률을 동시에 계산합니다. 모두 맞으면 순전파 한 번으로 여러 토큰을 확정한 셈이라 타깃 호출이 줄어듭니다.

  • 초안 모델: 타깃보다 10~20배 작아 순전파가 훨씬 싸다. 여러 토큰을 빠르게 순차 추측.
  • 타깃 모델: 후보 시퀀스를 한 번에 병렬 검증. 스텝당 비용은 토큰 1개 생성과 거의 같다.
  • 순수 이득: 통과한 토큰 수만큼 타깃 순전파를 아낀다. 스텝당 평균 2~3토큰을 확정하면 실측 처리량이 약 2배가 된다.

정확성의 핵심: 거부 샘플링

스페큘러티브 디코딩이 “근사”가 아니라 “정확히 같은 분포”를 보장하는 이유는 수정된 거부 샘플링(modified rejection sampling) 때문입니다. 초안 분포를 q, 타깃 분포를 p라 하면, 초안이 뽑은 토큰 x를 다음 규칙으로 받아들이거나 버립니다.

  • p(x) >= q(x)이면 항상 수락. 타깃이 초안보다 그 토큰을 더 선호하므로 안전하다.
  • p(x) < q(x)이면 확률 p(x)/q(x)로 수락. 초안이 과대평가한 만큼 확률적으로 거부한다.
  • 거부되면 그 위치의 토큰을 보정 분포 (p - q)+에서 다시 뽑고, 뒤의 초안 토큰은 전부 버린다.

이 규칙의 결과로 최종 출력 분포는 정확히 p가 됩니다. 조건부 확률을 전개하면 수락 경로와 거부-재샘플 경로의 확률 합이 p(x)로 떨어집니다. 실무적으로 중요한 결론은 하나입니다. 초안 모델의 품질은 속도에만 영향을 주고 출력 정확성에는 영향을 주지 않는다. 초안이 형편없으면 수락률이 낮아 느려질 뿐, 틀린 답이 나오지는 않습니다.

# 수정된 거부 샘플링의 핵심 로직 (개념 코드)
import torch

def accept_or_resample(p, q, drafted_token):
    # p: 타깃 모델 분포, q: 초안 모델 분포 (같은 vocab 크기)
    px = p[drafted_token]
    qx = q[drafted_token]
    # p(x) >= q(x) 이면 비율이 1 이상 → 항상 수락
    accept_prob = torch.clamp(px / qx, max=1.0)
    if torch.rand(()) < accept_prob:
        return drafted_token, True          # 초안 토큰 수락
    # 거부 → 보정 분포 (p - q)+ 에서 재샘플
    residual = torch.clamp(p - q, min=0.0)
    residual = residual / residual.sum()    # 정규화
    return torch.multinomial(residual, 1).item(), False

temperature=0 탐욕적(greedy) 디코딩은 위 샘플링의 특수 케이스입니다. 규칙이 단순해져서, 초안 토큰이 타깃의 argmax와 일치하면 수락, 아니면 거부하고 타깃 argmax를 채택합니다. 이 성질 덕분에 디버깅이 편합니다. 스페큘러티브를 켜고 끈 결과가 토큰 단위로 같아야 하므로, 다르다면 구현 버그입니다.

초안 모델을 고르는 세 가지 전략

초안을 어떻게 마련하느냐가 실전 성능을 좌우합니다. 세 갈래가 있습니다.

  • 별도 소형 모델: 같은 계열의 작은 모델을 초안으로 쓴다. 타깃과 토크나이저·어휘(vocab)가 동일해야 확률 비교가 성립한다.
  • 자기추측(self-speculation): 타깃 모델의 앞쪽 레이어 일부만 통과시켜 초안을 만들고 전체를 통과해 검증한다. 별도 모델 관리가 필요 없다.
  • N-그램/프롬프트 룩업: 모델 없이, 이미 생성된 텍스트나 프롬프트의 반복 구절을 초안으로 재사용한다. 요약·코드 편집처럼 입력을 그대로 되뱉는 구간이 많은 작업에서 놀랄 만큼 효과적이다.

세 번째가 특히 실무에서 저평가돼 있습니다. 문서 요약이나 “함수명만 바꿔 줘” 같은 작업은 출력의 상당 부분이 입력과 겹칩니다. 이럴 때는 초안 모델을 돌리지 않고 최근 컨텍스트에서 매칭되는 다음 토큰들을 후보로 제시하면 됩니다.

# 프롬프트 룩업 초안: 컨텍스트에서 최근 생성 접미부와 일치하는 구간을 찾아 초안 제시
def prompt_lookup_draft(tokens, k=5, ngram=3):
    # tokens: 지금까지의 전체 토큰 시퀀스
    suffix = tuple(tokens[-ngram:])          # 최근 ngram 토큰을 검색 패턴으로
    for i in range(len(tokens) - ngram - 1, ngram, -1):
        if tuple(tokens[i:i + ngram]) == suffix:
            # 과거 동일 패턴 뒤에 이어졌던 k개 토큰을 초안으로 재사용
            return tokens[i + ngram: i + ngram + k]
    return []                                # 매칭 없으면 초안 없음(일반 디코딩)

k 값과 수락률의 트레이드오프

초안 길이 k는 크게 잡을수록 좋은 값이 아닙니다. k가 커지면 한 번에 확정할 토큰의 상한은 늘지만 뒤로 갈수록 초안이 틀릴 확률이 누적됩니다. 앞 토큰이 거부되면 뒤 초안은 전부 버려지므로 검증 계산이 낭비됩니다.

실전 지표는 평균 수락 길이(mean accepted length)입니다. 초안 k개 중 평균 몇 개가 수락되는지를 뜻하며, 곧 스텝당 확정 토큰 수와 직결됩니다. 작업 특성에 따라 최적값이 다릅니다.

  • 예측 가능한 텍스트(정형 요약, 보일러플레이트 코드): 수락률이 높아 k=6~8도 이득.
  • 창의적·다양한 텍스트(자유 서술, 높은 temperature): 수락률이 낮아 k=3~4가 무난.
  • 동적 조정: 최근 수락 길이를 관찰해 k를 실시간으로 조절하는 적응형 스케줄이 고정값보다 낫다.
# 적응형 k: 최근 수락 길이에 따라 초안 길이를 늘리고 줄인다
class AdaptiveK:
    def __init__(self, k=4, k_min=1, k_max=10):
        self.k, self.k_min, self.k_max = k, k_min, k_max

    def update(self, accepted_len):
        # 초안을 거의 다 수락했으면 더 공격적으로, 초반부터 거부되면 보수적으로
        if accepted_len >= self.k:
            self.k = min(self.k + 1, self.k_max)   # 낙관적 확장
        elif accepted_len <= 1:
            self.k = max(self.k - 1, self.k_min)   # 방어적 축소
        return self.k

KV 캐시 관리라는 진짜 난관

구현에서 가장 까다로운 부분은 알고리즘이 아니라 KV 캐시 관리입니다. 타깃이 초안 k개를 검증할 때 이 토큰들의 어텐션 키·값이 캐시에 쓰이는데, 일부만 수락되면 거부된 위치의 캐시를 정확히 되돌려야 합니다. 롤백을 놓치면 다음 스텝의 어텐션이 유령 토큰을 참조해 출력이 오염됩니다. 초안 모델도 거부된 부분을 폐기하고 재샘플 토큰으로 다음 스텝을 시작해야 합니다. 두 모델의 캐시를 수락 지점에 맞춰 동기화하는 것이 정확성의 핵심입니다.

# 검증 후 KV 캐시 롤백: 수락 지점 이후를 잘라낸다
def rollback_kv_cache(cache, keep_len):
    # cache: (layers, ..., seq_len, ...) 형태의 key/value 텐서 묶음
    # keep_len: 수락된 토큰까지의 시퀀스 길이
    for layer in cache:
        layer.key = layer.key[..., :keep_len, :]     # 거부 위치 이후 폐기
        layer.value = layer.value[..., :keep_len, :]
    return cache
# 주의: 타깃/초안 캐시를 같은 keep_len 으로 맞춰야 어텐션이 정상 동작

서빙 프레임워크로 켜기

다행히 프로덕션에서는 이 저수준 로직을 직접 짤 일이 드뭅니다. 주요 추론 서버들이 옵션으로 제공하므로 설정만으로 켤 수 있습니다. 다만 초안·타깃 모델의 토크나이저 호환성과 GPU 메모리 예산은 반드시 확인해야 합니다. 초안 모델도 메모리를 차지하기 때문입니다.

# vLLM: 초안 모델을 지정해 스페큘러티브 디코딩 활성화
python -m vllm.entrypoints.openai.api_server 
  --model org/target-model-large 
  --speculative-model org/draft-model-small 
  --num-speculative-tokens 5 
  --gpu-memory-utilization 0.90

# 별도 초안 모델 없이 N-그램 룩업 방식으로 켜기 (요약/편집 작업에 유리)
#   --speculative-model "[ngram]" --ngram-prompt-lookup-max 4

어디서 이득이 크고 어디서 무의미한가

스페큘러티브 디코딩은 만능이 아닙니다. 이득의 크기는 워크로드 특성과 배치 상황에 크게 좌우되며, 무작정 켜면 오히려 느려질 수 있습니다.

  • 이득이 큰 경우: 배치가 작은 대화형 저지연 서빙, 예측 가능한 구간이 많은 작업(코드 생성, 정형 요약). GPU가 놀 때 여유를 검증에 쓴다.
  • 역효과인 경우: 이미 대형 배치로 GPU가 연산 포화(compute-bound)라면 여유가 없어 초안 비용만 추가된다. 처리량 최우선 오프라인 배치에서는 손해일 수 있다.
  • 수락률이 낮은 도메인: 초안과 타깃 예측이 자주 엇갈리면(높은 temperature, 초안 품질 저하) 낭비가 커진다.

따라서 도입 전에 반드시 측정해야 합니다. 켜기 전후의 초당 출력 토큰 수와 평균 수락 길이를 대표 트래픽으로 비교하는 것만이 믿을 수 있는 판단 근거입니다.

# 도입 판단용 최소 벤치: 처리량과 수락 길이를 함께 본다
import time

def bench(generate_fn, prompts):
    t0 = time.perf_counter()
    total_tokens, total_accept, steps = 0, 0, 0
    for p in prompts:
        out = generate_fn(p)                 # {"tokens": int, "accept_lens": [...]}
        total_tokens += out["tokens"]
        total_accept += sum(out["accept_lens"])
        steps += len(out["accept_lens"])
    dt = time.perf_counter() - t0
    return {
        "tok_per_sec": total_tokens / dt,                 # 실측 처리량(핵심 지표)
        "mean_accept_len": total_accept / max(steps, 1),  # 스텝당 확정 토큰 수
    }
# tok_per_sec 가 baseline 대비 오르지 않으면 스페큘러티브를 끄는 게 맞다

마무리

스페큘러티브 디코딩의 매력은 공짜 점심에 가깝다는 데 있습니다. 거부 샘플링이 출력 분포를 타깃 모델과 정확히 동일하게 유지하므로, 품질을 내주지 않고 순전파 횟수만 줄입니다. 예측 가능한 텍스트가 많은 저지연 서빙에서는 실측 처리량이 흔히 2배 안팎이 됩니다.

다만 이득은 상황 의존적입니다. 초안 모델의 토크나이저 호환, KV 캐시 롤백의 정확성, 워크로드에 맞는 k 선택, 그리고 배치가 이미 연산 포화인지 여부가 성패를 가릅니다. 원리는 우아하지만 판단은 데이터로 해야 합니다. 켜기 전후의 초당 토큰 수와 평균 수락 길이를 실측하고 이득이 확인될 때만 유지하십시오.