LLM呼び出しのコスト最適化: プロンプトキャッシング、小さいモデル、バッチ処理

1. 概要

LLM機能がデモからプロダクションに移行する時、コストは通常、最初に対処すべき問題になります。請求額はトークン数に応じて増加し、トークンはユーザー数、プロンプトの長さ、エージェントの呼び出し回数に応じて増加します。

この記事では、コストを削減するための3つの一般的なテクニックを紹介します:プロンプトキャッシング、小さいモデルへのルーティング、バッチ処理。各テクニックにはJava 21 / Spring Boot 3.xの例が付いています。読み終わったら、キャッシュを活用するためのプロンプト構成方法、タスクタイプに応じたモデル選択方法、バッチ処理をどこで使うべきかが分かるようになります。また、各テクニックを適用すべきでない場合も指摘します。

2. LLMのコストはどこから来るのか

ほとんどのプロバイダーはトークン単位で課金し、3つのグループに分けられます:

  • 入力トークン:システムプロンプト、会話履歴、RAGドキュメント、ツール定義。
  • キャッシュ入力トークン:キャッシュから再度読み込まれた入力部分で、通常の入力より安いことが多い。
  • 出力トークン:モデルが生成した内容で、通常は入力より高い。

多くのアプリケーションでは、入力がコストの大部分を占めています。これは、各リクエストが同じ長いシステムプロンプト、同じツールセット、同じドキュメント部分を繰り返し送信するためです。コストをモデル化すると、次のようになります:

public record TokenUsage(long inputTokens, long cachedInputTokens, long outputTokens) {}

public record ModelPricing(double inputPerMillion, double cachedInputPerMillion, double outputPerMillion) {

    public double cost(TokenUsage u) {
        long uncached = u.inputTokens() - u.cachedInputTokens();
        return (uncached * inputPerMillion
                + u.cachedInputTokens() * cachedInputPerMillion
                + u.outputTokens() * outputPerMillion) / 1_000_000.0;
    }

    public static void main(String[] args) {
        // 示例価格です。実際のプロバイダーの価格ではありません
        var pricing = new ModelPricing(10, 1, 30);
        System.out.printf("キャッシュなし: %.3f%n", pricing.cost(new TokenUsage(20_000, 0, 500)));
        System.out.printf("キャッシュあり: %.3f%n", pricing.cost(new TokenUsage(20_000, 18_000, 500)));
    }
}

TokenUsageには、プロバイダーが各レスポンスで返すトークン数が含まれます。cost()は、通常の入力部分とキャッシュから取得した入力部分を分離し、対応する単価を掛けます。上記の想定価格では、結果は次のようになります:

キャッシュなし: 0.215
キャッシュあり:    0.053

ここの数字は説明用です。重要な点は構造です:プロンプトの大部分がリクエスト間で繰り返される場合、キャッシュは最大の支出に直接影響します。プロバイダーの実際の価格をセットアップに含め、各リクエストのコストをログに記録する必要があります。

3. プロンプトキャッシング

3.1. 概念

プロンプトキャッシングはプロバイダー側のメカニズムです。プロンプトを初めて処理するとき、プロバイダーはそのプロンプトの最初の部分(プリフィックス)について計算済み状態を保存します。後続のリクエストが完全に同じプリフィックスを持つ場合、この状態を再利用するため、より安く、通常はより高速です。

覚えておくべき3つの特徴:

  • キャッシュはプリフィックスに基づいて一致します。プロンプトの先頭に1文字違う部分があれば、その後の部分全体がキャッシュを利用できなくなります。
  • キャッシュの有効期限は短く、通常は十分に長いプリフィックスが必要です。具体的な閾値はプロバイダーによります。
  • キャッシュを自動的に行うプロバイダーもあれば、リクエスト内でキャッシュ切断点を明示的にマークするよう要求するプロバイダーもあります。

3.2. プロンプトの構成:静的部分が最初、動的部分が後

最も重要な原則は、安定したコンテンツを最初に配置し、変更されるコンテンツを最後に配置することです:

import java.time.LocalDate;
import java.util.List;

public record ChatMessage(String role, String content) {}

public final class SupportPromptBuilder {

    private final String instructions;
    private final String productCatalog;

    public SupportPromptBuilder(String instructions, String productCatalog) {
        this.instructions = instructions;
        this.productCatalog = productCatalog;
    }

