Documents
Home>Documents>AI>Inference

Prefix Caching의 히트율은 왜 실제 서빙에서 기대치를 밑도는가: 요청 분포, 키 충돌, 제거 정책의 삼중 함정

13 min readAug 23, 2026Aug 23, 2026

Prefix Caching을 켜면 TTFT가 크게 준다는 글은 많다. 실제로 프로덕션에 켜면 10~20%에 그치거나, 아예 효과를 못 느끼는 경우가 더 많다. 히트율이 낮아서다. 그런데 왜 낮은지가 단순하지 않다—블록 정렬 방식, 제거 정책, 요청 분포, 프롬프트 설계가 각자 다른 방식으로 히트율을 깎는다.

블록 단위 해시(vLLM)와 Radix Tree(SGLang): 같은 요청에서 히트율이 갈리는 이유

Prefix Caching의 전제는 단순하다. 이전 요청의 prefix와 현재 요청의 prefix가 동일하면, 이미 계산된 KV cache를 재사용해 Attention 연산을 건너뛴다. 문제는 "동일하다"를 어떻게 판단하느냐다—vLLM과 SGLang이 완전히 다른 방식을 택했다.

vLLM은 블록 단위 해시다. block_size(기본 16) 개의 토큰을 묶어 하나의 해시 키를 만들고, 전체 KV cache를 그 블록 단위로 관리한다. prefix가 블록 경계에 딱 떨어지면 아무 문제가 없다. 하지만 prefix 길이가 16의 배수가 아니면, 마지막 불완전 블록은 캐시되지 않는다.

# vLLM block_size=16 기준 정렬 손실 계산
prefix_len = 100  # 실제 prefix 토큰 수
block_size = 16

