🎁 신규 무료 가입, 10회 호출 제공. 최대 $1, 카드 불필요.
LLM 게이트웨이의 캐시 정보를 믿어도 될까? 5분 감사

LLM 게이트웨이의 캐시 정보를 믿어도 될까? 5분 감사

목차
  1. 게이트웨이가 캐시에 관해 거짓말하는 4가지 방식
  2. 두 가지 캐시 방식, 하나의 감사
  3. 검사 1: 캐시가 실제로 동작하는가?
  4. 검사 2: 할인이 비용에 실제로 반영되는가?
  5. 검사 3: token 수의 합이 맞는가?
  6. 검사 4: streaming에서도 metadata가 유지되는가?
  7. 검사 5: negative control
  8. Scorecard 해석하기
  9. 마무리
  10. FAQ

게이트웨이는 코드와 모델 제공자 사이에 있습니다. 응답에서 cached_tokens를 확인하고 값이 줄어든 것을 보면, 실제로 비용이 절감됐다고 믿게 됩니다. 하지만 upstream 호출은 직접 볼 수 없습니다. 게이트웨이가 캐시 적중을 보고하면서도 입력 요금을 전액 청구할 수 있습니다. 응답은 멀쩡한데 실제로는 캐시가 전혀 동작하지 않을 수도 있습니다. 운영 트래픽 대부분이 사용하는 streaming 경로에서 usage metadata를 누락해, 캐시 상태를 확인할 수 없게 만들 수도 있습니다.

TL;DR

  • Hacker News의 한 PSA에 따르면, 인기 게이트웨이를 통해 DeepSeek V4를 호출했을 때 DeepSeek를 직접 호출한 경우보다 cached token이 2-3배 적게 반환됐습니다.
  • 실행 가능한 스크립트 하나로 캐시 동작 여부, 실제 비용 절감 여부, token 수 정합성, streaming의 usage metadata 보존 여부, negative control의 cache miss 여부까지 5가지를 검사합니다.
  • Synthorai 게이트웨이를 감사한 결과, deepseek-v4-flash는 warm cache-hit rate 96%와 호출당 비용 절감률 72.3%를 기록했고, claude-opus-4-8은 각각 99.9%와 90.6%를 기록했습니다.
  • cold 호출과 warm 호출의 비용이 같은데 cached_tokens > 0이라면, 게이트웨이가 가격에 반영하지 않은 cache hit을 보고하는 것입니다.

가정이 아니라 실제 사례입니다. Hacker News의 PSA에 따르면, 인기 게이트웨이를 통해 DeepSeek V4를 호출했을 때 DeepSeek를 직접 호출한 경우보다 cached token이 2–3배 적게 반환됐습니다. 한 댓글에는 게이트웨이를 거칠 때 caching 통계가 전혀 보고되지 않았음을 보여주는 청구 내역도 올라왔습니다. 게이트웨이 팀은 문제를 재현하지 못했으며 조사 중이라고 답했습니다. 양측의 주장이 엇갈릴 때 기준이 될 수 있는 것은 직접 실행한 측정뿐입니다.

대개 악의가 있어서 생기는 문제는 아닙니다. 변환 계층의 누락이나 미완성된 code path가 원인인 경우가 많습니다. 하지만 청구서에 미치는 영향은 같습니다. 이 글에서는 실행 가능한 스크립트 하나로 자동 방식(DeepSeek)과 마커 기반 방식(Claude)의 prompt caching을 모든 게이트웨이에서 감사합니다. 여기서 사용하는 게이트웨이도 포함됩니다. 5분 안에 두 방식을 나란히 비교한 scorecard가 출력됩니다.


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

장애 유형겉으로 보이는 현상실제 상황
조용한 no-cache오류 없이 정상 응답아무것도 캐시되지 않아 호출할 때마다 전체 요금을 냄
캐시 연극응답에 cached_tokens > 0…하지만 청구 비용은 전체 입력 요금
마크업 잠식그럴듯한 비용 수치게이트웨이의 마크업이 할인분을 조용히 잠식함
metadata 차단정상적인 텍스트 출력usage field가 누락돼, 특히 streaming에서는 감사할 수 없음

가장 위험한 것은 처음 두 가지입니다. 응답만 보면 캐시가 정상적으로 동작하는 것처럼 보입니다. 문제는 월말이 돼서야 드러납니다.


두 가지 캐시 방식, 하나의 감사