    public List<ChatMessage> build(String customerQuestion, LocalDate today) {
        return List.of(
                new ChatMessage("system", instructions),                          // 静的
                new ChatMessage("system", "製品カタログ:\n" + productCatalog), // 静的
                new ChatMessage("user", "本日: " + today + "\n質問: " + customerQuestion)); // 動的
    }
}

指示と製品カタログは顧客間で変わらないため、リストの最初に配置されます。現在の日付と質問はリクエストごとに変わるため、最後のメッセージに配置されます。

よくある間違いは、"あなたはアシスタントです。今は" + LocalDateTime.now()をシステムプロンプトの最初の行に書くことです。タイムスタンプは各リクエストで変わるため、キャッシュが一致することはありません。次のようなミスはより検出しにくいです:

  • HashMapからツール定義をシリアル化するため、キーの順序が不安定になります。
  • ユーザー名またはリクエストIDをシステムプロンプトに挿入します。
  • RAGドキュメントを呼び出し間でランダムに並べ替えます。

コードが変更されてもプリフィックスが安定したままであるかを確認するテストを書くことができます:

import static org.assertj.core.api.Assertions.assertThat;

import java.time.LocalDate;
import org.junit.jupiter.api.Test;

class SupportPromptBuilderTest {

    private final SupportPromptBuilder builder =
            new SupportPromptBuilder("あなたは店舗のサポート担当者です。", "Tシャツ、ジーンズ、靴");

    @Test
    void prefixIsIdenticalAcrossDifferentRequests() {
        var first = builder.build("サイズMはありますか?", LocalDate.of(2024, 1, 1));
        var second = builder.build("送料は?", LocalDate.of(2024, 1, 2));

        assertThat(first.subList(0, 2)).isEqualTo(second.subList(0, 2));
        assertThat(first.get(2)).isNotEqualTo(second.get(2));
    }
}

テストは2つの異なるリクエストの最初の2つのメッセージを比較します。後で誰かが動的データを静的部分に挿入する場合、テストはすぐにエラーを報告します。

3.3. キャッシュ明示的なマーク

マークが必要なプロバイダーの場合、リクエストは通常「ここまでキャッシュ」を指定するフィールドを持ちます。たとえば、JSONボディは次のようになります。フィールド名と場所はプロバイダーのAPIによって異なります:

{
  "model": "your-model",
  "system": [
    { "type": "text", "text": "あなたはサポート担当者です..." },
    { "type": "text", "text": "製品カタログ: ...", "cache_control": { "type": "ephemeral" } }
  ],
  "messages": [ { "role": "user", "content": "サイズMはありますか?" } ]
}

マーク点は最後の静的ブロックの最後に配置されます。その前のすべてのコンテンツはキャッシュの候補です。

3.4. キャッシュヒット率の測定

レスポンスは通常、キャッシュから読み取られたトークン数を返します。この数値を記録すべきです。メトリクスがないと、キャッシュが機能しているかどうかを知ることができません:

public static double cacheHitRatio(TokenUsage u) {
    return u.inputTokens() == 0 ? 0 : (double) u.cachedInputTokens() / u.inputTokens();
}

この比率をMicrometerまたはメトリクスとしてログにプッシュしてください。デプロイ後に比率が突然0になった場合、ほぼ確実に誰かがプリフィックスを変更しています。

4. アプリケーション側でのレスポンスキャッシング

プロンプトキャッシングは入力コストのみを削減します。同じ質問が繰り返される場合、LLMへの呼び出しを完全にスキップできます。次の依存関係を追加します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
spring.cache.cache-names=faq-answers
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=6h
public interface LlmClient {
    String complete(String model, String prompt);
}

@Service
public class FaqAnswerService {

    private final LlmClient llmClient;

    public FaqAnswerService(LlmClient llmClient) {
        this.llmClient = llmClient;
    }

    @Cacheable(cacheNames = "faq-answers", key = "#root.target.normalize(#question)")
    public String answer(String question) {
        return llmClient.complete("small-model", "FAQ質問に短く答えてください: " + question);
    }

    public String normalize(String question) {
        return question.strip().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
    }
}

@Configurationクラスに@EnableCachingを追加する必要があります。キャッシュキーは正規化された質問です:余分なスペースを削除し、小文字に変換します。このため、「配送料金は?」と「 配送 料金? 」は同じ結果を共有します。

