•
9분 읽기

프리필과 디코드는 다른 일이다: 프리픽스 캐시 적중률이 비용이 되는 이유

앞 글에서는 한 번의 요청 안에서 KV 캐시가 어떻게 재계산을 없애는지 봤다. 이 글은 두 가지를 이어서 다룬다. 하나는 한 요청이 성격이 완전히 다른 두 구간으로 갈린다는 사실이고, 다른 하나는 요청과 요청 사이에도 캐시를 재사용할 수 있다는 점이다.

두 번째가 특히 중요하다. 재사용 비율이 곧 청구서 금액이 되고, 그 결과 로드밸런싱의 상식이 뒤집히기 때문이다.

한 요청은 두 구간으로 갈린다

프롬프트를 보내고 답변을 받기까지, GPU가 하는 일은 중간에 성격이 한 번 바뀐다.

flowchart LR
  REQ["요청 도착<br/>프롬프트 8,000토큰"] --> PF["Prefill<br/>8,000토큰을 한꺼번에 읽어<br/>KV 캐시를 만든다"]
  PF --> FT["첫 토큰 출력"]
  FT --> DC["Decode<br/>토큰 1개씩 생성하며<br/>KV 캐시를 계속 읽는다"]
  DC --> DONE["종료 토큰"]
PrefillDecode
하는 일프롬프트 전체의 K·V를 계산해 캐시를 만든다토큰을 하나씩 만들며 캐시를 읽는다
한 번에 처리하는 토큰수천~수만 개를 병렬로1개
병목연산량 (compute-bound)메모리 대역폭 (memory-bandwidth-bound)
사용자가 느끼는 지표TTFT (첫 토큰까지의 시간)TPOT (토큰 간 간격)

차이가 생기는 이유는 단순하다. prefill은 토큰 수천 개를 한꺼번에 행렬곱에 넣을 수 있어 GPU 연산기를 꽉 채운다. 반면 decode는 토큰 하나를 만들려고 모델 가중치 전체와 쌓인 KV 캐시를 메모리에서 읽어 와야 한다. 연산기는 한가하고 메모리 버스만 바쁘다.

한 장비에서 둘을 섞어 돌리면 서로를 방해한다. 긴 prefill이 들어오면 이미 진행 중이던 decode들의 토큰 간격이 들쭉날쭉해진다. DistServe와 Splitwise가 두 구간을 아예 다른 GPU 그룹에 나눠 배치하자고 제안한 배경이 이것이다.

요청 사이의 재사용 — 프리픽스 캐싱

여기서 전제 하나를 분명히 해야 한다. LLM API는 상태를 기억하지 않는다(stateless). 서버는 내가 누구인지, 아까 무슨 말을 했는지 모른다. 그래서 채팅이 10턴째라면 클라이언트가 10턴치 대화 전체를 매번 다시 보낸다.

그러면 서버 입장에서 이런 모양이 된다.

1턴 요청:  [시스템 프롬프트][도구 정의][Q1]
2턴 요청:  [시스템 프롬프트][도구 정의][Q1][A1][Q2]
3턴 요청:  [시스템 프롬프트][도구 정의][Q1][A1][Q2][A2][Q3]
                └──────────── 앞머리가 매번 똑같다 ────────────┘

이미 prefill을 끝내 둔 앞머리를 또 계산할 이유가 없다. 그래서 추론 엔진은 요청이 끝나도 KV 캐시 블록을 바로 버리지 않고 들고 있다가, 다음 요청이 같은 앞머리로 시작하면 겹치는 데까지 건너뛰고 새로 붙은 부분만 prefill 한다. vLLM의 Automatic Prefix Caching, SGLang의 RadixAttention이 이 기능이다.

프리픽스 캐시 적중률은 입력 토큰 중 이렇게 재계산을 건너뛴 비율이다. 줄여 주는 건 decode가 아니라 prefill 쪽이라는 점도 기억해 둘 만하다.

적중의 조건: 토큰 0번부터 연속 일치

