Contar tokens e estimar custos ao chamar LLM em Java
1. Visão geral
Toda vez que você chama um LLM como GPT-4o, você paga por token, não por número de caracteres ou número de requisições. Se você não medir, a fatura do final do mês pode ser uma surpresa desagradável. Especialmente quando o prompt contém histórico de conversa longo ou documentos RAG.
Neste artigo, você vai contar tokens diretamente na JVM usando JTokkit, calcular custos com BigDecimal e bloquear requisições que excedem o orçamento. Por fim, você vai comparar a estimativa com o número real de tokens que o Spring AI retorna.
2. O que é token e por que contar
Token é a unidade em que o tokenizer do modelo divide o texto. Em inglês, um token em média tem cerca de 4 caracteres. Português com diacríticos costuma consumir muito mais tokens para o mesmo significado, porque caracteres como “ã”, “ç” aparecem menos nos dados de treinamento do tokenizer.
O custo de uma requisição tem duas partes, cada uma com preço diferente:
- Input tokens (prompt tokens): system prompt, histórico de chat, pergunta, documentos anexados.
- Output tokens (completion tokens): o que o modelo gera. O preço dessa parte costuma ser várias vezes mais alto que o input.
Existem dois momentos para contar:
- Antes de chamar: para estimar, truncar contexto, ou rejeitar requisições muito caras.
- Depois de chamar: para registrar o custo real, baseado nos dados que o provider retorna.
3. Contar tokens localmente com JTokkit
JTokkit é uma biblioteca Java pura que implementa as tabelas de codificação BPE como o tiktoken do OpenAI. A biblioteca não precisa de chamadas de rede.
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
Contar tokens para uma string:
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingRegistry;
import com.knuddels.jtokkit.api.EncodingType;
public class TokenCountDemo {
public static void main(String[] args) {
EncodingRegistry registry = Encodings.newDefaultEncodingRegistry();
Encoding encoding = registry.getEncoding(EncodingType.O200K_BASE);
String english = "Hello, world!";
String portuguese = "Olá, mundo! Seu pedido foi confirmado.";
System.out.println(english + " -> " + encoding.countTokens(english));
System.out.println(portuguese + " -> " + encoding.countTokens(portuguese));
}
}
Explicando cada parte:
Encodings.newDefaultEncodingRegistry()carrega as tabelas de codificação. Esse carregamento consome tempo e memória, então você deve criar apenas uma vez e reutilizar.O200K_BASEé a codificação dos modelos GPT-4o. Modelos mais antigos como GPT-4 e GPT-3.5 usamCL100K_BASE.countTokensretorna apenas o número. É mais rápido queencodeporque não precisa manter a lista de tokens.
O resultado com a sentença em inglês é o seguinte. O número para a sentença em português depende da codificação, mas será significativamente mais alto que o número de palavras.
Hello, world! -> 4
Olá, mundo! Seu pedido foi confirmado. -> (cerca de 15–20)
Se você quer escolher encoding pelo nome do modelo, use registry.getEncodingForModel(ModelType.GPT_4O).
Observação: O JTokkit é preciso apenas com o tokenizer do OpenAI. Claude, Gemini e Llama usam tokenizers diferentes. Para esses modelos, use JTokkit apenas para estimativas aproximadas e adicione uma margem de segurança de cerca de 10–20%.
3.1. Contar tokens para uma conversa
A API de chat não envia uma string simples. Ela envia uma lista de mensagens com role, e cada mensagem tem alguns tokens de formatação anexados. De acordo com a documentação do OpenAI para modelos de chat, cada mensagem consome cerca de 3 tokens extras. A resposta também é “iniciada” com 3 tokens extras.
import com.knuddels.jtokkit.api.Encoding;
import java.util.List;
public record ChatMessage(String role, String content) {}
public class ChatTokenCounter {
private static final int TOKENS_PER_MESSAGE = 3;
private static final int REPLY_PRIMING = 3;
private final Encoding encoding;
public ChatTokenCounter(Encoding encoding) {
this.encoding = encoding;
}
public int count(List<ChatMessage> messages) {
int total = REPLY_PRIMING;
for (ChatMessage message : messages) {
total += TOKENS_PER_MESSAGE
+ encoding.countTokens(message.role())
+ encoding.countTokens(message.content());
}
return total;
}
}
Esse é um número aproximado. Se a requisição tem tool/function definitions ou imagens, elas também são contadas no input. O JTokkit não pode contar essa parte diretamente. Por isso, você sempre precisa comparar com os dados reais (seção 6).
4. Configurar tabela de preços e calcular custos
Os preços dos modelos mudam frequentemente, então não coloque hardcode de preços no código. Coloque os preços em um arquivo de configuração. Os números abaixo são apenas exemplos, em USD por 1 milhão de tokens.
pricing:
models:
gpt-4o:
input-per-million: 2.50
output-per-million: 10.00
gpt-4o-mini:
input-per-million: 0.15
output-per-million: 0.60
Vincule a configuração a um record:
import java.math.BigDecimal;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "pricing")
public record PricingProperties(Map<String, ModelPrice> models) {
public record ModelPrice(BigDecimal inputPerMillion, BigDecimal outputPerMillion) {}
public ModelPrice priceOf(String model) {
ModelPrice price = models.get(model);
if (price == null) {
throw new IllegalArgumentException("Preço não configurado para o modelo: " + model);
}
return price;
}
}
Lembre-se de ativar o binding com @EnableConfigurationProperties(PricingProperties.class) ou @ConfigurationPropertiesScan na classe @SpringBootApplication. Se o nome do modelo contiver um ponto (por exemplo, gpt-4.1), escreva a chave no YAML como "[gpt-4.1]". Caso contrário, o Spring interpretará o ponto como um nível aninhado.
Agora vem o serviço de cálculo de custos:
import java.math.BigDecimal;
import java.math.RoundingMode;
import org.springframework.stereotype.Service;
@Service
public class CostEstimator {
private static final BigDecimal ONE_MILLION = BigDecimal.valueOf(1_000_000);
private final PricingProperties pricing;
public CostEstimator(PricingProperties pricing) {
this.pricing = pricing;
}
public BigDecimal estimate(String model, long inputTokens, long outputTokens) {
PricingProperties.ModelPrice price = pricing.priceOf(model);
BigDecimal inputCost = price.inputPerMillion()
.multiply(BigDecimal.valueOf(inputTokens));
BigDecimal outputCost = price.outputPerMillion()
.multiply(BigDecimal.valueOf(outputTokens));
return inputCost.add(outputCost)
.divide(ONE_MILLION, 8, RoundingMode.HALF_UP);
}
}
Alguns pontos a notar:
- Use
BigDecimalem vez dedouble. Uma requisição custa alguns milésimos de USD, e somar milhões de requisições comdoublecausará erros de arredondamento. - Multiplique primeiro, divida depois, para manter a precisão.
- Arredonde para 8 casas decimais. Isso é pequeno o suficiente para não perder as requisições baratas.
Exemplo com gpt-4o, 1.200 input tokens e 300 output tokens:
entrada : 1200 × 2.50 / 1_000_000 = 0.003
saída: 300 × 10.00 / 1_000_000 = 0.003
total : 0.00600000 USD
Você vê que output tem apenas 1/4 do número de tokens do input mas custa o mesmo. É por isso que você deve limitar o comprimento da resposta.
5. Bloquear requisições que excedem o orçamento antes de chamar
Antes de chamar, você não sabe quanto o modelo vai responder. Mas você conhece o limite superior: o parâmetro max_tokens. O pior custo é o input já contado mais max_tokens de output.
import java.math.BigDecimal;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class BudgetGuard {
private final ChatTokenCounter tokenCounter;
private final CostEstimator costEstimator;
public BudgetGuard(ChatTokenCounter tokenCounter, CostEstimator costEstimator) {
this.tokenCounter = tokenCounter;
this.costEstimator = costEstimator;
}
public BigDecimal checkWorstCase(String model, List<ChatMessage> messages,
int maxOutputTokens, BigDecimal limitUsd) {
int inputTokens = tokenCounter.count(messages);
BigDecimal worstCase = costEstimator.estimate(model, inputTokens, maxOutputTokens);
if (worstCase.compareTo(limitUsd) > 0) {
throw new IllegalStateException(
"Requisição estimada em %s USD, ultrapassa o orçamento de %s USD"
.formatted(worstCase.toPlainString(), limitUsd.toPlainString()));
}
return worstCase;
}
}
ChatTokenCounter precisa ser declarado como um bean, junto com um Encoding compartilhado:
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.EncodingType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class TokenizerConfig {
@Bean
public ChatTokenCounter chatTokenCounter() {
var encoding = Encodings.newDefaultEncodingRegistry()
.getEncoding(EncodingType.O200K_BASE);
return new ChatTokenCounter(encoding);
}
}
Você pode usar BudgetGuard de várias formas. Você pode rejeitar a requisição. Você também pode truncar o histórico de chat mais antigo até ter orçamento suficiente. Ou você pode mudar para um modelo mais barato como gpt-4o-mini.
6. Obter o número real de tokens do Spring AI
A estimativa é apenas para tomar decisões. Para calcular o custo real, use os dados que o provider retorna. Adicione Spring AI:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
</dependencies>
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o
spring.ai.openai.chat.options.max-tokens=500
Chame o modelo e leia Usage:
import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;
@Service
public class SupportAssistant {
private static final Logger log = LoggerFactory.getLogger(SupportAssistant.class);
private final ChatClient chatClient;
private final CostEstimator costEstimator;
public SupportAssistant(ChatClient.Builder builder, CostEstimator costEstimator) {
this.chatClient = builder.build();
this.costEstimator = costEstimator;
}
public String answer(String question) {
ChatResponse response = chatClient.prompt()
.user(question)
.call()
.chatResponse();
Usage usage = response.getMetadata().getUsage();
String model = response.getMetadata().getModel();
BigDecimal cost = costEstimator.estimate(
"gpt-4o", usage.getPromptTokens(), usage.getCompletionTokens());
log.info("model={} prompt={} completion={} custo={} USD",
model, usage.getPromptTokens(), usage.getCompletionTokens(),
cost.toPlainString());
return response.getResult().getOutput().getText();
}
}
A saída do log terá a seguinte forma:
model=gpt-4o-2024-08-06 prompt=412 completion=187 custo=0.00290000 USD
Observe que getModel() retorna o nome com a versão, por exemplo gpt-4o-2024-08-06. Por isso o exemplo usa a chave fixa "gpt-4o" para procurar o preço. Na prática, você deve normalizar o nome do modelo antes de procurar na tabela de preços.
Ao comparar o número real de prompt com o que você contou na seção 3.1, uma diferença de alguns tokens é normal. Se a diferença for grande, geralmente é porque o system prompt padrão, tool definitions ou advisors (como memory, RAG) adicionaram conteúdo que você não contou.
7. Testes para CostEstimator
A lógica de cálculo de custo não pode errar. Você deve escrever testes independentes, sem precisar do contexto do Spring:
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.EncodingType;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class CostEstimatorTest {
private final PricingProperties pricing = new PricingProperties(Map.of(
"gpt-4o", new PricingProperties.ModelPrice(
new BigDecimal("2.50"), new BigDecimal("10.00"))));
private final CostEstimator estimator = new CostEstimator(pricing);
@Test
void givenInputAndOutputTokens_whenEstimate_thenSumsBothParts() {
BigDecimal cost = estimator.estimate("gpt-4o", 1_200, 300);
assertThat(cost).isEqualByComparingTo("0.006");
}
@Test
void givenUnknownModel_whenEstimate_thenThrows() {
assertThatThrownBy(() -> estimator.estimate("unknown-model", 10, 10))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("unknown-model");
}
@Test
void givenVietnameseText_whenCount_thenUsesMoreTokensThanWords() {
var encoding = Encodings.newDefaultEncodingRegistry()
.getEncoding(EncodingType.O200K_BASE);
var counter = new ChatTokenCounter(encoding);
String content = "Seu pedido foi confirmado";
int tokens = counter.count(List.of(new ChatMessage("user", content)));
assertThat(tokens).isGreaterThan(content.split(" ").length);
}
}
O primeiro teste usa isEqualByComparingTo em vez de isEqualTo. A razão é que 0.00600000 e 0.006 têm scale diferentes, então equals do BigDecimal retornará false.
8. Erros comuns
- Criar
EncodingRegistryem cada requisição. Carregar as tabelas de codificação é bastante pesado. Crie uma vez e reutilize, poisEncodingé thread-safe. - Usar encoding incorreto. Se você contar GPT-4o com
CL100K_BASE, obterá números incorretos. Escolha encoding de acordo com o modelo. - Contar apenas input. Output geralmente custa 4 vezes ou mais que input. Ignorar isso é o erro maior.
- Esquecer tokens “ocultos”. Reasoning models (série o) geram reasoning tokens. Esses tokens não aparecem na resposta mas ainda são cobrados como output. Por outro lado, cached input tokens geralmente têm desconto. Leia
getNativeUsage()se precisar de detalhes. - Usar
doublepara dinheiro. Erros de arredondamento cumulativos farão o relatório de custos desviar da fatura. - Confiar absolutamente na estimativa. Estimativas são para bloquear e alertar. Registrar custos deve ser baseado no
Usagereal.
Observação: Você não precisa contar tokens antes de cada requisição. Se o prompt é curto, fixo e tem
max_tokens, o custo já está limitado. Nesse caso, basta registrarUsageapós chamar. Vale contar antes quando o contexto muda por usuário, como histórico de chat, RAG ou upload de arquivo.
9. Conclusão
Neste artigo, você aprendeu a contar tokens localmente com JTokkit, incluindo o overhead de cada mensagem de chat. Você calculou custos precisamente com BigDecimal usando preços em configuração. Você também usou max_tokens para limitar o pior custo antes de chamar o modelo.
O princípio-chave é: estimar para decidir, dados reais para cobrar. Combinando BudgetGuard antes da requisição com logging de Usage após a resposta, você terá dados suficientes para rastrear custos por modelo, por recurso ou por cliente.