Seu gateway de LLM mente sobre o cache? Uma auditoria em 5 min

Conteúdo
  1. Quatro maneiras de um gateway mentir sobre o cache
  2. Dois mecanismos de cache, uma auditoria
  3. Verificação 1: o cache é ativado?
  4. Verificação 2: o custo reflete o desconto?
  5. Verificação 3: as contagens de tokens batem?
  6. Verificação 4: o streaming preserva os metadados?
  7. Verificação 5: o controle negativo
  8. Lendo o seu placar
  9. Encerramento
  10. FAQ

Um gateway fica entre o seu código e o provedor do modelo. Você lê cached_tokens de volta na resposta, vê um número menor e confia que os dólares economizados são reais. Mas você nunca vê a chamada upstream. O gateway poderia relatar um acerto de cache e ainda assim cobrar a tarifa de entrada cheia. Poderia não cachear nada por trás de uma resposta perfeitamente limpa. Poderia remover os metadados de uso no streaming, o caminho por onde passa a maior parte do seu tráfego de produção, de modo que você não consiga verificar de jeito nenhum.

Isso não é hipotético. Um PSA no Hacker News relatou que rotear o DeepSeek V4 por um gateway popular devolvia 2 a 3× menos tokens cacheados do que chamar o DeepSeek diretamente; um comentarista publicou faturas mostrando que as estatísticas de cache não eram relatadas pelo gateway de forma alguma. A equipe do gateway respondeu que não conseguia reproduzir o problema e que estava investigando. Essa divergência é justamente o ponto. Quando duas partes não conseguem concordar se o seu cache está funcionando, o único árbitro é uma medição feita por você mesmo.

Geralmente não é maldade. É uma lacuna de tradução ou um caminho de código inacabado. O efeito na sua fatura é o mesmo nos dois casos. Esta publicação é um único script executável que audita os dois estilos de cache de prompts, automático (DeepSeek) e baseado em marcadores (Claude), contra qualquer gateway, incluindo este. Ele imprime um placar comparativo em menos de cinco minutos.


Quatro maneiras de um gateway mentir sobre o cache

Modo de falhaO que você vêO que de fato acontece
Não-cache silenciosoUma resposta limpa, sem erroNada foi cacheado; você paga o preço cheio a cada chamada
Teatro de cachecached_tokens > 0 na resposta…mas o custo cobrado é a tarifa de entrada cheia
Desvio de markupUm número de custo plausívelO markup do gateway devora silenciosamente o desconto
Apagão de metadadosSaída de texto limpaCampos de uso removidos (sobretudo no streaming), você não consegue auditar

Os perigosos são os dois primeiros: a resposta parece que o cache está funcionando. Você descobre no fim do mês.


Dois mecanismos de cache, uma auditoria

Os provedores expõem o cache de duas formas, e um gateway de verdade precisa repassar ambas com fidelidade:

  • Automático (DeepSeek, GPT, Gemini, Qwen): o provedor cacheia por conta própria qualquer prefixo suficientemente longo. Sem marcadores. Os acertos aparecem em usage.prompt_tokens_details.cached_tokens.
  • Baseado em marcadores (Anthropic Claude): você marca os trechos cacheáveis com cache_control. Os acertos aparecem como cache_read_input_tokens.

O script esconde essa diferença atrás de um fino adaptador Lane e então roda todas as cinco verificações contra ambos. Aqui está tudo: dois trilhos (lanes) e um audit() que realiza cada verificação.

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))

O restante da publicação percorre cada verificação: as linhas que a implementam, o que ambos os trilhos retornaram e como ler o resultado.


Verificação 1: o cache é ativado?

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 cachedresultado
deepseek-v4-flash07,552 / 7,870 (96%)PASS
claude-opus-4-8012,446 / 12,454 (99.9%)PASS

Uma chamada a frio sobre um prefixo único não deve cachear nada; uma repetição deve acertar. O falso alarme mais comum, de longe, é declarar “sem cache” depois de uma única chamada a quente, porque os caches nem sempre ficam legíveis instantaneamente. O laço sonda algumas vezes com uma pausa de um segundo, o que elimina a instabilidade. Se você ainda obtiver 0 depois de várias chamadas a quente em um prompt acima do piso de tamanho (~1.024 tokens na maioria dos provedores; o DeepSeek se alinha a uma granularidade mais fina de 64), o cache realmente não está sendo ativado.


Verificação 2: o custo reflete o desconto?

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 costdescontoresultado
deepseek-v4-flash$0.00107$0.0003072.3%PASS
claude-opus-4-8$0.07112$0.0067290.6%PASS

Esta é a verificação que desmascara o teatro de cache. O custo da chamada a quente deve realmente cair. O total por chamada do DeepSeek caiu ~72% (a entrada cacheada é descontada de forma mais acentuada; a saída e o restante não cacheado diluem o número de manchete). O read cacheado do Claude tem ~90% de desconto. O sinal de falha é inconfundível: cached_tokens > 0 com custo a frio e a quente idêntico significa que o gateway está relatando um acerto que não está tarifando. Você está pagando o preço cheio por um cache que “funciona” no papel.


