19 분 소요

실습 3 — vLLM 운영 서빙(Production Serving): 페이지드어텐션, 연속 배칭, 접두어 캐싱

운영 환경에서 대규모 언어 모델(LLM, Large Language Model)을 transformers.generate()만으로 서빙하는 것은 효율적이지 않다. 단순한 방식은 요청마다 별도의 순전파(forward pass)를 수행한다. 키-값 캐시(KV cache)의 상당 부분을 패딩(padding)으로 낭비한다. 동시 사용자 토큰 수가 아니라 동시 사용자 수에 비례해 자원 사용량이 증가한다. 이 때문에 A100에서 이론상 100개의 동시 요청을 처리할 수 있는 2GB 모델도 10개 요청 수준에서 메모리 부족(OOM, Out of Memory)이 발생할 수 있다.

vLLM은 운영 환경의 LLM 추론 서빙을 위해 설계된 엔진이다. 핵심은 다음 세 가지다.

  1. 페이지드어텐션(PagedAttention) — 키-값 캐시를 고정 크기 블록으로 나누고 필요할 때만 할당한다. 내부 단편화(internal fragmentation)를 줄이며, 동일한 하드웨어에서 단순 서빙 방식보다 더 많은 동시 시퀀스를 수용할 수 있다.
  2. 연속 배칭(continuous batching) — 디코딩 단계(decode step)마다 새 요청을 실행 중인 배치에 동적으로 편입한다. 기존 배치 전체가 끝날 때까지 기다리지 않으므로 대기 요청이 있는 동안 GPU 유휴 시간을 줄인다.
  3. 접두어 캐싱(prefix caching) — 새 요청이 이전 요청과 시스템 프롬프트, 퓨샷 예제, 긴 문서 같은 공통 접두어를 공유하면 이미 계산한 키-값 캐시 블록을 재사용한다. 공유 구간의 프리필(prefill) 계산과 메모리 중복을 줄인다.

이 실습에서는 소비자용 GPU에도 적재할 수 있는 TinyLlama-1.1B 모델을 사용해 세 기능의 동작과 성능 효과를 측정한다.

실습 서빙 구성

  • vLLM의 오프라인 LLM 엔진을 사용한다. 온라인 OpenAI 호환 서버와 동일한 스케줄러와 메모리 관리자를 사용하지만 HTTP 대신 Python에서 호출한다.
  • 컨테이너에 미리 내려받은 TinyLlama-1.1B-Chat 모델을 사용한다.
  • 단일 프롬프트, 소규모 배치, 다중 요청 배치, 공통 접두어 시나리오를 순차적으로 실행한다.

결과

  • 페이지드어텐션의 키-값 캐시 총 토큰 용량과 예상 최대 동시성
  • 연속 배칭의 직렬 처리 대비 처리량 향상
  • 공통 시스템 프롬프트 시나리오에서 접두어 캐싱의 성능 향상
  • 운영용 vLLM 서버 인자, Kubernetes Deployment, Prometheus 지표, 수평형 파드 자동 확장(HPA, Horizontal Pod Autoscaler) 정책

참고 자료

1단계 — vLLM 엔진 적재와 페이지드어텐션 용량 확인

LLM(model=...)은 vLLM 엔진을 초기화한다. 모델 가중치를 GPU에 적재하고, KV 캐시 블록 풀(KV-cache block pool)을 만들며, CUDA 그래프 또는 커널을 워밍업한다. 시작 로그에 표시되는 키-값 캐시 용량(KV cache capacity)은 모든 활성 시퀀스가 합산해 저장할 수 있는 전체 토큰 수를 의미한다.

주요 인자는 다음과 같다.

  • max_model_len — 서빙할 최대 컨텍스트 길이다. 요청이 이 값을 초과하면 잘리거나 거부될 수 있다.
  • gpu_memory_utilization — vLLM이 관리하도록 허용할 GPU 메모리 비율이다. 0.5는 전체 VRAM의 절반을 사용한다는 뜻이며 운영 환경에서는 보통 실제 워크로드를 검증한 뒤 0.85~0.90 수준을 검토한다.
  • enforce_eager=True — CUDA 그래프 캡처를 비활성화하고 이거 실행(eager execution)을 사용한다. 시작 시간은 짧아지지만 안정 상태 처리량은 낮아질 수 있다.
  • block_size — 페이지드어텐션의 블록 크기다. 기본값은 일반적으로 16토큰이며 특별한 이유가 없으면 변경하지 않는다.

vLLM 엔진 적재

vLLM의 LLM(...) 생성자는 반환되기 전에 다음 작업을 수행한다.

  1. 모델 가중치를 GPU에 적재한다.
  2. 실행 중 VRAM의 상당 부분을 차지하는 KV 캐시 블록(KV-cache block)을 할당한다.
  3. 짧은 워밍업 실행으로 커널과 내부 버퍼를 준비한다.

1B급 모델의 최초 적재에는 환경에 따라 수십 초 이상 걸릴 수 있으며 정상적인 동작이다.

