리서치

TOOLS / 4번째 글

프롬프트 캐시의 손익분기는 읽기 횟수가 아니라 요청 간격이었다

캐시 손익분기를 닫힌 식으로 풀어 벤더 문서의 서술과 대조했다. 식은 문서와 정확히 맞았지만, 진짜 분기점은 읽기 횟수가 아니라 TTL을 넘는 요청 간격이다 — 그 자리에서 캐시는 안 쓰느니만 못해진다.

PALDYN Team17 MIN READ

프롬프트 캐시는 "같은 시스템 프롬프트를 매번 다시 처리하지 말고 저장해 두고 읽자"는 기능이다. 쓸 때 웃돈을 내고 읽을 때 크게 깎아 주므로, 몇 번 읽어야 본전인가라는 산수가 생긴다. 캐시 비용과 캐시 없는 비용이 같아지는 그 읽기 횟수가 손익분기다. 이 글은 그 산수를 닫힌 식으로 풀고, 벤더 문서가 산문으로 적어 둔 답과 맞는지 대조한다.

캐시 계층을 어떻게 설계하는가는 LLMOps 캐시 전략이 맡는다. 여기서는 과금 규칙의 산수만 다룬다.

결론부터 적으면, 식은 문서와 정확히 맞았다. 그런데 식이 답하지 못하는 축이 하나 더 있었고 그쪽이 훨씬 위험했다.

손익분기 식

비용 식

기본 입력 단가를 1로 두고 두 배수를 정의한다. ww는 캐시에 쓸 때의 배수, rr는 캐시에서 읽을 때의 배수다. 프롬프트를 한 번 쓰고 NN번 읽는다면

캐시 비용=w+Nr,캐시 없는 비용=N+1\text{캐시 비용} = w + N r, \qquad \text{캐시 없는 비용} = N + 1

캐시 없는 쪽이 N+1N+1인 것은 최초 요청 한 번과 이후 NN번을 전부 정가로 내기 때문이다.

임계 읽기 횟수

캐시가 이기는 조건은

w+Nr<N+1  ⟺  w−1<N(1−r)  ⟺  N>w−11−rw + N r < N + 1 \;\Longleftrightarrow\; w - 1 < N(1 - r) \;\Longleftrightarrow\; N > \frac{w-1}{1-r}

배수만 넣으면 임계 읽기 횟수가 나온다. 단가가 얼마인지는 들어가지 않는다 — 분자와 분모에서 같이 약분되기 때문이다. Opus든 Haiku든 손익분기 횟수는 같다.

재현

배수 출처

Anthropic 값은 벤더 문서에서 직접 받았다. 문서는 배수를 표로 못박아 둔다 — 5분 캐시 쓰기 1.25x, 1시간 캐시 쓰기 2x, 캐시 읽기 0.1x. 모델 표의 단가도 이 배수를 그대로 따른다(Opus 5는 입력 $5, 5분 쓰기 $6.25, 1시간 쓰기 $10, 읽기 $0.50).

OpenAI와 Google 가격 문서는 우리 실행 환경의 이그레스 정책에 막혀 받을 수 없다. 대신 pip install litellm이 벤더 셋의 단가표를 통째로 들고 온다 — 패키지 안의 model_prices_and_context_window_backup.json이 1.75MB에 모델 3,040개다.

그런데 이건 벤더가 쓴 문서가 아니라 커뮤니티가 옮겨 적은 2차 출처다. 그래서 그대로 싣지 않고, Anthropic이라는 겹치는 칸으로 이 JSON을 먼저 검증한다. 문서에서 받은 배수 셋이 JSON의 anthropic 행에서 지켜지는지 전수 대조하는 것이 이 글의 첫 번째 측정 항목이다.

스크립트

pip install litellm
python cache_breakeven.py
import json, os, math, litellm

D = json.load(open(os.path.join(os.path.dirname(litellm.__file__),
                                "model_prices_and_context_window_backup.json")))
MULT = {"cache_read_input_token_cost": (0.1, "읽기"),
        "cache_creation_input_token_cost": (1.25, "5분쓰기"),
        "cache_creation_input_token_cost_above_1hr": (2.0, "1시간쓰기")}
DOC_MIN = {"claude-opus-5": 512, "claude-fable-5": 512, "claude-mythos-5": 512,
           "claude-mythos-preview": 2048, "claude-opus-4-7": 2048, "claude-opus-4-8": 1024,
           "claude-opus-4-6": 4096, "claude-opus-4-5": 4096, "claude-sonnet-5": 1024,
           "claude-sonnet-4-6": 1024, "claude-sonnet-4-5": 1024, "claude-haiku-4-5": 4096}