가장 많이 오해하는 지점이다. 중간에 같은 문장이 들어 있다고 적중하지 않는다. 처음부터 끊기지 않고 같아야 한다.

이유는 앞 글에서 본 구조에 있다. 어떤 토큰의 K·V는 자기 내용뿐 아니라 자기 위치와 앞에 무엇이 있었는지가 모두 반영된 값이다. 앞에 한 토큰이라도 끼어들면 그 뒤의 K·V는 전부 다른 값이 된다.

flowchart TB
  subgraph HIT["적중"]
    H1["이전: [공통 앞머리][질문1]"]
    H2["이번: [공통 앞머리][질문1][답변1][질문2]"]
    H3["앞머리 + 질문1 + 답변1 까지 재사용<br/>질문2만 새로 계산"]
    H1 --> H3
    H2 --> H3
  end
  subgraph MISS["전부 미스"]
    M1["이전: [공통 앞머리][질문1]"]
    M2["이번: [현재시각 14:23:11][공통 앞머리][질문1]"]
    M3["0번 토큰부터 다르다<br/>전체 재계산"]
    M1 --> M3
    M2 --> M3
  end

실무에서 캐시를 통째로 날리는 전형적인 패턴은 이렇다.

  • 시스템 프롬프트 맨 앞에 타임스탬프나 요청 ID를 넣는다 → 매 요청 100% 미스
  • 사용자 이름·세션 ID를 앞머리에 넣는다 → 사용자별로 캐시가 쪼개진다
  • 대화가 길어져 오래된 메시지를 앞에서 잘라낸다 → 자르는 순간 그 뒤가 전부 무효

그래서 프롬프트 설계의 기본기가 고정된 것은 앞으로, 변하는 것은 뒤로 몰아넣는 것이 됐다. 뒤에 덧붙이는 건 적중을 깨지 않는다.

에이전트 워크로드는 구조적으로 적중률이 높다

사람의 채팅은 턴마다 몇십 토큰이 더 붙는 정도다. 반면 에이전트는 지시 하나가 수십 턴으로 번지고, 매 턴 긴 시스템 프롬프트 + 도구 정의 전체 + 지금까지의 모든 도구 호출과 결과를 다시 올린다.

에이전트 7턴째 요청
  [고정 지시문         2,000토큰]  ← 1턴째와 글자 하나 안 틀림
  [도구 정의           3,000토큰]  ← 동일
  [누적 대화·도구 결과 12,000토큰]  ← 앞부분은 동일, 뒤에만 덧붙음
  [이번에 새로 붙은 것    500토큰]  ← 실제 새 작업

17,500토큰 중 17,000토큰이 이미 본 것이다. 요청이 길어질수록 새로 계산할 비율은 오히려 줄어든다. 에이전트 트래픽에서 입력 토큰의 대부분이 캐시된 앞머리로 분류되는 건 이런 구조 때문이다.

적중률이 성능이 아니라 청구서인 이유

여기가 핵심이다. 캐시 적중은 응답을 빠르게 만들기도 하지만, 더 직접적으로는 단가를 바꾼다. 주요 API 제공자들이 캐시에서 읽은 입력 토큰에 별도의, 훨씬 낮은 단가를 매기기 때문이다.

구체적인 할인율과 캐시 유지 시간은 제공자·모델마다 다르고 바뀌므로 반드시 위 공식 문서에서 확인해야 한다. 요점은 똑같은 에이전트를 돌려도 프롬프트를 어떻게 조립했느냐에 따라 입력 토큰 비용이 몇 배 차이 난다는 것이다. 성능 지표가 아니라 비용 지표로 봐야 하는 이유다.

자체 호스팅이라면 돈 대신 처리량으로 돌아온다. prefill을 건너뛴 만큼 GPU 시간이 비고, 그 자리에 다른 요청이 들어간다.

로드밸런싱의 상식이 뒤집힌다

이 사실이 인프라 설계로 번지는 지점이 있다. KV 캐시는 특정 GPU의 메모리 안에만 있다. 복제본이 3대라면 1번 서버가 들고 있는 캐시를 2번 서버는 쓸 수 없다.