아래 코드의 주요 설정은 다음과 같다.

  • gpu_memory_utilization=0.5 — 실습의 다른 셀이 사용할 메모리를 남기기 위해 vLLM의 VRAM 사용 비율을 절반으로 제한한다.
  • enforce_eager=True — CUDA 그래프를 비활성화한다. 콜드 스타트(cold start)는 예측하기 쉬워지지만 디코딩 처리량이 낮아질 수 있다.
  • enable_prefix_caching=True — 3단계에서 측정할 자동 접두어 캐시를 활성화한다.
import os
import time
import warnings

# 실습 결과에 직접 영향을 주지 않는 경고 메시지를 숨긴다.
warnings.filterwarnings('ignore')

# Hugging Face 모델 캐시 경로가 설정되지 않은 경우 컨테이너의 공용 모델 경로를 사용한다.
os.environ.setdefault(
    'HF_HOME',
    '/models/huggingface',
)

# vLLM 엔진 클래스와 생성 파라미터 클래스를 불러온다.
from vllm import LLM, SamplingParams

# 실습에 사용할 사전 학습 모델의 저장소 식별자를 정의한다.
MODEL = 'TinyLlama/TinyLlama-1.1B-Chat-v1.0'

# 모델 가중치, 키-값 캐시, 워밍업을 포함한 엔진 초기화를 시작한다.
print(
    'vLLM 엔진을 적재한다. '
    '모델 가중치, 키-값 캐시 설정, 워밍업에 시간이 걸릴 수 있다.'
)

# 엔진 초기화 시작 시각을 기록한다.
t0 = time.time()

# vLLM 오프라인 추론 엔진을 생성한다.
llm = LLM(
    # GPU에 적재할 모델을 지정한다.
    model=MODEL,

    # 하나의 요청에서 허용할 최대 컨텍스트 길이를 1,024토큰으로 제한한다.
    max_model_len=1024,

    # 전체 GPU 메모리의 50%까지 vLLM이 사용하도록 허용한다.
    gpu_memory_utilization=0.5,

    # CUDA 그래프 대신 이거 실행 방식을 사용해 실습 초기화 동작을 단순화한다.
    enforce_eager=True,

    # 페이지드어텐션 키-값 캐시의 블록 크기를 16토큰으로 설정한다.
    block_size=16,

    # 동일한 프롬프트 접두어의 키-값 캐시 블록을 재사용하도록 설정한다.
    enable_prefix_caching=True,
)

# 엔진 초기화에 걸린 전체 시간을 계산한다.
load_s = time.time() - t0

# 엔진이 요청을 처리할 준비가 되었음을 출력한다.
print(f'\n엔진 준비 완료: {load_s:.1f}초')

첫 번째 생성과 콜드 스타트 지연 시간

엔진 초기화 후 llm.generate([prompt], SamplingParams(...))를 호출하면 프리필(prefill)과 디코딩(decode)을 모두 수행한다. TinyLlama-Chat은 채팅 템플릿 없이 완료형 프롬프트(completion-style prompt)를 사용할 때 빈 출력을 반환할 수 있다. 첫 결과가 비어 있으면 온도(temperature)를 0으로 낮춘 탐욕적 디코딩(greedy decoding)에 가까운 설정으로 다시 실행한다. 이와 같은 예외 처리는 운영용 서빙 래퍼(serving wrapper)가 내부에서 처리해야 하는 대표적인 경계 사례(edge case)다.

# 채팅 템플릿 없이 사용할 수 있는 영문 완료형 프롬프트를 유지한다.
# 입력 문자열을 번역하면 토큰 길이와 벤치마크 조건이 달라지므로 원문을 사용한다.
prompt = (
    'Question: What is a GPU used for in machine learning?\n'
    'Answer:'
)

# 생성 다양성과 최대 출력 길이, 난수 시드를 지정한다.
sp = SamplingParams(
    temperature=0.7,
    max_tokens=60,
    seed=42,
)

# 첫 생성 요청의 시작 시각을 기록한다.
t0 = time.time()

# 단일 프롬프트에 대해 프리필과 디코딩을 수행한다.
outputs = llm.generate(
    [prompt],
    sp,
    use_tqdm=False,
)

# 첫 생성 요청의 전체 경과 시간을 계산한다.
single_time = time.time() - t0

# 첫 번째 요청의 첫 번째 후보 출력 문자열을 추출하고 양쪽 공백을 제거한다.
first_output = outputs[0].outputs[0].text.strip()

# 모델이 빈 문자열을 반환하면 더 결정적인 설정으로 다시 시도한다.
if not first_output:
    # 온도를 0으로 설정해 탐욕적 디코딩에 가까운 결과를 생성한다.
    sp2 = SamplingParams(
        temperature=0.0,
        max_tokens=80,
    )

    # 모델이 자연스럽게 문장을 이어 쓰기 쉬운 대체 프롬프트를 사용한다.
    outputs = llm.generate(
        ['The GPU is used in machine learning because'],
        sp2,
        use_tqdm=False,
    )

    # 재시도 결과에서 텍스트를 추출한다.
    first_output = outputs[0].outputs[0].text.strip()

페이지드어텐션 KV 캐시