print("# 1. 문서가 못박은 배수를 2차 출처가 지키는가 — anthropic 행 전수 대조")
ok = bad = 0
for n, v in sorted(D.items()):
    if not isinstance(v, dict) or v.get("litellm_provider") != "anthropic":
        continue
    base = v.get("input_cost_per_token")
    if not base or "cache_read_input_token_cost" not in v:
        continue
    msg = []
    for k, (m, ko) in MULT.items():
        got = v.get(k)
        if got is None:
            msg.append(f"{ko} 필드 없음")
        elif abs(got / base - m) > 1e-9:
            msg.append(f"{ko} {got / base:.4g}x (문서 {m}x)")
    if n in DOC_MIN and v.get("prompt_cache_min_tokens") != DOC_MIN[n]:
        msg.append(f"최소토큰 {v.get('prompt_cache_min_tokens')} (문서 {DOC_MIN[n]})")
    if msg:
        bad += 1
        print(f"   {n:26s} " + ", ".join(msg))
    else:
        ok += 1
print(f"   → 대조 {ok + bad}행: 일치 {ok}, 어긋남 {bad} ({100 * ok / (ok + bad):.1f}% 일치)\n")

print("# 2. 손익분기 읽기 횟수:  w + N·r < N + 1  →  N > (w-1)/(1-r)")
for lab, w, r in [("Anthropic 5분", 1.25, 0.1), ("Anthropic 1시간", 2.0, 0.1),
                  ("OpenAI 자동캐시", 1.0, 0.25), ("Gemini 암묵캐시", 1.0, 0.25)]:
    t = (w - 1) / (1 - r)
    print(f"   {lab:16s} w={w:<5} r={r:<5} 임계 N > {t:.4f} → 읽기 {math.floor(t) + 1}회부터 이득")

def total(reqs, gap, ttl, w, r):
    cost, last = 0.0, None
    for i in range(reqs):
        t = i * gap
        cost += w if (last is None or t - last > ttl) else r
        last = t                                    # 읽기도 TTL을 갱신한다
    return cost

print("\n# 3. 요청 20회의 총 입력 비용 (기본 입력 단가를 1로 둔 상대값)")
print(f"   {'요청 간격':>8} | {'캐시 없음':>9} | {'5분 TTL':>8} | {'1시간 TTL':>9}")
for gap, lab in [(60, "1분"), (600, "10분"), (1800, "30분"), (7200, "2시간")]:
    a, b = total(20, gap, 300, 1.25, 0.1), total(20, gap, 3600, 2.0, 0.1)
    print(f"   {lab:>8} | {20.0:9.2f} | {a:8.2f} | {b:9.2f}")

출력

# 1. 문서가 못박은 배수를 2차 출처가 지키는가 — anthropic 행 전수 대조
   claude-3-haiku-20240307    읽기 0.12x (문서 0.1x), 5분쓰기 1.2x (문서 1.25x), 1시간쓰기 24x (문서 2.0x)
   claude-3-opus-20240229     1시간쓰기 0.4x (문서 2.0x)
   claude-4-opus-20250514     1시간쓰기 필드 없음
   claude-4-sonnet-20250514   1시간쓰기 필드 없음
   claude-mythos-preview      최소토큰 512 (문서 2048)
   → 대조 26행: 일치 21, 어긋남 5 (80.8% 일치)

# 2. 손익분기 읽기 횟수:  w + N·r < N + 1  →  N > (w-1)/(1-r)
   Anthropic 5분     w=1.25  r=0.1   임계 N > 0.2778 → 읽기 1회부터 이득
   Anthropic 1시간    w=2.0   r=0.1   임계 N > 1.1111 → 읽기 2회부터 이득
   OpenAI 자동캐시      w=1.0   r=0.25  임계 N > 0.0000 → 읽기 1회부터 이득
   Gemini 암묵캐시      w=1.0   r=0.25  임계 N > 0.0000 → 읽기 1회부터 이득

# 3. 요청 20회의 총 입력 비용 (기본 입력 단가를 1로 둔 상대값)
      요청 간격 |     캐시 없음 |   5분 TTL |   1시간 TTL
         1분 |     20.00 |     3.15 |      3.90
        10분 |     20.00 |    25.00 |      3.90
        30분 |     20.00 |    25.00 |      3.90
        2시간 |     20.00 |    25.00 |     40.00

