당신의 LLM 게이트웨이는 캐시에 대해 거짓말하는가? 5분 감사

목차
  1. 게이트웨이가 캐시에 대해 거짓말하는 네 가지 방식
  2. 두 가지 캐시 메커니즘, 하나의 감사
  3. 검사 1: 캐시가 작동하는가?
  4. 검사 2: 비용이 할인을 반영하는가?
  5. 검사 3: 토큰 수가 맞아떨어지는가?
  6. 검사 4: 스트리밍이 메타데이터를 보존하는가?
  7. 검사 5: 음성 대조군
  8. 점수표 읽는 법
  9. 맺음말
  10. FAQ

게이트웨이는 당신의 코드와 모델 제공자 사이에 자리한다. 응답에서 cached_tokens를 다시 읽어 더 작은 숫자를 보고, 절약된 금액이 진짜라고 믿는다. 하지만 당신은 상류 호출을 결코 보지 못한다. 게이트웨이는 캐시 히트를 보고하면서도 여전히 전액 입력 요율로 청구할 수 있다. 완벽하게 깔끔한 응답 뒤에서 아예 캐시하지 않을 수도 있다. 당신의 프로덕션 트래픽 대부분이 거치는 경로인 스트리밍에서 usage 메타데이터를 떼어내, 어느 쪽도 판단할 수 없게 만들 수도 있다.

이것은 가설이 아니다. 한 Hacker News PSA는 DeepSeek V4를 인기 게이트웨이를 통해 라우팅하면 DeepSeek를 직접 호출할 때보다 캐시 토큰이 2~3배 적게 반환되었다고 보고했다. 한 댓글 작성자는 캐시 통계가 게이트웨이를 통해 전혀 보고되지 않았음을 보여주는 청구서를 올렸다. 게이트웨이 팀은 재현할 수 없으며 조사 중이라고 답했다. 이 불일치가 바로 핵심이다. 캐시가 작동하는지를 두고 양측이 합의할 수 없을 때, 유일한 결정타는 당신 스스로 실행한 측정뿐이다.

대개 이것은 악의가 아니다. 변환의 간극이거나 미완성된 코드 경로다. 하지만 어느 쪽이든 청구서에 미치는 영향은 같다. 이 글은 실행 가능한 스크립트 하나로, 자동(DeepSeek)과 마커 기반(Claude)이라는 두 스타일의 프롬프트 캐시를 본 게이트웨이를 포함한 어떤 게이트웨이에 대해서도 감사한다. 5분 안에 나란히 놓인 점수표를 출력한다.


게이트웨이가 캐시에 대해 거짓말하는 네 가지 방식

실패 모드당신이 보는 것실제로 벌어지는 것
조용한 무캐시깔끔한 응답, 오류 없음아무것도 캐시되지 않음. 매 호출마다 전액 지불
캐시 연극응답에서 cached_tokens > 0…그러나 청구 비용은 전액 입력 요율
마크업 잠식그럴듯한 비용 숫자게이트웨이의 마크업이 조용히 할인을 먹어치움
메타데이터 암전깔끔한 텍스트 출력usage 필드가 제거됨(특히 스트리밍에서)으로 감사 불가

위험한 것은 앞의 둘이다. 응답이 캐시가 작동하는 것처럼 보인다. 당신이 알아차리는 것은 월말이다.


두 가지 캐시 메커니즘, 하나의 감사

제공자는 캐시를 두 가지 형태로 노출하며, 진정한 게이트웨이는 그 둘 모두를 충실히 통과시켜야 한다.

  • 자동(DeepSeek, GPT, Gemini, Qwen): 제공자가 충분히 긴 접두부를 스스로 캐시한다. 마커가 없다. 히트는 usage.prompt_tokens_details.cached_tokens에 나타난다.
  • 마커 기반(Anthropic Claude): 캐시 가능한 구간을 cache_control로 태그한다. 히트는 cache_read_input_tokens로 나타난다.

스크립트는 그 차이를 얇은 Lane 어댑터 뒤에 숨긴 뒤, 둘 모두에 대해 다섯 가지 검사를 전부 실행한다. 다음이 그 전부다. 두 개의 lane과, 모든 검사를 수행하는 하나의 audit().

import os, time, uuid
from openai import OpenAI
from anthropic import Anthropic

KEY  = os.environ["GATEWAY_KEY"]
oai  = OpenAI(api_key=KEY,    base_url="https://synthorai.io/v1")   # auto lane
anth = Anthropic(api_key=KEY, base_url="https://synthorai.io/")     # marker lane

