Prompt Cache

Prompt / KV Cache의 원리와 캐시 친화 layout

2026. 07. 02.
AILLMPromptCache

같은 시스템 프롬프트, 같은 도구 정의를 들고 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_control breakpoint로 명시적 제어
  • 코딩 에이전트류처럼 호출이 많은 앱일수록 layout 한 번 실수로 비용이 즉시 두 배

다음 글에서는 Claude / OpenAI / Gemini가 prompt cache를 어떻게 다르게 다루는지 비교한다. "하나만 알고 다 똑같다고 믿으면 안 되는" 차이가 있다.