Prompt / KV Cache의 원리와 캐시 친화 layout
같은 시스템 프롬프트, 같은 도구 정의를 들고 LLM을 매번 호출하는데 어떤 회사는 비용이 절반이고 어떤 회사는 두 배다.
이 차이의 8할은 prompt cache(=KV cache) 사용 여부에서 온다. 이번 글에서는 prompt cache가 왜 prefix에 의존하는지, 그리고 그 prefix를 깨지 않는 prompt layout이 어떻게 생겼는지 정리한다.
KV cache란 뭔가 (아주 짧게)
Transformer가 토큰을 하나 만들 때 일어나는 일.
입력 토큰 t1, t2, ... tn 이 있다고 하자
각 layer마다 (Key, Value) 행렬을 계산해야 다음 토큰을 만들 수 있다
여기서 핵심은 t1~tn은 매번 같은 (K, V)를 만든다는 점이다. 한 번 계산했으면 캐시해두고 그 다음 토큰부터만 계산하면 된다.
처음 호출: t1...tn 의 (K, V) 다 계산
같은 시작으로 다시 호출: t1...tn 캐시 그대로 쓰고 새 토큰부터만 계산
이게 KV cache다. Provider들이 말하는 "Prompt Cache"는 이걸 요청 간에도 재사용하게 만든 기능이다.
요청 A: [system + tools + user_1]
요청 B: [system + tools + user_2]
↑ 앞 prefix가 똑같으면 그 부분 (K, V)는 재사용 가능
그래서 핵심 규칙 하나
prompt cache는 prefix가 동일할수록 효과가 크다
이게 prompt cache의 전부다. prefix가 한 글자라도 다르면 그 뒤부터의 계산은 다시 해야 한다.
요청 A prefix: "현재 시간: 14:23 너는 코딩 에이전트다 ..."
요청 B prefix: "현재 시간: 14:24 너는 코딩 에이전트다 ..."
↑ 한 자 다름
요청 B는 처음부터 다시 계산 (cache miss)
그래서 prompt 설계는 결국 두 질문으로 요약된다.
1. 어디까지를 prefix(고정)로 만들 수 있나?
2. 가변값을 prefix 안에 넣고 있진 않은가?
prompt 구조의 일반적인 모양
LLM API에 들어가는 메시지는 보통 이렇게 생겼다.
system: 고정 시스템 프롬프트
user: 사용자 입력
assistant: 이전 답변
user: 다음 질문
assistant: 다음 답변
...
Tools를 쓰는 경우 그 앞에 tool 정의도 들어간다.
[tools] ← 도구 스키마 (변함 없음)
[system] ← 시스템 규칙 (변함 없음)
[examples] ← few-shot (변함 없음)
[history] ← 누적 대화 (조금씩 늘어남)
[current_user] ← 이번 요청
prompt cache가 최대로 일하는 건 위에서 아래로 갈수록 가변성이 커지는 구조다.
가장 위 = 가장 적게 변함 = 캐시 친화적
가장 아래 = 가장 자주 변함
캐시를 깨는 나쁜 layout
전형적으로 비싸지는 prompt.
system:
너는 코딩 에이전트다.
현재 시간은 2026-06-16T14:23:11Z
현재 사용자 ID는 u_481923
이번 요청 ID는 req_abc123
현재 파일 목록은 [src/main.py, src/api.py, ...] ← 매번 바뀜
지난 검색 결과는 ... ← 매번 바뀜
항상 다음 규칙을 따른다.
1. ...
2. ...
3. ...
문제는 명확하다.
- 현재 시간이 매 요청마다 다름
- request_id가 매번 다름
- 동적 파일 목록이 prompt 앞쪽에 있음
→ system 문장이 시작되자마자 prefix가 깨짐
→ 그 뒤의 모든 규칙도 cache hit 안 됨
→ 매 요청마다 system 전체를 다시 계산
비용이 폭발한다.
캐시를 살리는 좋은 layout
같은 정보를 두 부분으로 쪼갠다.
실제 예시로 풀면 이렇다.
[tools] ← 절대 안 바뀜
[system: 고정 규칙] ← 절대 안 바뀜
너는 코딩 에이전트다.
항상 다음 규칙을 따른다.
1. ...
2. ...
3. ...
[user/context] ← 매 요청마다 바뀜
## 컨텍스트
현재 시간: 2026-06-16T14:23:11Z
현재 사용자 ID: u_481923
요청 ID: req_abc123
현재 파일 목록:
- src/main.py
- src/api.py
지난 검색 결과:
- ...
[user]
실제 사용자 질문 / 명령
핵심은 두 가지.
1. 가변값은 user 메시지로 빼서, prefix(tools+system) 안에 절대 안 들어가게 함
2. system은 "절대 안 바뀌는 것"만 남김
이렇게만 해도 system 부분의 K/V는 영구 캐시 후보가 된다.
Claude의 명시적 캐시: cache_control
Claude API는 캐시를 자동으로 잡지 않고, 개발자가 cache breakpoint를 명시한다.
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": LONG_SYSTEM_BLOCK, # 시스템 규칙 + few-shot
"cache_control": {"type": "ephemeral"}, # ← 여기 캐시 경계
},
{
"type": "text",
"text": user_question,
},
],
}
]cache_control이 박힌 지점까지가 prefix로 캐시된다.
다음 요청에서 그 위까지 토큰열이 똑같으면 cache hit이 된다.
캐시 단위는 두 가지.
- ephemeral : 보통 5분 정도 살아 있음 (TTL)
- 1h opt-in : 한 시간 캐시 (가격 다름)
Tool 정의에도 cache_control을 박을 수 있다. 긴 tool 스키마는 거의 안 변하니까 캐시 후보 1순위다.
tools = [
{ ...,
"cache_control": {"type": "ephemeral"} },
]주의할 점.
- cache_control 위쪽 한 글자가 다르면 cache miss
- 그래서 tool 스키마 → system → few-shot 순서로 두고
맨 마지막 안 변하는 블록에 breakpoint를 박는다
- 동적 값(사용자 메시지, 검색 결과)은 breakpoint "뒤"에 둔다
Claude Code 같은 장기 agent가 더 민감한 이유
Claude Code, Cursor, 사내 코딩 에이전트들은 한 세션에서 수십 번~수백 번 호출이 일어난다. 그 호출마다 prompt 앞쪽의 tool 정의와 시스템 규칙은 똑같다.
요청 1: [tools+system+history_1+user_1]
요청 2: [tools+system+history_2+user_2]
...
요청 N: [tools+system+history_N+user_N]
여기서 tool+system이 캐시 안 잡히면 요청 N번 = 모델 호출 N번 + 비싼 prefix 계산 N번 이다. 잘 잡히면 prefix 계산은 1번이면 끝난다.
그래서 코딩 에이전트류 앱에서는 prompt layout 한 번 잘못 잡으면 비용이 즉시 두 배로 뛴다.
prompt 안에 자주 들어가는 "가변값" 체크리스트
다음 중 어느 것이라도 system이나 tool 위치에 들어가 있으면 캐시가 깨진다.
- 현재 시간/날짜 (절대 system에 넣지 말 것)
- request id, trace id
- user id (필요하면 user 메시지로)
- session id
- 매 요청마다 다른 retrieved docs
- 매 요청마다 다른 파일 목록
- 매번 다른 random seed
- A/B 실험 그룹 키 (실험별로 별도 system을 쓰는 게 나음)
이걸 다 user 또는 별도 가변 블록으로 빼고 나면 system은 정말로 안 변한다.
prompt 구조 권장 골격
[tools] (캐시)
[system: 정책/규칙/페르소나] (캐시)
[system: few-shot 예시] (캐시) ← cache_control은 여기 뒤에
---- 캐시 경계 ----
[user: 컨텍스트]
- 시간, request id, 사용자 정보
- 동적 retrieved docs
[user: 현재 요청]
[history] (필요한 경우)
이렇게 두면.
- prefix는 system+tools+few-shot까지 → 캐시 hit률 높음
- 가변 정보는 prefix 뒤로 빠짐 → 캐시를 깨지 않음
- 디버깅도 쉬움 (가변값을 한 블록에 모음)
RAG에서의 특별한 고민: 검색 결과는 어디로?
RAG에서 어려운 건 retrieved docs다. 사용자 질문마다 다른 문서가 들어오니까 캐시가 자연히 깨진다.
대응 전략 두 가지.
1. retrieved docs는 user 메시지로 둔다
[user: 컨텍스트]
[문서 1] ...
[문서 2] ...
[user: 질문]
...
이렇게 두면 적어도 앞쪽 system+tools 캐시는 보존된다. 한국어 long context에서 system 1500 토큰 + 문서 1500 토큰이라면 절반은 캐시되는 셈이다.
2. "고정 문서"는 위로, "동적 문서"는 아래로
자주 등장하는 문서(공통 정책, FAQ)는 user_context로 들어가도 캐시 친화적일 수 있다. 같은 사용자/같은 세션이 반복 질문하면 prefix가 유지된다.
정리
- prompt cache는 prefix 일치율에 비례한다
- 시스템 프롬프트와 도구 정의가 prefix 후보 0순위
- 동적값(시간, request id, retrieved docs)을 prefix에 섞으면 캐시가 다 깨진다
- 좋은 layout은 위에서 아래로 고정 → 가변 순
- Claude는
cache_controlbreakpoint로 명시적 제어 - 코딩 에이전트류처럼 호출이 많은 앱일수록 layout 한 번 실수로 비용이 즉시 두 배
다음 글에서는 Claude / OpenAI / Gemini가 prompt cache를 어떻게 다르게 다루는지 비교한다. "하나만 알고 다 똑같다고 믿으면 안 되는" 차이가 있다.