Documents
Home>Documents>AI>Inference

Prefix Caching의 숨겨진 적: 동일해 보이는 프롬프트가 캐시 미스를 내는 이유와 운영 대응법

12 min readSep 5, 2026Sep 5, 2026

vLLM이나 SGLang을 배포하고 prefix cache 지표를 처음 들여다보면 당황스러운 경우가 있다. 모든 요청이 같은 시스템 프롬프트를 쓴다는 걸 확인했는데도 히트율이 0에 붙어 있다. 로그를 봐도 요청 자체는 정상이다.

제거 정책(LRU eviction)이나 요청 분포 문제를 다루는 글은 이미 많다. 이 글은 그것보다 더 자주, 더 눈에 안 띄게 발생하는 세 가지 원인에 집중한다: 토크나이저 변형, 멀티 인스턴스 라우팅 분산, 롤링 컨텍스트. 세 원인은 진단과 대응이 각기 달라서 구분해서 이해해야 한다.

Prefix Caching이 미스를 내는 구조

vLLM의 Automatic Prefix Caching은 KV 블록을 해시로 식별한다. 각 블록의 해시는 hash(앞선 블록 해시, 현재 블록의 토큰 ID 시퀀스)로 계산된다. v0.11부터 기본 알고리즘이 SHA-256으로 바뀌어 해시 충돌 위험은 줄었지만, 토큰 ID가 1개라도 달라지면 해당 블록부터 그 이후 모든 블록의 해시가 전부 바뀐다.

SGLang의 RadixAttention도 같은 원리다. Radix Tree로 prefix를 매칭하기 때문에, 공유 prefix의 마지막 토큰이 다르면 트리의 분기점이 달라져 재사용 구간이 0이 된다.

여기서 "토큰 ID가 달라지는" 원인이 반드시 텍스트 차이일 필요가 없다는 게 문제다. 같은 문자열이 서로 다른 토큰 시퀀스를 만들어낼 수 있다.

함정 1: 토크나이저가 같은 문자열에서 다른 토큰을 만드는 방식

현장에서 가장 빈번하게 발생하는 원인이다.

BOS 토큰 중복: HuggingFace tokenizers에서 add_bos_token=True이고 채팅 템플릿에도 <s> 삽입 로직이 동시에 있으면 BOS 토큰이 두 번 들어간다. vLLM은 서버 시작 옵션에 따라 tokenizer의 add_bos_token 처리 방식이 달라질 수 있어서, 배포 환경이 바뀌면 같은 모델이라도 prefix 첫 블록의 해시가 달라진다.

from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3-8B-Instruct")
sys_prompt = "You are a helpful assistant."

tok.add_bos_token = True
ids_with_bos = tok.encode(sys_prompt)

tok.add_bos_token = False
ids_without_bos = tok.encode(sys_prompt)

print("with BOS:   ", ids_with_bos[:5])
print("without BOS:", ids_without_bos[:5])
# with BOS:    [1, 2683, 527, 264, 11190]
# without BOS: [2683, 527, 264, 11190, ...]

첫 토큰부터 다르고, 이후 모든 블록의 해시가 연쇄 변경된다.

trailing whitespace: 더 교묘한 경우다. 시스템 프롬프트 마지막에 공백 하나가 붙고 안 붙고의 차이도 토큰 시퀀스를 바꾼다. SentencePiece 기반 토크나이저(LLaMA 계열)는 단어 앞 공백을 해당 단어 토큰에 포함시키는 방식이라, trailing whitespace가 있으면 뒤따라오는 유저 메시지의 첫 토큰 ID가 달라진다.

sys_a = "You are a helpful assistant."
sys_b = "You are a helpful assistant. "  # trailing space 1개

user_msg = "What is the capital of France?"

ids_a = tok.encode(sys_a + user_msg)
ids_b = tok.encode(sys_b + user_msg)

diff_pos = next(i for i, (a, b) in enumerate(zip(ids_a, ids_b)) if a != b)
print(f"첫 불일치 위치: {diff_pos}번째 토큰")
print(f"  ids_a[{diff_pos}]={ids_a[diff_pos]}, ids_b[{diff_pos}]={ids_b[diff_pos]}")

이 불일치가 발생한 블록부터 prefix 해시가 달라진다. prefix_match_length는 0이 된다.

서비스 레이어에서 프롬프트를 구성하는 경로가 여러 개라면 — API 게이트웨이, 파이프라인 변환, 클라이언트 라이브러리 — 각 경로의 토큰 ID를 직접 로깅해서 비교해야 한다. 텍스트 레벨로 diff를 찍어서는 이 문제를 발견할 수 없다.

