Você ativa prompt caching no seu coding agent, o custo cai, todo mundo comemora e a métrica vira gráfico de sucesso no dashboard. Semanas depois alguém reordena a lista de tools num refactor "cosmético", ou adiciona um timestamp no bloco de sistema para debugar melhor os logs, ou passa a injetar memória de projeto no topo do prompt. Nenhuma dessas mudanças quebra teste, nenhuma delas retorna erro. A API continua respondendo 200. O que muda é que cada chamada volta a pagar o prefixo inteiro como se o cache nunca tivesse existido — e isso só aparece na fatura, semanas depois, quando alguém pergunta por que o custo por tarefa voltou a subir.
Esse é o problema deste artigo: não é estourar um teto de custo (isso já cobrimos no teto de custo), nem provar qual versão de prompt rodou numa tarefa (isso é o prompt versionado). É uma mudança inofensiva de formatação invalidar o cache de tokens, sem log de erro e sem alerta de teto, porque o "erro" é indistinguível de uma chamada normal — a única diferença está em campos numéricos dentro de um objeto usage que quase ninguém olha em produção.
Como o cache decide "sim" ou "não" (e por que é tudo ou nada)
Cache de prompt funciona por correspondência exata de prefixo. O provedor guarda o resultado de processar um conjunto de tokens; na próxima chamada, ele compara byte a byte o início do payload com o que está em cache. Se bater até um certo ponto (o "breakpoint"), reaproveita; a partir do primeiro byte divergente, reprocessa tudo do zero — inclusive tudo que vem depois, mesmo que fosse idêntico. Não existe cache parcial "quase igual".
A ordem em que o provedor monta esse prefixo importa mais do que qualquer coisa:
- Anthropic monta o prefixo como
tools→system→messages. Qualquer mudança em nome, descrição, parâmetro ou ordem das ferramentas invalida tudo que vem depois, incluindo osystem. O cache aqui é opt-in viacache_control: você pode marcar um breakpoint automático no nível superior ou marcar blocos específicos manualmente, com um limite de 4 breakpoints por requisição. - OpenAI renderiza um prefixo que inclui system oculto + tools + developer + histórico de mensagens. Mudar
tools(nome, schema, descrição, ordem), omodel,parallel_tool_calls,text.formatoutext.verbosityquebra o cache. O cache aqui é automático por padrão (implícito); breakpoints explícitos só existem a partir da família GPT-5.6+, e sem marcador explícito o provedor decide sozinho onde cortar — o que nem sempre coincide com o fim do seu conteúdo estático.
Essa diferença de filosofia — Anthropic opt-in e granular, OpenAI automático e "confie em nós" — explica por que os dois provedores erram de formas diferentes. Na Anthropic, você esquece de mover o cache_control para o bloco certo. Na OpenAI, o provedor breakpointa o fim da conversa, que muda a cada turno, e nunca reaproveita o prefixo estático que ficou mais atrás.
As três causas que aparecem na prática
Reordenar tools. Times fazem isso sem pensar duas vezes: ordenar alfabeticamente, mover a ferramenta nova para o topo, gerar a lista a partir de um dicionário Python cuja ordem de iteração não é garantida entre execuções. Qualquer uma dessas ações reescreve o prefixo inteiro. Correção: serialize tools com uma ordem canônica fixa (por nome, sempre) antes de montar o payload — nunca dependa da ordem de inserção de um dicionário ou de um ORDER BY implícito de banco. Observação em produção: comparar o hash do array de tools enviado com o hash do array em cache; se divergir sem uma mudança de ferramenta intencional, é bug de serialização, não drift de produto.
Timestamp (ou qualquer coisa que mude a cada chamada) no bloco onde está o breakpoint. É comum colocar "hora atual" ou "id de sessão" no system para dar contexto ao modelo. Se esse bloco é o último antes do corte de cache, toda chamada escreve e nenhuma lê. Correção: mova conteúdo dinâmico (timestamp, memória de projeto, estado da sessão) para depois do ponto de corte — o prefixo cacheável deve conter só o que é idêntico entre chamadas: tools e instruções estáveis. Observação em produção: um teste de fixture que roda o mesmo prompt duas vezes com apenas o timestamp variando; se a segunda chamada não mostra leitura de cache, o breakpoint está no lugar errado.
Memória de projeto injetada no prefixo estático. Isso é distinto do problema de "quem monta o contexto" que já tratamos em memória de projeto — aqui o ponto não é a origem da memória, é a posição dela no payload. Se a memória entra antes do breakpoint (ou reordena linhas dentro do system), cada tarefa nova gera um prefixo levemente diferente e o cache nunca converge. Correção: trate a memória de projeto como conteúdo pós-breakpoint, com formatação estável (mesma ordem de campos, mesmo json.dumps com sort_keys=True) sempre que ela precisar entrar antes do corte. Observação em produção: logar o tamanho em tokens do bloco pré-breakpoint por chamada; variação de tamanho nesse bloco é sinal de vazamento de conteúdo dinâmico para dentro do prefixo estático.
Medindo a taxa de acerto pelos campos de uso
A resposta da API sempre te diz o que aconteceu — só não da forma que você está acostumado a olhar (código de status). Você precisa ler os campos de uso.
Na Anthropic, o objeto usage traz cache_read_input_tokens, cache_creation_input_tokens e input_tokens (este último conta só os tokens depois do último breakpoint). Os campos de criação se dividem em ephemeral_5m_input_tokens e ephemeral_1h_input_tokens, que somados batem com cache_creation_input_tokens. Se os dois campos de cache vierem zerados, a chamada não usou cache — nem para ler, nem para escrever, geralmente porque o prefixo ficou abaixo do mínimo cacheável.
Na OpenAI, o usage.input_tokens_details.cached_tokens mostra quanto foi lido do cache; a partir de GPT-5.6+ existe também cache_write_tokens (não invente esse campo em respostas de modelos anteriores — cache ali é só leitura implícita, sem cobrança de escrita).
Um miss silencioso é isto, em uma linha: HTTP 200 com cache_read (ou cached_tokens) igual a zero, enquanto write/uncached é maior que zero. Nada quebrou. O agente respondeu certo. Só o preço voltou ao cheio.
Fixture mínima de um miss silencioso (formato Anthropic, um turno depois do primeiro):
{
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 41230,
"input_tokens": 812
}
Se essa fosse uma leitura de cache saudável, cache_read_input_tokens estaria perto de 41230 e cache_creation_input_tokens seria zero. Aqui o prefixo inteiro foi reescrito — alguém mudou algo antes do breakpoint.
Calcular a taxa de acerto é aritmética simples, mas vale ter a função pronta para reaproveitar em CI e em alerta:
def hit_rate(read: int, write: int, uncached: int) -> float:
den = read + write + uncached
return 0.0 if den == 0 else read / den
CI que trava o prefixo estático antes que ele vá para produção
Medir em produção avisa depois do fato. O que evita o problema é um teste de CI que hasheia exatamente o que deveria ser estável — tools e o system fixo, sem timestamp e sem memória — e falha se esse hash mudar sem uma decisão explícita.
import hashlib
import json
import pathlib
import sys
ROOT = pathlib.Path(__file__).resolve().parent / "agent_prompts"
def canonical_prefix(tools, system: str) -> bytes:
blob = json.dumps(
{"tools": tools, "system": system},
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
return blob.encode()
def main() -> int:
tools = json.loads((ROOT / "tools.json").read_text())
system = (ROOT / "system.txt").read_text()
if "T=" in system or "{{now}}" in system:
sys.stderr.write("DYNAMIC MARKER IN STATIC PREFIX\n")
return 2
digest = hashlib.sha256(canonical_prefix(tools, system)).hexdigest()
golden = (ROOT / "prefix.sha256").read_text().strip()
if digest != golden:
sys.stderr.write(f"STATIC PREFIX DRIFT {golden[:12]} -> {digest[:12]}\n")
return 1
print(f"PREFIX OK {digest}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Só stdlib (hashlib, json, pathlib) — sem Docker, sem chave de LLM de verdade, sem AWS. O teste roda em qualquer runner de CI.
Rodando contra um tools.json e system.txt estáveis, o hash bate com o golden gravado (03fd72daf7ab0ff42250b0f9a974bbb14d2533c93a8dc56fcd24aa509a02e8a1) e o script imprime PREFIX OK. Reordenar as duas ferramentas dentro de tools.json — sem mudar uma vírgula de conteúdo, só a ordem das entradas — já é suficiente para produzir:
STATIC PREFIX DRIFT 03fd72daf7ab -> 3635aaa3d313
com código de saída 1. Desfazendo a reordenação, o script volta a imprimir PREFIX OK. É o mesmo tipo de mudança citado acima como causa mais comum de miss silencioso — só que agora capturada antes do merge, não na fatura do mês. O golden só deve ser atualizado numa PR que pretende quebrar o cache (troca de ferramenta, mudança de instrução); qualquer outro diff nesse arquivo é sinal de alerta, não rotina.
Alertar quando a taxa de acerto cair, sem confundir com TTL
Hash de CI pega drift de configuração. Não pega o caso em que o prefixo está correto mas o cache expirou por tempo — isso é esperado, não é bug. Por isso o alerta de produção precisa de um limiar, e o limiar é política do time, não SLA do provedor.
THRESHOLD = 0.80 # limiar local, não é garantia de nenhum provedor
def check(rows):
alerts = []
by = {}
for r in rows:
by.setdefault(r["conv_id"], []).append(r)
for cid, rs in by.items():
rs = rs[1:] if len(rs) > 1 else rs # descarta a escrita do primeiro turno
read = sum(x["read"] for x in rs)
total = sum(x["read"] + x["write"] + x["uncached"] for x in rs)
rate = 0.0 if total == 0 else read / total
if total and rate < THRESHOLD:
alerts.append({"conv_id": cid, "hit_rate": round(rate, 4), "total_input": total})
return alerts
O primeiro turno de qualquer conversa é sempre escrita — não existe cache antes de existir uma primeira chamada. Descartá-lo do cálculo evita que o alerta dispare em falso todo início de sessão.
Antes de tratar uma queda pontual como incidente, lembre que um miss isolado pode ser só expiração de TTL: na Anthropic o cache dura 5 minutos a partir do início da requisição (com refresh a cada acerto); na OpenAI, a partir de GPT-5.6+, o mínimo documentado é 30 minutos. Um miss depois de um período de silêncio do agente é esperado. Uma taxa de acerto que cai e não se recupera nas chamadas seguintes é o sinal que importa — é aí que vale investigar reordenação de ferramentas ou conteúdo dinâmico vazando para o prefixo estático.
Quanto isso custa em números, sem generalização vaga
Preços oficiais em 13/09/2026, para um prefixo de 100.000 tokens, sem saída:
| Modelo | sem cache | acerto (leitura) | escrita | miss vs. acerto |
|---|---|---|---|---|
Anthropic claude-sonnet-5 | $0,20 | $0,02 | $0,25 (janela de 5 min) | 10× (escrita: 12,5×) |
OpenAI gpt-6-astra | $1,00 | $0,10 | $1,25 | 10× |
xAI grok-4.6 (contexto curto) | $0,20 | $0,05 | sem taxa de escrita documentada | 4× |
def prefix_cost_usd(tokens: int, uncached: float, hit: float, write: float | None = None, mode: str = "hit") -> float:
price = {"uncached": uncached, "hit": hit, "write": write or uncached}[mode]
return price * tokens / 1_000_000
# claude-sonnet-5, 100k tokens: hit $0.02, uncached $0.20
O número que costuma surpreender não é o de uma chamada isolada, é o de uma sessão inteira. No formato documentado pela OpenAI para GPT-5.6+ — dez chamadas sobre o mesmo prefixo estático, sendo a primeira uma escrita e as nove seguintes leituras — o custo agregado do prefixo fica em 1,25 + 9 × 0,10 = 2,15× o valor de uma chamada sem cache, contra 10× se as dez chamadas fossem todas sem cache. É a diferença entre o cache funcionando de verdade ao longo da sessão e o cache "ligado" mas reescrevendo a cada turno porque o breakpoint está no lugar errado — o segundo cenário custa exatamente como se o recurso tivesse sido desligado, só que sem ninguém ter tomado essa decisão.
Recomendação
Se o seu coding agent já roda em produção com mais de uma chamada por sessão, vale investir nas três camadas: hash de prefixo em CI (pega drift antes do merge), leitura dos campos de uso a cada chamada (pega o miss silencioso em tempo real) e um alerta de taxa de acerto com limiar de time (pega degradação lenta que nenhum PR isolado explica). Nenhuma das três substitui a outra — o hash não vê TTL, o campo de uso isolado não vê tendência, e o alerta sem o hash não te diz por que a taxa caiu.
Onde a DevDojo recuaria: se o volume de chamadas por sessão for baixo (uma ou duas por tarefa) e o prefixo estático for pequeno, o retorno de manter essa instrumentação toda pode não valer o esforço — nesse caso, um log simples de cache_read_input_tokens por chamada, revisado manualmente de vez em quando, já cobre o risco. A engenharia extra compensa quando o prefixo é grande (tools numerosas, instruções longas) e a sessão é longa o bastante para o cache pagar a escrita inicial várias vezes — que é exatamente o perfil de um coding agent que fica horas dentro do mesmo repositório.
Para os detalhes de cada provedor que não cabem aqui — limites mínimos de tokens cacheáveis, opções de TTL estendido, formato exato de cache_control — vale ler a documentação de prompt caching da Anthropic, a tabela de preços da Anthropic, o guia de prompt caching da OpenAI, a tabela de preços da OpenAI e a página de preços da xAI antes de fixar o limiar e o mínimo cacheável no seu próprio agente.