1편에서 원리를 잡고, 2편에서 적용 방식과 모델별 특징을 봤다면, 3편에서는 손을 움직일 차례다. Speculative decoding은 논문에서 끝나는 기법이 아니다. Hugging Face Transformers, vLLM, TensorRT-LLM 같은 추론 스택에서 이미 실험 가능한 형태로 들어와 있고, 코드 생성, RAG, 챗봇, 배치 추론에서 각각 다른 얼굴을 보인다.
가장 먼저 버려야 할 기대가 있다. 옵션 하나 켜면 모든 요청이 빨라지는 식으로 동작하지 않는다. 같은 모델이라도 프롬프트 종류, 출력 길이, 샘플링 설정, batch size, draft 방식에 따라 결과가 갈린다. 실무에서는 “speculative decoding을 쓴다”보다 “어떤 요청군에서 accept rate가 높고, GPU 메모리와 지연 시간이 어느 정도로 움직이는가”가 더 중요하다.
그림 1. speculative decoding은 요청 유형별로 적용 후보가 달라진다.
실험 전에 잡아야 하는 기준선
Speculative decoding을 붙이기 전에 기본 서빙 성능을 먼저 재야 한다. 이 순서를 건너뛰면 빨라졌는지 느려졌는지 판단할 기준이 사라진다. 특히 초보자에게 자주 생기는 착시는 첫 토큰 지연 시간과 전체 토큰 처리량을 섞어 보는 데서 나온다.
서빙 성능은 적어도 아래 항목으로 나눠서 본다.
| 지표 | 의미 | 왜 봐야 하는가 |
|---|---|---|
| TTFT | Time To First Token | 사용자가 체감하는 첫 응답 속도 |
| TPOT | Time Per Output Token | 긴 답변 생성 중 토큰당 지연 |
| End-to-end latency | 요청 전체 완료 시간 | 실제 API 응답 시간 |
| Output tokens/sec | 초당 생성 토큰 수 | 처리량 비교 |
| GPU memory | 모델, KV cache, draft 비용 | 동시성 한계 |
| Accept rate | draft token 중 target이 받아들인 비율 | speculative decoding의 효율 |
Accept rate를 꼭 봐야 한다. draft가 5개 토큰을 제안했는데 target이 매번 1개만 받아들이면, 작은 모델을 추가로 돌린 비용이 그대로 손해가 된다. 반대로 코드처럼 반복 패턴이 많거나, RAG 답변처럼 컨텍스트를 거의 베껴 쓰는 출력에서는 여러 토큰이 연속으로 받아들여질 가능성이 커진다.
내가 실무에서 잡는 최소 실험 단위는 30개 요청이다. 아주 정확한 벤치마크라기보다는 “이 설정이 말이 되는가”를 보는 스모크 테스트에 가깝다. 요청은 짧은 질의, 긴 질의, 코드 생성, 요약, RAG 답변처럼 섞지 말고 유형별로 분리한다. 섞어서 평균을 내면 어떤 요청에서 이겼고 어떤 요청에서 졌는지 바로 묻힌다.
baseline/
chat_short.jsonl
chat_long.jsonl
code_completion.jsonl
rag_answer.jsonl
speculative/
same_prompts_same_sampling.jsonl
비교할 때는 프롬프트와 sampling 설정을 고정한다. temperature, top_p, max_tokens가 달라지면 speculative decoding 효과가 아니라 생성 조건 차이를 보고 있는 셈이다.
사례 1: Hugging Face Transformers에서 assistant model로 감 잡기
가장 쉽게 시작하는 방법은 Transformers assisted generation이다. 큰 모델을 target으로 두고, 작은 모델을 assistant로 붙인다. 여기서 assistant model은 draft model 역할을 한다.
초보자에게 이 방식이 좋은 이유는 구조가 눈에 잘 보이기 때문이다. 별도 서버를 띄우지 않아도 되고, 코드 한 파일에서 target과 assistant를 같이 호출한다. 운영용 서빙 엔진을 만지기 전에 원리와 실패 양상을 확인하기 좋다.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
TARGET = "Qwen/Qwen2.5-7B-Instruct"
ASSISTANT = "Qwen/Qwen2.5-0.5B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(TARGET)
target_model = AutoModelForCausalLM.from_pretrained(
TARGET,
torch_dtype=torch.float16,
device_map="auto",
)
assistant_model = AutoModelForCausalLM.from_pretrained(
ASSISTANT,
torch_dtype=torch.float16,
device_map="auto",
)
messages = [
{"role": "user", "content": "Python에서 asyncio와 thread pool의 차이를 예제로 설명해줘."}
]
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
inputs = tokenizer(prompt, return_tensors="pt").to(target_model.device)
with torch.no_grad():
output = target_model.generate(
**inputs,
assistant_model=assistant_model,
max_new_tokens=256,
temperature=0.0,
)
print(tokenizer.decode(output[0], skip_special_tokens=True))
이 예제에서 제일 먼저 맞춰야 하는 것은 tokenizer다. target과 assistant가 같은 tokenizer 계열이면 실험이 단순해진다. tokenizer가 다르면 토큰 경계가 달라지고, draft가 제안한 토큰을 target 쪽에서 그대로 검증하기 어렵다. Transformers에는 tokenizer가 다른 assistant를 다루는 경로도 있지만, 첫 실험에서는 같은 모델 패밀리의 크기만 다른 조합이 낫다.
좋은 첫 조합은 이런 형태다.
| Target | Assistant | 이유 |
|---|---|---|
| 같은 패밀리 7B | 같은 패밀리 0.5B 또는 1.5B | tokenizer와 스타일이 맞을 가능성이 높다 |
| code 7B | 같은 계열 code 1B | 반복 패턴과 문법 구조를 잘 맞춘다 |
| instruct 7B | 같은 계열 instruct 소형 모델 | 채팅 포맷과 답변 톤이 덜 어긋난다 |
Assistant가 너무 작으면 빠르게 draft를 만들지만 틀릴 확률이 올라간다. 너무 크면 accept rate는 좋아져도 draft 비용이 커진다. 실험에서는 assistant latency와 accept rate를 같이 봐야 한다. accept rate만 높은 조합이 항상 빠른 조합은 아니다.
같은 프롬프트로 baseline을 먼저 찍는다
아래처럼 같은 모델에서 assistant를 빼고 한 번 더 돌린다. 이 값이 기준선이다.
with torch.no_grad():
baseline = target_model.generate(
**inputs,
max_new_tokens=256,
temperature=0.0,
)
로컬 단일 요청에서는 큰 차이가 안 보일 수 있다. Python 오버헤드, GPU 메모리 배치, 모델 로딩 상태가 섞인다. 그래도 첫 단계에서는 충분하다. “동작한다”, “출력이 깨지지 않는다”, “assistant 조합이 말이 된다”를 확인하면 된다.
사례 2: vLLM에서 API 서버 기준으로 실험하기
운영에 가까운 실험은 vLLM speculative decoding 문서의 흐름을 따르는 편이 낫다. vLLM은 continuous batching과 KV cache 관리가 강점이라, 단일 generate 호출보다 실제 API 서버 조건을 보기 좋다.
명령어는 설치 버전과 지원 모델에 따라 달라진다. 구조는 아래와 같다.
vllm serve Qwen/Qwen2.5-7B-Instruct \
--served-model-name target \
--tensor-parallel-size 1 \
--speculative-model Qwen/Qwen2.5-0.5B-Instruct \
--num-speculative-tokens 4
--num-speculative-tokens는 draft가 한 번에 몇 개 후보를 제안할지 정한다. 처음부터 크게 잡지 않는다. 3이나 4에서 시작하는 편이 안전하다. 8 이상으로 올리면 잘 맞는 요청에서는 이득이 커질 수 있지만, 틀리는 요청에서는 버리는 토큰이 늘어난다.
API는 OpenAI 호환 엔드포인트로 호출할 수 있다.
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "target",
"messages": [
{"role": "user", "content": "FastAPI에서 dependency injection을 쓰는 이유를 예제로 설명해줘."}
],
"temperature": 0,
"max_tokens": 256
}'
실무 실험에서는 같은 서버에서 baseline과 speculative를 번갈아 재기보다, 프로세스를 나눠 재는 편이 깔끔하다. 한쪽은 target만 띄우고, 다른 한쪽은 speculative 옵션을 붙인다. 포트도 분리한다.
# baseline
vllm serve Qwen/Qwen2.5-7B-Instruct \
--served-model-name baseline \
--port 8000
# speculative
vllm serve Qwen/Qwen2.5-7B-Instruct \
--served-model-name speculative \
--port 8001 \
--speculative-model Qwen/Qwen2.5-0.5B-Instruct \
--num-speculative-tokens 4
그리고 같은 요청 파일을 두 서버에 던진다.
import json
import time
import httpx
PROMPTS = "code_completion.jsonl"
def run(endpoint: str):
rows = []
with open(PROMPTS, "r", encoding="utf-8") as f:
for line in f:
item = json.loads(line)
payload = {
"model": item["model"],
"messages": item["messages"],
"temperature": 0,
"max_tokens": 256,
}
start = time.perf_counter()
r = httpx.post(endpoint, json=payload, timeout=120)
elapsed = time.perf_counter() - start
r.raise_for_status()
data = r.json()
usage = data.get("usage", {})
rows.append({
"elapsed": elapsed,
"completion_tokens": usage.get("completion_tokens"),
"total_tokens": usage.get("total_tokens"),
})
return rows
baseline = run("http://localhost:8000/v1/chat/completions")
speculative = run("http://localhost:8001/v1/chat/completions")
for name, rows in [("baseline", baseline), ("speculative", speculative)]:
latencies = [r["elapsed"] for r in rows]
tokens = [r["completion_tokens"] or 0 for r in rows]
print(name)
print("avg latency", sum(latencies) / len(latencies))
print("tokens/sec", sum(tokens) / sum(latencies))
이 정도 스크립트만 있어도 “느낌상 빨라졌다”를 벗어난다. 더 정교하게 가려면 warmup 요청, p50/p95 지연 시간, 동시 요청 수, 스트리밍 모드 TTFT를 추가한다. 처음부터 완벽한 벤치마크를 만들려고 하면 실험이 멈춘다. baseline과 speculative의 차이가 충분히 큰지 먼저 본다.
그림 2. baseline을 먼저 잡고, 작은 트래픽부터 speculative 경로로 보낸다.
사례 3: Prompt lookup이 잘 맞는 RAG와 문서 답변
Draft model을 따로 붙이지 않아도 speculative decoding 계열의 이득을 볼 수 있는 경우가 있다. prompt lookup, n-gram lookup 방식이다. 프롬프트 안에 이미 나온 토큰 조각을 후보로 재사용한다. 2편에서 설명한 대로, 모델이 새 지식을 추론해서 쓰기보다 입력 문서 일부를 재구성하거나 인용하는 작업에서 잘 맞는다.
RAG 답변이 대표적이다. 사용자의 질문에 대해 검색된 문서가 길게 들어오고, 답변은 그 문서의 표현을 많이 따라간다. 이런 요청에서는 다음 토큰이 프롬프트 어딘가에 이미 등장했을 가능성이 높다.
예를 들어 입력 컨텍스트에 이런 문장이 있다고 하자.
Redis Stream은 append-only log 형태의 자료구조이며, consumer group을 사용하면 여러 소비자가 메시지를 나눠 처리할 수 있다.
답변이 아래처럼 시작하면, 상당수 토큰은 프롬프트의 n-gram과 겹친다.
Redis Stream은 append-only log 형태의 자료구조다. consumer group을 사용하면...
이 방식의 장점은 별도 draft model 메모리가 거의 들지 않는다는 점이다. 작은 모델을 하나 더 올릴 GPU 여유가 없는 환경에서 특히 좋다. 대신 창의적인 생성, 추론형 답변, 프롬프트에 없는 표현을 많이 쓰는 답변에서는 효과가 제한된다.
초보자에게 추천하는 테스트셋은 두 개다.
rag_copy_heavy.jsonl # 원문 근거를 많이 인용하는 답변
rag_reasoning.jsonl # 문서를 읽고 새로 종합해야 하는 답변
두 요청군에서 latency를 따로 비교한다. prompt lookup은 첫 번째에서 이길 가능성이 높고, 두 번째에서는 차이가 작거나 오히려 관리 비용만 생길 수 있다.
사례 4: 코드 생성에서 speculative decoding이 잘 맞는 이유
코드는 자연어보다 다음 토큰의 후보가 좁아지는 순간이 많다. 괄호, 들여쓰기, import, 함수 시그니처, 반복되는 변수명, JSON 스키마 같은 패턴이 계속 나온다. 작은 draft model이 몇 토큰 앞을 맞히기 쉬운 구간이 생긴다.
아래 프롬프트를 보면 출력의 상당 부분이 이미 구조적으로 정해져 있다.
FastAPI로 /healthz 엔드포인트와 /items/{item_id} GET 엔드포인트를 만들어줘.
응답 모델은 Pydantic BaseModel을 사용하고, 타입 힌트를 빠짐없이 넣어줘.
생성될 코드는 대체로 이런 골격을 따른다.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
...
@app.get("/healthz")
def healthz():
...
speculative decoding을 코드 LLM에 붙일 때는 temperature 0 또는 낮은 temperature부터 본다. 샘플링이 강해질수록 draft와 target의 경로가 갈라지기 쉽다. 자동완성, 테스트 코드 생성, boilerplate 생성은 좋은 후보군이다. 반대로 알고리즘 풀이처럼 중간 reasoning에 따라 방향이 크게 바뀌는 요청은 accept rate가 들쭉날쭉할 수 있다.
코드 생성에서는 품질 평가도 같이 해야 한다. 출력 속도가 빨라졌는데 syntax error가 늘어나면 운영에서 쓸 수 없다. 최소한 아래 검사를 붙인다.
python -m py_compile generated.py
ruff check generated.py
pytest tests/
LLM 출력 품질을 사람 눈으로만 보면 속도 실험과 품질 실험이 섞인다. 코드에서는 컴파일, lint, 테스트라는 자동 평가 축이 있으니 꼭 써먹는 편이 낫다.
사례 5: Medusa와 EAGLE은 “준비된 모델”이 있을 때 본다
Medusa는 target 모델 위에 여러 개의 decoding head를 붙여 다음 여러 토큰 후보를 만든다. 별도 작은 draft model을 매번 호출하는 방식과 다르게, target 계열 내부에서 후보를 만든다. EAGLE은 feature level에서 다음 상태를 예측해 draft를 만든다. 둘 다 “draft를 더 싸고 더 잘 만들자”는 연구자들의 고민에서 나온 방향이다.
초보자가 바로 운영에 넣기에는 작은 draft model 방식보다 준비물이 많다. head가 붙은 모델이 필요하거나, 지원 런타임과 모델 조합을 맞춰야 한다. 그래서 첫 실험 순서는 보통 이렇게 간다.
- 1단계: target only baseline
- 2단계: 같은 패밀리의 작은 draft model
- 3단계: prompt lookup 또는 n-gram lookup
- 4단계: 모델과 런타임이 준비된 경우 Medusa, EAGLE 계열
Medusa와 EAGLE을 뒤로 미루는 이유는 성능이 낮아서가 아니다. 초보자가 실험 변수를 통제하기 어렵기 때문이다. target, draft, tokenizer, head, 런타임 지원 여부가 한꺼번에 얽히면 느려진 원인을 찾기 힘들다. 먼저 작은 draft model로 accept rate와 latency를 보는 습관을 잡고, 그다음 고급 방식을 보는 편이 빠르다.
운영 롤아웃은 요청 유형별로 쪼갠다
Speculative decoding을 전체 트래픽에 한 번에 켜는 방식은 위험하다. 같은 서비스 안에서도 요청 성격이 다르다. 짧은 인사 응답, 긴 문서 요약, 코드 생성, RAG 답변, JSON 생성은 decoding 패턴이 전부 다르다.
나는 라우팅을 이렇게 나누는 편이 좋다고 본다.
| 요청 유형 | 우선 적용 방식 | 판단 기준 |
|---|---|---|
| 코드 자동완성 | 작은 draft model | 낮은 temperature, 반복 구조, 긴 출력 |
| RAG 인용 답변 | prompt lookup | 입력 문서와 출력의 n-gram 중복 |
| 긴 요약 | 작은 draft model 또는 prompt lookup | 원문 표현 재사용 비율 |
| 짧은 챗봇 응답 | 보수적 적용 | draft overhead가 이득을 먹을 수 있음 |
| JSON/스키마 출력 | 작은 draft model | 구조 반복이 많고 품질 검증 가능 |
| 창의적 글쓰기 | 신중하게 적용 | 샘플링 경로가 자주 갈라짐 |
처음에는 전체 요청의 5퍼센트만 speculative 경로로 보낸다. 그리고 요청 로그에 최소한 아래 값을 남긴다.
{
"route": "code_completion",
"model": "target",
"speculative": true,
"draft_model": "assistant",
"num_speculative_tokens": 4,
"temperature": 0.0,
"prompt_tokens": 812,
"completion_tokens": 256,
"ttft_ms": 180,
"latency_ms": 2100,
"tokens_per_second": 121.9,
"accepted_tokens": 430,
"draft_tokens": 512
}
런타임에서 accepted_tokens와 draft_tokens를 직접 주지 않는 경우도 있다. 그때는 서버 메트릭, 디버그 로그, 벤치마크 도구가 제공하는 값을 쓴다. 값이 없으면 accept rate를 추정하려고 억지로 로그를 비틀기보다 latency, throughput, 품질 평가를 먼저 본다.
그림 3. 평균 하나보다 route별 latency, 처리량, 품질을 함께 보는 편이 안전하다.
실패 패턴을 먼저 알고 들어가야 한다
Speculative decoding이 느려지는 패턴은 꽤 반복적이다.
첫 번째는 draft model이 target과 말투를 못 맞추는 경우다. base model을 assistant로 붙이고 instruct model을 target으로 쓰면 채팅 포맷부터 흔들릴 수 있다. 같은 패밀리라도 instruct 여부를 맞추는 편이 안전하다.
두 번째는 draft가 너무 길게 제안하는 경우다. num_speculative_tokens를 크게 잡으면 잘 맞을 때는 좋다. 틀릴 때는 검증 후 버리는 토큰이 늘어난다. 초반 실험에서는 2, 4, 6처럼 작게 훑는다.
세 번째는 batch size와 동시성에서 손해가 나는 경우다. draft model도 결국 GPU 일을 만든다. target만 돌릴 때는 batch가 잘 차서 효율적이었는데, draft까지 들어오면서 스케줄링이 꼬일 수 있다. 단일 요청에서 빨랐던 설정이 동시 요청 32개에서 그대로 이기지 않는다.
네 번째는 출력이 너무 짧은 경우다. 20토큰 안팎의 짧은 답변은 draft를 준비하고 검증하는 비용을 회수하기 어렵다. 이런 요청은 speculative 경로에서 빼는 편이 낫다.
다섯 번째는 품질 평가 없이 속도만 보는 경우다. 원칙적으로 speculative decoding은 target 분포를 유지하도록 설계된다. 구현, 설정, tokenizer 조합, 샘플링 조건이 엉키면 사용자가 보는 출력 품질 문제가 생길 수 있다. 운영에서는 golden set을 두고 출력 차이를 계속 봐야 한다.
초보자용 실험 순서
처음부터 vLLM, TensorRT-LLM, Medusa, EAGLE을 한꺼번에 보지 않는다. 하루 안에 끝낼 수 있는 순서로 쪼개야 한다.
1. target only로 baseline latency를 잰다.
2. 같은 프롬프트 파일을 만든다.
3. 같은 모델 패밀리의 작은 assistant를 붙인다.
4. num_speculative_tokens를 2, 4, 6으로 바꿔 본다.
5. 요청 유형을 code, rag, chat으로 나눠 결과를 따로 본다.
6. 출력 품질 검사를 붙인다.
7. 이긴 요청 유형만 서버 라우팅 후보로 올린다.
실험 결과는 평균 하나로 끝내지 않는다.
route=code_completion
baseline tokens/sec: 84
speculative tokens/sec: 128
latency change: -31%
quality check: pass
route=chat_short
baseline tokens/sec: 71
speculative tokens/sec: 68
latency change: +5%
quality check: pass
이런 결과가 나오면 코드 생성에만 적용하고 짧은 챗봇 응답은 제외한다. 둘을 섞어서 “전체 평균 12퍼센트 개선”이라고 쓰면 운영 판단이 흐려진다.
실무에서 쓸 만한 설정 감각
작은 draft model 방식은 같은 패밀리의 0.5B, 1B, 1.5B급에서 먼저 본다. target이 7B라면 0.5B나 1.5B가 출발점으로 무난하다. target이 70B라면 7B급 draft도 후보가 될 수 있지만, 메모리와 병렬화 비용을 다시 계산해야 한다.
num_speculative_tokens는 4부터 시작한다. accept rate가 높고 출력이 길면 6이나 8을 본다. 짧은 응답이 많으면 2로 줄이거나 speculative 경로에서 제외한다.
temperature는 0부터 본다. 서비스가 temperature 0.7을 쓴다면 그 설정으로도 다시 재야 한다. greedy에서 이긴 설정이 sampling에서도 이긴다는 보장은 없다.
RAG는 prompt lookup을 따로 본다. 문서 인용형 답변이 많으면 draft model 없이도 이득을 볼 가능성이 있다. 코드 생성은 작은 draft model부터 본다. JSON 출력은 schema validation을 품질 평가에 넣는다.
TensorRT-LLM처럼 엔진 빌드와 배포 파이프라인이 무거운 스택에서는 더 보수적으로 접근한다. TensorRT-LLM speculative decoding 문서를 보면 Medusa, lookahead, explicit draft tokens 같은 방식이 런타임 최적화와 엮여 있다. 이런 계층에서는 실험 한 번의 비용이 크다. 모델 조합과 요청 유형을 vLLM이나 Transformers에서 먼저 좁히고, 마지막에 엔진 최적화로 옮기는 순서가 실무적으로 낫다.
운영 체크리스트
실서비스에 넣기 전에는 아래 항목을 체크한다.
- target only baseline이 있다.
- 프롬프트 파일과 sampling 설정이 고정돼 있다.
- 요청 유형별 결과를 따로 기록한다.
- p50, p95 latency를 본다.
- TTFT와 전체 latency를 분리한다.
- GPU memory와 동시성 변화를 본다.
- 출력 품질 golden set이 있다.
- 짧은 응답을 speculative 경로에서 제외할지 결정했다.
- draft model 장애 시 target only로 fallback한다.
- 모델 교체 시 accept rate를 다시 측정한다.
모델을 바꾸면 다시 재야 한다. target을 같은 크기의 다른 instruct 모델로 바꾸는 정도여도 draft와의 궁합이 달라진다. 프롬프트 템플릿을 바꿔도 다시 봐야 한다. 시스템 프롬프트가 답변 스타일을 강하게 바꾸면 draft가 맞히던 경로가 흔들린다.
다음에 더 파고들 부분은 accept rate를 어떻게 수집하고 시각화할지다. 런타임마다 노출하는 메트릭이 달라서, 이 부분은 vLLM 기준으로 따로 실험해 볼 만하다.