class AutoLane:      # DeepSeek / GPT / Gemini / Qwen: provider caches automatically
    mode = "auto"
    def __init__(self, model): self.model = model
    def call(self, sys, q, stream=False):
        if stream:
            cached = cost = None
            s = oai.chat.completions.create(model=self.model, max_tokens=48, stream=True,
                stream_options={"include_usage": True},
                messages=[{"role":"system","content":sys},{"role":"user","content":q}])
            for ev in s:
                if ev.usage:
                    d = ev.usage.prompt_tokens_details
                    cached, cost = (d.cached_tokens if d else None), getattr(ev.usage,"cost",None)
            return {"cached": cached or 0, "cost": cost, "prompt_total": None}
        u = oai.chat.completions.create(model=self.model, max_tokens=48,
            messages=[{"role":"system","content":sys},{"role":"user","content":q}]).usage
        cached = u.prompt_tokens_details.cached_tokens if u.prompt_tokens_details else 0
        return {"cached": cached or 0, "cost": u.cost, "prompt_total": u.prompt_tokens}

class MarkerLane:    # Anthropic Claude: explicit cache_control markers
    mode = "marker"
    def __init__(self, model): self.model = model
    def call(self, sys, q, stream=False):
        block = {"type":"text","text":sys,"cache_control":{"type":"ephemeral"}}
        if stream:
            with anth.messages.stream(model=self.model, max_tokens=48, system=[block],
                    messages=[{"role":"user","content":q}]) as s:
                for _ in s.text_stream: pass
                u = s.get_final_message().usage.model_dump()
            return {"cached": u.get("cache_read_input_tokens") or 0,
                    "cost": u.get("cost"), "prompt_total": None}
        u = anth.messages.create(model=self.model, max_tokens=48, system=[block],
            messages=[{"role":"user","content":q}]).usage.model_dump()
        read, created = u.get("cache_read_input_tokens",0), u.get("cache_creation_input_tokens",0)
        return {"cached": read, "cost": u.get("cost"),
                "prompt_total": u.get("input_tokens",0) + read + created}

def audit(lane, long_prompt):
    SYS = f"[audit {uuid.uuid4().hex}]\n\n" + long_prompt    # unique => guaranteed cold start
    r = {"lane": lane.model, "mode": lane.mode}

    # CHECK 1: cache engages. Cold misses; a repeat should hit. A cache can
    # take a moment to become readable, so poll the warm read (sleep 1s between
    # attempts) before concluding "no cache".
    cold = lane.call(SYS, "Q1")
    warm = cold
    for i in range(4):
        warm = lane.call(SYS, f"warm {i}")
        if warm["cached"] > 0: break
        time.sleep(1.0)
    r["cold"], r["warm"] = cold, warm
    r["check1"] = cold["cached"] == 0 and warm["cached"] > 0

    # CHECK 2: cost reflects the discount (catches "cache theater").
    disc = (1 - warm["cost"]/cold["cost"])*100 if cold["cost"] and warm["cost"] else None
    r["discount"], r["check2"] = disc, (disc is not None and disc > 30)

    # CHECK 3: token accounting. cached fits inside the prompt total.
    r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]

    # CHECK 4: streaming preserves usage metadata (cache count AND cost).
    st = lane.call(SYS, "stream", stream=True)
    r["stream_cached"], r["stream_cost"] = st["cached"] > 0, st["cost"] is not None
    r["check4"] = r["stream_cached"] and r["stream_cost"]

    # CHECK 5: negative control. a unique prefix must always miss.
    n1 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "x")
    n2 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "y")
    r["check5"] = n1["cached"] == 0 and n2["cached"] == 0
    return r

# Any long, STABLE text works as the cacheable prefix: a system prompt, tool
# schemas, or a retrieved document. It only needs to clear the provider's
# minimum cacheable size (see Check 1). Load yours however you like.
LONG_SYSTEM_PROMPT = open("system_prompt.txt").read()   # ~8K+ tokens

for lane in [AutoLane("deepseek-v4-flash"), MarkerLane("claude-opus-4-8")]:
    print(audit(lane, LONG_SYSTEM_PROMPT))

이 글의 나머지에서는 각 검사를 차례로 짚는다. 그것을 구현하는 줄, 두 lane이 각각 반환한 것, 그리고 결과를 읽는 법이다.


검사 1: 캐시가 작동하는가?