제공자가 노출하는 caching 방식은 두 가지이며, 제대로 구현된 게이트웨이는 둘 다 정확하게 전달해야 합니다.

  • 자동 방식(DeepSeek, GPT, Gemini, Qwen): 충분히 긴 prefix를 제공자가 자동으로 캐시합니다. 별도 marker는 필요 없습니다. 적중 결과는 usage.prompt_tokens_details.cached_tokens에 표시됩니다.
  • 마커 기반 방식(Anthropic Claude): 캐시할 구간을 cache_control로 표시합니다. 적중 결과는 cache_read_input_tokens에 표시됩니다.

스크립트는 얇은 Lane adapter로 이 차이를 감추고, 두 방식에 동일한 5가지 검사를 실행합니다. 전체 코드는 다음과 같습니다. 두 개의 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%)통과
claude-opus-4-8012,446 / 12,454 (99.9%)통과

고유한 prefix를 사용한 cold 호출에서는 아무것도 캐시되지 않아야 하고, 같은 prefix로 다시 호출하면 적중해야 합니다. 가장 흔한 오판은 warm 호출을 한 번만 하고 “캐시가 동작하지 않는다”고 결론 내리는 것입니다. 캐시가 즉시 조회 가능한 상태가 되지 않을 수 있기 때문입니다. 이 loop는 1초 간격으로 몇 차례 polling해 불안정성을 없앱니다. 최소 크기를 넘는 prompt에서 warm 호출을 여러 번 했는데도 계속 0이 나온다면 캐시가 실제로 동작하지 않는 것입니다. 최소 크기는 대부분 제공자에서 약 1,024 token이며, DeepSeek는 더 세밀한 64 단위로 일치시킵니다.


검사 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 비용warm 비용할인율결과
deepseek-v4-flash$0.00107$0.0003072.3%통과
claude-opus-4-8$0.07112$0.0067290.6%통과

이 검사는 캐시 연극을 찾아냅니다. warm 호출의 비용이 실제로 줄어야 합니다. DeepSeek는 호출당 총비용이 약 72% 감소했습니다. 캐시된 입력에는 더 큰 할인율이 적용되지만, 출력과 캐시되지 않은 나머지 입력에는 할인이 적용되지 않아 전체 절감률이 낮아집니다. Claude의 cached read에는 약 90% 할인이 적용됩니다. 실패 신호는 명확합니다. cold 호출과 warm 호출의 비용이 동일한데 cached_tokens > 0이라면, 게이트웨이가 가격에 반영하지 않은 cache hit을 보고하는 것입니다. 서류상으로만 “동작하는” 캐시에 전체 요금을 내고 있는 셈입니다.


검사 3: token 수의 합이 맞는가?

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

cached는 prompt 합계 안에 포함돼야 하며, 나머지는 캐시되지 않은 입력으로 청구돼야 합니다. 두 결과 모두 일치합니다. cached_tokensprompt_tokens보다 크거나, 안정적인 prefix인데도 캐시되지 않은 나머지가 비정상적으로 크다면 게이트웨이의 집계가 잘못된 것입니다. 변환 과정 어딘가에서 tokenization을 다시 수행하거나 중복 집계하고 있을 수 있습니다.


검사 4: streaming에서도 metadata가 유지되는가?

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 비용결과
deepseek-v4-flash유지됨유지됨통과
claude-opus-4-8유지됨유지됨통과

운영 환경의 chat은 대부분 streaming을 사용하므로, 이 경로가 가장 중요합니다. 두 lane 모두 stream이 끝날 때까지 cache hit 신호와 비용 정보가 유지됐습니다. cached_tokenscost는 마지막 usage chunk에 포함되므로 트래픽이 가장 많은 경로도 감사할 수 있습니다. 주의할 장애 유형은 streaming에서 usage를 누락하는 게이트웨이입니다. token 출력은 정상인데 cached_tokenscost가 없다면, 가장 많이 사용하는 경로의 상태를 전혀 파악할 수 없습니다. usage chunk가 생성되도록 stream_options={"include_usage": True}를 전달해야 합니다.


검사 5: negative control

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
고유 prefix A고유 prefix B결과
deepseek-v4-flashcached 0cached 0통과
claude-opus-4-8cached 0cached 0통과

호출할 때마다 고유한 prefix를 보내면 절대 적중해서는 안 됩니다. 두 lane 모두 서로 다른 prefix에 대해 전체 비용과 함께 cached=0을 정확히 보고했습니다. 여기서 “적중”이 나온다면 캐시 보고 자체가 false positive이므로 신뢰할 수 없습니다. negative control이 정상이어야 검사 1과 2의 positive 결과도 의미가 있습니다.


