Contar tokens y estimar costos al llamar a LLM en Java
1. Descripción general
Cada vez que llamas un LLM como GPT-4o, pagas por token, no por número de caracteres ni por número de solicitudes. Si no lo mides, la factura a fin de mes puede sorprenderte. Especialmente cuando el prompt contiene un historial de conversación largo o documentos RAG.
En este artículo, contarás tokens directamente en la JVM usando JTokkit, calcularás costos con BigDecimal y bloquearás solicitudes que excedan el presupuesto. Finalmente, reconciliarás tu estimación con el número real de tokens que Spring AI devuelve.
2. Token es qué y por qué debes contarlos
Token es la unidad en la que el tokenizer del modelo divide el texto. Para el inglés, un token promedio es alrededor de 4 caracteres. El vietnamita con diacríticos suele consumir más tokens para el mismo significado, porque caracteres como “ệ”, “ữ” aparecen poco en los datos de entrenamiento del tokenizer.
El costo de una solicitud tiene dos partes, con precios diferentes:
- Tokens de entrada (prompt tokens): system prompt, historial de chat, pregunta, documentos adjuntos.
- Tokens de salida (completion tokens): la parte que genera el modelo. El precio unitario de esta parte suele ser varias veces más alto que el de entrada.
Hay dos momentos en los que necesitas contar:
- Antes de llamar: para estimar, acortar contexto, o rechazar solicitudes demasiado costosas.
- Después de llamar: para registrar el costo real, basándose en los datos que devuelve el proveedor.
3. Contar tokens localmente con JTokkit
JTokkit es una librería Java pura que implementa las tablas de codificación BPE igual que tiktoken de OpenAI. La librería no requiere llamadas de red.
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
Contar tokens para una cadena:
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 vietnamese = "¡Hola, mundo! Tu pedido ha sido confirmado.";
System.out.println(english + " -> " + encoding.countTokens(english));
System.out.println(vietnamese + " -> " + encoding.countTokens(vietnamese));
}
}
Explicación de cada parte:
Encodings.newDefaultEncodingRegistry()carga las tablas de codificación. Esta carga consume tiempo y memoria, por lo que debes crear solo una vez y reutilizarla.O200K_BASEes la codificación para la familia GPT-4o. Modelos más antiguos como GPT-4 y GPT-3.5 usanCL100K_BASE.countTokenssolo devuelve la cantidad. Es más rápido queencodeporque no necesita mantener la lista de tokens.
El resultado para la oración en inglés es el siguiente. Los números de la oración traducida dependen de la codificación, pero serán considerablemente más altos comparados con el número de palabras.
Hello, world! -> 4
¡Hola, mundo! Tu pedido ha sido confirmado. -> (aproximadamente 15–20)
Si quieres elegir la codificación según el nombre del modelo, usa registry.getEncodingForModel(ModelType.GPT_4O).
Nota: JTokkit es exacto solo para el tokenizer de OpenAI. Claude, Gemini o Llama usan tokenizers diferentes. Para esos modelos, usa JTokkit solo para estimaciones aproximadas y debes añadir un margen de seguridad de alrededor del 10–20%.
3.1. Contar tokens para una conversación
La Chat API no envía una cadena única. Envía una lista de mensajes con rol, y cada mensaje tiene algunos tokens de formato asociados. Según lo que describe OpenAI para los modelos de chat, cada mensaje consume aproximadamente 3 tokens adicionales. La respuesta también se “ceba” con 3 tokens adicionales.
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;
}
}
Estos son números aproximados. Si la solicitud tiene tool/function definitions o imágenes, también se cuentan en la entrada. JTokkit no puede contar esa parte directamente. Por lo tanto, siempre necesitas reconciliar con los datos reales (sección 6).
4. Configurar tabla de precios y calcular costos
El precio del modelo cambia con frecuencia, así que no hard-codes los precios en el código. Extrae los precios a un archivo de configuración. Los números a continuación son solo ejemplos, calculados en USD por 1 millón 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
Vincula la configuración a un 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("Precio no configurado para el modelo: " + model);
}
return price;
}
}
Recuerda habilitar el binding con @EnableConfigurationProperties(PricingProperties.class) o @ConfigurationPropertiesScan en la clase @SpringBootApplication. Si el nombre del modelo contiene un punto (por ejemplo, gpt-4.1), escribe la clave en YAML como "[gpt-4.1]". De lo contrario, Spring interpretará el punto como un nivel de anidamiento.
Ahora está el servicio que calcula el costo:
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);
}
}
Algunos puntos a tener en cuenta:
- Usa
BigDecimalen lugar dedouble. Una solicitud cuesta unos pocos milésimos de USD, y al sumar millones de solicitudes condoublegenerarás errores de redondeo. - Multiplica primero, divide después, para mantener la precisión.
- Redondea a 8 decimales. Esto es lo suficientemente pequeño para no perder las solicitudes baratas.
Ejemplo con gpt-4o, 1.200 tokens de entrada y 300 tokens de salida:
entrada : 1200 × 2.50 / 1_000_000 = 0.003
salida : 300 × 10.00 / 1_000_000 = 0.003
total : 0.00600000 USD
Verás que la salida es solo 1/4 del número de tokens de entrada pero cuesta lo mismo. Esa es la razón por la que debes limitar la longitud de la respuesta.
5. Bloquear solicitudes que excedan presupuesto antes de llamar
Antes de llamar, no sabes cuán larga será la respuesta del modelo. Pero conoces el límite superior: el parámetro max_tokens. El costo del peor caso es el input ya contado más max_tokens de salida.
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(
"La solicitud estimada %s USD, excede el presupuesto %s USD"
.formatted(worstCase.toPlainString(), limitUsd.toPlainString()));
}
return worstCase;
}
}
ChatTokenCounter necesita declararse como bean, junto con una Encoding compartida:
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);
}
}
Puedes usar BudgetGuard de varias maneras. Puedes rechazar la solicitud. También puedes truncar el historial de chat más antiguo hasta tener presupuesto suficiente. O puedes cambiar a un modelo más barato como gpt-4o-mini.
6. Obtener el número real de tokens desde Spring AI
La estimación es solo para tomar decisiones. Para calcular el costo real, debes usar los datos que devuelve el proveedor. Añade 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
Llama al modelo y lee 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={} cost={} USD",
model, usage.getPromptTokens(), usage.getCompletionTokens(),
cost.toPlainString());
return response.getResult().getOutput().getText();
}
}
La salida del registro será así:
model=gpt-4o-2024-08-06 prompt=412 completion=187 cost=0.00290000 USD
Ten en cuenta que getModel() devuelve el nombre con la versión, por ejemplo, gpt-4o-2024-08-06. Por lo tanto, el ejemplo usa una clave fija "gpt-4o" para buscar el precio. En la práctica, debes normalizar el nombre del modelo antes de consultar la tabla de precios.
Cuando compares el número de prompt real con el que contaste en la sección 3.1, una diferencia de unos pocos tokens es normal. Si la diferencia es grande, generalmente es porque el system prompt predeterminado, tool definitions o advisor (por ejemplo, memory, RAG) han añadido contenido que no contaste.
7. Probar CostEstimator
La lógica de cálculo de costos es donde no se pueden permitir errores. Debes escribir pruebas independientes sin necesidad del contexto de 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 = "Tu pedido ha sido confirmado";
int tokens = counter.count(List.of(new ChatMessage("user", content)));
assertThat(tokens).isGreaterThan(content.split(" ").length);
}
}
La primera prueba usa isEqualByComparingTo en lugar de isEqualTo. La razón es que 0.00600000 y 0.006 tienen diferentes escales, por lo que el método equals de BigDecimal devolvería false.
8. Errores comunes
- Crear
EncodingRegistryen cada solicitud. Cargar las tablas de codificación es bastante pesado. Crea una sola vez y reutiliza, porqueEncodinges thread-safe. - Usar la codificación incorrecta. Si cuentas GPT-4o usando
CL100K_BASE, obtendrás números incorrectos. Elige la codificación según el modelo. - Contar solo la entrada. La salida suele costar 4 veces más que la entrada o más. Omitir esto es el error más grande.
- Olvidar tokens “ocultos”. Los reasoning models (familia o-series) generan reasoning tokens. Estos tokens no aparecen en la respuesta pero se cobran como output. Por el contrario, los cached input tokens generalmente se descuentan. Lee
getNativeUsage()si necesitas más detalles. - Usar
doublepara dinero. Los errores acumulativos harán que tu reporte de costos difiera de la factura. - Confiar ciegamente en la estimación. La estimación es para bloquear y advertir. El registro de costos debe basarse en el
Usagereal.
Nota: No necesitas contar tokens con anticipación para cada solicitud. Si el prompt es corto, fijo y ya tiene
max_tokens, el costo ya está limitado. En ese caso, solo necesitas registrar elUsagedespués de la llamada. Contar con anticipación vale la pena cuando el contexto cambia según el usuario, como el historial de chat, RAG o carga de archivos.
9. Conclusión
En este artículo, has contado tokens localmente usando JTokkit, incluida la sobrecarga de cada mensaje de chat. Has calculado el costo con precisión usando BigDecimal con la tabla de precios en la configuración. También has usado max_tokens para limitar el costo del peor caso antes de llamar al modelo.
El principio fundamental es: estima para tomar decisiones, usa datos reales para calcular costos. Cuando combinas BudgetGuard antes de la solicitud con el registro de Usage después de la respuesta, tendrás datos suficientes para rastrear costos por modelo, por característica o por cliente.