LLM-Aufrufe optimieren: Prompt-Caching, kleinere Modelle und Batch

1. Überblick

Wenn ein LLM-basiertes Feature aus dem Demo in die Produktion geht, wird die Kostenfrage normalerweise zur ersten Herausforderung. Rechnungen steigen mit der Token-Anzahl, und Token wachsen mit der Nutzerzahl, Prompt-Länge und Agentenaufrufen.

Dieser Artikel stellt drei verbreitete Techniken vor, um die Kosten zu senken: Prompt-Caching, Routing zu kleineren Modellen und Batch-Verarbeitung. Jede Technik enthält Beispiele in Java 21 und Spring Boot 3.x. Nach dem Lesen wissen Sie, wie Sie Prompts für Cache-Nutzung strukturieren, Modelle nach Aufgabentyp wählen und wann Batch-Verarbeitung sinnvoll ist. Wir zeigen auch, wann man diese Techniken nicht einsetzen sollte.

2. Woher die LLM-Kosten kommen

Die meisten Provider berechnen nach Token und unterteilen sie in drei Gruppen:

  • Input-Token: System-Prompt, Konversationsverlauf, RAG-Dokumente, Tool-Definitionen.
  • Cached Input-Token: Input-Teile, die aus dem Cache gelesen werden, normalerweise günstiger als reguläre Input-Token.
  • Output-Token: Vom Modell generierter Inhalt, üblicherweise teurer als Input.

In vielen Anwendungen macht Input den Großteil der Kosten aus. Der Grund: jeder Request sendet denselben langen System-Prompt, dieselbe Toolbox, dieselben Dokumente erneut. Wir modellieren die Kosten so:

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) {
        // Beispielpreise in angenommenen Einheiten, NICHT die tatsächlichen Preise eines Providers
        var pricing = new ModelPricing(10, 1, 30);
        System.out.printf("Ohne Cache: %.3f%n", pricing.cost(new TokenUsage(20_000, 0, 500)));
        System.out.printf("Mit Cache:    %.3f%n", pricing.cost(new TokenUsage(20_000, 18_000, 500)));
    }
}

TokenUsage enthält die vom Provider zurückgegebene Token-Anzahl pro Response. cost() trennt reguläre Input-Token von Cache-Token und multipliziert sie mit ihren jeweiligen Preisen. Mit den Beispielpreisen oben sind die Ergebnisse:

Ohne Cache: 0.215
Mit Cache:    0.053

Diese Zahlen dienen nur zu Demonstrationszwecken. Die Takeaway ist die Struktur: Wenn sich große Teile des Prompts über Requests hinweg wiederholen, wirkt sich Caching direkt auf die größten Kosten aus. Legen Sie die tatsächlichen Preise Ihres Providers in die Konfiguration und berechnen Sie die Kosten pro Request in Ihren Logs.

3. Prompt-Caching

3.1. Konzept

Prompt-Caching ist ein Mechanismus auf der Seite des Providers. Wenn dieser einen Prompt zum ersten Mal verarbeitet, speichert er den berechneten Zustand für den Anfang (Prefix) des Prompts. Spätere Requests mit einem identischen Prefix bis zum Zeichen können diesen Zustand wiederverwenden, was billiger und normalerweise auch schneller ist.

Drei Punkte, die Sie beachten sollten:

  • Caching funktioniert auf Prefix-Basis. Ein einziger anderer Charakter am Anfang des Prompts macht den gesamten Rest ungültig für das Cache.
  • Cache hat eine kurze Lebensdauer und erfordert normalerweise einen ausreichend langen Prefix. Der genaue Schwellenwert hängt vom Provider ab.
  • Manche Provider aktivieren Caching automatisch, andere erfordern, dass Sie die Cache-Grenze im Request explizit markieren.

3.2. Prompt-Struktur: statischer Teil vorne, dynamischer Teil hinten