Scorecard 해석하기

검사정상 결과위험 신호
1. 캐시 동작cold는 0, polling 후 warm은 >0최소 크기를 넘었는데도 여러 번의 warm 호출 후 0
2. 비용 할인 반영warm 비용 ≪ cold 비용cached > 0인데 비용이 같음
3. token 집계cached ≤ prompt_total, 합계 일치수치의 합이 맞지 않음
4. streaming metadatacache + 비용 정보가 stream에서도 유지됨streamed 호출에서 usage 누락
5. negative control고유 prefix는 항상 miss서로 다른 prefix가 “hit”

비용이 조용히 새는 검사는 21입니다. 2는 적중을 보고하면서 전체 요금을 청구하는 경우이고, 1은 정상 응답 뒤에서 caching이 전혀 동작하지 않는 경우입니다. 비용이 청구되는 모든 모델에서 둘 다 실행해야 합니다.


마무리

Caching은 LLM 앱의 비용을 줄이는 데 가장 큰 효과를 내는 수단입니다. 그래서 “캐시가 동작한다”는 사실은 가정할 것이 아니라 검사해야 합니다. 비용이 청구되는 각 모델에 대해 검사 1과 검사 2를 CI에 연결하고, 할인율이 예상 범위를 벗어나면 alert를 발생시키십시오. 게이트웨이나 upstream 제공자의 동작이 바뀌어도 청구 주기가 끝날 때가 아니라 변경 당일에 조용한 regression을 발견할 수 있습니다. 어떤 방식으로 감사하든, 캐시가 고장 났다고 판단하기 전에 반드시 warm read를 polling해야 합니다.

이 수치의 기반이 되는 원리인 prefill, KV cache, TTL은 KV cache와 TTL의 동작 원리에서 확인할 수 있습니다. 제공자별로 바로 적용할 수 있는 caching pattern은 튜토리얼을 참고하십시오.


FAQ

검사 1의 warm 호출에서 0이 나옵니다. 게이트웨이가 거짓말하는 건가요? 먼저 세 가지를 확인하십시오. (1) prompt가 제공자의 최소 캐시 가능 크기를 넘습니까? 대부분은 약 1,024 token이며, DeepSeek는 더 세밀한 64-token 단위로 일치시킵니다. (2) warm read를 몇 차례 polling했습니까? 캐시는 바로 다음 호출에서 항상 조회 가능한 상태가 되지는 않습니다. (3) 호출 간 prefix가 byte 단위로 동일합니까? 앞부분에 timestamp나 요청별 ID가 들어가면 안 됩니다. 세 조건을 모두 확인한 뒤에 게이트웨이를 의심해야 합니다.

“캐시 연극”이 실제로 얼마나 큰 비용을 발생시키나요? 일부만 지불한다고 믿으면서 실제로는 호출할 때마다 전체 입력 요금을 냅니다. 크고 안정적인 prefix를 사용하는 high-volume endpoint라면 실제 청구액이 예상치의 몇 배가 될 수 있습니다. alert는 검사 2에 설정해야 합니다.

여기서 DeepSeek의 할인율이 Claude보다 낮은 이유는 무엇인가요? 측정 대상이 다릅니다. Claude의 약 90%는 캐시된 입력의 read 할인율입니다. DeepSeek의 약 72%는 호출당 총비용 절감률입니다. 출력과 캐시되지 않은 나머지 입력은 전체 요금으로 청구되므로 전체 절감률이 낮아집니다. 자체 prompt 구조를 측정할 때는 같은 기준끼리 비교해야 합니다.

GPT, Gemini, Qwen에서도 사용할 수 있나요? 사용할 수 있습니다. 모두 자동 방식이므로 model만 바꾸고 AutoLane을 그대로 사용하면 됩니다. MarkerLane이 필요한 것은 Claude뿐입니다. 어느 방식이든 동일한 5가지 검사를 실행합니다.

CI에서 실행해야 하나요? 그렇습니다. 비용이 청구되는 모든 모델을 대상으로 검사 1과 검사 2를 주기적으로 실행하고, 측정된 할인율이 예상 범위를 벗어나면 alert를 발생시키십시오. 상시 감사 체계를 두면 조용한 regression을 알림으로 바꿀 수 있습니다.

← 블로그로 돌아가기