flowchart TB
  subgraph RR["라운드로빈"]
    R1["1턴 → 서버 A (캐시 생성)"]
    R2["2턴 → 서버 B (A의 캐시 못 씀)"]
    R3["3턴 → 서버 C (또 미스)"]
    R1 --> R2 --> R3 --> RX["적중률 0%<br/>매번 전체 prefill"]
  end
  subgraph PA["프리픽스 인지 라우팅"]
    P1["1턴 → 서버 A (캐시 생성)"]
    P2["2턴 → 서버 A (캐시 재사용)"]
    P3["3턴 → 서버 A (캐시 재사용)"]
    P1 --> P2 --> P3 --> PX["적중률 높음<br/>새로 붙은 만큼만 prefill"]
  end

부하 분산의 상식(골고루 뿌려라)과 캐시의 상식(같은 데로 보내라)이 정면으로 충돌한다. 그래서 LLM 앞단 게이트웨이·라우터는 라운드로빈이나 최소 지연 외에 프리픽스 인지(prefix-aware) 라우팅을 따로 갖는다. 요청 앞머리의 해시를 보고 같은 앞머리는 같은 복제본으로 보내는 방식이다.

물론 한쪽으로만 몰면 그 서버가 터지므로, 실제로는 적중률과 부하를 함께 보고 균형을 잡는다. NVIDIA Dynamo 같은 분산 추론 프레임워크가 KV 캐시 인지 라우팅을 전면에 내세우는 것도 같은 맥락이다.

남은 숙제: 캐시의 위치를 어떻게 표현할 것인가

prefill과 decode를 다른 노드에 나누고 프리픽스 캐시까지 쓰기 시작하면, 스케줄링이 풀어야 할 문제가 하나 더 생긴다. “이 요청의 캐시가 어느 노드에 올라가 있는가” 를 보고 배치해야 한다는 것이다.

이 지점에서 일반적인 컨테이너 오케스트레이션의 어휘가 부족해진다. Kubernetes는 Pod에게 nvidia.com/gpu: 1을 할당할 줄은 알지만, “3번 노드에 이 대화의 KV 캐시가 올라가 있다”는 상태를 자원 모델로 표현하지 못한다. GPU를 정수 개수로 세는 모델과, 캐시 지역성·prefill/decode 역할 분리를 요구하는 서빙의 요구가 아직 맞물리지 않은 영역이다.

정리

  • 한 요청은 prefill(연산 병목) 과 decode(메모리 대역폭 병목) 로 갈리고, 둘은 성격이 달라 분리 배치가 연구·제품 양쪽에서 진행 중이다.
  • LLM API는 stateless라 대화 전체가 매번 다시 올라간다. 겹치는 앞머리의 KV 캐시를 재사용하는 것이 프리픽스 캐싱이고, 줄여 주는 건 prefill이다.
  • 적중하려면 토큰 0번부터 연속으로 같아야 한다. 앞머리의 타임스탬프 하나가 캐시를 통째로 날린다.
  • 에이전트 워크로드는 매 턴 누적분 전체를 다시 올리는 구조라 적중률이 구조적으로 높다.
  • 적중률은 속도보다 단가에 직접 작용한다. 성능 지표가 아니라 비용 지표로 다뤄야 한다.
  • 캐시는 특정 GPU 메모리에만 있으므로 프리픽스 인지 라우팅이 필요하다. 균등 분산이 늘 정답은 아니다.

KV 캐시 자체가 무엇이고 왜 메모리를 그렇게 많이 쓰는지는 앞 글에, 이 캐시 지역성이 모델 백엔드 앞단의 라우팅 설계를 어떻게 바꾸는지는 다음 글에 이어진다.

→ 같은 문장을 왜 매번 다시 계산하지 않을까: LLM 추론과 KV 캐시 → LLM 게이트웨이는 왜 균등 분산을 포기하는가: 프리픽스 인지 라우팅