JavaでLLMを呼び出す際のトークンカウント・費用推定

1. 概要

GPT-4o のようなLLMを呼び出すたびに、トークン単位で料金を支払います。文字数やリクエスト数ではなく。計測しないと、月末の請求額が想定外になる可能性があります。特に長いチャット履歴やRAGドキュメントを含むプロンプトの場合です。

このガイドでは、JTokkit を使用してJVM内でローカルにトークンをカウント、BigDecimalで費用を計算し、予算超過のリクエストをブロックします。最後に、Spring AIが返す実際のトークン数と推定値を照合します。

2. トークンとは何か、なぜ数える必要があるか

トークンはモデルのトークナイザーが分割するテキストの単位です。英語では、平均的に1トークンは約4文字です。ベトナム語のような声調記号付きの言語は、同じ意味に対してより多くのトークンを消費します。なぜなら、「ệ」や「ữ」のような文字がトークナイザーの学習データにあまり出現しないからです。

1回のリクエストの費用は2つの部分で構成されており、異なる価格で計算されます:

  • 入力トークン(プロンプトトークン):システムプロンプト、チャット履歴、質問、添付ドキュメント。
  • 出力トークン(補完トークン):モデルが生成する部分。この部分の単価は通常、入力の数倍高いです。

トークンをカウントする必要があるタイミングは2つです:

  1. 呼び出す前:推定するため、コンテキストをカットするため、または高すぎるリクエストを拒否するため。
  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ログを組み合わせると、モデル、機能、または顧客ごとに費用を追跡するのに十分なデータが得られます。

llmtokenspring-aijavaコスト