Compter les tokens et estimer les coûts lors d'appels LLM en Java
1. Aperçu
Chaque fois que vous appelez un LLM comme GPT-4o, vous payez par tokens, non par nombre de caractères ou nombre de requêtes. Si vous ne mesurez pas, votre facture de fin de mois risque de vous surprendre. Surtout quand le prompt contient un long historique de conversation ou des documents RAG.
Dans cet article, vous allez compter les tokens directement dans la JVM avec JTokkit, calculer les coûts avec BigDecimal et bloquer les requêtes qui dépassent le budget. Enfin, vous allez comparer votre estimation avec le nombre de tokens réel que Spring AI retourne.
2. Qu’est-ce qu’un token et pourquoi faut-il les compter
Un token est l’unité par laquelle le tokenizer du modèle découpe le texte. Pour l’anglais, un token représente en moyenne environ 4 caractères. Le vietnamien avec accents consomme plus de tokens pour exprimer la même idée, car les caractères comme « ệ » ou « ữ » n’apparaissent rarement dans les données d’entraînement du tokenizer.
Le coût d’une requête comprend deux parties, facturées à des prix différents :
- Input tokens (prompt tokens) : system prompt, historique de chat, question, documents joints.
- Output tokens (completion tokens) : la partie que le modèle génère. Le prix au token est généralement beaucoup plus élevé que pour l’input.
Il y a deux moments où il faut compter les tokens :
- Avant d’appeler : pour estimer, réduire le contexte, ou rejeter une requête trop coûteuse.
- Après d’appeler : pour enregistrer le coût réel, en fonction des données que le fournisseur retourne.
3. Compter les tokens localement avec JTokkit
JTokkit est une pure bibliothèque Java qui implémente les tables de codage BPE de tiktoken d’OpenAI. La bibliothèque n’a pas besoin d’appels réseau.
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
Compter les tokens pour une chaîne :
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 = "Xin chào, thế giới! Đơn hàng của bạn đã được xác nhận.";
System.out.println(english + " -> " + encoding.countTokens(english));
System.out.println(vietnamese + " -> " + encoding.countTokens(vietnamese));
}
}
Explication de chaque partie :
Encodings.newDefaultEncodingRegistry()charge les tables de codage au démarrage. Ce chargement consomme du temps et de la mémoire, donc vous devriez créer une seule instance et la réutiliser.O200K_BASEest l’encoding pour la famille GPT-4o. Les anciens modèles comme GPT-4 et GPT-3.5 utilisentCL100K_BASE.countTokensretourne seulement le nombre. Il est plus rapide queencodecar il ne stocke pas la liste des tokens.
Les résultats avec la phrase anglaise sont comme suit. Le nombre pour la phrase vietnamienne dépend de l’encoding, mais sera considérablement plus élevé que le nombre de mots.
Hello, world! -> 4
Xin chào, thế giới! Đơn hàng của bạn đã được xác nhận. -> (environ 15–20)
Si vous voulez sélectionner l’encoding en fonction du nom du modèle, vous pouvez utiliser registry.getEncodingForModel(ModelType.GPT_4O).
Remarque : JTokkit ne fonctionne précisément qu’avec le tokenizer d’OpenAI. Claude, Gemini et Llama utilisent des tokenizers différents. Pour ces modèles, vous ne devriez utiliser JTokkit que pour une estimation approximative, et vous devriez ajouter une marge de sécurité d’environ 10–20%.
3.1. Compter les tokens pour une conversation
L’API Chat n’envoie pas une simple chaîne. Elle envoie une liste de messages avec une role, et chaque message a quelques tokens de formatage ajoutés. Selon la description d’OpenAI pour les modèles de chat, chaque message coûte environ 3 tokens supplémentaires. La réponse est également « amorcée » avec 3 tokens supplémentaires.
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;
}
}
C’est une approximation. Si la requête a des définitions d’outils/fonctions ou des images, ils sont aussi comptés dans l’input. JTokkit ne peut pas compter cela directement. Par conséquent, vous devez toujours vérifier avec les données réelles (section 6).
4. Configurer la table des prix et calculer les coûts
Les prix des modèles changent fréquemment, donc ne codez pas les prix en dur. Mettez les prix dans un fichier de configuration. Les chiffres ci-dessous ne sont que des exemples, en USD pour 1 million 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
Liez la configuration à 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("Le prix n'est pas configuré pour le modèle : " + model);
}
return price;
}
}
N’oubliez pas d’activer la liaison avec @EnableConfigurationProperties(PricingProperties.class) ou @ConfigurationPropertiesScan sur la classe @SpringBootApplication. Si le nom du modèle contient un point (par exemple gpt-4.1), écrivez la clé en YAML comme "[gpt-4.1]". Sinon, Spring interprétera le point comme un niveau imbriqué.
Voici maintenant le service qui calcule le coût :
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);
}
}
Quelques points à noter :
- Utilisez
BigDecimalau lieu dedouble. Une requête coûte quelques millièmes d’USD, et additionner des millions de requêtes avecdoubleva accumuler les erreurs d’arrondi. - Multipliez d’abord, divisez ensuite, pour garder la précision.
- Arrondissez à 8 décimales. C’est assez petit pour ne pas perdre les requêtes bon marché.
Par exemple, avec gpt-4o, 1 200 tokens d’entrée et 300 tokens de sortie :
input : 1200 × 2.50 / 1_000_000 = 0.003
output: 300 × 10.00 / 1_000_000 = 0.003
total : 0.00600000 USD
Vous voyez que l’output n’a que 1/4 du nombre de tokens d’input mais coûte presque autant. C’est pourquoi vous devriez limiter la longueur de la réponse.
5. Bloquer les requêtes qui dépassent le budget avant d’appeler
Avant d’appeler, vous ne savez pas combien de temps le modèle va répondre. Mais vous connaissez la limite supérieure : le paramètre max_tokens. Le pire cas de coût est égal au nombre d’input tokens comptés plus les max_tokens 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(
"Requête estimée à %s USD, dépassant le budget de %s USD"
.formatted(worstCase.toPlainString(), limitUsd.toPlainString()));
}
return worstCase;
}
}
ChatTokenCounter doit être déclaré comme bean, avec une Encoding partagée :
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);
}
}
Vous pouvez utiliser BudgetGuard de plusieurs façons. Vous pouvez rejeter la requête. Vous pouvez aussi réduire le plus ancien historique de chat jusqu’à ce que le budget soit suffisant. Ou vous pouvez basculer vers un modèle moins cher comme gpt-4o-mini.
6. Récupérer le nombre de tokens réel de Spring AI
L’estimation est juste pour prendre des décisions. Pour calculer le vrai coût, utilisez les données que le fournisseur retourne. Ajoutez 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
Appelez le modèle et lisez 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();
}
}
Le message de log ressemblera à ceci :
model=gpt-4o-2024-08-06 prompt=412 completion=187 cost=0.00290000 USD
Notez que getModel() retourne le nom avec la version, par exemple gpt-4o-2024-08-06. C’est pourquoi l’exemple utilise une clé fixe "gpt-4o" pour chercher le prix. En pratique, vous devriez normaliser le nom du modèle avant de consulter la table des prix.
Quand vous comparez le nombre réel de prompt avec celui que vous avez compté à la section 3.1, une différence de quelques tokens est normale. S’il y a une grande différence, c’est généralement dû au system prompt par défaut, aux définitions d’outils ou aux conseillers (comme la mémoire, RAG) qui ont ajouté du contenu que vous n’aviez pas compté.
7. Tester CostEstimator
La logique de calcul des coûts est un endroit où les erreurs ne sont pas acceptables. Vous devriez écrire des tests indépendants, sans avoir besoin du contexte 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 = "Votre commande a été confirmée";
int tokens = counter.count(List.of(new ChatMessage("user", content)));
assertThat(tokens).isGreaterThan(content.split(" ").length);
}
}
Le premier test utilise isEqualByComparingTo au lieu de isEqualTo. La raison en est que 0.00600000 et 0.006 ont une scale différente, donc equals de BigDecimal retournerait false.
8. Erreurs courantes
- Créer
EncodingRegistrydans chaque requête. Le chargement des tables de codage est assez lourd. Créez-le une seule fois et réutilisez-le, carEncodingest thread-safe. - Utiliser le mauvais encoding. Si vous comptez GPT-4o avec
CL100K_BASE, vous obtiendrez un nombre incorrect. Choisissez l’encoding en fonction du modèle. - Compter seulement l’input. L’output coûte généralement 4 fois plus ou plus que l’input. Ignorer cette partie est l’erreur la plus importante.
- Oublier les tokens « cachés ». Les modèles de raisonnement (série o) génèrent des tokens de raisonnement. Ces tokens n’apparaissent pas dans la réponse mais sont quand même facturés comme output. À l’inverse, les tokens d’input mis en cache sont généralement remisés. Lisez
getNativeUsage()si vous avez besoin de détails. - Utiliser
doublepour l’argent. L’accumulation d’erreurs d’arrondi fera dévier votre rapport de coûts par rapport à la facture. - Faire confiance aveuglément aux estimations. Les estimations sont utilisées pour bloquer et avertir. L’enregistrement des coûts doit être basé sur
Usageréel.
Remarque : Vous n’avez pas besoin de compter les tokens au préalable pour chaque requête. Si le prompt est court, fixe et que vous avez déjà
max_tokens, le coût est déjà plafonné. Dans ce cas, il suffit de journaliserUsageaprès l’appel. Il vaut la peine de compter à l’avance quand le contexte change en fonction de l’utilisateur, comme l’historique de chat, RAG ou le chargement de fichiers.
9. Conclusion
Dans cet article, vous avez compté les tokens localement avec JTokkit, y compris le surcoût de chaque message de chat. Vous avez calculé les coûts avec précision en utilisant BigDecimal avec une table des prix dans la configuration. Vous avez également utilisé max_tokens pour plafonner le pire cas de coût avant d’appeler le modèle.
Le principe fondamental est : estimer pour prendre des décisions, données réelles pour facturer. En combinant BudgetGuard avant la requête avec la journalisation de Usage après la réponse, vous aurez suffisamment de données pour suivre les coûts par modèle, par fonctionnalité ou par client.