Prompt caching: medir o prefixo antes de ligar a flag
Esta nota é sobre uma otimização de custo de LLM, os modelos de linguagem grandes. Mas o assunto de verdade é a ordem das operações: a medição veio antes da otimização, e derrubou a hipótese que tinha motivado a medição. O que sobrou foi uma mudança pequena, um número que justifica cada linha dela, e uma lista honesta do que ainda não dá para afirmar.
O contexto, em termos genéricos: um serviço de agentes em produção, coordenador + subagentes, atendendo mensagens de operação por webhook, o aviso que outro sistema envia ao nosso. Modelos servidos via OpenRouter, com protocolo OpenAI na frente e Anthropic atrás. Esse detalhe vai importar.
A hipótese errada, e o número que a derrubou
A suspeita original era plausível. Boa parte das mensagens é trivial (“ok”, “obrigado”, “bom dia”), e mensagens triviais atravessando um grafo de agentes custam caro. O plano era classificar e desviar as triviais.
O primeiro passo, porém, não foi construir o classificador. Foi instrumentar: marcar as mensagens triviais no analytics e medir quanto elas de fato custavam. Resultado da medição em produção (um dia de 2026-08):
| métrica | valor |
|---|---|
| p50 da mensagem inteira | 9,7 s |
| p95 | 21,9 s |
| tokens de entrada por chamada | ~18.000 |
| chamadas por mensagem | 2 |
| custo por mensagem | US$ 0,07 |
A conclusão que a medição impôs: não é a fatia das triviais que dói. É o preço de qualquer mensagem. Cada chamada, trivial ou não, carregava ~18 mil tokens de entrada, os pedaços de texto que entram no preço. O remédio não era um classificador. Era olhar para o que são esses 18 mil tokens.
A decomposição: de que é feito um payload de agente
O passo seguinte foi montar offline o payload real, o pacote que sai para o modelo. O cliente HTTP foi o mesmo que produção usa, para pegar a serialização exata. E então, a decomposição:
| parte | tamanho (chars) |
|---|---|
| definições de ferramentas (24, em JSON) | 26.087 |
| system prompt (coordenador + áreas + módulos + roteamento + memória + middlewares) | 34.767 |
| a mensagem do usuário | ~105 |
| prefixo estável | 60.854 = 99,8% do payload |
Aí veio a verificação que transforma o número em decisão. Prefixo é o trecho inicial do payload que não muda de uma mensagem para a outra: duas mensagens diferentes o produzem byte a byte igual. Não “parecido”: igual, verificado. É isso que cache de prompt exige. O provedor cacheia por prefixo exato, e um único byte diferente no meio invalida tudo dali para frente.
Esse é o perfil típico de qualquer serviço de agentes com ferramentas: a conversa é uma gota, o andaime é o oceano. Mas “típico” não é medição. O que autorizou a mudança foi o 99,8% deste sistema, medido neste payload. Se o seu prefixo estável for 60%, a conta muda inteira.
A implementação: um breakpoint, no lugar certo, pelo motivo certo
Com o número na mão, a mudança é pequena. E cada decisão dela tem um porquê:
Onde interceptar. Uma subclasse do cliente de chat sobrescreve o último ponto antes do HTTP. É depois de todos os middlewares do framework de agentes terem acrescentado seus blocos ao system prompt, as instruções fixas que vão em toda chamada. Marcar antes disso é marcar um payload que ainda vai mudar. Nos testes, que injetam modelos falsos, a subclasse não participa.
Onde vai a marca. Um único cache_control: {"type": "ephemeral"} no último bloco da última mensagem system. O insight que evita uma gambiarra está na ordem de renderização da Anthropic: tools → system → messages. Um breakpoint no fim do system, ou seja, o ponto onde o cache é cortado, cacheia também as definições de ferramentas, que vêm antes. Não existe marca por ferramenta no formato OpenAI que o OpenRouter aceita, e não precisa existir: um breakpoint bem posicionado cobre os dois maiores blocos do payload.
Por que a marca é explícita. O OpenRouter não liga cache sozinho para modelos Anthropic (conferido na documentação na data da mudança): ou o payload leva cache_control, ou não há cache. A flag “deixa o provedor decidir” não existe nesse caminho.
As guardas. A marcação é idempotente: aplicada de novo, não cria um segundo breakpoint. Payload sem system passa intacto. E há um piso de tamanho (~4.000 chars ≈ 1.024 tokens) abaixo do qual a marca não é aplicada. O provedor tem mínimo de cache (1.024 tokens no modelo maior, 4.096 no menor), e marcar um payload abaixo do mínimo só gasta bytes.
Uma env var desliga tudo (PROMPT_CACHE=0). O TTL estendido de 1 hora, o tempo que o cache sobrevive, ficou documentado mas desligado por padrão. Escrita a 2× em vez de 1,25× só compensa com três ou mais leituras no intervalo: é uma conta, não uma preferência.
O que dá para afirmar — e o que ainda não
Aqui a nota fica chata de propósito, porque é onde a maioria dos posts sobre caching mente por entusiasmo.
O efeito no custo é, por enquanto, um modelo aritmético, não uma medição de produção. A leitura de cache sai a ~0,1× e a escrita a 1,25× do preço de entrada. Com isso, uma mensagem isolada cai para ~0,67× do custo anterior. Uma segunda mensagem dentro da janela de 5 minutos cai para ~0,10×. Os ~18 mil tokens continuam sendo enviados: o que muda é o preço deles.
Na latência, o provedor deixa de reprocessar o prefixo. Isso deve cortar a maior parte do time-to-first-token, o tempo até a primeira palavra da resposta, nas chamadas subsequentes. “Deve”: ainda sem número medido depois da ativação.
A regra da casa é não publicar o número antes de duas semanas de uso real. A instrumentação para isso subiu junto com a feature, não depois. São colunas de tokens de cache, contadas como recorte da entrada e não somáveis a ela, distinção que evita contagem dupla no relatório. São também um hit_pct no analytics interno e uma métrica exportada.
Quando o número existir, ele vai sair da telemetria, não da estimativa. Se você for copiar uma coisa desta nota, copie essa: a feature de custo nasce com o medidor dela.
O runbook do sinal também nasceu junto. Runbook aqui é o procedimento de quem opera: hit_pct perto de zero por dois dias sem deploy é investigação. Alguma coisa está variando no prefixo que deveria ser estável.
E a mesma régua explica o custo escondido do cache: editar qualquer arquivo que compõe o system prompt (coordenador, áreas, skills, memória) muda o prefixo e invalida o cache. No deploy, esperado; fora dele, sintoma. O cache transforma “mexi num prompt” de mudança gratuita em mudança com preço. Para um sistema que trata prompts como configuração versionada, isso é até saudável: o preço já era pago em revisão, e agora aparece no medidor.
Um efeito de segunda ordem no design. Antes do cache, ligar um módulo novo para um cliente encarecia toda mensagem (mais ferramentas, mais system prompt, mais tokens em cada chamada). Com o prefixo cacheado, o andaime maior é pago quase uma vez por janela, não por mensagem. O custo marginal de capacidade instalada caiu. O que continua caro por uso é delegação: cada subagente acionado é uma rodada nova de modelo. A conta de custo do sistema muda de “quantos módulos” para “quantas delegações”, e isso reordena o que vale otimizar.
A armadilha que esta nota quer que você evite
O erro simétrico existe dos dois lados:
- Ligar cache sem medir o prefixo. Um timestamp, um id de sessão ou um bloco de contexto dinâmico no meio do system prompt limita o cache a uma fração. Você descobre tarde, ou pior, não descobre: o dashboard do provedor mostra “caching ativo” e todo mundo segue feliz.
- Otimizar a hipótese em vez da medição. O classificador de triviais teria sido construído e funcionaria. E não teria mudado a conta, porque a conta não era sobre as triviais.
O caminho chato que funcionou: instrumentar → medir → decompor → verificar a estabilidade byte a byte → mudar pouco código → instrumentar o efeito → só então ter opinião. O 99,8% não foi o argumento para ligar o cache; foi o fato que dispensou argumento.
O que fazer na segunda-feira
- Instrumente antes de otimizar. Marque no analytics a hipótese que você quer testar e meça o custo real por mensagem. Só depois decida se o classificador (ou o desvio, ou o modelo menor) precisa existir.
- Monte o payload real offline, com o mesmo cliente HTTP que produção usa, e decomponha por parte: definições de ferramentas, system prompt, mensagem do usuário. O tamanho de cada uma decide o resto.
- Prove a estabilidade byte a byte. Gere o payload de duas mensagens diferentes e compare o prefixo. “Parecido” não cacheia: um único byte diferente invalida tudo dali para frente.
- Marque um breakpoint só, no último bloco da última mensagem system. A ordem de renderização (tools → system → messages) faz ele cobrir também as ferramentas. Com piso de tamanho para não marcar payload abaixo do mínimo do provedor, e uma env var que desliga.
- Suba a instrumentação junto com a feature, nunca depois: tokens de cache como recorte da entrada (não somáveis a ela),
hit_pctno analytics, e o runbook escrito.hit_pctperto de zero por dois dias sem deploy é investigação, não paciência.
Agentes de IA que aguentam produção
Escrevo aqui sobre os agentes que eu mesmo opero: memória em Postgres, ferramentas registradas em código e limites que o prompt não pode contornar. O método e os números medidos vão junto com cada post.
Ver os posts sobre agentes →