Das wichtigste Prinzip ist, stabilen Inhalt am Anfang zu platzieren und dynamischen Inhalt am Ende:

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),                          // statisch
                new ChatMessage("system", "Produktkatalog:\n" + productCatalog), // statisch
                new ChatMessage("user", "Heute: " + today + "\nFrage: " + customerQuestion)); // dynamisch
    }
}

Anleitung und Produktkatalog ändern sich nicht zwischen Kunden, also stehen sie am Anfang der Liste. Das heutige Datum und die Frage ändern sich pro Request und stehen daher in der letzten Nachricht.

Ein häufiger Fehler ist, "Sie sind ein Assistent. Jetzt ist " + LocalDateTime.now() gleich in die erste Zeile des System-Prompts zu schreiben. Der Zeitstempel ändert sich bei jedem Request, weshalb der Cache niemals trifft. Ähnliche, schwer zu erkennende Fehler sind:

  • Tool-Definitionen aus einer HashMap serialisieren, was zu instabiler Key-Reihenfolge führt.
  • Benutzeramen oder Request-IDs in den System-Prompt einfügen.
  • RAG-Dokumente bei jedem Aufruf in zufälliger Reihenfolge sortieren.

Sie können Tests schreiben, um den Prefix stabil zu halten, wenn sich der Code ändert:

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("Sie sind ein Kundensupport-Agent.", "T-Shirt, Jeans, Schuhe");

    @Test
    void prefixIsIdenticalAcrossDifferentRequests() {
        var first = builder.build("Gibt es Größe M?", LocalDate.of(2024, 1, 1));
        var second = builder.build("Wie viel kostet der Versand?", LocalDate.of(2024, 1, 2));

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

Der Test vergleicht die ersten zwei Messages von zwei verschiedenen Requests. Wenn später jemand dynamische Daten in den statischen Teil einfügt, wird der Test sofort fehlschlagen.

3.3. Cache-Grenzen explizit markieren

Bei Providern, die Markierungen erfordern, hat der Request normalerweise ein Feld, das angibt, “bis hier cachen”. Ein JSON-Body könnte zum Beispiel so aussehen. Der Feldname und die Position hängen von der API des Providers ab:

{
  "model": "your-model",
  "system": [
    { "type": "text", "text": "Sie sind ein Kundensupport-Agent..." },
    { "type": "text", "text": "Produktkatalog: ...", "cache_control": { "type": "ephemeral" } }
  ],
  "messages": [ { "role": "user", "content": "Gibt es Größe M?" } ]
}

Die Markierung wird am Ende des letzten statischen Blocks platziert. Alles davor ist ein Kandidat für das Caching.

3.4. Cache-Hit-Ratio messen

Die Response enthält normalerweise die Anzahl der Token, die aus dem Cache gelesen wurden. Sie sollten diese Zahl protokollieren, denn ohne sie wissen Sie nicht, ob das Caching funktioniert:

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

Pushen Sie diese Ratio zu Micrometer oder loggen Sie sie als Metrik. Wenn die Ratio nach einem Deploy plötzlich 0 wird, hat fast sicher jemand den Prefix geändert.

4. Response-Caching auf der Anwendungsseite

Prompt-Caching reduziert nur die Input-Kosten. Wenn die gleiche Frage mehrfach gestellt wird, können Sie den LLM-Aufruf ganz überspringen. Fügen Sie diese Abhängigkeiten hinzu:

<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", "Antworte kurz auf diese FAQ-Frage: " + question);
    }

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

Sie müssen @EnableCaching in eine @Configuration-Klasse hinzufügen. Der Cache-Schlüssel ist die normalisierte Frage: überflüssige Leerzeichen entfernt und in Kleinbuchstaben. Dadurch teilen sich “Versandgebühr?” und “ VERSANDGEBÜHR? “ ein Ergebnis.

