JavaでLLMを呼び出す際のトークンカウント・費用推定
1. 概要
GPT-4o のようなLLMを呼び出すたびに、トークン単位で料金を支払います。文字数やリクエスト数ではなく。計測しないと、月末の請求額が想定外になる可能性があります。特に長いチャット履歴やRAGドキュメントを含むプロンプトの場合です。
このガイドでは、JTokkit を使用してJVM内でローカルにトークンをカウント、BigDecimalで費用を計算し、予算超過のリクエストをブロックします。最後に、Spring AIが返す実際のトークン数と推定値を照合します。
2. トークンとは何か、なぜ数える必要があるか
トークンはモデルのトークナイザーが分割するテキストの単位です。英語では、平均的に1トークンは約4文字です。ベトナム語のような声調記号付きの言語は、同じ意味に対してより多くのトークンを消費します。なぜなら、「ệ」や「ữ」のような文字がトークナイザーの学習データにあまり出現しないからです。
1回のリクエストの費用は2つの部分で構成されており、異なる価格で計算されます:
- 入力トークン(プロンプトトークン):システムプロンプト、チャット履歴、質問、添付ドキュメント。
- 出力トークン(補完トークン):モデルが生成する部分。この部分の単価は通常、入力の数倍高いです。
トークンをカウントする必要があるタイミングは2つです:
- 呼び出す前:推定するため、コンテキストをカットするため、または高すぎるリクエストを拒否するため。
- 呼び出した後:実際の費用を記録するため、プロバイダーが返したデータに基づいて。
3. JTokkit でローカルトークンをカウント
JTokkit はJava製の純粋なライブラリで、OpenAIのtiktokenと同じBPEエンコーディングテーブルを実装しています。ネットワーク呼び出しは必要ありません。
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
文字列のトークンをカウントします:
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));
}
}
各部分の説明:
Encodings.newDefaultEncodingRegistry()は事前ロードされたエンコーディングテーブルを読み込みます。このロードは時間とメモリを消費するため、1回だけ実行して再利用する必要があります。O200K_BASEはGPT-4o のエンコーディングです。GPT-4やGPT-3.5などの古いモデルはCL100K_BASEを使用します。countTokensは数のみを返します。トークンリストを保持する必要がないため、encodeより高速です。
英語の文でのテスト結果は次のようになります。ベトナム語の文のトークン数はエンコーディングに依存しますが、単語数に比べてかなり多くなります。
Hello, world! -> 4
Xin chào, thế giới! Đơn hàng của bạn đã được xác nhận. -> (約15~20)
モデル名に基づいてエンコーディングを選択したい場合は、registry.getEncodingForModel(ModelType.GPT_4O)を使用できます。
注意:JTokkit はOpenAIのトークナイザーにのみ正確です。ClaudeやGemini、Llamaは異なるトークナイザーを使用しています。これらのモデルでは、JTokkit を使用して大ざっぱな推定値を取得し、10~20%のセーフティマージンを追加する必要があります。
3.1. 会話のトークンをカウント
Chat APIは単純な文字列を送信しません。roleを持つメッセージのリストを送信し、各メッセージにはいくつかのフォーマットトークンが付属しています。OpenAIがチャットモデルに記載した方法によると、各メッセージには約3トークンの追加が必要です。回答も3トークンで「プライミング」されます。
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;
}
}
これは概算です。リクエストにツール/関数定義や画像が含まれる場合、それらも入力にカウントされます。JTokkit はそれらを直接数えることができません。したがって、実際のデータと常に照合する必要があります(セクション6)。
4. 価格表を設定してコストを計算
モデルの価格は頻繁に変更されるため、コードに価格をハードコードしないでください。価格を設定ファイルに移してください。以下の数値は例にすぎず、100万トークンあたりのUSD建てです。
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
設定をレコードにバインドします:
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("モデルの価格が未設定です:" + model);
}
return price;
}
}
@EnableConfigurationProperties(PricingProperties.class)または@SpringBootApplicationクラスの@ConfigurationPropertiesScanを使用してバインディングを有効にしてください。モデル名にドット(例:gpt-4.1)が含まれる場合は、YAMLのキーを"[gpt-4.1]"と記述してください。そうしないと、Springはドットをネストレベルとして解釈します。
これでコスト計算サービスは次のようになります:
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);
}
}
いくつかの重要なポイント:
doubleの代わりにBigDecimalを使用してください。1回のリクエストは数千分の1ドルの費用がかかり、doubleで数百万のリクエストを合算すると丸め誤差が生じます。- 精度を保つために、乗算してから除算します。
- 小数点以下8桁まで四捨五入します。これにより、安いリクエストを失わないほど十分に小さいです。
gpt-4oの例:1,200入力トークンと300出力トークン:
入力 : 1200 × 2.50 / 1_000_000 = 0.003
出力 : 300 × 10.00 / 1_000_000 = 0.003
合計 : 0.00600000 USD
出力はトークン数で入力の1/4ですが、コストはほぼ同じです。これが回答の長さを制限すべき理由です。
5. 呼び出し前に予算超過リクエストをブロック
呼び出す前は、モデルの回答がどのくらい長いかわかりません。しかし、上限は知っています:max_tokensパラメータです。最悪のコストは、カウント済みの入力にプラスmax_tokens出力に等しいです。
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(
"推定コスト %s USD は予算上限 %s USD を超えています"
.formatted(worstCase.toPlainString(), limitUsd.toPlainString()));
}
return worstCase;
}
}
ChatTokenCounterはbean として宣言され、共有の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);
}
}
BudgetGuardは複数の方法で使用できます。リクエストを拒否できます。または、予算に収まるまで古いチャット履歴をトリミングできます。またはgpt-4o-miniなど安いモデルに切り替えられます。
6. Spring AI から実際のトークンを取得
推定は決定を下すためだけです。実際に課金するには、プロバイダーが返すデータを使用してください。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
モデルを呼び出して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();
}
}
ログ出力は次のようになります:
model=gpt-4o-2024-08-06 prompt=412 completion=187 cost=0.00290000 USD
getModel()はバージョンを含む名前を返すことに注意してください。例:gpt-4o-2024-08-06。そのため、価格テーブルを照合するには固定キー"gpt-4o"を使用しています。実際には、価格テーブルを照合する前にモデル名を正規化すべきです。
セクション3.1でカウントした実際のpromptとの比較では、数トークンの差は正常です。大きく異なる場合は、通常、システムプロンプトのデフォルト、ツール定義、またはアドバイザー(メモリ、RAGなど)が追加のコンテンツをカウントしていないことが原因です。
7. CostEstimator をテスト
お金を計算するロジックは誤りを許しません。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 = "Đơ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);
}
}
最初のテストはisEqualToの代わりにisEqualByComparingToを使用しています。理由は、0.00600000と0.006は異なるスケールを持つため、BigDecimalのequalsはfalseを返すためです。
8. よくあるエラー
- **各リクエストで
EncodingRegistryを作成する。**エンコーディングテーブルの読み込みはかなり重いです。1回作成して再利用してください。Encodingはスレッドセーフです。 - 誤ったエンコーディングを使用する。
CL100K_BASEでGPT-4o をカウントすると、結果がずれます。モデルに基づいてエンコーディングを選択してください。 - 入力のみを計算する。 出力は通常、入力の4倍以上高いです。これを省略することが最大の偏差です。
- 「隠れた」トークンを忘れる。 Reasoning モデル(o-series ファミリー)はreasoningトークンを生成します。これらのトークンは回答には表示されませんが、出力として課金されます。反対に、キャッシュされた入力トークンはしばしば割引されます。詳細が必要な場合は
getNativeUsage()を読んでください。 - お金に
doubleを使用する。 丸め誤差が蓄積すると、請求書からの費用レポートがずれます。 - 推定を絶対に信頼する。 推定はブロックと警告用です。費用記録は実際の
Usageに基づく必要があります。
注意:すべてのリクエストについてあらかじめトークンをカウントする必要はありません。プロンプトが短く、固定で、すでに
max_tokensがある場合、コストは既に上限が制限されています。その場合は、呼び出し後にUsageをログするだけで十分です。コンテキストがユーザーに応じて変わる場合(チャット履歴、RAG、ファイルアップロードなど)、事前のカウントが価値があります。
9. 結論
このガイドでは、JTokkit を使用してJVM内でローカルにトークンをカウントしました。チャットメッセージごとのオーバーヘッドを含めて。BigDecimalを使用して、設定ファイルに置かれた価格表で正確に費用を計算しました。max_tokensを使用して、モデルを呼び出す前に最悪の場合のコストをブロックしました。
基本原則は:推定は決定用、実際のデータは課金用です。リクエスト前のBudgetGuardとレスポンス後のUsageログを組み合わせると、モデル、機能、または顧客ごとに費用を追跡するのに十分なデータが得られます。