vLLM의 핵심 기능인 페이지드어텐션(PagedAttention)은 시퀀스마다 연속된 대형 KV 캐시 영역을 미리 예약하지 않는다. KV 캐시를 16토큰 단위의 블록으로 나누어 필요할 때 할당하고, 시퀀스가 완료되면 해당 블록을 반환한다. 공통 접두어가 있는 시퀀스 사이에서는 블록을 공유할 수도 있다.

용량 계획(capacity planning)에서 중요한 값은 다음과 같다.

  • max_tokens — 현재 VRAM 예산에서 KV 캐시에 저장할 수 있는 전체 토큰 수다. 일반적인 요청의 평균 컨텍스트 길이로 나누면 대략적인 최대 동시 시퀀스 수를 계산할 수 있다.
  • block_size — KV 캐시 페이지 크기다. 블록이 작으면 내부 단편화는 줄지만 관리해야 할 블록 수가 증가한다. 기본값은 일반적으로 16이다.
# vLLM 내부 설정에서 KV 캐시 블록 크기를 조회한다.
try:
    # 엔진의 모델 설정 객체를 가져온다.
    cfg = llm.llm_engine.model_config

    # 현재 vLLM 버전에서 block_size 속성이 없으면 기본값 16을 사용한다.
    block_size = getattr(
        cfg,
        'block_size',
        16,
    )
except Exception:
    # 내부 API 구조가 버전별로 다를 수 있으므로 조회 실패 시 기본값을 사용한다.
    block_size = 16

# GPU KV 캐시 블록 수와 블록 크기로 전체 토큰 용량을 계산한다.
try:
    # GPU에 할당된 키-값 캐시 블록 수를 읽어 전체 토큰 수로 환산한다.
    kv_tokens = (
        llm.llm_engine.cache_config.num_gpu_blocks
        * block_size
    )
except Exception:
    # 내부 속성을 읽을 수 없는 환경에서는 실습 검증용 대체값을 사용한다.
    kv_tokens = 70_000

# 최대 컨텍스트 길이가 1,024토큰이라고 가정해 대략적인 동시 시퀀스 수를 계산한다.
max_concurrency = kv_tokens / 1024

# 용량 계획에 사용할 핵심 값을 딕셔너리로 저장한다.
kv_cache_info = {
    'max_tokens': int(kv_tokens),
    'block_size': int(block_size),
    'max_concurrency': float(max_concurrency),
    'vram_budget': 0.5,
}

# 페이지드어텐션 KV 캐시 정보를 출력한다.
print('\n=== 페이지드어텐션 키-값 캐시 ===')

for key, value in kv_cache_info.items():
    print(f'  {key:>20s}: {value}')

# 첫 요청의 콜드 스타트 지연 시간을 출력한다.
print(f'\n단일 프롬프트 지연 시간: {single_time:.2f}초')

# 출력이 지나치게 길어지지 않도록 앞부분 140자만 표시한다.
print(f'첫 번째 출력: {first_output[:140].strip()!r}')

2단계 — 연속 배칭 처리량 측정

단순한 transformers.generate() 방식은 요청을 하나씩 시작부터 끝까지 처리한다. 요청이 32개이고 요청당 지연 시간이 1초라면 총 경과 시간은 약 32초가 된다. 단일 요청의 디코딩은 연산량보다 메모리 대역폭의 영향을 크게 받으므로, 이 과정에서 GPU 연산 자원이 충분히 활용되지 않을 수 있다.

연속 배칭(continuous batching)에서는 vLLM 스케줄러가 토큰 단위로 요청을 교차 실행한다. 새 요청이 도착하면 다음 디코딩 단계에서 실행 중인 배치에 편입한다. 기존 요청의 완료를 기다리지 않으므로 GPU 파이프라인을 더 지속적으로 활용할 수 있다.

다음 두 방식을 비교한다.

  1. 단일 요청 기준선(single-request baseline) — 요청을 소규모 묶음으로 처리해 요청당 처리량을 측정한다.
  2. 일괄 제출(batched submission) — 32개 프롬프트를 한 번에 전달해 스케줄러가 연속 배칭하도록 한다.

직렬 처리 기준선

직렬 방식에서는 프롬프트를 한 번에 하나씩 처리한다. 동시성이 없으며 각 요청마다 별도의 GPU 실행 구간이 발생한다. 실습 시간을 줄이기 위해 전체 32개 중 4개만 기준선 측정에 사용한다.

# 동일한 생성 조건을 유지하면서 서로 다른 숫자를 질문하는 32개 프롬프트를 만든다.
# 프롬프트를 영문으로 유지해 모델의 원래 토크나이저 조건을 보존한다.
prompts = [
    f'Tell me one interesting fact about the number {i}.'
    for i in range(32)
]

# 짧은 출력 생성에 사용할 샘플링 파라미터를 정의한다.
sp_small = SamplingParams(
    temperature=0.7,
    max_tokens=40,
    seed=42,
)

# 단일 요청 처리에 가까운 기준선 측정을 시작한다.
print('직렬 처리 기준선을 측정한다.')

# 기준선 측정 시작 시각을 기록한다.
t0 = time.time()

