Blog

Custo por conversa: atribuir a fatura de IA ao que gerou valor

A fatura do provedor de LLM chega como um número só. Ela diz quanto você gastou e não diz em quê: qual cliente, qual jornada, qual etapa do agente, qual resposta que resolveu e qual resposta que o cliente ignorou antes de pedir um humano. Enquanto a conta é pequena, ninguém sente falta desse detalhe. Quando ela cresce, o time descobre que não consegue responder a pergunta mais básica de qualquer decisão de custo: onde cortar sem cortar o que funciona. Sem atribuição, toda medida de economia vira aposta no escuro, e a mais comum delas, trocar por um modelo mais barato em tudo, costuma economizar na etapa que já era barata e degradar exatamente a que gerava valor. Este artigo mostra como sair do número único: qual é a unidade de custo que faz sentido em atendimento, como propagar um escopo de custo por toda a cadeia sem poluir cada função, o que fazer com o custo que não pertence a nenhuma conversa, como ratear o compartilhado sem inventar precisão, e como fechar o mês reconciliando o que você contabilizou com o que o provedor cobrou.

2026-07-28 / IA Aplicada / 14 min

01

A fatura única esconde a decisão que você precisa tomar

O painel do provedor mostra tokens por dia e às vezes por chave de API. Isso é suficiente para saber que gastou mais na terça, e insuficiente para qualquer decisão. A pergunta real nunca é "quanto gastamos", é "quanto custa resolver um caso de segunda via de boleto comparado com um caso de troca de produto, e qual dos dois vale a pena continuar automatizando". Um número agregado não responde isso porque mistura, dentro do mesmo total, conversas que terminaram em resolução com conversas que terminaram em transbordo para humano depois de queimar cinco chamadas de modelo. As duas consomem tokens. Só uma gerou valor.

O efeito prático dessa cegueira é que o esforço de otimização vai para o lugar errado. Sem atribuição, o instinto é olhar o que é grande em volume, e volume não é custo: mil classificações de intenção com um modelo pequeno e prompt curto podem custar menos que trinta conversas longas que carregam histórico inteiro a cada turno. Já vi time gastar um trimestre encurtando o system prompt de uma etapa que representava dois por cento da fatura, enquanto o retrieval mal calibrado enchia a janela de contexto com trechos redundantes e respondia por metade do gasto. Nenhum dos dois fatos era visível no painel do provedor, e os dois ficaram óbvios no primeiro relatório por conversa.

Pergunta de negócioA fatura agregada responde?O que é preciso registrar
Quanto custa resolver este tipo de caso?Não, mistura tipos de caso no mesmo totalCusto somado por conversa, com o tipo de jornada como atributo
Automatizar este fluxo se paga?Não, não separa conversa resolvida de transbordadaCusto por conversa cruzado com o desfecho da conversa
Qual etapa do agente é cara?Não, todas as chamadas caem no mesmo baldeCusto por etapa dentro do escopo da conversa
Este cliente grande é lucrativo?Só se cada tenant tiver a própria chave de APIIdentificador de tenant carregado em cada registro de uso
A mudança de ontem encareceu?Parcialmente, e sem isolar a causaVersão de prompt e de modelo gravadas junto com o uso

02

Escolher a unidade: conversa, jornada ou resolução

Antes de instrumentar qualquer coisa, é preciso decidir a que se atribui o custo, e essa escolha determina tudo o que vem depois. A requisição é a unidade mais fácil de medir e a menos útil de todas, porque ninguém toma decisão de produto sobre uma chamada isolada. A conversa é o primeiro nível com significado de negócio: tem começo, tem fim, tem um cliente do outro lado e tem um desfecho. A jornada é mais precisa quando uma conversa trata de vários assuntos, e a resolução é a unidade mais próxima do valor, mas exige que você saiba dizer se o caso foi realmente resolvido, o que nem todo sistema sabe.

A recomendação prática é começar pela conversa e enriquecer o registro com atributos que permitam agregar de outras formas depois. Se cada evento de uso carregar o identificador da conversa, o do tenant, o tipo de jornada detectado, a etapa do pipeline e o desfecho final, você consegue calcular custo por resolução sem reinstrumentar nada, apenas agrupando de outro jeito na consulta. O erro caro é o oposto: escolher direto a unidade mais sofisticada, descobrir que o dado de desfecho é pouco confiável e ficar sem nenhum número utilizável por três meses. Comece pela unidade que você consegue fechar com confiança e suba de granularidade quando o dado sustentar.