effective_hit_tokens = (prefix_len // block_size) * block_size  # = 96
alignment_loss       = prefix_len % block_size                  # = 4

prefix 100 토큰에서 4 토큰이 날아간다. 100 토큰짜리라면 4%라 무시할 수 있다. 그런데 prefix가 짧을수록 손실 비율이 올라간다. prefix 20 토큰이라면 4 토큰 손실로 20%다. vLLM 이슈 #40696에서 prefix가 block_size 미만이면 캐시가 완전히 무효화되는 사례가 보고됐는데, 이 역시 같은 구조에서 비롯된다.

SGLang은 RadixAttention이다. 런타임 내 모든 요청의 KV cache를 하나의 Radix Tree(Trie)로 관리한다. 새 요청이 들어오면 트리를 순회해 가장 긴 공통 prefix 경로를 찾고, 그 지점부터 새로 계산한다. 토큰 단위로 매칭하기 때문에 블록 정렬 제약이 없다. prefix 20 토큰이든 100 토큰이든 매칭된 만큼 정확하게 재사용된다.

같은 요청셋을 줬을 때 두 구현의 히트율 차이는 요청 분포에 따라 크게 달라진다. prefix 길이가 짧고 분포가 고른 트래픽에서 vLLM의 블록 정렬 손실이 특히 두드러지며, 요청 길이 분포에 따라 히트율 격차가 20~30%p까지 벌어질 수 있다. SGLang NeurIPS 2024 논문의 벤치마크에서 prefix 중복이 높은 워크로드—RAG, 다중 턴 대화—에서 vLLM 대비 최대 6.4배 처리량 향상이 보고됐는데, 토큰 단위 매칭이 이 차이의 핵심 원인 중 하나다. 반대로 prefix 중복이 전혀 없는 워크로드에서는 두 구현의 성능 차이가 노이즈 수준이다—RadixAttention은 캐시 미스 시 오버헤드가 없도록 설계되어 있다.

히트율을 낮추는 세 가지 구조적 원인

구현 방식 외에, 실제 트래픽에서 히트율을 깎는 원인이 셋 더 있다.

요청 분포의 long-tail 효과

Prefix Caching이 잘 작동하려면 많은 요청이 동일한 prefix를 공유해야 한다. 시스템 프롬프트 1,024 토큰을 고정하고 사용자 입력만 다른 챗봇이라면 이 조건이 성립한다. 하지만 요청마다 prefix가 다른 서비스—검색 결과를 그대로 컨텍스트에 넣는 RAG나, 문서 요약 파이프라인—에서는 고유 prefix 수(U)가 많아지고 캐시 용량(C) 대비 U/C 비율이 높아진다. 이 비율이 1을 넘는 순간부터 LRU 제거가 빈번해지고, 캐시에 넣자마자 지워지는 순환이 시작된다.

캐시 용량 C, 총 요청 수 N, 고유 prefix 수 U일 때, U/C < 1이면 모든 prefix가 캐시에 들어갈 수 있고 이론적 히트율 상한은 (N - U) / N이다. U/C > 1이면 LRU 제거가 시작되고 히트율은 급격히 떨어진다.

시스템 프롬프트 중간의 변수 삽입

변수 삽입 위치 하나가 히트율을 결정한다. 동일 시스템 프롬프트 1,024 토큰 기준으로 두 구조를 비교하면 차이가 명확하다.

구조프롬프트 형태캐시 가능 토큰 수
변수 중간 삽입[시스템 500 토큰] + {user_name} + [시스템 524 토큰]최대 500 토큰 (이후 전부 미스)
변수 suffix 삽입[시스템 1,024 토큰] + {user_name}1,024 토큰 전체

중간에 변수가 들어가면 그 위치 이후 토큰이 요청마다 달라진다. vLLM은 prefix를 순서대로 해시하기 때문에, 변수가 삽입된 지점 이후의 블록들은 전혀 다른 해시를 갖게 돼 캐시 미스가 강제된다. 1,024 토큰짜리 시스템 프롬프트에서 500번째 위치에 변수를 넣으면, 뒤의 524 토큰은 캐시를 타지 못한다.

모델 문제가 아니라 프롬프트 구조 설계의 문제다. 변수를 suffix로 밀어내는 것만으로 캐시 가능 토큰이 두 배 가까이 늘어나는 경우가 실제로 많다.

배치 경쟁과 캐시 수명의 단축

처리량을 높이려고 배치 크기를 키우면 Prefix Caching 효과가 동시에 줄어드는 역방향 트레이드오프가 생긴다. 더 많은 요청이 동시에 들어오면 KV cache 메모리 경쟁이 격해지고, LRU 제거가 훨씬 빈번해진다. 배치 크기 B에 각 요청이 평균 L 토큰의 prefix를 가지면, 한 스텝에서 B×L 토큰의 캐시 공간이 필요하다. 이 값이 총 캐시 용량에 가까워질수록 이전 요청의 캐시가 같은 배치 내에서 이미 제거된다.

vLLM에서 max_num_seqs를 늘리면서 hit rate가 내려가는 패턴을 관찰하면, 이 트레이드오프를 직접 확인할 수 있다.

제거 정책: LRU가 언제 최악이 되는가

vLLM의 기본 제거 정책은 LRU(Least Recently Used)다. 직관적으로는 합리적으로 보이지만, 특정 트래픽 패턴에서 심각하게 나빠진다.

LRU는 최근에 접근된 항목을 보존한다. 요청 분포가 멱함수(power-law)를 따르는 경우—인기 prefix 몇 개에 요청이 몰리는 경우—LRU는 잘 작동한다. 인기 prefix가 계속 접근되므로 캐시에 남는다.

문제는 요청 다양성이 높을 때다. 각 prefix에 요청이 고르게 분산되면, 새 요청이 들어올 때마다 오래된 prefix가 밀려난다. 이때 LFU(Least Frequently Used)가 역전된다—오래된 prefix라도 자주 접근됐으면 보존하는 게 이득이기 때문이다. 아래 시뮬레이션으로 직접 확인할 수 있다.

from collections import OrderedDict, Counter
import random

def simulate_hit_rate(policy, capacity, requests):
    cache = OrderedDict()
    freq  = Counter()
    hits  = 0

    for req in requests:
        freq[req] += 1
        if req in cache:
            hits += 1
            if policy == 'lru':
                cache.move_to_end(req)
        else:
            if len(cache) >= capacity:
                if policy == 'lru':
                    cache.popitem(last=False)
                elif policy == 'lfu':
                    min_key = min(cache, key=lambda k: freq[k])
                    del cache[min_key]
            cache[req] = True

    return hits / len(requests)

capacity   = 20
n_requests = 10_000

# 멱함수 분포 (인기 prefix 쏠림)
power_law_reqs = [int(random.paretovariate(1.5)) % 100 for _ in range(n_requests)]
print("Power-law 분포:")
print(f"  LRU 히트율: {simulate_hit_rate('lru', capacity, power_law_reqs):.2%}")
print(f"  LFU 히트율: {simulate_hit_rate('lfu', capacity, power_law_reqs):.2%}")

# 균등 분포 (요청 다양성 높음)
uniform_reqs = [random.randint(0, 99) for _ in range(n_requests)]
print("균등 분포:")
print(f"  LRU 히트율: {simulate_hit_rate('lru', capacity, uniform_reqs):.2%}")
print(f"  LFU 히트율: {simulate_hit_rate('lfu', capacity, uniform_reqs):.2%}")

실행해 보면 멱함수 분포에서는 LRU와 LFU가 비슷하게 나온다. 균등 분포에서는 LFU가 LRU보다 눈에 띄게 낫다. 고유 prefix 수(U)가 캐시 용량(C)의 2배를 넘어가는 순간부터 LRU의 성능이 급격히 나빠지는 것도 이 코드로 직접 확인된다.

LFU 자체의 단점도 있다. 오래 전에 자주 쓰이다가 지금은 쓰이지 않는 prefix를 계속 캐시에 잡아둔다—시간적 지역성을 반영하지 못한다. LRFU나 TTL 기반 제거 정책이 유리한 환경이 많지만, vLLM v1은 아직 LRU만 제공한다.

히트율을 제대로 측정하는 법

vLLM의 /metrics 엔드포인트에서 prefix cache 관련 지표는 두 Prometheus 카운터로 노출된다. 이전에 쓰이던 gpu_prefix_cache_hit_rate_perc 게이지는 deprecated 처리됐다.

# Prometheus 카운터 (현재 권장 방식)
vllm:prefix_cache_queries   # 총 prefix cache 조회 수
vllm:prefix_cache_hits      # 캐시 히트 수

# 5분 윈도우 히트율
rate(vllm:prefix_cache_hits[5m]) / rate(vllm:prefix_cache_queries[5m])

# KV cache 전체 사용률 (0~1)
vllm:kv_cache_usage_perc

여기서 흔한 착각이 하나 있다. prefix_cache_hits는 캐시된 KV 블록 수를 기준으로 집계된다—token-level hit rate에 가깝다. 요청 하나가 1,024 토큰 prefix를 갖고 그 중 512 토큰이 캐시됐다면, 이 요청은 50% 히트로 집계된다.

TTFT 절감을 예측할 때 이 지표가 misleading해지는 이유다. TTFT가 실질적으로 줄려면 request-level hit rate—"이 요청에서 prefix 전체가 캐시됐는가"—가 높아야 한다. 짧은 prefix만 캐시되는 경우, token-level 히트율은 나쁘지 않게 보여도 긴 prefix를 가진 요청은 여전히 대부분을 재계산한다. prefix 길이 분포를 함께 봐야 이 함정을 피할 수 있다.

두 지표(vllm:kv_cache_usage_perc와 hit rate)를 동시에 볼 때 해석:

kv_cache_usage_perchit rate의미
높음높음정상. 캐시를 잘 활용 중
높음낮음캐시가 차 있지만 재사용 안 됨—prefix 다양성이 높거나 LRU 제거가 빠름
낮음높음드문 경우—요청 수가 적고 prefix 중복이 극단적으로 높음
낮음낮음요청 볼륨 자체가 낮거나 prefix 재사용이 전혀 없음

서빙 구조에서 히트율을 올리는 방법

원인이 파악됐으면 구조 수준에서 개입할 수 있는 지점이 세 군데 있다.

프롬프트 설계. 변수는 suffix로 밀어낸다. 시스템 프롬프트에서 사용자 ID, 세션 컨텍스트, 검색 결과 같은 동적 값은 고정 prefix 뒤에 붙인다. 이것만으로 캐시 가능한 토큰 수가 두 배 가까이 늘어나는 경우가 실제로 많다.

요청 라우팅. 동일한 prefix를 가진 요청들을 같은 서버 인스턴스로 보내는 prefix-aware 라우팅이 핵심이다. Ray Serve의 prefix-aware 라우팅이 이 용도로 쓰인다. prefix 해시를 키로 consistent hashing을 적용하면 동일 prefix 요청이 같은 인스턴스를 타도록 분배할 수 있다. 다중 인스턴스 서빙에서 이 설정 없이는 인스턴스마다 캐시를 따로 채워야 하므로 히트율이 인스턴스 수에 반비례해서 희석된다.

캐시 용량. 용량을 늘리면 히트율이 올라가지만 수익 체감 구간이 있다. U/C 비율이 1 미만으로 떨어지면—모든 고유 prefix가 캐시에 들어가면—용량을 더 늘려도 히트율은 거의 변하지 않는다. 반대로 U/C > 2인 상황에서 용량을 두 배로 늘리면 히트율이 큰 폭으로 뛴다. kv_cache_usage_perc와 hit rate가 둘 다 높으면 용량이 아니라 라우팅이나 프롬프트 설계가 문제다.

Prefix Caching이 실제로 효과를 내는 트래픽 조건

네 변수로 켜도 되는지 판단할 수 있다.

변수효과적인 조건효과 낮은 조건
시스템 프롬프트 길이512 토큰 이상, 요청 간 고정짧거나 요청마다 달라짐
요청 다양성 (U/C)< 1 (고유 prefix 수 < 캐시 용량)> 2 (LRU 제거 빈번)
배치 규모캐시 공간 여유가 남을 수준매우 커서 캐시 수명이 극단적으로 짧아짐
캐시 용량동시 고유 prefix 전부 수용 가능한참 부족

이 네 조건이 어긋나는 경우, 메트릭이 먼저 티를 낸다. hit rate가 낮게 고정되거나, kv_cache_usage_perc는 높은데 hit rate는 낮은 조합으로. 히트율이 기대치를 밑돈다면, 수치를 들여다보기 전에 먼저 이 네 변수 중 어디가 빗나갔는지부터 확인하는 게 순서다.

Tags
InferenceLLMvLLM서빙메모리TTFT아키텍처