# 실습 시간을 줄이기 위해 전체 32개 중 4개만 한 번의 소규모 호출로 처리한다.
# 이 값은 이후 토큰당 처리량 기준선으로 사용한다.
single_out = llm.generate(
    prompts[:4],
    sp_small,
    use_tqdm=False,
)

# 네 요청에서 실제로 생성된 전체 출력 토큰 수를 합산한다.
total_out_tokens = sum(
    len(output.outputs[0].token_ids)
    for output in single_out
)

# 기준선 호출에 걸린 전체 시간을 계산한다.
serial_time = time.time() - t0

# 생성 토큰 수를 경과 시간으로 나눠 초당 출력 토큰 처리량을 계산한다.
single_req_tokens_per_s = (
    total_out_tokens
    / serial_time
)

# 기준선 측정 결과를 출력한다.
print(
    f'  4개 프롬프트: {serial_time:.2f}초, '
    f'{total_out_tokens}토큰 → '
    f'{single_req_tokens_per_s:.1f}토큰/초'
)

연속 배칭 — 32개 프롬프트 일괄 제출

동일한 프롬프트 32개를 하나의 목록으로 전달한다. vLLM 스케줄러는 요청을 연속적으로 배치하며 완료된 시퀀스의 슬롯을 대기 중인 요청에 재사용한다. 작업량이 줄어드는 것은 아니지만 GPU 파이프라인의 빈 구간을 줄여 처리량을 높인다.

실제 향상 폭은 GPU, 모델, 출력 길이, 요청 길이 분포에 따라 달라진다.

# 32개 프롬프트를 한 번에 제출해 vLLM 스케줄러가 연속 배칭하도록 한다.
print('\n연속 배칭 경로를 측정한다. 32개 프롬프트를 동시에 제출한다.')

# 일괄 처리 시작 시각을 기록한다.
t0 = time.time()

# 모든 프롬프트를 하나의 호출로 전달한다.
batch_out = llm.generate(
    prompts,
    sp_small,
    use_tqdm=False,
)

# 전체 일괄 처리 시간을 계산한다.
batch_time = time.time() - t0

# 32개 요청에서 생성된 전체 출력 토큰 수를 합산한다.
batch_out_tokens = sum(
    len(output.outputs[0].token_ids)
    for output in batch_out
)

# 처리량 분석에 사용할 지표를 딕셔너리로 저장한다.
batch_result = {
    'n_prompts': len(prompts),
    'total_time_s': batch_time,
    'total_output_tokens': batch_out_tokens,
    'throughput_tokens_per_s': (
        batch_out_tokens
        / batch_time
    ),
    'throughput_reqs_per_s': (
        len(prompts)
        / batch_time
    ),
}

# 전체 처리 시간과 토큰·요청 처리량을 출력한다.
print(
    f"  {batch_result['n_prompts']}개 프롬프트: "
    f'{batch_time:.2f}초, '
    f'{batch_out_tokens}토큰 → '
    f"{batch_result['throughput_tokens_per_s']:.1f}토큰/초 "
    f"= {batch_result['throughput_reqs_per_s']:.1f}요청/초"
)

# 기준선 대비 토큰 처리량 향상 배수를 계산해 출력한다.
speedup = (
    batch_result['throughput_tokens_per_s']
    / single_req_tokens_per_s
)

print(f'\n단일 요청 기준선 대비 처리량 향상: {speedup:.2f}배')

# 결과가 정상적으로 생성되었는지 대표 요청 세 개의 출력 앞부분을 확인한다.
print('\n대표 출력:')

for index in [0, 15, 31]:
    sample_text = batch_out[index].outputs[0].text[:80].strip()
    print(f'  [{index}] {sample_text!r}')

3단계 — 접두어 캐싱

채팅 애플리케이션의 시스템 프롬프트, 검색 증강 생성(RAG)의 검색 문서, 퓨샷 프롬프트(few-shot prompt), 코드 완성의 파일 헤더처럼 여러 요청이 긴 공통 접두어를 공유하는 경우가 많다. vLLM의 접두어 캐시는 첫 요청에서 계산한 KV 캐시 블록을 다음 요청에서 재사용한다.

캐시 대상은 블록 경계에 맞는 접두어 토큰이다. 블록 크기가 16토큰이고 두 요청이 처음 128토큰을 공유하면 8개 블록을 재사용할 수 있다. 해당 구간은 다시 계산하거나 별도로 복제하지 않는다.

이 단계에서는 긴 공통 시스템 프롬프트와 짧은 요청별 후행 문자열(tail)을 사용한다. 첫 실행은 전체 프리필 비용을 지불하는 콜드 경로(cold path)이며, 두 번째 실행은 캐시를 사용하는 웜 경로(warm path)다.

긴 공통 접두어 구성

접두어 캐싱은 여러 요청이 충분히 긴 접두어를 공유할 때 효과가 크다. 캐시는 블록 단위로 동작하므로 짧은 접두어에서는 측정 잡음이 절감 효과보다 클 수 있다.

아래 코드는 운영용 기술 지원 챗봇의 시스템 지침과 두 개의 퓨샷 예제로 긴 공통 접두어를 구성한다. 벤치마크 토큰 길이와 모델 동작을 유지하기 위해 실제 입력 문자열은 영문 원문을 보존한다.