UnidadeFacilidade de medirDecisão que ela habilita
RequisiçãoTrivial, já vem na resposta da APIQuase nenhuma: serve para depurar uma chamada específica
ConversaFácil se houver um identificador estável de sessãoComparar tipos de caso e detectar conversa anômala
JornadaMédia, exige classificar o assunto do trechoDecidir qual fluxo automatizar ou desligar
ResoluçãoDifícil, depende de sinal confiável de desfechoCalcular custo por caso resolvido e comparar com o humano
TenantFácil, é um atributo carregado juntoPrecificar plano e identificar cliente que dá prejuízo

03

Propagar o escopo de custo sem poluir a assinatura de cada função

O problema técnico central é que a chamada ao modelo acontece em algum lugar fundo da pilha, e o identificador da conversa nasce lá em cima, no handler do webhook. Passar esse identificador como parâmetro por todas as camadas funciona e envenena o código: toda função no caminho ganha um argumento que não tem nada a ver com o que ela faz, e basta uma nova função esquecer de repassar para o custo virar não atribuído. A solução limpa em Node é o armazenamento de contexto assíncrono, que amarra um escopo à execução e o mantém disponível através de qualquer profundidade de await sem tocar em nenhuma assinatura intermediária.

Com o escopo disponível, o registro de custo deixa de ser responsabilidade de quem chama e passa a ser do cliente do provedor. Envolver a chamada num pequeno decorador que lê o uso da resposta, converte em dinheiro pela tabela de preços do modelo e credita no escopo ativo garante que nenhuma chamada nova nasça sem contabilidade. Esse é o ponto de estrangulamento certo: uma única função por onde todo o gasto passa. Se alguém adicionar uma etapa nova ao agente amanhã, ela é contabilizada de graça, porque usa o mesmo cliente. O detalhe que costuma escapar é que a conversão para dinheiro precisa da tabela de preços versionada por modelo, incluindo os preços diferentes de entrada, saída, escrita em cache e leitura de cache, que não são o mesmo número.

// cost-scope.js
// O escopo vive no contexto assincrono: nenhuma funcao intermediaria
// precisa receber conversationId como argumento para o custo ser atribuido.

import { AsyncLocalStorage } from 'node:async_hooks';

const storage = new AsyncLocalStorage();

// Preco por MILHAO de tokens. Entrada, saida, escrita e leitura de cache
// tem precos diferentes: tratar tudo como um numero so distorce o rateio.
const PRICING = {
  'claude-sonnet-5': { input: 3.0, output: 15.0, cacheWrite: 3.75, cacheRead: 0.3 },
  'claude-haiku-4-5': { input: 1.0, output: 5.0, cacheWrite: 1.25, cacheRead: 0.1 },
};

export function runInCostScope(attributes, fn) {
  const scope = { attributes, events: [] };
  return storage.run(scope, () => fn(scope));
}

export function currentScope() {
  return storage.getStore() || null;
}

// Preco de UM componente isolado: usado pelo relatorio para separar
// escrita de cache e economia de leitura do custo efetivo da jornada.
export function priceComponent(model, component, tokens) {
  const p = PRICING[model];
  if (!p) throw new Error('modelo sem preco cadastrado: ' + model);
  return (tokens * p[component]) / 1e6;
}

export function priceUsage(model, usage) {
  return (
    priceComponent(model, 'input', usage.inputTokens || 0) +
    priceComponent(model, 'output', usage.outputTokens || 0) +
    priceComponent(model, 'cacheWrite', usage.cacheWriteTokens || 0) +
    priceComponent(model, 'cacheRead', usage.cacheReadTokens || 0)
  );
}

// Credita um evento de uso no escopo ativo. Se nao houver escopo, o custo
// e real e precisa ir para o balde de nao atribuido, nunca ser descartado.
export function recordUsage({ model, usage, step, attempt = 1 }) {
  const scope = currentScope();
  const costUsd = priceUsage(model, usage);
  const event = { model, usage, step, attempt, costUsd };
  if (!scope) {
    unattributed.push(event);
    return event;
  }
  scope.events.push(event);
  return event;
}

export const unattributed = [];
// llm-client.js
// Ponto de estrangulamento: TODA chamada ao provedor passa por aqui, entao
// toda etapa nova do agente nasce contabilizada sem ninguem lembrar disso.

import { recordUsage } from './cost-scope.js';

