Pular para o conteúdo
upgbp

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.

10 min de leitura

Ambiente testado

  • Node 22
  • Postgres 15
  • API de LLM compatível com OpenAI
Neste artigo (8)

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

painel de faturamento
mês anterior     R$   1.180
mês atual        R$   4.020

tokens de entrada    128.400.000
tokens de saída        6.900.000

O 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:

prisma/schema.prisma
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])
}
src/lib/llm.ts
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:

diagnostico/custo-por-funcionalidade.sql
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;
psql · resultado
 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.75

A 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.

  1. 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.ts
    export 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.

  2. 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.

  3. 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.ts
    const 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.mjs
    const 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}`);
    }
    node scripts/avaliar-classificacao.mjs
    modelo-pequeno  47/50
    modelo-medio    48/50

    Um acerto a menos em cinquenta, por uma fração do preço. Sem essa medição, a troca seria fé.

  4. 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 fontes

    Vale acompanhar quantas respostas batem no teto. Muitas significa que o limite está cortando conteúdo útil; quase nenhuma significa que ele está frouxo.

  5. 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.ts
    import { 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á.

  6. 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:

psql · resultado
 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.31

Custo 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