# 접두어 캐싱 효과가 측정 잡음보다 크게 나타나도록 긴 공통 접두어를 구성한다.
# 벤치마크 토큰 수와 모델의 영문 응답 특성을 유지하기 위해 입력 문자열은 원문을 보존한다.
# vLLM은 공통 접두어를 16토큰 블록 단위로 캐시한다.
SHARED_PREFIX = (
    "You are a careful technical assistant working inside a cloud infrastructure team. "
    "Your responses must follow these rules strictly. Always answer in complete sentences "
    "using clear English. If the user asks about programming, provide a concise, executable "
    "example in the language they mention. If they ask about infrastructure, cite concrete "
    "numbers — memory sizes in GiB, latencies in ms, percentages for utilization. Never "
    "invent product names or version numbers; if you don't know, say so explicitly. Prefer "
    "accuracy over creativity. Keep responses under four sentences unless the user explicitly "
    "asks for more detail. When describing configuration files, format them with three "
    "backticks and the appropriate language tag. Avoid marketing language — stick to "
    "operational facts. Where a command is safer run non-destructively, prefer flags like "
    "--dry-run. When comparing options, always include at least one trade-off per option. "
    "Keep the tone professional but warm. Refuse requests that require credentials you do "
    "not have, and say which credential would be needed. If a question is ambiguous, ask "
    "one clarifying question before answering.\n\n"
    "Here are two worked examples to anchor your style:\n\n"
    "Example 1. User: 'How do I check NVIDIA driver version?' Assistant: 'Run `nvidia-smi`; "
    "the driver version is printed in the top row. From Python: `import pynvml; "
    "pynvml.nvmlInit(); pynvml.nvmlSystemGetDriverVersion()`. Trade-off: nvidia-smi "
    "adds ~50ms of fork overhead, pynvml is in-process but requires the package.'\n\n"
    "Example 2. User: 'What is fp16 vs bf16?' Assistant: 'Both are 16-bit. fp16 uses a "
    "5-bit exponent (range ±65504); bf16 uses 8 bits (same range as fp32). Trade-off: "
    "fp16 has finer mantissa so slightly higher precision when values are in range, but "
    "requires loss scaling in training because gradients can underflow.'\n\n"
    "End of instructions. Answer the next question following the same format.\n\n"
)

# 영문 텍스트에서 토큰 하나가 평균 약 4자라는 근사값으로 접두어 토큰 수를 추정한다.
# 정확한 토큰 수가 필요하면 모델 토크나이저를 직접 사용해야 한다.
shared_prefix_tokens = (
    len(SHARED_PREFIX)
    // 4
)

Cold 경로와 Warm 경로 비교

동일한 프롬프트를 두 번 실행한다.

  • 첫 번째 콜드 실행은 접두어 캐시를 초기화한 뒤 전체 공통 접두어를 프리필한다.
  • 두 번째 웜 실행은 첫 실행에서 생성한 접두어 블록을 찾아 요청별 고유 후행 문자열만 새로 처리한다.

접두어가 길수록 절감되는 프리필 연산량이 커진다. 실제 운영 환경에서 동일한 시스템 프롬프트를 수천 개 요청이 공유하면 누적 절감 효과가 커질 수 있다.

# 동일한 공통 접두어와 요청별 후행 질문을 결합한다.
# 영문 입력을 유지해 콜드·웜 실행의 토큰 조건을 동일하게 보존한다.
SINGLE_PROMPT = (
    SHARED_PREFIX
    + 'User: Explain what Kubernetes readiness probes do.\n'
    + 'Assistant:'
)

# 출력 변동을 줄이기 위해 온도 0과 최대 32토큰을 사용한다.
sp_short = SamplingParams(
    temperature=0.0,
    max_tokens=32,
)

# 콜드 경로 측정을 위해 기존 접두어 캐시를 초기화한다.
try:
    # 현재 vLLM 버전이 제공하는 내부 접두어 캐시 초기화 메서드를 호출한다.
    llm.llm_engine.reset_prefix_cache()
except Exception:
    # 내부 API가 버전별로 다를 수 있으므로 초기화 메서드가 없으면 계속 진행한다.
    pass

# 캐시 초기화와 내부 비동기 작업이 안정화될 시간을 짧게 둔다.
time.sleep(0.5)

# 콜드 실행 시작 시각을 기록한다.
t0 = time.time()

# 전체 공통 접두어를 처음부터 프리필하고 출력을 생성한다.
_ = llm.generate(
    [SINGLE_PROMPT],
    sp_short,
    use_tqdm=False,
)

# 콜드 실행의 전체 경과 시간을 계산한다.
cold_s = time.time() - t0

# 웜 실행 시작 시각을 기록한다.
t0 = time.time()

# 동일한 프롬프트를 다시 실행해 접두어 캐시 적중 효과를 측정한다.
_ = llm.generate(
    [SINGLE_PROMPT],
    sp_short,
    use_tqdm=False,
)

# 웜 실행의 전체 경과 시간을 계산한다.
warm_s = time.time() - t0

