LLM 앱 운영 #3 프롬프트 캐싱 실전
2편에서 입력과 출력을 줄이고 모델을 나눴습니다. 그런데 1편의 로그를 보면 입력 토큰의 큰 덩어리가 매 요청 똑같이 반복되고 있습니다. 시스템 프롬프트, 도구 정의, RAG의 공통 지침 같은 것들입니다. 프롬프트 캐싱(prompt caching)은 이 반복분을 API 쪽에 저장해 두고 재사용하는 기능으로, 캐시에서 읽는 토큰은 기본 단가의 약 10분의 1입니다. 제대로 걸리면 입력 비용의 절반 이상이 사라지는, 운영 단계의 가장 강력한 단일 기법입니다.
대원칙 — 캐싱은 접두사 일치입니다 #
캐싱을 다루는 데 필요한 원리는 사실 한 문장입니다. 캐시는 요청의 앞부분(접두사)이 바이트 단위로 정확히 같을 때만 적중합니다. 요청은 도구 정의 → 시스템 프롬프트 → 메시지 순서로 렌더링되고, 그 직렬화된 앞부분이 이전 요청과 한 글자라도 다르면 그 지점 이후의 캐시는 전부 무효가 됩니다.
이 원칙에서 설계 지침이 그대로 따라 나옵니다. 변하지 않는 것을 앞에, 변하는 것을 뒤에 둡니다.
[도구 정의] ← 고정 (순서까지 고정)
[시스템 프롬프트] ← 고정
--- cache_control 경계 ---
[대화 이력] ← 세션마다 다름
[이번 질문] ← 매번 다름cache_control — 경계 표시하기 #
캐시할 경계는 cache_control로 표시합니다. 표시한 블록까지의 접두사가 캐시 대상이 됩니다.
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=4096,
tools=tools, # 도구는 시스템보다 앞에 렌더링된다
system=[{
"type": "text",
"text": SYSTEM_PROMPT, # 고정된 시스템 프롬프트
"cache_control": {"type": "ephemeral"}, # 여기까지 캐시
}],
messages=conversation + [{"role": "user", "content": question}],
)시스템 블록에 표시 하나를 두면 그 앞의 도구 정의까지 함께 캐시됩니다. 수명(TTL)은 기본 5분이고, 적중할 때마다 연장됩니다. 요청이 5분 안에 이어지는 서비스라면 첫 요청이 캐시를 쓰고(write) 이후 요청들이 계속 읽는(read) 구조가 됩니다. 트래픽이 띄엄띄엄하다면 1시간 TTL 옵션("ttl": "1h")도 있는데, 쓰기 단가가 더 비싸져서 더 많은 적중이 있어야 본전입니다.
경제성 — 언제 이득인가 #
캐싱은 공짜가 아니라 거래입니다. 단가 구조가 판단 기준입니다.
| 구분 | 단가 (기본 입력 대비) |
|---|---|
| 캐시 쓰기 (5분 TTL) | 1.25배 |
| 캐시 쓰기 (1시간 TTL) | 2배 |
| 캐시 읽기 | 약 0.1배 |
5분 TTL 기준으로 두 번째 요청에서 이미 본전(1.25 + 0.1 < 2)이 넘습니다. 즉 같은 접두사로 5분 안에 두 번 이상 호출하면 무조건 이득입니다. 반대로 접두사가 요청마다 다르거나(개인화된 시스템 프롬프트 등) 너무 짧으면 캐시가 아예 안 만들어지거나 쓰기 비용만 냅니다. 모델별로 캐시 가능한 최소 접두사 길이가 있어서(수천 토큰 수준), 짧은 프롬프트는 표시를 해도 조용히 캐시되지 않는다는 점도 알아 둘 만합니다.
검증은 1편에서 이미 깔아 둔 로그로 합니다. usage의 cache_read_input_tokens가 0이 아니면 적중하고 있는 것입니다.
{"feature": "answer_question", "input_tokens": 412,
"cache_read": 8120, "cache_write": 0, ...}전체 입력 8,532토큰 중 8,120이 캐시에서 왔으므로, 이 요청의 입력 비용은 캐싱 전의 15% 수준입니다.
침묵의 캐시 무효화 — 적중률이 0일 때 #
캐싱을 켰는데 cache_read가 계속 0이라면, 십중팔구 접두사 어딘가가 매 요청 조금씩 다른 것입니다. 에러 없이 조용히 빗나가기 때문에 감사 목록을 갖고 찾는 것이 빠릅니다.
- 시스템 프롬프트의 동적 값 — “현재 시각: …”, “사용자 이름: …” 같은 삽입이 대표 범인입니다. 동적 정보는 시스템이 아니라 메시지 쪽(경계 뒤)으로 옮깁니다.
- 비결정적 직렬화 — 도구 정의를 dict에서 만들 때 키 순서가 흔들리면 바이트가 달라집니다. 정렬을 고정합니다.
- 도구 목록의 변동 — 요청마다 도구를 넣었다 뺐다 하면 위치 0부터 무효입니다. 에이전트 2편에서 작업별 도구 목록을 권했는데, 캐싱 관점에서는 작업별로 고정된 목록이어야 한다는 조건이 붙습니다.
- 모델 변경 — 캐시는 모델별입니다. 2편의 라우팅으로 모델이 갈리면 캐시도 모델별로 따로 쌓입니다(이것은 정상이고, 각 경로 안에서 적중하면 됩니다).
- 세션 ID나 난수 — 접두사 어딘가에 UUID 가 박혀 있으면 영원히 적중하지 않습니다.
요약하면, 캐싱의 80%는 cache_control 표시가 아니라 접두사를 고정하는 규율입니다.
대화형 앱에서 — 이력까지 캐시하기 #
챗봇처럼 대화가 길어지는 앱은 한 단계 더 갈 수 있습니다. 시스템 프롬프트만이 아니라 지난 대화 이력까지 캐시 경계에 넣는 것입니다. 마지막 사용자 메시지 직전 블록에 표시를 두면, 다음 턴에서 직전 턴까지의 전체가 캐시로 읽힙니다. 턴이 거듭될수록 입력은 길어지는데 비용은 새 메시지 분량만 내는 구조가 됩니다. 에이전트 시리즈의 루프처럼 같은 대화에 호출이 연속되는 워크로드에서 효과가 특히 큽니다.
흔히 걸려 넘어지는 곳 #
- 표시만 하고 검증하지 않는다 — cache_control을 붙였다고 끝이 아닙니다. cache_read 지표가 실제로 0이 아닌지 로그로 확인해야 침묵의 무효화를 잡습니다.
- 동적 값을 시스템 프롬프트에 둔다 — 날짜 한 줄이 캐시 전체를 무효화합니다. 변하는 것은 전부 경계 뒤로 보냅니다.
- 짧은 프롬프트에 기대한다 — 최소 길이 미만은 표시해도 캐시되지 않습니다. 캐싱은 긴 고정 접두사가 있는 기능부터 적용합니다.
마무리 #
이번 글에서는 반복 입력의 가격을 10분의 1로 만들었습니다.
- 캐싱은 접두사 일치입니다. 변하지 않는 것을 앞에, 변하는 것을 뒤에 두는 구조가 전부의 시작입니다.
- 5분 TTL 기준 두 번째 요청부터 이득입니다. cache_read 지표로 적중을 검증합니다.
- 적중률 0의 원인은 대부분 침묵의 무효화(동적 값, 직렬화 흔들림, 도구 변동)입니다. 감사 목록으로 찾습니다.
여기까지는 실시간 요청의 비용이었습니다. 그런데 당장 답이 필요 없는 작업도 있습니다. 다음 글인 “LLM 앱 운영 #4 배칭 — 급하지 않은 작업은 반값에"에서 Batches API로 비실시간 작업의 비용을 절반으로 만들겠습니다.