함정 2: Round-Robin 라우팅에서 prefix가 인스턴스마다 따로 캐싱되는 구조

인스턴스를 수평 확장하면 Prefix Caching 효율이 선형으로 희석된다.

Round-Robin 라우터에서 N개 인스턴스 중 하나로 요청이 분산된다고 하자. 동일 prefix P를 가진 요청이 처음 instance 1에 도달하면 cache miss, P가 instance 1에 캐싱된다. 같은 prefix의 다음 요청은 instance 2로 간다 — 또 miss, instance 2에 캐싱된다. N번째 요청까지 전부 miss가 난 뒤에야 N개 인스턴스 모두에 P가 캐싱된다.

동일 prefix 요청 비율을 p, 인스턴스 수를 N이라 하면 이론적 히트율 상한은 다음과 같다:

이론 히트율 상한 ≤ p × (1 - 1/N)
인스턴스 수 (N)동일 prefix 비율 (p)이론 히트율 상한
10.90.9 (warm-up 후)
20.90.45
40.90.675
80.90.787

N=8에서 p=0.9 트래픽이어도 히트율은 79% 안팎에 머문다. 여기에 LRU eviction과 실제 요청 분포 편차를 더하면 실측치는 이보다 낮다.

SGLang v0.4에서 도입한 cache-aware load balancer는 이를 완화한다. 각 인스턴스의 Radix Tree를 미리 질의해서 prefix 매칭률이 가장 높은 인스턴스로 라우팅하고, 내부 실험에서 히트율 3.8배, 처리량 1.9배 개선을 보고했다.

sticky session 라우팅은 인기 있는 시스템 프롬프트를 공유하는 요청들이 같은 인스턴스로 몰리는 탓에 해당 인스턴스만 GPU 사용률이 치솟는 문제가 있다. vLLM에는 cache-aware LB가 내장되어 있지 않으므로 별도 라우터를 직접 구현해야 하고, 그 라우터가 인스턴스 부하를 얼마나 고려하느냐에 따라 실제 효과가 달라진다.

함정 3: 롤링 컨텍스트 패턴에서 Prefix Caching은 구조적으로 무력화된다

챗봇·코드 어시스턴트 서빙에서 컨텍스트 길이 제한에 걸리면 가장 오래된 메시지를 잘라내고 최신 메시지를 붙이는 슬라이딩 윈도우 방식을 쓴다. 이 방식에서 각 요청의 prefix는 매 턴마다 달라진다.

턴 1: [system][user_1]
턴 2: [system][user_1][asst_1][user_2]
턴 3: [system][user_1][asst_1][user_2][asst_2][user_3]
...
윈도우 초과:
턴 k: [system][user_2][asst_2]...[user_k]   ← [user_1] 제거됨

[user_1]이 잘려나가는 순간, [system] 블록 이후의 모든 해시가 바뀐다. 시스템 프롬프트 블록만 재사용되고 대화 이력 전체가 매 요청마다 새로 prefill된다. 대화가 길어질수록 prefill 비용이 계속 누적된다.

세션 로그에서 이 패턴을 감지하려면 턴 간 prefix overlap 비율을 계산하면 된다:

def detect_rolling_context(session_turns):
    """각 턴 사이의 prefix overlap 비율을 계산한다."""
    overlaps = []
    for i in range(1, len(session_turns)):
        prev_ids = session_turns[i-1]['token_ids']
        curr_ids = session_turns[i]['token_ids']
        overlap_len = 0
        for a, b in zip(prev_ids, curr_ids):
            if a == b:
                overlap_len += 1
            else:
                break
        overlaps.append(overlap_len / max(len(curr_ids), 1))
    # overlap 비율이 단조 감소하면 rolling context 패턴
    return overlaps

이 패턴에서 Prefix Caching을 켜는 것은 TTFT를 유의미하게 줄이지 못한다. 대안은 두 가지다.

세션 고정(sticky session) 라우팅: 같은 세션 ID의 모든 요청이 동일 인스턴스로 가도록 고정한다. SGLang은 요청이 끝난 뒤에도 KV cache 블록을 LRU 캐시에 유지하므로, 같은 인스턴스로 도달한 다음 턴이 이전 턴의 KV cache를 재사용할 가능성이 높아진다. 인스턴스 재시작이나 스케일다운 시 세션 캐시가 날아가는 리스크는 상위 레이어에서 체크포인팅으로 처리해야 한다.

