Cortando o custo de API de LLM: medir, rotear e cachear
A fatura triplicou sem o uso triplicar. Como atribuir custo por funcionalidade e quais alavancas realmente rendem, na ordem de retorno.
Ambiente testado
- Node 22
- Postgres 15
- API de LLM compatível com OpenAI
Contexto: o que estava rodando
Um produto com três funcionalidades apoiadas em modelo de linguagem: um resumo automático de tickets, uma busca em linguagem natural sobre a documentação interna, e uma classificação de urgência.
A fatura do mês foi 3,4 vezes maior que a do anterior. O número de usuários tinha crescido 40%.
O sintoma
mês anterior R$ 1.180
mês atual R$ 4.020
tokens de entrada 128.400.000
tokens de saída 6.900.000O painel do fornecedor mostra o total. Ele não mostra qual funcionalidade gastou. Sem essa atribuição, qualquer otimização é chute.
Diagnóstico
Medir antes de otimizar
O primeiro trabalho não foi cortar nada — foi registrar cada chamada com um rótulo:
model UsoLlm {
id String @id @default(cuid())
funcionalidade String // 'resumo' | 'busca-docs' | 'classificacao'
modelo String
tokensEntrada Int
tokensSaida Int
tokensCache Int @default(0)
latenciaMs Int
usuarioId String?
criadoEm DateTime @default(now())
@@index([funcionalidade, criadoEm])
}export async function chamar(funcionalidade: string, corpo: CorpoChamada) {
const inicio = performance.now();
const resposta = await fetch(/* ... */).then((r) => r.json());
const uso = resposta.usage ?? {};
// Registro assíncrono: medir não pode atrasar a resposta ao usuário.
void prisma.usoLlm.create({
data: {
funcionalidade,
modelo: corpo.model,
tokensEntrada: uso.prompt_tokens ?? 0,
tokensSaida: uso.completion_tokens ?? 0,
tokensCache: uso.prompt_tokens_details?.cached_tokens ?? 0,
latenciaMs: Math.round(performance.now() - inicio),
},
}).catch(console.error);
return resposta;
}Uma semana depois, a consulta que responde tudo:
select
funcionalidade,
count(*) as chamadas,
sum(tokens_entrada) as entrada,
sum(tokens_saida) as saida,
round(avg(tokens_entrada)) as entrada_media,
-- Ajuste os preços por milhão para o seu contrato.
round((sum(tokens_entrada) * 0.90 + sum(tokens_saida) * 3.60) / 1e6, 2) as custo
from uso_llm
where criado_em > now() - interval '7 days'
group by funcionalidade
order by custo desc; funcionalidade | chamadas | entrada | saida | entrada_media | custo
----------------+----------+-------------+-----------+---------------+-------
busca-docs | 9.412 | 104.220.000 | 980.000 | 11.073 | 97.34
resumo | 14.880 | 18.400.000 | 4.100.000 | 1.236 | 31.32
classificacao | 118.200 | 5.780.000 | 1.820.000 | 49 | 11.75A busca na documentação — a funcionalidade menos usada — respondia por 69% do custo, com média de 11 mil tokens de entrada por chamada.
O motivo do gasto
O código mandava a documentação interna inteira em toda pergunta:
const prompt = `
Você é um assistente da documentação interna.
DOCUMENTAÇÃO:
${await lerToda()} // ~10.500 tokens, idênticos em toda chamada
PERGUNTA: ${pergunta}
`;
Duas coisas erradas ao mesmo tempo: mandar o que não é relevante, e mandar de novo o que já foi mandado.
A solução
Na ordem de retorno, do maior para o menor.
-
Mandar só o pedaço relevante.
Esta foi a alavanca principal. Em vez do documento inteiro, uma busca recupera os trechos que importam:
src/lib/busca-docs.tsexport async function responder(pergunta: string) { // Busca por similaridade devolve os cinco trechos mais próximos: // cerca de 1.400 tokens no lugar de 10.500. const trechos = await buscarTrechos(pergunta, 5); const contexto = trechos .map((t) => `[${t.titulo}]\n${t.conteudo}`) .join('\n\n---\n\n'); return chamar('busca-docs', { model: 'modelo-medio', max_tokens: 500, messages: [ { role: 'system', content: INSTRUCAO_FIXA }, { role: 'user', content: `${contexto}\n\nPERGUNTA: ${pergunta}` }, ], }); }Redução de 87% na entrada dessa funcionalidade. E a qualidade melhorou: com menos ruído, a resposta ficou mais precisa.
-
Colocar o que é estável no começo, para o cache pegar.
Provedores de LLM cacheiam prefixos idênticos de prompt e cobram bem menos pela parte cacheada. A regra é uma só: o que não muda vem primeiro; o que muda vem por último.
src/lib/prompts.ts// Constante de módulo: idêntica byte a byte em toda chamada, que é // exatamente a condição para o prefixo ser reaproveitado. export const INSTRUCAO_FIXA = `Você é um assistente da documentação interna. Responda apenas com base no contexto fornecido. Se a resposta não estiver no contexto, diga que não sabe. Formato: um parágrafo, seguido dos títulos das fontes usadas.`;Isso quebra se você interpolar qualquer coisa variável no começo — data e hora, nome do usuário, um identificador. Um único caractere diferente invalida o prefixo inteiro.
O desconto e o tamanho mínimo variam por provedor. Confira medindo o campo de tokens cacheados na resposta, não confiando na documentação.
-
Rotear por dificuldade, em vez de usar o melhor modelo em tudo.
A classificação de urgência — 118 mil chamadas de 49 tokens — não precisa do modelo mais caro. Ela precisa devolver uma de três palavras.
src/lib/roteamento.tsconst MODELO_POR_TAREFA = { classificacao: 'modelo-pequeno', // rótulo de um conjunto fechado resumo: 'modelo-medio', // texto curto, tolerante 'busca-docs': 'modelo-medio', 'redacao-cliente': 'modelo-grande', // vai direto ao usuário final } as const;A troca só é legítima com verificação. Um conjunto de cinquenta exemplos rotulados à mão responde se o modelo menor dá conta:
scripts/avaliar-classificacao.mjsconst exemplos = JSON.parse(await readFile('avaliacao/urgencia.json', 'utf8')); for (const modelo of ['modelo-pequeno', 'modelo-medio']) { let acertos = 0; for (const { texto, esperado } of exemplos) { const obtido = (await classificar(texto, modelo)).trim().toLowerCase(); if (obtido === esperado) acertos++; } console.log(modelo, `${acertos}/${exemplos.length}`); }modelo-pequeno 47/50 modelo-medio 48/50Um acerto a menos em cinquenta, por uma fração do preço. Sem essa medição, a troca seria fé.
-
Limitar a saída.
Tokens de saída custam várias vezes mais que os de entrada. Sem
max_tokens, o modelo escreve até se satisfazer:max_tokens: 500, // resposta de busca: um parágrafo e as fontesVale acompanhar quantas respostas batem no teto. Muitas significa que o limite está cortando conteúdo útil; quase nenhuma significa que ele está frouxo.
-
Cachear a resposta inteira quando a pergunta se repete.
Perguntas populares chegam muitas vezes. A chamada mais barata é a que não acontece:
src/lib/cache-llm.tsimport { createHash } from 'node:crypto'; const chave = (modelo: string, prompt: string) => createHash('sha256').update(`${modelo} ${prompt}`).digest('hex'); export async function comCache( modelo: string, prompt: string, executar: () => Promise<string>, validadeHoras = 24, ) { const hash = chave(modelo, prompt); const guardado = await prisma.cacheLlm.findUnique({ where: { hash } }); if (guardado && guardado.expiraEm > new Date()) return guardado.resposta; const resposta = await executar(); const expiraEm = new Date(Date.now() + validadeHoras * 3_600_000); await prisma.cacheLlm.upsert({ where: { hash }, create: { hash, resposta, expiraEm }, update: { resposta, expiraEm }, }); return resposta; }A validade importa: se a documentação muda, respostas guardadas ficam erradas. Invalidar por versão do conteúdo é mais correto que por tempo, quando dá.
-
Agrupar o que é processado em lote.
A classificação rodava um ticket por chamada. Dez por chamada mandam a instrução do sistema uma vez em vez de dez — e a instrução era a maior parte daqueles 49 tokens.
Como confirmar que resolveu
A mesma consulta, um mês depois:
funcionalidade | chamadas | entrada | entrada_media | cacheado | custo
----------------+----------+------------+---------------+----------+-------
busca-docs | 10.240 | 14.100.000 | 1.377 | 71,4% | 14.82
resumo | 16.100 | 19.900.000 | 1.236 | 68,1% | 22.05
classificacao | 121.800 | 1.940.000 | 16 | 44,0% | 3.31Custo semanal de R$ 140 para R$ 40, com mais chamadas do que antes.
Confirme que o cache de prefixo está pegando:
select
funcionalidade,
round(100.0 * sum(tokens_cache) / nullif(sum(tokens_entrada), 0), 1) as pct_cacheado
from uso_llm
where criado_em > now() - interval '1 day'
group by funcionalidade;
Zero por cento numa funcionalidade com instrução fixa longa significa que algo variável entrou no começo do prompt.
Não perca qualidade sem perceber. Rode o conjunto de avaliação depois de cada mudança de modelo ou de prompt. Economia que degrada a resposta é prejuízo com outro nome.
Estabeleça um teto e um alerta. Uma consulta diária comparando o gasto com a média dos sete dias anteriores avisa antes da fatura.
Armadilhas que sobram depois disso
Streaming não reduz custo. Ele melhora a latência percebida — o usuário vê a resposta se formando. Os tokens cobrados são exatamente os mesmos. É a confusão mais comum neste assunto.
Cachear resposta personalizada vaza dados. Se o prompt contém informação de um usuário, a chave precisa incluir o identificador dele. Sem isso, o próximo usuário com pergunta parecida recebe a resposta do anterior.
Prompt curto demais aumenta o custo. Instrução vaga produz resposta ruim, que o usuário refaz. Duas chamadas medianas custam mais que uma boa.
Contexto grande também é mais lento. O ganho de recuperar só o trecho relevante aparece na latência antes de aparecer na fatura — e é o que o usuário percebe.
Preço muda. Toda tabela de custo em artigo envelhece. Guarde os valores numa configuração, não espalhados pelas consultas, e revise quando o contrato mudar.
Continue por aqui
APIs & Resiliência
429 em API de IA: fila com concorrência e orçamento de tokens
Promise.all sobre quinhentos itens não é paralelismo, é um ataque. Como respeitar os dois limites que essas APIs impõem sem serializar tudo.
APIs & Resiliência
Fila no Postgres com SKIP LOCKED, ou serviço de fila: qual escolher
Dois workers pegando a mesma tarefa é o defeito clássico de fila caseira. A cláusula que resolve, e quando vale trocar o banco por um serviço.
APIs & Resiliência
Cron em serverless: Vercel Cron, pg_cron ou Cloudflare Workers
As três opções resolvem agendamento de formas incompatíveis. O critério de escolha, e os quatro erros que aparecem em qualquer uma delas.