Hinweis: @Cacheable arbeitet über Proxies. Wenn eine andere Methode in der gleichen Klasse answer() direkt aufruft, wird der Cache umgangen. Cachen Sie keine Antworten mit personalisierten Daten, etwa Bestellstatus eines bestimmten Kunden, mit einem Schlüssel, der keine Benutzer-ID enthält.

5. Routing zu kleineren Modellen

Viele Aufgaben benötigen nicht das stärkste Modell: Ticket-Klassifizierung, Feldextraktion aus E-Mails, kurze Zusammenfassungen, Format-Validierung. Kleinere Modelle sind üblicherweise erheblich billiger und schneller. Deshalb interessieren sich immer mehr Teams für “Model Routing”.

Der praktische Ansatz ist ein Cascade: rufen Sie zuerst das kleine Modell auf, überprüfen Sie die Ausgabe, und leiten Sie nur an das große Modell weiter, wenn die Ausgabe ungültig ist.

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 = "Klassifiziere das Ticket mit einem Label aus " + LABELS + ". Gib nur das Label zurück.\nTicket: " + ticket;

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

Das kleine Modell geht zuerst. Wenn es ein gültiges Label aus der erlaubten Menge zurückgibt, verwenden wir das Ergebnis sofort. Falls nicht, etwa weil das Modell eine ungefilterte Antwort gab, rufen wir das große Modell auf. Der Validierungsschritt muss deterministisch sein: JSON parsen, Enum abgleichen, Schema validieren. Fragen Sie das kleine Modell nicht, wie “sicher” es sich ist.

Test mit einem Mock 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") ? "Vielleicht Rechnungswesen?" : "BILLING";
        };

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

        assertThat(classifier.classify("Ich wurde zweimal belastet")).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("Paket ist nicht angekommen");

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

Sie sollten Modellnamen in die Konfiguration statt Hard-Code aufnehmen. So können Sie Modelle wechseln, ohne neu zu bauen:

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

Sie sollten auch die Eskalationsrate überwachen. Wenn die meisten Requests noch zu größeren Modellen gehen, kostet Sie das Cascade extra, weil jeder Request zweimal aufgerufen wird.

6. Batch-Verarbeitung

6.1. Mehrere Items in einem Request kombinieren

Wenn Sie viele kurze Items analysieren müssen, etwa 50 Produktbewertungen, können Sie sie in einem Prompt kombinieren. Die Anleitung wird nur einmal statt 50-mal gesendet:

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

public static String buildSentimentPrompt(List<Review> reviews) {
    var sb = new StringBuilder("""
            Für jede Bewertung gib eine Zeile JSON zurück: {"id": "...", "sentiment": "POSITIVE|NEGATIVE|NEUTRAL"}
            """);
    reviews.forEach(r -> sb.append(r.id()).append(": ").append(r.text()).append('\n'));
    return sb.toString();
}

Halten Sie die Größe jeder Gruppe angemessen. Zu große Gruppen führen dazu, dass das Modell Items übersieht. Außerdem ruiniert ein Parse-Fehler die gesamte Gruppe. Stellen Sie immer sicher, dass jede Eingabe-id in der Ausgabe vorhanden ist, und retry nur die fehlenden Items.

6.2. Asynchrone Batch-API

Viele Provider bieten eine Batch-API an. Sie senden eine Datei mit mehreren Requests, und der Provider verarbeitet diese über eine längere Zeit hinweg, normalerweise zu niedrigeren Kosten als synchrone Aufrufe. Das gängige Format ist JSONL, eine Zeile pro Request mit einer custom_id zum Abgleich der Ergebnisse. Die genaue Struktur hängt vom Provider ab:

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", "Klassifiziere Sentiment: " + r.text())))))
            .map(line -> {
                try {
                    return mapper.writeValueAsString(line);
                } catch (Exception e) {
                    throw new IllegalStateException(e);
                }
            })
            .collect(Collectors.joining("\n"));
}