Prefill-Decode 분리 구조: 세션 KV cache를 외부 스토리지에 저장하고, 다음 요청 시 Prefill 서버가 해당 KV cache를 복원해서 이어가는 방식이다. 구현 복잡도가 높지만 sticky session의 부하 불균형 문제를 함께 해결한다.

세 함정 진단하기

히트율이 낮을 때 먼저 볼 지표는 vLLM의 Prometheus 카운터다.

# vLLM metrics에서 prefix cache 지표 추출
curl -s http://localhost:8000/metrics | grep -E 'prefix_cache|gpu_cache'

# 출력 예시:
# vllm:prefix_cache_queries_total{model_name="llama3"} 42300
# vllm:prefix_cache_hits_total{model_name="llama3"} 1240
# vllm:gpu_cache_usage_perc{model_name="llama3"} 0.34

# PromQL rolling hit rate
# rate(vllm:prefix_cache_hits_total[5m]) / rate(vllm:prefix_cache_queries_total[5m])

SGLang은 --enable-metrics 플래그로 서버를 시작하면 /metrics에서 sglang:cache_hit_rate gauge가 나온다.

원인을 좁히는 흐름:

  • 히트율 ≈ 0, 인스턴스 1개: 토크나이저 변형 의심. 서비스 레이어의 토큰 ID를 직접 로깅해서 요청 간 diff 확인.
  • 히트율이 p × (1 - 1/N)보다 현저히 낮고 인스턴스 N개: Round-Robin 분산 의심. vllm:gpu_cache_usage_perc가 인스턴스마다 비슷하게 낮으면 확실하다.
  • 히트율이 시스템 프롬프트 토큰 비율(sys_len / total_len)에 수렴: 롤링 컨텍스트 패턴. 시스템 프롬프트 블록만 재사용되고 대화 이력은 전혀 매칭되지 않는 상태다.

운영 대응과 트레이드오프

토크나이저 변형 대응: 서비스 레이어에서 프롬프트를 표준화하는 단계를 넣는다. 시스템 프롬프트는 한 곳에서 단일 문자열로 관리하고 trailing whitespace를 정규화한다. vLLM 서버 시작 시 --tokenizer-mode 옵션을 명시해서 BOS 처리 방식이 배포마다 달라지지 않게 고정하는 것도 중요하다.

라우팅 분산 대응: 시스템 프롬프트 종류가 몇 가지 안 되고 요청량이 큰 경우라면 prefix 분리 서빙이 sticky session보다 낫다. 공유 prefix 전용 인스턴스를 따로 두고 해당 인스턴스는 prefix KV cache만 채운 채 상시 유지하는 방식이다.

메모리 예약 비용을 계산해보면: 4096 토큰 시스템 프롬프트, 32개 레이어, GQA KV head 4개, head dim 128, BF16 기준:

KV cache = 2(K+V) × 32 레이어 × 4 KV heads × 128 head_dim × 4096 tokens × 2 bytes
         = 268,435,456 bytes ≈ 256 MB

prefix 자체의 KV cache는 256 MB다. A100 80GB 한 장 대비 0.3% 수준이라 부담이 크지 않다. 다만 이 KV cache를 저장하기 위해 GPU 인스턴스 하나를 전용으로 잠가두어야 하고, 시스템 프롬프트 종류가 수십 가지로 늘어나면 prefix 인스턴스도 그만큼 늘어난다.

롤링 컨텍스트 대응: 세션 고정 라우팅을 구현하면 SGLang의 LRU KV cache 보존이 실제로 효과를 낸다. Prefix Caching 설정을 손보는 것보다 라우팅 고정이 먼저다.

Prefix Caching이 효과 있는 조건

세 함정을 전부 피하더라도, TTFT 개선이 의미 있으려면 먼저 확인할 세 가지 수치가 있다.

  • 시스템 프롬프트 토큰 수 / 평균 총 입력 토큰 수: 0.3 미만이면 캐시 히트 구간이 짧아 절감 효과가 작다.
  • 동일 시스템 프롬프트를 공유하는 요청 비율: 인스턴스 수가 많을수록 이 비율이 충분히 높아야 라우팅 희석 효과를 상쇄한다.
  • 세션 평균 턴 수: 1~2턴 이하라면 캐시 warm-up 비용이 이득보다 클 수 있다.

히트율 0% 알람을 처음 받으면 제거 정책부터 건드리기 쉽다. 그 전에 토큰 ID를 한 번 찍어보는 게 시간을 더 아끼는 방법이다.

Tags
KV 캐시vLLMInference서빙메모리모니터링