# 비교에 사용할 결과를 딕셔너리로 저장한다.
prefix_result = {
    'cold_time_s': cold_s,
    'warm_time_s': warm_s,
    'speedup': cold_s / warm_s,
    'shared_prefix_tokens': shared_prefix_tokens,
    'n_requests': 1,
}

# 공통 접두어 길이와 콜드·웜 실행 시간을 출력한다.
print(
    f'공통 접두어: 약 {shared_prefix_tokens}토큰 '
    f'(동일한 프롬프트를 두 번 실행함)'
)
print(
    f'콜드 실행(캐시 없음): '
    f'{cold_s * 1000:6.1f}ms'
)
print(
    f'웜 실행(캐시 적중): '
    f'{warm_s * 1000:6.1f}ms'
)
print(
    f"성능 향상: {prefix_result['speedup']:.2f}배"
)

운영 환경에서 접두어 캐싱이 효과적인 위치

접두어 캐싱은 많은 요청이 동일한 긴 선행 토큰 구간을 공유할 때 효과적이다. 대표적인 사용 사례는 시스템 프롬프트, 반복 검색되는 RAG 문서, 고정 퓨샷 예제, 동일 파일에 대한 연속 코드 완성이다.

설계 시에는 접두어를 정규화(canonicalization)해야 한다. 동일한 RAG 문서 집합이라도 문서 순서가 달라지면 토큰 접두어가 달라져 캐시 적중(cache hit)이 발생하지 않는다. 시스템 프롬프트에 매번 바뀌는 타임스탬프를 삽입해도 캐시를 재사용하기 어렵다. 가능한 한 공통 접두어를 안정적이고 동일한 순서로 구성해야 한다.

# 접두어 캐싱이 효과적인 대표 운영 사용 사례를 정리한다.
cache_use_cases = [
    {
        'name': '채팅 시스템 프롬프트',
        'prefix': (
            '모든 요청에 포함되는 약 500~2,000토큰의 '
            '고정 시스템 프롬프트'
        ),
        'savings_pct': 30,
        'notes': (
            '2,000토큰 시스템 프롬프트를 캐시하면 '
            '각 요청에서 해당 프리필 계산을 줄일 수 있다.'
        ),
    },
    {
        'name': '반복 문서를 사용하는 RAG',
        'prefix': (
            '유사한 질의에서 반복적으로 검색되는 문서 집합'
        ),
        'savings_pct': 40,
        'notes': (
            '검색 문서 순서를 정규화하면 동일한 문서 집합이 '
            '긴 접두어 캐시 적중으로 이어질 수 있다.'
        ),
    },
    {
        'name': '퓨샷 프롬프팅',
        'prefix': (
            '벤치마크 전체에서 고정된 N개 예제 접두어'
        ),
        'savings_pct': 50,
        'notes': (
            '반복 평가 루프에서는 고정 예제의 프리필을 '
            '재사용해 처리 시간을 줄일 수 있다.'
        ),
    },
    {
        'name': '파일 기반 코드 완성',
        'prefix': (
            '커서 위치 앞까지의 전체 파일 내용'
        ),
        'savings_pct': 35,
        'notes': (
            '연속된 커서 위치는 이전 요청과 대부분의 접두어를 공유한다.'
        ),
    },
]

# 사용 사례와 기대 절감 효과를 출력한다.
print('\n=== 접두어 캐싱이 효과적인 사용 사례 ===')

for use_case in cache_use_cases:
    print(
        f"  • {use_case['name']}: "
        f"최대 {use_case['savings_pct']}% 절감 — "
        f"{use_case['notes']}"
    )

4단계 — 운영 구성: 서버 인자, 쿠버네티스, 모니터링, 자동 확장

앞에서 사용한 오프라인 LLM() 엔진은 일괄 작업에 적합하다. 온라인 서빙에서는 동일한 엔진을 OpenAI 호환 HTTP 서버 뒤에서 실행한다.

python -m vllm.entrypoints.openai.api_server

이 단계에서는 다음 항목을 구성한다.

  1. 서버 인자(server arguments) — 컨테이너의 진입점(ENTRYPOINT)에서 사용할 명령줄 인자다.
  2. Deployment YAML — GPU 자원 요청, /health 준비 상태 검사(readiness probe), 생존 상태 검사(liveness probe)를 정의한다.
  3. Prometheus 지표 — 키-값 캐시 사용률, 대기열 깊이, 첫 토큰 시간, 출력 토큰당 시간을 포함한다.
  4. HPA 자동 확장 — 확장 기준 지표, 임계값, 최소·최대 레플리카 수를 정의한다.

TTFT와 TPOT

  • 첫 토큰 시간(TTFT, Time To First Token) — 프리필 지연 시간이다. 사용자가 요청을 보낸 뒤 첫 토큰 스트리밍이 시작될 때까지의 시간이다.
  • 출력 토큰당 시간(TPOT, Time Per Output Token) — 디코딩 단계에서 토큰 하나를 생성하는 평균 시간이다. 사용자가 느끼는 스트리밍 출력 속도와 관련된다.