注意: @Cacheableはプロキシを通じて機能します。同じクラス内の別のメソッドがanswer()を直接呼び出す場合、キャッシュはバイパスされます。ユーザー定義がない特定の顧客の注文ステータスなど、個人化されたデータを含むレスポンスをキャッシュしないでください。

5. 小さいモデルへのルーティング

多くのタスクは最も強力なモデルを必要としません:チケット分類、メールからのフィールド抽出、短い要約、形式検証。小さいモデルは通常、かなり安く、高速です。これが多くのチームが「モデルルーティング」に注目している理由です。

実際のアプローチはカスケード:最初に小さいモデルを呼び出し、出力を検証し、出力が無効な場合のみ大きいモデルにエスカレートします。

import java.util.Set;

public class TicketClassifier {

    private static final Set<String> LABELS = Set.of("BILLING", "SHIPPING", "TECHNICAL", "OTHER");

    private final LlmClient llm;
    private final String smallModel;
    private final String largeModel;

    public TicketClassifier(LlmClient llm, String smallModel, String largeModel) {
        this.llm = llm;
        this.smallModel = smallModel;
        this.largeModel = largeModel;
    }

    public String classify(String ticket) {
        String prompt = "チケットを " + LABELS + " のいずれかのラベルに分類します。ラベルのみを返します。\nチケット: " + ticket;

        String label = llm.complete(smallModel, prompt).strip();
        if (LABELS.contains(label)) {
            return label;
        }
        return llm.complete(largeModel, prompt).strip();
    }
}

小さいモデルが最初に処理します。許可されたセット内の有効なラベルを返す場合、その結果をすぐに使用します。そうでない場合、たとえばモデルが曖昧な回答をした場合、大きいモデルで再度呼び出します。検証ステップは決定的である必要があります:JSONを解析し、列挙型を照合し、スキーマを検証します。小さいモデルに「自信がある」かどうかを聞かないでください。

偽のLlmClientを使ってテストします:

import static org.assertj.core.api.Assertions.assertThat;

import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;

class TicketClassifierTest {

    @Test
    void escalatesToLargeModelWhenSmallModelReturnsInvalidLabel() {
        List<String> calledModels = new ArrayList<>();
        LlmClient fake = (model, prompt) -> {
            calledModels.add(model);
            return model.equals("small-model") ? "もしかして支払い?" : "BILLING";
        };

        var classifier = new TicketClassifier(fake, "small-model", "large-model");

        assertThat(classifier.classify("二重請求されました")).isEqualTo("BILLING");
        assertThat(calledModels).containsExactly("small-model", "large-model");
    }

    @Test
    void staysOnSmallModelWhenOutputIsValid() {
        List<String> calledModels = new ArrayList<>();
        LlmClient fake = (model, prompt) -> {
            calledModels.add(model);
            return "SHIPPING";
        };

        new TicketClassifier(fake, "small-model", "large-model").classify("商品がまだ到着していません");

        assertThat(calledModels).containsExactly("small-model");
    }
}

ハードコードするのではなく、モデル名を設定に配置する必要があります。これにより、リビルドせずにモデルを変更できます:

llm:
  models:
    small: small-model
    large: large-model

また、エスカレーション率を監視すべきです。ほとんどのリクエストが依然として大きいモデルにアップグレードする必要がある場合、カスケードは各リクエストが2回呼び出されるため、追加費用が発生しています。

6. バッチ

6.1. 複数の項目を1つのリクエストに結合

50件の製品レビューなど、複数の短い項目を分析する必要がある場合、それらを1つのプロンプトに結合できます。指示部分は50回ではなく1回だけ送信されます:

public record Review(String id, String text) {}

public static String buildSentimentPrompt(List<Review> reviews) {
    var sb = new StringBuilder("""
            各レビューについて、1行のJSONを返します:{"id": "...", "sentiment": "POSITIVE|NEGATIVE|NEUTRAL"}
            """);
    reviews.forEach(r -> sb.append(r.id()).append(": ").append(r.text()).append('\n'));
    return sb.toString();
}

各グループを適切なサイズで保つ必要があります。グループが大きすぎるとモデルが項目を見逃しやすくなります。さらに、1つの解析エラーでグループ全体が台無しになります。すべての入力idが出力に含まれていることを常に確認し、見落とされた項目を個別に再試行してください。