export function createCostAwareClient(provider) {
  return {
    async complete({ model, messages, step, attempt = 1, ...rest }) {
      const response = await provider.messages.create({ model, messages, ...rest });
      const u = response.usage || {};

      recordUsage({
        model,
        step,
        attempt,
        usage: {
          inputTokens: u.input_tokens || 0,
          outputTokens: u.output_tokens || 0,
          cacheWriteTokens: u.cache_creation_input_tokens || 0,
          cacheReadTokens: u.cache_read_input_tokens || 0,
        },
      });

      return response;
    },
  };
}

// No handler do webhook, o escopo abre uma vez e cobre a cadeia inteira:
//
//   await runInCostScope(
//     { conversationId, tenantId, journey: 'segunda-via', channel: 'whatsapp' },
//     async (scope) => {
//       await runAgent(message);          // nenhuma assinatura mudou
//       await emitCostReport(scope);      // fecha e publica o total
//     },
//   );

04

O custo que não pertence a nenhuma conversa

Uma parte da fatura não nasce de nenhuma conversa e some da contabilidade se você não reservar um lugar para ela. A execução da bateria de eval no CI consome tokens. O reprocessamento de embeddings quando o corpus muda consome tokens. A tarefa noturna que resume conversas para a memória de longo prazo consome tokens. O teste manual de um engenheiro consome tokens. Nada disso pertence a um cliente, e tratar esse gasto como ruído produz um efeito perverso: a soma dos custos por conversa fica sistematicamente abaixo da fatura, e quando alguém percebe a diferença, a confiança no relatório inteiro cai.

A saída é ter categorias explícitas de custo indireto e obrigar toda origem de gasto a declarar a sua. O escopo de custo não precisa ser sempre uma conversa: pode ser um job, com o nome do job como atributo. O que não pode existir é chamada sem escopo nenhum, e por isso vale tratar o balde de não atribuído como um alarme, não como uma categoria aceitável. Ele deve tender a zero, e qualquer crescimento significa um caminho de código novo que escapou da instrumentação, exatamente o tipo de regressão silenciosa que só aparece quando a fatura chega. Uma verificação simples no fim de cada dia, comparando o total atribuído com o total apurado por modelo, transforma esse desvio em um sinal acionável em vez de uma surpresa mensal.

Categoria de gastoA que atribuirComo tratar no relatório
Conversa com clienteConversa, tenant e jornadaCusto direto: base de todo cálculo de custo por resolução
Eval e teste automatizadoJob de eval, com a versão testadaIndireto de engenharia: rateado por período, não por cliente
Reindexação e embeddingsJob de ingestão, com o corpusIndireto de plataforma: amortizado no período de uso do índice
Resumo noturno de memóriaJob em lote, com a lista de conversas tocadasPode ser rateado por conversa quando o benefício é rastreável
Exploração manualAmbiente de desenvolvimentoIndireto: útil separar por chave para não sujar produção

05

Ratear o compartilhado sem inventar precisão

Dois gastos reais desafiam a atribuição direta porque uma conversa paga e outra colhe. O primeiro é o cache de prompt: a primeira conversa que atravessa um prefixo novo paga a escrita do cache, mais cara que a entrada normal, e as conversas seguintes leem esse cache por uma fração do preço. Atribuir a escrita inteira a quem chegou primeiro faz o relatório dizer que aquela conversa foi absurdamente cara e que as seguintes foram baratas, quando na prática o custo é do conjunto. O segundo é a retentativa: a chamada que falhou por instabilidade do provedor consumiu tokens de entrada e não produziu resposta, e esse desperdício pertence à conversa em sentido contábil, mas não deveria contaminar a comparação entre tipos de jornada.

A regra que funciona é separar o que é atribuição do que é análise. Contabilmente, tudo que aconteceu dentro do escopo pertence ao escopo: a escrita de cache e a retentativa entram no custo daquela conversa, e é isso que faz a soma bater com a fatura. Analiticamente, o relatório expõe cada componente em uma coluna própria, para que a comparação entre jornadas use o custo efetivo, sem escrita de cache e sem retentativa, enquanto o total continua íntegro. Quem quiser um rateio mais justo do cache pode amortizar a escrita entre as leituras que ela habilitou dentro de uma janela, mas isso só vale a pena quando o cache é grande e a decisão em jogo depende dele. Na maioria dos casos, expor as colunas separadas já resolve, e evita a tentação de construir um modelo de rateio que ninguém consegue auditar.

// report.js
// Total contabil = tudo que caiu no escopo (fecha com a fatura).
// Custo efetivo = so o que a jornada realmente consumiu para responder
// (e a coluna certa para comparar tipos de caso entre si).