두 지표는 병목 원인이 다르므로 별도로 모니터링해야 한다. TTFT는 긴 입력과 프리필 연산량의 영향을 크게 받고, TPOT는 키-값 캐시와 메모리 대역폭의 영향을 크게 받는다.

# OpenAI 호환 vLLM API 서버의 진입점에 전달할 명령줄 인자를 정의한다.
vllm_server_args = [
    # 서빙할 모델 저장소 또는 로컬 경로를 지정한다.
    '--model', 'TinyLlama/TinyLlama-1.1B-Chat-v1.0',

    # 컨테이너 외부에서 접근할 수 있도록 모든 네트워크 인터페이스에 바인딩한다.
    '--host', '0.0.0.0',

    # HTTP 서버가 수신할 포트를 지정한다.
    '--port', '8000',

    # 요청별 최대 모델 컨텍스트 길이를 4,096토큰으로 설정한다.
    '--max-model-len', '4096',

    # GPU 메모리의 85%까지 vLLM이 관리하도록 허용한다.
    '--gpu-memory-utilization', '0.85',

    # 공통 프롬프트 접두어의 키-값 캐시 블록 재사용을 활성화한다.
    '--enable-prefix-caching',

    # 페이지드어텐션 블록 크기를 16토큰으로 설정한다.
    '--block-size', '16',

    # 스케줄러가 동시에 유지할 최대 활성 시퀀스 수를 설정한다.
    '--max-num-seqs', '256',

    # 키-값 캐시 압력이 높을 때 사용할 CPU 메모리 스왑 공간을 4GB로 설정한다.
    '--swap-space', '4',

    # 운영 로그의 크기를 줄이기 위해 개별 요청 로깅을 비활성화한다.
    '--disable-log-requests',

    # OpenAI 호환 API에서 노출할 모델 식별자를 지정한다.
    '--served-model-name', 'chat-1b',
]

# 서버 실행 명령을 사람이 복사할 수 있는 형태로 출력한다.
print('vLLM 명령줄 인터페이스:')

print(
    'python -m vllm.entrypoints.openai.api_server \\'
)

# 인자와 값을 두 항목씩 묶어 여러 줄 명령으로 출력한다.
for index in range(
    0,
    len(vllm_server_args),
    2,
):
    pair = vllm_server_args[index:index + 2]

    # 마지막 줄을 제외한 각 줄 끝에 셸 줄 연속 문자를 추가한다.
    continuation = (
        ' \\'
        if index + 2 < len(vllm_server_args)
        else ''
    )

    print(
        '    '
        + ' '.join(pair)
        + continuation
    )

vllm CLI:
python -m vllm.entrypoints.openai.api_server \
    --model TinyLlama/TinyLlama-1.1B-Chat-v1.0 \
    --host 0.0.0.0 \
    --port 8000 \
    --max-model-len 4096 \
    --gpu-memory-utilization 0.85 \
    --enable-prefix-caching --block-size \
    16 --max-num-seqs \
    256 --swap-space \
    4 --disable-log-requests \
    --served-model-name chat-1b

from pathlib import Path

from IPython.display import Code, display

# 실습 컨테이너의 작업 공간 경로를 정의한다.
WORKSPACE = Path(
    '/home/labuser/workspace'
)

# 실제 Kubernetes 매니페스트 파일의 경로를 구성한다.
deployment_path = (
    WORKSPACE
    / 'k8s/vllm-deployment.yaml'
)

# Deployment는 실제 파일로 유지해 kubectl과 GitOps 도구가 관리할 수 있게 한다.
# 노트북에서는 해당 파일을 읽어 참고용으로 표시한다.
display(
    Code(
        filename=str(deployment_path),
        language='yaml',
    )
)

# 후속 자동 검증에서 사용할 수 있도록 YAML 전체 내용을 문자열로 읽는다.
k8s_deployment_yaml = deployment_path.read_text(
    encoding='utf-8',
)

vLLM 운영에 중요한 Prometheus 지표

vLLM은 기본적으로 /metrics 경로에 여러 지표를 노출한다. 모든 지표에 경보를 설정하기보다 서비스 수준 목표(SLO, Service Level Objective)와 직접 연결되는 지표를 우선해야 한다.

특히 gpu_cache_usage_perc는 키-값 캐시 압력이 증가하는 초기 신호로 사용할 수 있다. 이 값이 지속적으로 상승한 뒤 요청 대기열과 선점(preemption)이 증가할 수 있으므로 조기 경보 지표로 모니터링한다.