6.2. 非同期バッチAPI

多くのプロバイダーがバッチAPIを提供しています。複数のリクエストを含むファイルを送信すると、プロバイダーはより長い期間処理し、同期呼び出しよりも通常は低い価格を返します。一般的な形式はJSONLで、1行に1つのリクエスト、結果をマッピングするためのcustom_idがあります。具体的な構造はプロバイダーによります:

import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public static String toJsonl(List<Review> reviews, String model, ObjectMapper mapper) {
    return reviews.stream()
            .map(r -> Map.of(
                    "custom_id", "review-" + r.id(),
                    "body", Map.of(
                            "model", model,
                            "messages", List.of(Map.of("role", "user",
                                    "content", "感情を分類してください: " + r.text())))))
            .map(line -> {
                try {
                    return mapper.writeValueAsString(line);
                } catch (Exception e) {
                    throw new IllegalStateException(e);
                }
            })
            .collect(Collectors.joining("\n"));
}

各レビューは独立したJSON行になります。custom_idは、返却順序が保証されないため、どの結果がどのレビューに属しているかを知るのに役立ちます。出力は次のようになります:

{"custom_id":"review-1","body":{"model":"small-model","messages":[{"role":"user","content":"感情を分類してください: 配送が速い"}]}}
{"custom_id":"review-2","body":{"model":"small-model","messages":[{"role":"user","content":"感情を分類してください: シャツが破れていた"}]}}

Map.ofが順序を保持しないため、JSONのキーの順序は異なる可能性があります。これはバッチAPIには影響しません。プロセスは通常4つのステップで構成されます:夜間の@Scheduledジョブがファイルを作成し、アップロードしてバッチを作成し、バッチIDをデータベースに保存し、別のジョブが定期的にステータスをチェックして結果をダウンロードします。

7. 適用すべきでない場合

  • プロンプトキャッシングは、プロンプトが短い場合、ユーザーごとにプリフィックスが変わる場合、またはトラフィックが少なくてキャッシュが次のリクエストの前に期限切れになる場合、ほぼ利益がありません。キャッシュ書き込みに追加料金を請求するプロバイダーの場合、再度読み取られることのないコンテンツをキャッシュにマークするとコストが増加します。
  • レスポンスキャッシングは、回答が在庫や価格などの頻繁に変更されるデータに依存する場合、個人化が必要な場合、またはユーザーが多様な回答を期待する場合には適切ではありません。
  • 小さいモデルは、複数ステップの推論、複雑なコードの作成またはレビュー、または出力を決定的に検証する方法がない場合の大きな影響を持つ決定(法律、医療、財務)が必要なタスクには適していません。感覚だけに基づいてモデルを変更しないでください。事前に評価データセットで実行してください。
  • バッチは、チャットまたはオートコンプリートなどの即座のレスポンスが必要なすべてのフローに使用できません。複数の項目を結合することも、項目が長い独立したコンテキストを必要とする場合、または1つの項目エラーが別の項目に影響してはいけない場合には適切ではありません。

注意: 最適化する前に測定してください。各リクエストのTokenUsage、使用されたモデル、呼び出された機能を記録します。通常、わずかなエンドポイントがコストの大部分を占めるため、そこから始めるべきです。

8. 結論

LLMコストは主に繰り返されるトークンと、必要以上に強力なモデルの使用から生じます。この記事の各テクニックは、1つの浪費源に対応しています:

  • プロンプトキャッシング:静的部分を最初に配置し、プリフィックスを安定させ、キャッシュヒット率を測定します。
  • レスポンスキャッシング:繰り返される質問のLLM呼び出しをスキップします。
  • 小さいモデル→大きいモデルのカスケード:出力を決定的に検証し、必要な場合のみエスカレートします。
  • バッチ:項目を結合して指示部分を共有し、即座の結果が不要な作業にはバッチAPIを使用します。

これらのテクニックは組み合わせることができます。たとえば、夜間の分類ジョブは小さいモデルを使用し、バッチAPIで送信でき、安定したプリフィックスを持つプロンプトを使用できます。重要なのは、各リクエストのコストを測定できることです。メトリクスがあれば、どの変更が実際に効果的かを知ることができます。

llmaispring-bootcost-optimizationtech-trends