Token zählen und Kosten schätzen beim Aufrufen von LLMs in Java
1. Überblick
Jedes Mal wenn du ein LLM wie GPT-4o aufrufst, zahlst du nach Token, nicht nach Zeichen oder Anfragen. Wenn du nicht misst, kann die Abrechnung am Monatsende eine Überraschung sein. Besonders wenn der Prompt lange Chatverlauf oder RAG-Dokumente enthält.
In diesem Artikel wirst du Token direkt in der JVM mit JTokkit zählen, Kosten mit BigDecimal berechnen und Anfragen mit Budgetüberschreitung blockieren. Am Ende wirst du die Schätzung mit den tatsächlichen Token vergleichen, die Spring AI zurückgibt.
2. Was sind Token und warum muss man sie zählen
Token ist die Einheit, mit der der Tokenizer des Modells Text aufteilt. Bei Englisch entspricht ein Token durchschnittlich etwa 4 Zeichen. Vietnamesisch mit Diakritika benötigt viel mehr Token für die gleiche Bedeutung, da Zeichen wie „ệ” oder „ữ” selten in den Trainingsdaten des Tokenizers vorkommen.
Die Kosten einer Anfrage bestehen aus zwei Teilen mit unterschiedlichen Preisen:
- Input-Token (Prompt-Token): System-Prompt, Chat-Verlauf, Frage, angehängte Dokumente.
- Output-Token (Completion-Token): der vom Modell generierte Teil. Der Preis für diesen Teil ist normalerweise um ein Vielfaches höher als für Input.
Es gibt zwei Zeitpunkte, an denen du Token zählen musst:
- Vor dem Aufruf: um zu schätzen, Kontext zu kürzen oder zu teure Anfragen abzulehnen.
- Nach dem Aufruf: um tatsächliche Kosten zu erfassen, basierend auf den Daten des Providers.
3. Token lokal mit JTokkit zählen
JTokkit ist eine reine Java-Bibliothek, die die gleichen BPE-Codetabellen wie OpenAIs tiktoken implementiert. Die Bibliothek erfordert keine Netzwerk-Aufrufe.
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
Token für einen String zählen:
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));
}
}
Erklärung der einzelnen Teile:
Encodings.newDefaultEncodingRegistry()lädt die Codetabellen. Das Laden benötigt Zeit und Speicher, daher sollte es nur einmal erstellt und wiederverwendet werden.O200K_BASEist das Encoding für die GPT-4o-Familie. Ältere Modelle wie GPT-4 und GPT-3.5 verwendenCL100K_BASE.countTokensgibt nur die Anzahl zurück. Es ist schneller alsencode, da die Token-Liste nicht gespeichert werden muss.
Das Ergebnis für den englischen Satz ist wie folgt. Die Zahl für den vietnamesischen Satz hängt vom Encoding ab, aber es wird deutlich höher sein als die Wortanzahl.
Hello, world! -> 4
Xin chào, thế giới! Đơn hàng của bạn đã được xác nhận. -> (etwa 15–20)
Wenn du das Encoding basierend auf dem Modellnamen auswählen möchtest, verwendest du registry.getEncodingForModel(ModelType.GPT_4O).
Hinweis: JTokkit ist nur mit OpenAIs Tokenizer genau. Claude, Gemini und Llama verwenden andere Tokenizer. Bei diesen Modellen verwendest du JTokkit nur für grobe Schätzungen und solltest einen Sicherheitspuffer von etwa 10–20% hinzufügen.
3.1. Token für eine Konversation zählen
Die Chat-API sendet keinen glatten String. Sie sendet eine Liste von Nachrichten mit role, und jede Nachricht hat einige Formatierungs-Token. Gemäß OpenAIs Beschreibung für Chat-Modelle kostet jede Nachricht zusätzlich etwa 3 Token. Die Antwort wird auch mit 3 zusätzlichen Token „geprägt”.
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;
}
}
Das ist eine Annäherung. Wenn die Anfrage Tool- oder Funktionsdefinitionen oder Bilder enthält, werden diese auch in den Input eingerechnet. JTokkit kann diesen Teil nicht direkt zählen. Daher musst du immer mit den tatsächlichen Daten abgleichen (Abschnitt 6).
4. Preistabelle konfigurieren und Kosten berechnen
Modellpreise ändern sich häufig, daher codiere Preise nicht direkt in den Code. Lagere die Preise in eine Konfigurationsdatei aus. Die nachfolgenden Zahlen sind nur Beispiele für USD pro 1 Million Token.
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
Bind die Konfiguration an einen 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("Preis für Modell nicht konfiguriert: " + model);
}
return price;
}
}
Denke daran, das Binding mit @EnableConfigurationProperties(PricingProperties.class) oder @ConfigurationPropertiesScan in der Klasse @SpringBootApplication zu aktivieren. Wenn der Modellname Punkte enthält (z.B. gpt-4.1), schreibe den Schlüssel in YAML als "[gpt-4.1]". Sonst werden Punkte von Spring als verschachtelte Ebenen interpretiert.
Hier ist der Service zur Kostenberechnung:
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);
}
}
Ein paar wichtige Punkte:
- Verwende
BigDecimalstattdouble. Eine Anfrage kostet nur Bruchteile von USD, und bei Millionen von Anfragen mitdoubleentstehen Rundungsfehler. - Multipliziere vor dem Dividieren, um Genauigkeit zu bewahren.
- Runde auf 8 Dezimalstellen. Das ist klein genug, um keine günstigen Anfragen zu verlieren.
Beispiel mit gpt-4o, 1.200 Input-Token und 300 Output-Token:
input : 1200 × 2.50 / 1_000_000 = 0.003
output: 300 × 10.00 / 1_000_000 = 0.003
total : 0.00600000 USD
Du siehst, dass Output nur 1/4 der Eingabe-Token sind, aber die gleiche Menge kostet. Das ist der Grund, warum du die Länge der Antwort begrenzen solltest.
5. Anfragen mit Budgetüberschreitung vor dem Aufruf blockieren
Bevor du anrufst, weißt du nicht, wie lang die Antwort des Modells sein wird. Aber du kennst die obere Grenze: den Parameter max_tokens. Die Worst-Case-Kosten entsprechen der bereits gezählten Eingabe plus 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(
"Anfrage geschätzt auf %s USD, überschreitet Budget von %s USD"
.formatted(worstCase.toPlainString(), limitUsd.toPlainString()));
}
return worstCase;
}
}
ChatTokenCounter muss als Bean deklariert werden, zusammen mit einem gemeinsamen Encoding:
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);
}
}
Du kannst BudgetGuard auf verschiedene Weise nutzen. Du kannst die Anfrage ablehnen. Du kannst auch den ältesten Chat-Verlauf kürzen, bis das Budget ausreicht. Oder du wechselst zu einem günstigeren Modell wie gpt-4o-mini.
6. Tatsächliche Token von Spring AI abrufen
Schätzungen sind nur für Entscheidungen. Um echte Kosten zu berechnen, verwendest du die Daten, die der Provider zurückgibt. Füge Spring AI hinzu:
<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
Rufe das Modell auf und lese 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();
}
}
Die Log-Ausgabe sieht etwa so aus:
model=gpt-4o-2024-08-06 prompt=412 completion=187 cost=0.00290000 USD
Beachte, dass getModel() den Namen mit Version zurückgibt, z.B. gpt-4o-2024-08-06. Daher verwendet das Beispiel den festen Schlüssel "gpt-4o" für die Preisabfrage. In der Praxis solltest du den Modellnamen normalisieren, bevor du ihn in der Preistabelle nachschlägst.
Wenn du die tatsächliche prompt-Zahl mit der gezählten Zahl aus Abschnitt 3.1 vergleichst, ist eine Abweichung von wenigen Token normal. Wenn die Abweichung größer ist, liegt es normalerweise daran, dass ein Standard-System-Prompt, Tool-Definitionen oder Advisors (z.B. Memory, RAG) zusätzliche Inhalte eingefügt haben, die du nicht gezählt hast.
7. CostEstimator testen
Die Kostenberechnung darf nicht fehlerhaft sein. Du solltest unabhängige Tests schreiben, ohne Spring-Context:
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 = "Đơn hàng của bạn đã được xác nhận";
int tokens = counter.count(List.of(new ChatMessage("user", content)));
assertThat(tokens).isGreaterThan(content.split(" ").length);
}
}
Der erste Test verwendet isEqualByComparingTo statt isEqualTo. Der Grund ist, dass 0.00600000 und 0.006 unterschiedliche Skalen haben, daher würde equals von BigDecimal false zurückgeben.
8. Häufige Fehler
EncodingRegistryin jedem Request erstellen. Das Laden der Codetabellen ist relativ aufwändig. Erstelle es einmal und verwende es erneut, daEncodingthread-sicher ist.- Falsches Encoding verwenden. Wenn du GPT-4o mit
CL100K_BASEzählst, wirst du ein falsches Ergebnis bekommen. Wähle das Encoding basierend auf dem Modell. - Nur Input rechnen. Output ist normalerweise 4x teurer als Input oder mehr. Das Auslassen dieses Teils ist der größte Fehler.
- „Versteckte” Token vergessen. Reasoning-Modelle (o-Serie) erzeugen Reasoning-Token. Diese Token erscheinen nicht in der Antwort, kosten aber wie Output. Umgekehrt werden zwischengespeicherte Input-Token normalerweise günstiger. Lies
getNativeUsage(), wenn du Details benötigst. doublefür Geld verwenden. Kumulierte Rundungsfehler führen zu Kostenbericht-Abweichungen von der Rechnung.- Bedingungslos der Schätzung trauen. Schätzungen dienen zum Blockieren und Warnen. Die Erfassung von Kosten muss auf tatsächlicher
Usagebasieren.
Hinweis: Du musst nicht bei jeder Anfrage Token zählen. Wenn der Prompt kurz ist, konstant und
max_tokensbereits gesetzt, sind die Kosten bereits begrenzt. Dann ist es ausreichend, dieUsagenach dem Aufruf zu protokollieren. Das vorherige Zählen lohnt sich, wenn sich der Kontext nach Benutzer ändert, z.B. Chat-Verlauf, RAG oder Datei-Upload.
9. Fazit
In diesem Artikel hast du Token lokal mit JTokkit gezählt, einschließlich des Overheads für jede Chat-Nachricht. Du hast Kosten präzise mit BigDecimal berechnet mit Preisen, die in der Konfiguration festgelegt sind. Du hast auch max_tokens verwendet, um die Worst-Case-Kosten zu begrenzen, bevor du das Modell aufrufst.
Das Kernprinzip lautet: Schätzung für Entscheidungen, echte Daten für Kostenberechnung. Wenn du BudgetGuard vor der Anfrage mit dem Protokollieren von Usage nach der Antwort kombinierst, hast du genügend Daten, um Kosten nach Modell, nach Funktion oder pro Kunde zu verfolgen.