import { priceComponent } from './cost-scope.js';

// Desperdicio de retentativa e o custo das tentativas que NAO produziram a
// resposta. Marcar "attempt > 1" seria o contrario: a ultima tentativa e
// justamente a que respondeu. Por etapa, o evento vencedor fica no custo
// efetivo e os anteriores saem dele.
function winningAttempts(events) {
  const best = new Map();
  for (const event of events) {
    const current = best.get(event.step);
    if (current === undefined || event.attempt > current) best.set(event.step, event.attempt);
  }
  return best;
}

export function summarize(scope) {
  const byStep = new Map();
  const winners = winningAttempts(scope.events);
  let totalUsd = 0;
  let retryWasteUsd = 0;
  let cacheWriteUsd = 0;
  let cacheSavingsUsd = 0;

  for (const event of scope.events) {
    totalUsd += event.costUsd;

    if (event.attempt < winners.get(event.step)) retryWasteUsd += event.costUsd;

    const cacheWrite = event.usage.cacheWriteTokens || 0;
    const cacheRead = event.usage.cacheReadTokens || 0;
    if (cacheWrite) cacheWriteUsd += priceComponent(event.model, 'cacheWrite', cacheWrite);
    // Economia = o que aquelas leituras teriam custado como entrada normal.
    if (cacheRead) {
      cacheSavingsUsd +=
        priceComponent(event.model, 'input', cacheRead) -
        priceComponent(event.model, 'cacheRead', cacheRead);
    }

    byStep.set(event.step, (byStep.get(event.step) || 0) + event.costUsd);
  }

  return {
    ...scope.attributes,
    totalUsd,                                  // fecha com a fatura
    effectiveUsd: totalUsd - retryWasteUsd - cacheWriteUsd, // compara jornadas
    retryWasteUsd,
    cacheWriteUsd,
    cacheSavingsUsd,
    calls: scope.events.length,
    byStep: Object.fromEntries(byStep),
  };
}
Onde o custo nasce e onde ele e atribuido

  webhook recebe mensagem
        |
        v
  [runInCostScope] abre escopo { conversationId, tenantId, journey }
        |
        +--> classificar intencao      -> recordUsage(step: 'classify')
        |
        +--> recuperar contexto (RAG)  -> recordUsage(step: 'embed-query')
        |
        +--> gerar resposta            -> recordUsage(step: 'answer', attempt: 1)
        |        |                        429 do provedor: nao respondeu
        |        |                        (entra no total, sai do efetivo)
        |        \-- nova tentativa    -> recordUsage(step: 'answer', attempt: 2)
        |                                  respondeu: fica no custo efetivo
        v
  [summarize] fecha o escopo
        |
        +--> custo total       -> reconciliacao com a fatura
        +--> custo efetivo     -> comparacao entre jornadas
        +--> custo por etapa   -> onde otimizar primeiro

  fora do escopo de conversa:
    job de eval, reindexacao, resumo noturno -> escopo proprio, custo indireto
    chamada sem escopo nenhum                -> balde NAO ATRIBUIDO (alarme)

06

Fechar o mês: reconciliar o contabilizado com o cobrado

Um sistema de atribuição só é confiável se alguém verificar que ele bate com a realidade, e a realidade é a fatura. A reconciliação é simples de descrever e reveladora na prática: some tudo que você contabilizou no período, por modelo, e compare com o que o provedor cobrou pelo mesmo modelo no mesmo período. A diferença nunca é exatamente zero, porque há arredondamento, fuso horário de fechamento e chamadas em voo na virada, mas ela deve ser pequena e estável. Uma divergência que cresce de um por cento para oito por cento em um mês não é ruído: é um caminho de código novo chamando o provedor por fora do cliente instrumentado, ou uma tabela de preços desatualizada depois de um reajuste.

  1. Congele o período: defina a janela pelo mesmo fuso que o provedor usa no fechamento, senão a diferença de horário vira divergência falsa todo mês.
  2. Agregue por modelo, não só o total: um erro de preço em um modelo específico se dilui no total e fica óbvio quando quebrado por modelo.
  3. Compare também o volume de tokens, não apenas o dinheiro: token igual com dinheiro diferente aponta tabela de preços errada, e token diferente aponta instrumentação faltando.
  4. Verifique o balde de não atribuído: ele explica parte da diferença e deve tender a zero, com qualquer crescimento tratado como incidente.
  5. Investigue por etapa antes de por cliente: a etapa nova que ninguém instrumentou aparece como buraco em uma etapa específica, não espalhada.
  6. Versione a tabela de preços com data de vigência: reajuste do provedor no meio do mês exige preço por período, senão a reconciliação nunca fecha de novo.