문서 대조

Anthropic 배수

문서는 배수 표 아래에 이렇게 적는다 — "캐시는 5분 지속(1.25x 쓰기)에서는 캐시 읽기 1회 후에, 1시간 지속(2x 쓰기)에서는 2회 후에 이득이 된다."

식이 낸 값은 0.27780.2778과 1.11111.1111이고, 정수로 올리면 1회와 2회다. 문서의 산문과 정확히 같다. 문서가 어떤 계산으로 그 문장을 썼는지가 이걸로 닫힌다.

읽기 배수가 0.1이라 분모 1−r1-r이 0.9로 거의 1이다. 그래서 임계값이 사실상 w−1w-1이고, 쓰기 웃돈이 곧 필요한 읽기 횟수다. 5분 캐시의 웃돈 0.25는 한 번만 읽어도 회수되고, 1시간 캐시의 웃돈 1.0은 두 번 읽어야 한다.

OpenAI와 Gemini

OpenAI와 Gemini는 모양이 다르다. JSON에 캐시 쓰기 단가 필드 자체가 없다 — 93개 openai 행과 32개 gemini 행 중 cache_creation_input_token_cost를 가진 것이 하나도 없다. 쓰기가 공짜라 w=1w=1이고 임계값이 0이 된다. 읽기 배수는 0.25x(일부 파인튜닝 모델은 0.5x)다. 즉 이 둘에서는 손익분기를 따질 일이 없다. 캐시가 맞으면 이득이고 안 맞으면 본전이다. 대신 무엇을 캐시할지 고를 권한도 없다.

litellm 행 대조

전수 대조 결과는 26행 중 21행 일치, 5행 어긋남(80.8%)이다. 어긋난 다섯을 보면 성격이 갈린다.

  • claude-3-opus-20240229의 1시간 쓰기가 기본 입력가의 0.4x다. 캐시 쓰기가 정가보다 싸다는 뜻이라 문서를 안 봐도 틀린 줄 안다. 값(6e-06)이 Sonnet 계열의 1시간 쓰기와 같은 것으로 보아 옮겨 적다 섞인 자리다.
  • claude-3-haiku-20240307은 세 배수가 전부 어긋난다. 1시간 쓰기가 기본가의 24배다.
  • claude-4-opus-20250514·claude-4-sonnet-20250514는 1시간 쓰기 필드가 아예 없다. 같은 모델의 다른 별칭인 claude-opus-4-20250514에는 값이 있다. 별칭마다 데이터가 다르다는 것이 이 JSON의 구조적 약점이다.
  • claude-mythos-preview의 최소 토큰이 512인데 문서는 2,048이라고 적는다. 넷과 달리 이건 산술로는 못 잡는다 — 문서를 봐야만 걸린다.

어긋난 것이 전부 은퇴했거나 별칭인 행이고 현행 주력 모델 행은 다 맞았다는 점이 실무적으로는 다행이다. 하지만 80.8%라는 숫자가 이 글이 OpenAI·Gemini 칸에 붙이는 오차 표시다. 그쪽은 대조할 문서가 없어 검증하지 못했고, 같은 비율로 틀렸다면 다섯 행에 하나꼴이다.

TTL 절벽

캐시 갱신

위 식에는 시간이 안 들어간다. 그런데 캐시에는 TTL, 곧 저장된 캐시가 만료되기까지의 유지 시간이 있고, 문서의 캐시 설명 쪽에 식을 완전히 바꾸는 문장이 하나 더 있다.

캐시된 내용이 사용될 때마다 추가 비용 없이 캐시가 갱신된다.

읽기가 TTL 타이머를 되돌린다는 뜻이다. 그러면 요청 간격 gg가 TTL보다 짧은 한 쓰기는 영원히 한 번뿐이다. 반대로 gg가 TTL보다 길면 캐시는 매번 만료돼 있고, 모든 요청이 쓰기가 된다. 그때 비용은

(N+1)⋅w  >  (N+1)⋅1(N+1) \cdot w \;>\; (N+1) \cdot 1

읽기가 한 번도 안 일어나므로 손익분기 식이 적용될 자리가 없고, 요청마다 w−1w-1만큼 순손실이다. 5분 캐시면 +25%, 1시간 캐시면 +100%다.

