🎁 Novo Cadastre-se grátis, 10 chamadas por nossa conta. Até US$ 1, sem cartão.
Seu gateway de LLM mente sobre o cache? Uma auditoria de 5 minutos

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

Conteúdo
  1. Quatro formas de um gateway mentir sobre o cache
  2. Dois mecanismos de cache, uma auditoria
  3. Verificação 1: o cache entra em ação?
  4. Verificação 2: o custo reflete o desconto?
  5. Verificação 3: a contagem de tokens fecha?
  6. Verificação 4: o streaming preserva os metadados?
  7. Verificação 5: o controle negativo
  8. Como interpretar o comparativo
  9. Conclusão
  10. Perguntas frequentes

Um gateway fica entre seu código e o provedor do modelo. A resposta traz cached_tokens, o valor parece menor e você confia que a economia é real. Mas a chamada ao upstream não fica visível. O gateway pode informar um cache hit e ainda cobrar a tarifa integral de input. Pode não armazenar nada em cache e, mesmo assim, retornar uma resposta aparentemente normal. Também pode remover os metadados de uso no streaming, por onde passa a maior parte do tráfego de produção, impedindo qualquer verificação.

TL;DR

  • Um alerta no Hacker News relatou que um gateway popular retornava 2-3x menos tokens em cache para o DeepSeek V4 do que uma chamada direta ao DeepSeek.
  • Um único script executável faz cinco verificações: se o cache entra em ação, se o custo realmente cai, se a contagem de tokens fecha, se o streaming preserva os metadados de uso e se um controle negativo continua sem cache.
  • Na auditoria pelo gateway Synthorai, deepseek-v4-flash apresentou 96% de cache hit após o aquecimento e redução de 72.3% no custo por chamada; claude-opus-4-8 apresentou 99.9% e 90.6%.
  • cached_tokens > 0 com o mesmo custo na chamada fria e na aquecida significa que o gateway informa um hit sem aplicar o preço correspondente.

O problema é real. Um alerta no Hacker News relatou que encaminhar o DeepSeek V4 por um gateway popular retornava 2–3× menos tokens em cache do que chamar o DeepSeek diretamente; um comentário mostrou faturas indicando que as estatísticas de cache nem sequer eram repassadas pelo gateway. A equipe do gateway respondeu que não conseguia reproduzir o problema e estava investigando. Essa divergência é justamente o ponto. Quando as duas partes discordam sobre o funcionamento do cache, o único critério confiável é uma medição feita por você.

Em geral, não há má-fé. O problema costuma estar na conversão entre APIs ou em algum fluxo ainda incompleto. Para a fatura, porém, o efeito é o mesmo. Este post apresenta um único script executável que audita os dois tipos de prompt caching — automático (DeepSeek) e baseado em marcadores (Claude) — em qualquer gateway, inclusive este. Em menos de cinco minutos, ele imprime um comparativo dos resultados.


Quatro formas de um gateway mentir sobre o cache

Modo de falhaO que você vêO que realmente acontece
Cache silenciosamente ausenteUma resposta normal, sem erroNada foi armazenado em cache; todas as chamadas são cobradas pelo preço integral
Cache de fachadacached_tokens > 0 na resposta…mas o custo faturado corresponde à tarifa integral de input
Markup crescenteUm custo aparentemente plausívelO markup do gateway consome o desconto sem deixar isso claro
Apagão de metadadosOutput de texto normalOs campos de uso são removidos, principalmente no streaming, impedindo a auditoria

Os dois primeiros são os mais perigosos: a resposta parece indicar que o cache funciona. Você só descobre no fim do mês.


Dois mecanismos de cache, uma auditoria

Os provedores oferecem cache de duas formas, e um gateway de verdade precisa repassar ambas sem alterações:

  • Automático (DeepSeek, GPT, Gemini, Qwen): o provedor armazena por conta própria qualquer prefixo longo o suficiente. Não há marcadores. Os hits aparecem em usage.prompt_tokens_details.cached_tokens.
  • Baseado em marcadores (Anthropic Claude): os trechos armazenáveis em cache são marcados com cache_control. Os hits aparecem como cache_read_input_tokens.

O script esconde essa diferença atrás de um adapter Lane simples e executa as cinco verificações nos dois casos. Este é o script completo: duas lanes e uma função audit() que realiza todas as verificações.

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 do post detalha cada verificação: as linhas que a implementam, o retorno das duas lanes e como interpretar o resultado.


Verificação 1: o cache entra em ação?

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

Uma chamada fria com prefixo exclusivo não pode ter nada em cache; a repetição precisa produzir um hit. O falso alarme mais comum é declarar que não há cache depois de uma única chamada aquecida, porque o cache nem sempre fica disponível para leitura imediatamente. O loop tenta algumas vezes, com uma pausa de 1 segundo, eliminando essa instabilidade. Se o retorno continuar em 0 após várias chamadas aquecidas com um prompt acima do tamanho mínimo — cerca de 1,024 tokens para a maioria dos provedores; o DeepSeek faz a correspondência em blocos menores, de 64 —, o cache realmente não está entrando em ação.


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

