Seu gateway de LLM mente sobre o cache? Uma auditoria de 5 minutos
Conteúdo
- Quatro formas de um gateway mentir sobre o cache
- Dois mecanismos de cache, uma auditoria
- Verificação 1: o cache entra em ação?
- Verificação 2: o custo reflete o desconto?
- Verificação 3: a contagem de tokens fecha?
- Verificação 4: o streaming preserva os metadados?
- Verificação 5: o controle negativo
- Como interpretar o comparativo
- Conclusão
- 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 > 0com 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 falha | O que você vê | O que realmente acontece |
|---|---|---|
| Cache silenciosamente ausente | Uma resposta normal, sem erro | Nada foi armazenado em cache; todas as chamadas são cobradas pelo preço integral |
| Cache de fachada | cached_tokens > 0 na resposta | …mas o custo faturado corresponde à tarifa integral de input |
| Markup crescente | Um custo aparentemente plausível | O markup do gateway consome o desconto sem deixar isso claro |
| Apagão de metadados | Output de texto normal | Os 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 comocache_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 frio | cache aquecido | resultado | |
|---|---|---|---|
deepseek-v4-flash | 0 | 7,552 / 7,870 (96%) | PASS |
claude-opus-4-8 | 0 | 12,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 frio | custo aquecido | desconto | resultado | |
|---|---|---|---|---|
deepseek-v4-flash | $0.00107 | $0.00030 | 72.3% | PASS |
claude-opus-4-8 | $0.07112 | $0.00672 | 90.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 cache | total do prompt | resultado | |
|---|---|---|---|
deepseek-v4-flash | 7,552 | 7,870 | PASS |
claude-opus-4-8 | 12,446 | 12,454 | PASS |
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 streaming | custo no streaming | resultado | |
|---|---|---|---|
deepseek-v4-flash | preservado | preservado | PASS |
claude-opus-4-8 | preservado | preservado | PASS |
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 A | prefixo exclusivo B | resultado | |
|---|---|---|---|
deepseek-v4-flash | cached 0 | cached 0 | PASS |
claude-opus-4-8 | cached 0 | cached 0 | PASS |
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ção | Resultado saudável | Sinal de alerta |
|---|---|---|
| 1. cache entra em ação | 0 frio, >0 aquecido (após novas tentativas) | 0 após várias chamadas aquecidas, acima do tamanho mínimo |
| 2. custo reflete o desconto | custo aquecido ≪ custo frio | cached > 0, mas os custos são iguais |
| 3. contabilização de tokens | cached ≤ prompt_total, conta fecha | os números não fecham |
| 4. metadados no streaming | cache + custo sobrevivem ao stream | dados de uso ausentes nas chamadas com streaming |
| 5. controle negativo | prefixo exclusivo nunca produz hit | um 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.