cold = lane.call(SYS, "Q1")
warm = cold
for i in range(4):                       # poll: a cache may take a beat to be readable
    warm = lane.call(SYS, f"warm {i}")
    if warm["cached"] > 0: break
    time.sleep(1.0)
r["check1"] = cold["cached"] == 0 and warm["cached"] > 0
cold cachedwarm cached결과
deepseek-v4-flash07,552 / 7,870 (96%)PASS
claude-opus-4-8012,446 / 12,454 (99.9%)PASS

고유 접두부에 대한 콜드 호출은 아무것도 캐시해서는 안 되며, 반복 호출은 반드시 히트해야 한다. 가장 흔한 단일 오경보는 한 번의 웜 호출 후 “캐시 없음”이라고 단정하는 것이다. 캐시가 항상 즉시 읽을 수 있게 되는 것은 아니기 때문이다. 이 루프는 1초 간격으로 몇 번 폴링하여 그 변덕스러움을 제거한다. 크기 하한(대부분의 제공자에서 약 1,024 토큰. DeepSeek은 더 세밀한 64 단위로 맞춘다)을 넘는 프롬프트에서 여러 번의 웜 호출 후에도 여전히 0이 나온다면, 캐시는 정말로 작동하지 않는 것이다.


검사 2: 비용이 할인을 반영하는가?

disc = (1 - warm["cost"]/cold["cost"])*100 if cold["cost"] and warm["cost"] else None
r["check2"] = disc is not None and disc > 30
cold costwarm cost할인결과
deepseek-v4-flash$0.00107$0.0003072.3%PASS
claude-opus-4-8$0.07112$0.0067290.6%PASS

이것이 캐시 연극을 잡아내는 검사다. 웜 호출의 비용은 실제로 떨어져야 한다. DeepSeek의 호출당 총액은 약 72% 떨어졌다(캐시된 입력은 더 가파르게 할인되고, 출력과 캐시되지 않은 나머지가 표면 수치를 희석한다). Claude의 캐시 읽기는 약 90% 할인이다. 실패 신호는 명백하다. cached_tokens > 0인데도 콜드와 웜의 비용이 완전히 동일하다면, 게이트웨이가 가격에 반영하지 않은 히트를 보고하고 있다는 뜻이다. 당신은 종이 위에서만 “작동하는” 캐시에 전액을 지불하고 있다.


검사 3: 토큰 수가 맞아떨어지는가?

r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]
cachedprompt total결과
deepseek-v4-flash7,5527,870PASS
claude-opus-4-812,44612,454PASS

cached는 prompt total 안에 들어가야 하며, 나머지는 캐시되지 않은 입력으로 청구된다. 둘 다 들어맞는다. cached_tokensprompt_tokens를 초과하거나, 안정적인 접두부치고 캐시되지 않은 나머지가 비현실적으로 크다면, 게이트웨이가 회계를 잘못하고 있는 것이다. 변환 과정의 어딘가에서 재토큰화하거나 이중 계산하고 있다.


검사 4: 스트리밍이 메타데이터를 보존하는가?

st = lane.call(SYS, "stream", stream=True)
r["stream_cached"], r["stream_cost"] = st["cached"] > 0, st["cost"] is not None
r["check4"] = r["stream_cached"] and r["stream_cost"]
stream cachedstream cost결과
deepseek-v4-flash보존됨보존됨PASS
claude-opus-4-8보존됨보존됨PASS

대부분의 프로덕션 채팅은 스트리밍이므로, 이것이 가장 중요한 경로다. 두 lane 모두에서 캐시 히트 신호와 비용이 스트림에서 살아남았다. cached_tokenscost가 마지막 usage 청크로 전달되므로, 가장 트래픽이 많은 경로가 감사 가능한 상태로 유지된다. 주의해야 할 실패 모드는 스트리밍에서 usage를 떨어뜨리는 게이트웨이다. cached_tokenscost도 없는 깔끔한 토큰 출력은, 당신이 가장 많이 실행하는 경로에서 눈을 가린 채 날고 있다는 뜻이다. (usage 청크가 애초에 발행되도록 stream_options={"include_usage": True}를 전달하라.)


검사 5: 음성 대조군

n1 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "x")
n2 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "y")
r["check5"] = n1["cached"] == 0 and n2["cached"] == 0
고유 접두부 A고유 접두부 B결과
deepseek-v4-flashcached 0cached 0PASS
claude-opus-4-8cached 0cached 0PASS