요청 20회 비용

출력 3번 표가 이 절벽이다. 요청 20회를 같은 프롬프트로 보낼 때 —

요청 간격 캐시 없음 5분 TTL 1시간 TTL
1분 20.00 3.15 3.90
10분 20.00 25.00 3.90
30분 20.00 25.00 3.90
2시간 20.00 25.00 40.00

5분 TTL은 간격이 1분에서 10분으로 늘어나는 사이에 3.15에서 25.00으로 뛴다. 7.9배이고, 캐시를 아예 안 쓴 20.00보다도 비싸다. 곡선이 완만하게 나빠지는 것이 아니라 TTL 경계에서 수직으로 선다 — 간격이 TTL보다 1초라도 길면 적중률이 100%에서 0%로 떨어지기 때문이다.

꺾이는 지점

요청 간격별 선택

손익분기를 결정하는 변수는 읽기 횟수가 아니라 요청 간격이고, 경계는 정확히 TTL이다. 트래픽이 뜸해지는 구간의 요청 간격을 재서 규칙을 고른다.

요청 간격 고를 것 근거
5분 미만 5분 TTL 쓰기 1회 + 읽기 N회. 웃돈 0.25를 첫 읽기에 회수
5분 ~ 1시간 1시간 TTL 5분 TTL은 +25% 순손실. 1시간 TTL은 읽기 2회부터 이득
1시간 초과 캐시 끄기 5분 +25%, 1시간 +100%. 둘 다 안 쓰느니만 못하다

평균 간격이 아니라 꼬리를 봐야 한다. 평균 2분이어도 야간에 20분씩 벌어지는 서비스라면 그 구간의 요청은 전부 25% 웃돈짜리 쓰기다.

최소 캐시 길이

그리고 그 위에 하드 게이트가 하나 더 있다. 캐시가 걸리는 프롬프트의 최소 토큰 수, 곧 최소 캐시 길이다. 문서의 표는 Opus 5가 512토큰, Sonnet 5가 1,024토큰, Opus 4.6과 Haiku 4.5가 4,096토큰이라고 적는다. 그보다 짧은 프롬프트는 cache_control을 붙여도 캐시되지 않는데, 에러가 나지 않는다. 문서가 직접 적어 둔 확인 방법은 응답의 cache_creation_input_tokens와 cache_read_input_tokens를 보는 것이다 — 둘 다 0이면 캐시가 안 걸린 것이다. 모델마다 문턱이 8배까지 차이 나므로 모델을 바꾸면 어제까지 캐시되던 프롬프트가 조용히 안 캐시된다.

실험의 범위

한계

  • 이 글은 청구서를 보고 쓴 것이 아니다. 벤더 문서의 규칙과 그 규칙으로 푼 산수이지, 실제 과금 명세와 대조한 것이 아니다. API 키가 필요한 검증이라 하지 못했다.
  • 3번 표는 시뮬레이션이지 측정이 아니다. 요청 간격이 일정하다고 가정했고, 캐시 적중을 TTL 안이면 100%로 뒀다. 실제로는 프롬프트 접두사가 조금만 달라도 적중이 깨지고, 여러 인스턴스가 각자 캐시를 쓰면 쓰기가 그만큼 늘어난다.
  • OpenAI·Gemini 칸은 전부 2차 출처다. 위 80.8%가 그 칸에 붙는 유일한 신뢰도 정보다. Gemini의 명시적 컨텍스트 캐시에 붙는 시간당 저장 요금은 이 JSON에 필드가 없어 표에서 뺐다 — 그 요금이 있으면 손익분기 식에 시간 항이 하나 더 붙는다.
  • 배타 조건은 다루지 않았다. 배치 API·Fast mode·데이터 레지던시 배수가 캐시 배수와 어떤 순서로 곱해지는지는 다음 글의 자리다.

측정 환경

항목 값
OS Ubuntu 24.04.4 LTS · Linux 6.18.44 x86_64
CPU Intel Xeon @ 2.80GHz · 4코어
Python 3.11.15
패키지 litellm==1.98.0 (model_prices_and_context_window_backup.json 1,747,806바이트 · 모델 3,040개)
1차 출처 platform.claude.com/docs/en/about-claude/pricing · .../build-with-claude/prompt-caching
실행 시간 3초 미만 (네트워크 접근 없음)
측정일 2026-08-28

읽어주셔서 감사합니다. 😊

LATEST

도구의 최신 글