Verificação 3: as contagens de tokens batem?

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

cached precisa caber dentro do total do prompt, com o restante cobrado como entrada não cacheada. Ambos se reconciliam. Se cached_tokens exceder prompt_tokens, ou se o restante não cacheado for implausivelmente grande para um prefixo estável, o gateway está contabilizando errado: retokenizando ou contando em dobro em algum ponto da tradução.


Verificação 4: o streaming preserva os metadados?

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 costresultado
deepseek-v4-flashpreservadopreservadoPASS
claude-opus-4-8preservadopreservadoPASS

A maioria dos chats de produção usa streaming, então esse é o caminho que mais importa. Em ambos os trilhos, tanto o sinal de acerto de cache quanto o custo sobrevivem ao stream. cached_tokens e cost chegam no chunk final de uso, de modo que o seu caminho de maior volume continua auditável. O modo de falha a observar é um gateway que descarta o uso no streaming: uma saída de tokens limpa sem cached_tokens nem cost significa que você está voando às cegas no caminho que mais usa. (Passe stream_options={"include_usage": True} para que o chunk de uso seja emitido.)


Verificação 5: o controle negativo

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
prefixo único Aprefixo único Bresultado
deepseek-v4-flashcached 0cached 0PASS
claude-opus-4-8cached 0cached 0PASS

Envie um prefixo único a cada chamada; ele nunca deve acertar. Ambos os trilhos relataram corretamente cached=0 a preço cheio para prefixos distintos. Um “acerto” aqui tornaria o reporte de cache um falso positivo no qual você nunca poderia confiar. O controle negativo limpo é o que torna os resultados positivos nas verificações 1–2 significativos em primeiro lugar.


Lendo o seu placar

VerificaçãoResultado saudávelSinal de alerta
1. o cache é ativado0 a frio, >0 a quente (após sondagem)0 após várias chamadas a quente, acima do piso de tamanho
2. o custo reflete o descontocusto a quente ≪ custo a friocached > 0 mas custos iguais
3. contabilidade de tokenscached ≤ prompt_total, reconciliaas contagens não batem
4. metadados no streamingcache + custo sobrevivem ao streamuso ausente nas chamadas com streaming
5. controle negativoum prefixo único sempre erraum prefixo distinto “acerta”

As duas que custam dinheiro silenciosamente são a 2 (preço cheio por um acerto relatado) e a 1 (sem cache por trás de uma resposta limpa). Rode ambas em cada modelo que você fatura.


Encerramento

O cache é a alavanca de custo de maior impacto em um app de LLM, que é exatamente por isso que “o cache está funcionando” merece um teste, não uma suposição. Conecte a verificação 1 + a verificação 2 na CI contra cada modelo que você fatura, alerte se o desconto desviar para abaixo da sua faixa esperada, e você flagrará uma regressão silenciosa no dia em que um gateway ou provedor upstream mudar de comportamento, em vez de no fim do ciclo de faturamento. E faça o que fizer a sua auditoria, sonde o read a quente antes de declarar um cache quebrado.

Para a mecânica por trás desses números (prefill, cache KV, TTLs) comece por Como funcionam o cache KV e o TTL. Para padrões de cache funcionais por provedor, veja o tutorial.


FAQ

Minha verificação 1 mostra 0 na chamada a quente. Meu gateway está mentindo? Verifique três coisas primeiro. (1) Seu prompt ultrapassa o tamanho mínimo cacheável do provedor (~1.024 tokens na maioria; o DeepSeek se alinha a uma granularidade mais fina de 64 tokens)? (2) Você sondou o read a quente algumas vezes? Os caches nem sempre ficam legíveis na chamada imediatamente seguinte. (3) O prefixo é idêntico byte a byte entre as chamadas, sem timestamps ou IDs por requisição no início? Só depois dos três você deveria suspeitar do gateway.

O que o “teatro de cache” me custa na prática? Você paga a tarifa de entrada cheia a cada chamada acreditando que paga uma fração. Em um endpoint de alto volume com um grande prefixo estável, isso significa que a sua fatura fica várias vezes maior do que você modelou. A verificação 2 é a que vale alertar.

Por que o desconto do DeepSeek é menor que o do Claude aqui? Coisas diferentes estão sendo medidas. Os ~90% do Claude são o desconto de read sobre a entrada cacheada. Os ~72% do DeepSeek são a redução do total por chamada, em que a saída e o restante não cacheado são cobrados à tarifa cheia e diluem o número de manchete. Compare o comparável para o formato do seu próprio prompt.

Isso funciona também para GPT, Gemini, Qwen? Sim. Todos são automáticos, então usam o AutoLane sem alterações com um model diferente. Só o Claude precisa do MarkerLane. As mesmas cinco verificações em qualquer caso.

Isso deve viver na CI? Sim. Rode a verificação 1 + a verificação 2 contra cada modelo que você fatura, de forma agendada, e alerte quando o desconto observado desviar para fora da sua faixa esperada. Uma auditoria permanente transforma uma regressão silenciosa em uma notificação.

← Voltar ao blog