Com a reconciliação estável, o relatório por conversa deixa de ser um gráfico bonito e vira base de decisão. É nesse ponto que perguntas antes impossíveis ficam triviais: a jornada de segunda via custa oito centavos e resolve sozinha em noventa por cento dos casos, enquanto a de troca de produto custa quarenta e dois centavos e transborda em metade, o que dá um custo por resolução quase dez vezes maior. Com esse número na mão, a discussão sai do achismo sobre qual modelo é caro e vai para onde deveria estar desde o começo: reduzir o contexto da jornada cara, mover a classificação para um modelo menor, ou simplesmente aceitar que aquele fluxo específico é melhor atendido por um humano e desligar a automação dele sem culpa.

FAQ

Perguntas frequentes

Qual é a unidade certa para atribuir custo de IA em atendimento?

A conversa é o melhor ponto de partida porque tem começo, fim, um cliente identificável e um desfecho, o que já permite comparar tipos de caso e detectar conversas anômalas. A requisição é fácil de medir e quase inútil para decisão, porque ninguém decide produto olhando uma chamada isolada. A resolução é a unidade mais próxima do valor, já que custo por caso resolvido é o número que se compara com o custo do atendimento humano, mas ela depende de um sinal confiável de desfecho que muitos sistemas não têm. O caminho prático é contabilizar por conversa e enriquecer cada evento de uso com tenant, tipo de jornada, etapa do pipeline e desfecho final. Com esses atributos gravados, você calcula custo por resolução, por tenant ou por jornada apenas agrupando de outro jeito na consulta, sem reinstrumentar nada quando a pergunta mudar.

Como atribuir o custo do cache de prompt e das retentativas?

Separando atribuição de análise. Contabilmente, tudo que aconteceu dentro do escopo pertence ao escopo: a escrita de cache paga pela primeira conversa que atravessou o prefixo e os tokens gastos na tentativa que falhou entram no custo daquela conversa, e é justamente isso que faz a soma dos custos fechar com a fatura do provedor. Analiticamente, o relatório precisa expor cada componente em coluna própria, porque comparar jornadas usando o total distorce: a conversa que por acaso chegou primeiro no cache novo aparece absurdamente cara e as seguintes aparecem baratas, sem que nenhuma das duas tenha consumido mais recurso de fato. A coluna de custo efetivo, que exclui escrita de cache e desperdício de retentativa, é a que serve para comparar tipos de caso entre si. Rateio elaborado do cache entre as leituras que ele habilitou só compensa quando o cache é grande e alguma decisão concreta depende dele.

Como saber se a atribuição está correta?

Reconciliando com a fatura todo período. Some tudo que foi contabilizado, quebrado por modelo, e compare com o que o provedor cobrou por aquele modelo na mesma janela, usando o fuso horário de fechamento dele para não criar divergência falsa. A diferença nunca será zero por causa de arredondamento e chamadas em voo na virada, mas precisa ser pequena e estável ao longo dos meses. Compare também o volume de tokens e não apenas o valor em dinheiro, porque isso separa as duas causas possíveis: mesmo volume com dinheiro diferente aponta tabela de preços desatualizada depois de um reajuste, enquanto volume diferente aponta um caminho de código chamando o provedor por fora do cliente instrumentado. Some a isso um balde explícito de custo não atribuído, tratado como alarme e não como categoria aceitável, e você detecta a etapa nova sem instrumentação no dia seguinte, em vez de descobrir quando a fatura chegar.

Sem atribuição, toda economia é aposta

A fatura agregada informa quanto você gastou e esconde a única coisa que importa: o que gerou valor e o que apenas consumiu tokens. Escolher a conversa como unidade, propagar o escopo de custo pelo contexto assíncrono para que nenhuma chamada nasça sem contabilidade, dar lugar explícito ao custo indireto de eval e reindexação, separar o total contábil do custo efetivo ao tratar cache e retentativa, e fechar o ciclo reconciliando com a fatura transforma custo de IA de mistério mensal em métrica de produto. Posso implementar essa camada de atribuição no seu sistema com LLM, da instrumentação no cliente ao relatório de custo por resolução, para que a decisão de onde cortar pare de ser aposta e passe a ser leitura de número.