Esta verificação detecta o cache de fachada. O custo da chamada aquecida precisa cair de fato. O total por chamada do DeepSeek diminuiu cerca de 72% — o desconto no input em cache é maior, mas o output e o restante fora do cache reduzem o percentual total. No Claude, a leitura do cache tem desconto de cerca de 90%. O sinal de falha é inequívoco: cached_tokens > 0 com custo idêntico nas chamadas fria e aquecida significa que o gateway informa um hit, mas não aplica o preço correspondente. Você paga a tarifa integral por um cache que só “funciona” no relatório.


Verificação 3: a contagem de tokens fecha?

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

cached precisa fazer parte do total do prompt; o restante é cobrado como input fora do cache. Nos dois casos, a conta fecha. Se cached_tokens exceder prompt_tokens, ou se o restante fora do cache for grande demais para um prefixo estável, o gateway está contabilizando incorretamente: há uma nova tokenização ou uma contagem duplicada em algum ponto da conversã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"]
cache no streamingcusto no streamingresultado
deepseek-v4-flashpreservadopreservadoPASS
claude-opus-4-8preservadopreservadoPASS

A maioria dos chats em produção usa streaming, então este é o fluxo mais relevante. Nas duas lanes, tanto o sinal de cache hit quanto o custo são preservados no stream. cached_tokens e cost chegam no último chunk de uso, mantendo auditável o fluxo de maior volume. Fique atento a gateways que descartam os dados de uso no streaming: output normal, mas sem cached_tokens ou cost, deixa você sem visibilidade justamente no fluxo mais utilizado. (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 exclusivo Aprefixo exclusivo Bresultado
deepseek-v4-flashcached 0cached 0PASS
claude-opus-4-8cached 0cached 0PASS

Envie um prefixo exclusivo em cada chamada; nunca pode haver hit. As duas lanes informaram corretamente cached=0, com custo integral, para prefixos distintos. Um “hit” aqui tornaria os dados de cache falsos positivos e, portanto, inúteis. É o controle negativo limpo que dá validade aos resultados positivos das Verificações 1–2.


Como interpretar o comparativo

VerificaçãoResultado saudávelSinal de alerta
1. cache entra em ação0 frio, >0 aquecido (após novas tentativas)0 após várias chamadas aquecidas, acima do tamanho mínimo
2. custo reflete o descontocusto aquecido ≪ custo friocached > 0, mas os custos são iguais
3. contabilização de tokenscached ≤ prompt_total, conta fechaos números não fecham
4. metadados no streamingcache + custo sobrevivem ao streamdados de uso ausentes nas chamadas com streaming
5. controle negativoprefixo exclusivo nunca produz hitum prefixo distinto produz “hit”

As duas falhas que aumentam o custo sem chamar atenção são a 2 — cobrança integral apesar do hit informado — e a 1 — ausência de cache por trás de uma resposta normal. Execute ambas para todos os modelos pelos quais você paga.


Conclusão

O cache é a forma mais eficiente de reduzir custos em uma aplicação com LLM. Por isso, “o cache está funcionando” precisa ser verificado, não presumido. Coloque as Verificações 1 + 2 no CI para cada modelo faturado e gere um alerta caso o desconto saia da faixa esperada. Assim, qualquer regressão silenciosa causada por uma mudança no gateway ou no provedor upstream será detectada no mesmo dia, não no fim do ciclo de faturamento. E, independentemente da implementação da auditoria, faça novas tentativas de leitura do cache aquecido antes de concluir que ele está quebrado.

Para entender os mecanismos por trás desses números — prefill, KV cache e TTLs —, comece por Como funcionam o KV cache e o TTL. Para ver padrões de cache funcionais em cada provedor, consulte o tutorial.


Perguntas frequentes

Minha Verificação 1 mostra 0 na chamada aquecida. Meu gateway está mentindo? Primeiro, confira três pontos. (1) O prompt ultrapassa o tamanho mínimo armazenável em cache do provedor — cerca de 1,024 tokens para a maioria; o DeepSeek trabalha com granularidade menor, de 64 tokens? (2) Você tentou ler o cache aquecido mais de uma vez? O cache nem sempre fica disponível logo na chamada seguinte. (3) O prefixo é idêntico byte a byte entre as chamadas, sem timestamps nem IDs específicos da requisição no início? Só suspeite do gateway depois de confirmar os três.

Quanto custa, na prática, um “cache de fachada”? Você paga a tarifa integral de input em todas as chamadas, acreditando estar pagando apenas uma fração. Em um endpoint de alto volume com um prefixo grande e estável, a fatura pode ficar várias vezes acima da estimativa. Configure alertas para a Verificação 2.

Por que o desconto do DeepSeek é menor que o do Claude neste teste? As métricas são diferentes. Os cerca de 90% do Claude correspondem ao desconto de leitura do input em cache. Os cerca de 72% do DeepSeek representam a redução no total por chamada: o output e o restante fora do cache são cobrados pela tarifa integral, reduzindo o percentual final. Ao avaliar seu próprio formato de prompt, compare métricas equivalentes.

Isso também funciona com GPT, Gemini e Qwen? Sim. Todos usam cache automático, portanto basta reutilizar AutoLane sem alterações e trocar o model. Somente o Claude precisa de MarkerLane. As mesmas cinco verificações valem para todos.

Isso deve ficar no CI? Sim. Execute as Verificações 1 + 2 periodicamente em todos os modelos faturados e gere um alerta quando o desconto observado sair da faixa esperada. Uma auditoria contínua transforma uma regressão silenciosa em uma notificação.

← Voltar ao blog