Todos os artigos

// Knowledge.log — 技術記事

Cache de prompt em coding agents: o miss silencioso que volta a cobrar o preço cheio

Reordenar tools, um timestamp no prefixo ou memória de projeto mal posicionada quebram o cache sem erro. Como medir, travar em CI e alertar antes da fatura.

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 toolssystemmessages. Qualquer mudança em nome, descrição, parâmetro ou ordem das ferramentas invalida tudo que vem depois, incluindo o system. O cache aqui é opt-in via cache_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), o model, parallel_tool_calls, text.format ou text.verbosity quebra 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:

Modelosem cacheacerto (leitura)escritamiss 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,2510×
xAI grok-4.6 (contexto curto)$0,20$0,05sem taxa de escrita documentada
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.

ai-agentsdevex

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos