Seu gateway de LLM mente sobre o cache? Uma auditoria em 5 min
Conteúdo
- Quatro maneiras de um gateway mentir sobre o cache
- Dois mecanismos de cache, uma auditoria
- Verificação 1: o cache é ativado?
- Verificação 2: o custo reflete o desconto?
- Verificação 3: as contagens de tokens batem?
- Verificação 4: o streaming preserva os metadados?
- Verificação 5: o controle negativo
- Lendo o seu placar
- Encerramento
- 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 falha | O que você vê | O que de fato acontece |
|---|---|---|
| Não-cache silencioso | Uma resposta limpa, sem erro | Nada foi cacheado; você paga o preço cheio a cada chamada |
| Teatro de cache | cached_tokens > 0 na resposta | …mas o custo cobrado é a tarifa de entrada cheia |
| Desvio de markup | Um número de custo plausível | O markup do gateway devora silenciosamente o desconto |
| Apagão de metadados | Saída de texto limpa | Campos 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 comocache_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 cached | warm cached | 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 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 cost | warm cost | desconto | resultado | |
|---|---|---|---|---|
deepseek-v4-flash | $0.00107 | $0.00030 | 72.3% | PASS |
claude-opus-4-8 | $0.07112 | $0.00672 | 90.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"]
| cached | prompt total | resultado | |
|---|---|---|---|
deepseek-v4-flash | 7,552 | 7,870 | PASS |
claude-opus-4-8 | 12,446 | 12,454 | PASS |
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 cached | stream cost | resultado | |
|---|---|---|---|
deepseek-v4-flash | preservado | preservado | PASS |
claude-opus-4-8 | preservado | preservado | PASS |
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 A | prefixo único B | resultado | |
|---|---|---|---|
deepseek-v4-flash | cached 0 | cached 0 | PASS |
claude-opus-4-8 | cached 0 | cached 0 | PASS |
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ção | Resultado saudável | Sinal de alerta |
|---|---|---|
| 1. o cache é ativado | 0 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 desconto | custo a quente ≪ custo a frio | cached > 0 mas custos iguais |
| 3. contabilidade de tokens | cached ≤ prompt_total, reconcilia | as contagens não batem |
| 4. metadados no streaming | cache + custo sobrevivem ao stream | uso ausente nas chamadas com streaming |
| 5. controle negativo | um prefixo único sempre erra | um 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.