# vLLM의 /metrics 경로에서 수집할 핵심 운영 지표를 정의한다.
monitoring_metrics = [
    {
        'metric': 'vllm:gpu_cache_usage_perc',
        'why_it_matters': (
            '키-값 캐시 사용률이다. 90% 이상이 지속되면 '
            '선점, 스왑, 재계산 위험이 커질 수 있다.'
        ),
        'alert_threshold': '2분 동안 0.90 초과',
    },
    {
        'metric': 'vllm:e2e_request_latency_seconds',
        'why_it_matters': (
            '요청 수신부터 완료까지의 종단 간 지연 시간이다. '
            'p99는 사용자 체감 SLO와 직접 연결된다.'
        ),
        'alert_threshold': '5분 동안 p99 > 5초',
    },
    {
        'metric': 'vllm:time_to_first_token_seconds',
        'why_it_matters': (
            '첫 토큰 시간(TTFT)이다. '
            '스트리밍 응답이 시작되기 전 사용자가 기다리는 시간이다.'
        ),
        'alert_threshold': '2분 동안 p99 > 500ms',
    },
    {
        'metric': 'vllm:time_per_output_token_seconds',
        'why_it_matters': (
            '출력 토큰당 시간(TPOT)이다. '
            '스트리밍 출력 속도와 관련된다.'
        ),
        'alert_threshold': '2분 동안 p99 > 50ms',
    },
    {
        'metric': 'vllm:num_requests_waiting',
        'why_it_matters': (
            '스케줄러 대기열 깊이다. '
            '지속적으로 증가하면 현재 레플리카 용량이 부족하다는 뜻이다.'
        ),
        'alert_threshold': '1분 동안 50 초과',
    },
    {
        'metric': 'vllm:num_preemptions_total',
        'why_it_matters': (
            '키-값 캐시에서 시퀀스가 제거되어 '
            '스왑 또는 재계산되는 누적 횟수다.'
        ),
        'alert_threshold': '선점 비율이 지속적으로 초당 1회 초과',
    },
]

# 핵심 모니터링 지표와 경보 기준을 출력한다.
print('=== 모니터링할 vLLM Prometheus 지표 ===')

for item in monitoring_metrics:
    print(f"\n  {item['metric']}")
    print(f"    중요성: {item['why_it_matters']}")
    print(f"    경보:   {item['alert_threshold']}")

자동 확장 정책 — CPU 사용률이 아니라 대기열 깊이 사용

GPU 추론 작업에서는 CPU 사용률이 사용자 대기 시간을 잘 반영하지 못한다. GPU 사용률도 적은 요청만 있어도 높게 유지될 수 있어 자동 확장 신호로는 민감도가 낮을 수 있다.

num_requests_waiting은 스케줄러 대기열에 있는 요청 수를 나타낸다. 대기열이 증가하면 사용자가 실제로 기다리고 있다는 뜻이므로 온라인 추론의 자동 확장 지표로 활용하기 적합하다.

아래 임계값은 시작점이다. vLLM 레플리카의 콜드 스타트가 길 수 있으므로 실제 모델 적재 시간, 트래픽 패턴, 축소 안정화 시간에 맞춰 조정해야 한다.

# CPU 또는 GPU 사용률 대신 요청 대기열 깊이를 사용하는 HPA 정책을 정의한다.
autoscaling_policy = {
    # 자동 확장에 사용할 vLLM 스케줄러 대기 요청 수 지표다.
    'metric': 'vllm:num_requests_waiting',

    # 대기 요청이 20개를 초과하면 레플리카 추가를 시작한다.
    'scale_up_threshold': 20,

    # 대기 요청이 5분 동안 2개 이하이면 레플리카 축소를 검토한다.
    'scale_down_threshold': 2,

    # 고가용성을 위해 최소 두 개의 레플리카를 유지한다.
    'min_replicas': 2,

    # 클러스터 GPU 예산을 고려해 최대 레플리카 수를 16개로 제한한다.
    'max_replicas': 16,

    # vLLM 모델 적재와 워밍업 시간을 고려해 300초의 안정화 시간을 사용한다.
    'cooldown_s': 300,

    # 정책 설계 근거를 운영 메타데이터로 남긴다.
    'notes': (
        'GPU 사용률은 요청이 적어도 높게 유지될 수 있다. '
        '요청 대기열 깊이는 실제 사용자 대기 시간을 더 직접적으로 반영한다.'
    ),
}

# 자동 확장 정책을 항목별로 출력한다.
print('\n=== 자동 확장 정책 ===')

for key, value in autoscaling_policy.items():
    print(f'  {key:>22s}: {value}')


실습 결과

다음 구성 요소를 구현하고 측정했다.

  • TinyLlama를 서빙하는 vLLM 엔진과 페이지드어텐션(PagedAttention) 기반 KV 캐시 용량
  • 단일 요청 기준선 대비 연속 배칭(continuous batching) 처리량
  • 긴 공통 시스템 프롬프트를 사용한 접두어 캐싱(prefix caching) 성능
  • vLLM 서버 인자, Kubernetes Deployment, vLLM 전용 Prometheus 지표, 대기열 기반 HPA 정책

확장 방법

  • 텐서 병렬성(tensor parallelism)--tensor-parallel-size 4를 사용하면 모델을 4개의 GPU에 분할한다. 단일 GPU 메모리에 적재할 수 없는 대형 모델에 필요하다.
  • 추측 디코딩(speculative decoding) — 초안 토큰을 먼저 생성하고 대상 모델이 여러 토큰을 한 번에 검증해 지연 시간을 줄인다.
  • 양자화(quantization) — AWQ, GPTQ, FP8 등을 사용해 모델 메모리와 키-값 캐시 외 가중치 메모리를 줄인다. 품질과 하드웨어 지원을 함께 검증해야 한다.

댓글남기기