매 호출마다 고유한 접두부를 보낸다. 그것은 결코 히트해서는 안 된다. 두 lane 모두 서로 다른 접두부에 대해 cached=0을 올바르게 보고하고 전액으로 청구했다. 여기서 “히트”가 나온다면, 캐시 보고는 결코 신뢰할 수 없는 거짓 양성이 되어버린다. 바로 이 깔끔한 음성 대조군이 애초에 검사 1~2의 양성 결과에 의미를 부여하는 것이다.


점수표 읽는 법

검사건강한 결과위험 신호
1. 캐시 작동콜드 0, 웜(폴링 후) >0크기 하한을 넘었는데도 여러 웜 호출 후 0
2. 비용이 할인을 반영웜 비용 ≪ 콜드 비용cached > 0인데 비용이 같음
3. 토큰 회계cached ≤ prompt_total, 맞아떨어짐수가 맞지 않음
4. 스트리밍 메타데이터캐시 + 비용이 스트림에서 살아남음스트림 호출에서 usage 누락
5. 음성 대조군고유 접두부는 항상 미스서로 다른 접두부가 “히트”함

조용히 돈을 쓰게 만드는 것은 2(보고된 히트에 대해 전액 지불)와 1(깔끔한 응답 뒤에 캐시 없음)다. 청구 대상인 모든 모델에 대해 이 둘을 실행하라.


맺음말

캐시는 LLM 앱에서 레버리지가 가장 큰 비용 지렛대이며, 바로 그렇기에 “캐시가 작동한다”는 것은 가정이 아니라 테스트할 가치가 있다. 검사 1 + 검사 2를 청구 대상인 각 모델에 대해 CI에 연결하고, 할인이 예상 대역 아래로 벗어나면 경보하라. 그러면 게이트웨이나 상류 제공자가 동작을 바꾼 바로 그날에 조용한 회귀를 잡아낼 수 있다. 청구 주기 끝이 아니라. 그리고 당신의 감사가 무엇을 하든, 캐시가 고장 났다고 단정하기 전에 웜 읽기를 폴링하라.

이 숫자들 뒤의 메커니즘(프리필, KV 캐시, TTL)에 대해서는 KV 캐시와 TTL의 작동 원리부터 시작하라. 제공자별로 실제로 작동하는 캐시 패턴은 튜토리얼을 참고하라.


FAQ

검사 1이 웜 호출에서 0을 보인다. 내 게이트웨이가 거짓말하는 건가? 먼저 세 가지를 확인하라. (1) 당신의 프롬프트가 제공자의 최소 캐시 가능 크기(대부분 약 1,024 토큰. DeepSeek은 더 세밀한 64 토큰 단위로 맞춘다)를 넘는가? (2) 웜 읽기를 몇 번 폴링했는가? 캐시가 바로 다음 호출에서 읽을 수 있게 되는 것은 아니다. (3) 접두부가 호출 간에 바이트 단위까지 동일하고, 앞에 타임스탬프나 요청별 ID가 없는가? 이 셋이 모두 충족된 후에야 게이트웨이를 의심해야 한다.

“캐시 연극”은 실제로 내게 얼마의 손해인가? 극히 일부만 낸다고 믿으면서, 매 호출마다 전액 입력 요율을 지불하게 된다. 크고 안정적인 접두부를 가진 고볼륨 엔드포인트에서는, 그것이 당신의 추산보다 몇 배 많은 청구액을 의미한다. 경보를 걸어야 할 것은 검사 2다.

왜 여기서 DeepSeek의 할인이 Claude보다 낮은가? 측정하는 대상이 다르다. Claude의 약 90%는 캐시된 입력에 대한 읽기 할인이다. DeepSeek의 약 72%는 호출당 총액의 감소이며, 여기서는 출력과 캐시되지 않은 나머지가 전액으로 청구되어 표면 수치를 희석한다. 당신 자신의 프롬프트 형태에 대해 같은 것끼리 비교하라.

이것이 GPT, Gemini, Qwen에도 적용되는가? 적용된다. 그것들은 모두 자동이므로 model만 바꿔 AutoLane을 그대로 쓴다. MarkerLane이 필요한 것은 Claude뿐이다. 어느 쪽이든 같은 다섯 가지 검사다.

이것을 CI에 두어야 하는가? 그렇다. 청구 대상인 모든 모델에 대해 검사 1 + 검사 2를 일정에 따라 실행하고, 관측된 할인이 예상 대역을 벗어나면 경보하라. 상시 감사는 조용한 회귀를 알림으로 바꾼다.

← 블로그로 돌아가기