Jede Bewertung wird zu einer separaten JSON-Zeile. custom_id hilft Ihnen, welches Ergebnis zu welcher Bewertung gehört, da die Rückgabereihenfolge nicht garantiert ist. Die Ausgabe sieht so aus:

{"custom_id":"review-1","body":{"model":"small-model","messages":[{"role":"user","content":"Klassifiziere Sentiment: Schnelle Lieferung"}]}}
{"custom_id":"review-2","body":{"model":"small-model","messages":[{"role":"user","content":"Klassifiziere Sentiment: Hemd war zerrissen"}]}}

Die Reihenfolge der Keys im JSON kann unterschiedlich sein, da Map.of keine Reihenfolge garantiert. Das beeinflusst die Batch-API nicht. Der Prozess besteht normalerweise aus vier Schritten: Ein @Scheduled-Job erstellt nachts eine Datei, lädt sie hoch und erstellt den Batch, speichert die Batch-ID in der Datenbank, und ein anderer Job prüft regelmäßig den Status und lädt die Ergebnisse herunter.

7. Wann man diese Techniken nicht anwenden sollte

  • Prompt-Caching ist kaum sinnvoll bei kurzen Prompts, wenn sich der Prefix pro Benutzer ändert, oder wenn der Traffic so dünn ist, dass der Cache abläuft, bevor der nächste Request kommt. Bei Providern, die Cache-Schreibvorgänge berechnen, wird das Markieren von Inhalten, die nie wieder gelesen werden, die Kosten erhöhen.
  • Response-Caching passt nicht, wenn Antworten von sich ständig ändernden Daten abhängen, wie Lagerbestand oder Preise, wenn Personalisierung nötig ist, oder wenn Benutzer unterschiedliche Antworten erwarten.
  • Kleinere Modelle passen nicht zu Aufgaben, die Mehrschrittiges Denken erfordern, komplexer Code-Schreibung oder -Prüfung, oder Entscheidungen mit großen Konsequenzen (rechtlich, medizinisch, finanziell), wenn Sie keine deterministische Ausgabeverifizierung haben. Wechseln Sie nicht das Modell auf Vermutung hin. Testen Sie es zuerst auf einem Bewertungsdatensatz.
  • Batch-Verarbeitung geht nicht für Flows, die sofortige Responses brauchen wie Chat oder Autocomplete. Mehrere Items zusammenzufassen ist auch ungeeignet, wenn jedes Item seinen eigenen langen Kontext braucht, oder wenn der Fehler bei einem Item andere Items nicht beeinträchtigen darf.

Hinweis: Messen Sie, bevor Sie optimieren. Protokollieren Sie TokenUsage, genutztes Modell und betroffene Features pro Request. Normalerweise verursachen nur ein paar Endpoints die meisten Kosten – da sollte man anfangen.

8. Fazit

LLM-Kosten kommen hauptsächlich von wiederholten Token und der Nutzung stärkerer Modelle als nötig. Jede Technik in diesem Artikel zielt auf eine andere Verschwendungsquelle:

  • Prompt-Caching: Statischen Inhalt vorne, Prefix stabil halten und Cache-Hit-Ratio messen.
  • Response-Caching: LLM-Aufrufe bei wiederholten Fragen überspringen.
  • Kleine → große Modelle kaskadieren: Ausgabe deterministisch validieren und nur eskalieren, wenn nötig.
  • Batch-Verarbeitung: Items zusammenfassen, um Anleitung zu teilen, und Batch-APIs für nicht-dringende Arbeit nutzen.

Diese Techniken lassen sich kombinieren. Ein nächtlicher Klassifizierungs-Job könnte zum Beispiel kleinere Modelle nutzen, über die Batch-API gesendet werden, mit stabilem Prompt-Prefix. Das Wichtigste ist, dass Sie die Kosten pro Request messen. Mit Daten wissen Sie, welche Änderungen wirklich helfen.

llmaispring-bootkostenoptimierungtech-trends