Billiger pro Aufruf heißt
nicht billiger pro Aufgabe.

Wenn ein Kunde nach den Kosten seiner KI-Anbindung fragt, schaut er fast immer auf dieselbe Zahl: den Preis pro Million Tokens. Das ist verständlich, denn sie steht auf der Preisseite. Für einen Agenten, der eine Aufgabe in mehreren Schritten erledigt, erklärt sie aber erstaunlich wenig von dem, was am Monatsende auf der Rechnung steht.

Das billigere Modell

Ein Onlineshop, ein Assistent für Kundenanfragen. Er beantwortet Fragen wie „Wo ist meine Bestellung?“ oder „Kann ich den Mantel noch zurückschicken?“ und greift dafür über Tools auf die Warenwirtschaft zu: Bestellung suchen, Sendung verfolgen, Rückgabefrist prüfen. Die Anwendung ist Laravel, das Modell kommt per API.

Nach ein paar Monaten kam der naheliegende Wunsch: ein kleineres Modell, das pro Token nur ein Viertel kostet. Die Testfragen liefen durch, die Antworten lasen sich ordentlich, also wurde umgestellt.

Die Rechnung im Folgemonat war nicht auf ein Viertel gesunken, sondern auf etwas mehr als die Hälfte. Dazu kamen mehr Rückfragen beim Kundenservice, weil der Assistent häufiger danebenlag. Rechnet man die Zeit dazu, die Mitarbeiter für diese Nacharbeit gebraucht haben, war die Umstellung ein Verlust.

Die Ursache stand in keinem einzelnen Aufruf. Jeder Aufruf war tatsächlich billiger. Aber das kleinere Modell brauchte für dieselbe Anfrage mehr Schritte: Es suchte die Bestellung zweimal mit leicht anderen Parametern, fragte die Rückgabefrist ab, bevor es wusste, welcher Artikel gemeint war, und gab bei jedem dritten Fall auf halbem Weg eine Antwort, die der Kunde dann mit einer zweiten Nachricht korrigieren musste. Mehr Schritte heißt mehr Aufrufe, und jeder Aufruf schickt den gesamten bisherigen Verlauf erneut mit.

Die richtige Einheit

Bei einer einzelnen Anfrage, etwa einer Zusammenfassung oder einer Klassifikation, sind Aufruf und Aufgabe dasselbe. Da ist der Preis pro Token eine brauchbare Größe. Sobald ein Modell selbst entscheidet, wie viele Schritte es braucht, fallen die beiden auseinander, und dann ist nur noch eine Einheit ehrlich: Kosten pro erledigter Aufgabe.

Das Wort „erledigt“ trägt dabei die Hälfte der Aussage. Ein Lauf, der abbricht oder eine falsche Antwort liefert, hat trotzdem Tokens verbraucht. Diese Kosten gehören in den Zähler. In den Nenner gehören nur die Aufgaben, die tatsächlich gelöst wurden. Wer einen Fehlschlag einfach aus der Statistik nimmt, rechnet sich jedes schwache Modell schön.

Mit dieser Einheit sieht der Modellwechsel aus dem Shop anders aus. Das kleinere Modell war pro Aufruf um drei Viertel billiger. Pro erledigter Anfrage blieb davon nur ein kleiner Teil übrig, weil es mehr Runden brauchte und weniger Anfragen tatsächlich löste. Genau das hätte eine Messung vor der Umstellung gezeigt – wenn es eine gegeben hätte.

Pro Aufgabe messen

Die Voraussetzung ist unspektakulär: Jeder Modellaufruf braucht eine Kennung der Aufgabe, zu der er gehört. Ohne diese Klammer hast du nur einen Strom einzelner Aufrufe, und aus dem lässt sich nachträglich kaum rekonstruieren, welche zusammengehören.

Pro Aufruf lohnt es sich, wenig, aber vollständig mitzuschreiben: Modell, Dauer, Eingabe- und Ausgabetokens, wie viel davon aus dem Cache kam, und am Ende des Laufs, ob die Aufgabe erledigt wurde. Für die Namen dieser Felder gibt es inzwischen einen herstellerneutralen Vorschlag: die semantischen Konventionen von OpenTelemetry für generative KI. Sie sind noch als „Development“ gekennzeichnet, können sich also ändern. Trotzdem ist es klug, sie von Anfang an zu verwenden – dann kann dasselbe Protokoll später ohne Umbau in ein Tracing-Werkzeug fließen.

Eine Stolperfalle steckt dabei in der Frage, was „Eingabetokens“ eigentlich bedeutet. Anthropic meldet in input_tokens nur den Teil, der nicht aus dem Cache kam; Lesen und Schreiben stehen in eigenen Feldern daneben. Die OpenTelemetry-Konvention zählt in gen_ai.usage.input_tokens dagegen alles zusammen. Wer die Rohwerte eins zu eins überträgt, sieht bei einem gut gecachten Agenten winzige Eingaben und wundert sich über die Rechnung.

app/Support/Llm/TokenVerbrauch.php
final class TokenVerbrauch
{
    /**
     * Übersetzt das usage-Objekt der Anthropic-API in die Attributnamen der
     * OpenTelemetry-Konvention. Anthropic zählt Cache-Tokens nicht in
     * input_tokens mit, die Konvention schon – deshalb wird hier addiert.
     */
    public static function ausAnthropic(array $usage): array
    {
        $cacheGelesen     = $usage['cache_read_input_tokens'] ?? 0;
        $cacheGeschrieben = $usage['cache_creation_input_tokens'] ?? 0;

        return [
            'gen_ai.usage.input_tokens'             => $usage['input_tokens'] + $cacheGelesen + $cacheGeschrieben,
            'gen_ai.usage.cache_read.input_tokens'  => $cacheGelesen,
            'gen_ai.usage.cache_write.input_tokens' => $cacheGeschrieben,
            'gen_ai.usage.output_tokens'            => $usage['output_tokens'],
        ];
    }
}

Aus den gespeicherten Aufrufen ergibt sich die Kennzahl dann mit einer einzigen Abfrage. Entscheidend ist die Asymmetrie zwischen Zähler und Nenner:

app/Console/Commands/LlmKostenbericht.php
// Kosten zählen für alle Läufe, auch für abgebrochene und falsch beantwortete.
// Geteilt wird nur durch die Läufe, die ihre Aufgabe tatsächlich erledigt haben.
$laeufe = AgentLauf::query()
    ->where('gestartet_am', '>=', now()->subDays(7))
    ->withSum('aufrufe', 'kosten_cent')
    ->get();

$gesamtCent = $laeufe->sum('aufrufe_sum_kosten_cent');
$erledigt   = $laeufe->where('erledigt', true)->count();

$centProErledigterAufgabe = $erledigt > 0 ? $gesamtCent / $erledigt : null;

Das Feld erledigt ist dabei der Teil, der Nachdenken verlangt. Bei einem Support-Assistenten kann es heißen: Der Kunde hat innerhalb eines Tages nicht noch einmal zum selben Thema geschrieben. Bei einem Import-Agenten: Die Daten haben die Validierung bestanden. Eine grobe, aber ehrliche Definition ist besser als keine.

Wo die Tokens hingehen

Sobald die Zahlen pro Aufgabe vorliegen, fällt meist zuerst auf, wie ungleich sie über einen Lauf verteilt sind. Der Grund liegt in der Arbeitsweise der APIs: Sie sind zustandslos. Jeder Aufruf schickt Systemprompt, Tool-Definitionen und den kompletten bisherigen Verlauf erneut mit – samt allen Tool-Ergebnissen, die das Modell unterwegs angefordert hat.

Ein durchgerechnetes Beispiel. Systemprompt und Tool-Definitionen umfassen 3.000 Tokens. Jede Runde, also Tool-Aufruf plus Ergebnis, fügt 1.500 Tokens hinzu. Eine Aufgabe braucht acht Modellaufrufe. Dann wächst die Eingabe von Aufruf zu Aufruf: 3.000, 4.500, 6.000 und so weiter bis 13.500 beim achten. Zusammen sind das 66.000 Eingabetokens für eine einzige Kundenfrage.

Anders betrachtet: Die 1.500 Tokens, die das erste Tool-Ergebnis lang ist, werden danach noch siebenmal bezahlt. Aus einer einmal eingelesenen Bestellung werden so 12.000 Tokens. Jede Information, die früh im Lauf in den Kontext kommt, ist die teuerste im ganzen Lauf – nicht wegen ihrer Länge, sondern wegen der Zahl der Aufrufe, die noch folgen.

An dieser Stelle ändert Prompt-Caching die Rechnung erheblich. Bei Anthropic kostet das Lesen aus dem Cache ein Zehntel des normalen Eingabepreises, das Schreiben in den Cache mit fünf Minuten Lebensdauer kostet 25 Prozent Aufschlag. OpenAI cacht bei den unterstützten Modellen automatisch; die Konditionen hängen dort vom Modell ab. Mit den Anthropic-Faktoren gerechnet schrumpft das Beispiel von 66.000 auf umgerechnet rund 22.000 Tokens zum vollen Preis, also auf ein Drittel. Das Mitschleppen des ersten Tool-Ergebnisses kostet dann zusammen weniger als sein erstes Einlesen.

Der Haken: Ein Cache greift nur auf einen unveränderten Anfang der Anfrage. Ändert sich ein einziges Zeichen weit vorne, ist alles danach ein Cache-Fehlschlag, und niemand bekommt eine Fehlermeldung. Die üblichen Ursachen sind banal:

  • Kein Datum, keine Uhrzeit und keine Anfrage-ID im Systemprompt – solche Werte gehören ans Ende der Anfrage.
  • Tool-Definitionen immer in derselben Reihenfolge übergeben, nicht je nach Anfrage zusammenstellen.
  • JSON im Kontext mit stabiler Schlüsselreihenfolge serialisieren.
  • Unterhalb einer modellabhängigen Mindestlänge wird gar nicht gecacht – bei kurzen Prompts also nicht auf den Effekt zählen.
  • Nach jeder Änderung am Prompt-Aufbau prüfen, ob die Cache-Felder im usage-Objekt noch Treffer melden.

Kürzen mit Rückweg

Aus der Rechnung oben folgt ein Reflex: Tool-Ergebnisse kürzen. Die Warenwirtschaft liefert zu einer Bestellung vierzig Felder, das Modell braucht selten mehr als sechs. Also nur die sechs zurückgeben.

Das ist oft richtig und manchmal teuer. Rechnerisch gewinnt das Kürzen fast immer, solange das Modell die weggelassene Information nicht braucht. Braucht es sie doch – etwa die einzelnen Positionen, weil der Kunde nur einen Artikel zurückgeben will –, bezahlst du die kurze Fassung, eine zusätzliche Runde, in der das Modell nachfragt, und dann die lange Fassung obendrauf. Hat das Modell keinen Weg nachzufragen, bezahlst du stattdessen mit einer falschen Antwort, und die taucht in keiner Tokenstatistik auf.

Bewährt hat sich deshalb eine Reihenfolge. Zuerst alles entfernen, was keine Information trägt: leere Felder, interne Kennungen ohne Bedeutung für die Aufgabe, Einrückung im JSON. Das ist verlustfrei. Erst danach inhaltlich kürzen, und zwar nur so, dass ein Rückweg zum Vollständigen bleibt:

app/Assistent/Tools/BestellungTools.php
/**
 * Liefert dem Modell die Übersicht einer Bestellung. Die Positionen stehen
 * nur als Anzahl darin; wer sie braucht, fragt sie über das zweite Tool ab.
 * So kostet der häufige Fall („Wo ist mein Paket?“) wenig, und der seltene
 * Fall (Teilretoure) bleibt lösbar, statt geraten zu werden.
 */
public function bestellungSuchen(string $bestellnummer): array
{
    $bestellung = $this->warenwirtschaft->bestellung($bestellnummer);

    return array_filter([
        'bestellnummer'   => $bestellung->nummer,
        'status'          => $bestellung->status->label(),
        'bestellt_am'     => $bestellung->datum->toDateString(),
        'versand'         => $bestellung->sendung?->statusText(),
        'rueckgabe_bis'   => $bestellung->rueckgabefrist()?->toDateString(),
        'anzahl_artikel'  => $bestellung->positionen->count(),
        'details_per'     => 'bestellpositionen_abrufen',
    ], fn ($wert) => $wert !== null);
}

Ob die Grenze richtig gezogen ist, zeigt keine Überlegung, sondern die Messung: Wie oft ruft das Modell das Detail-Tool tatsächlich auf? Passiert das in fast jedem Lauf, gehört die Information in die Übersicht. Passiert es fast nie, war die Kürzung ein Gewinn.

Runden, die der Code sparen kann

Die wirksamste Einsparung ist oft keine kürzere Runde, sondern eine, die gar nicht stattfindet. Jede Runde kostet Ausgabetokens, verlängert den Kontext für alle folgenden Aufrufe und macht den Assistenten langsamer. Viele davon holen nur Informationen, die die Anwendung längst hat.

Was der Code sicher weiß, muss das Modell nicht erfragen. Ist der Kunde eingeloggt, stehen seine letzten Bestellungen bereits fest. Sie gehören als kurzer Block in die erste Anfrage, statt dass das Modell zuerst ein Tool „Kunde ermitteln“ und dann „Bestellungen auflisten“ aufruft. Zwei Runden weniger, bei jeder einzelnen Anfrage.

Unabhängige Abfragen gehören in eine Runde. Die aktuellen Modelle können mehrere Tools in einer Antwort anfordern, etwa Sendungsstatus und Rückgabefrist zugleich. Die Anwendung sollte diese Aufrufe dann gemeinsam ausführen und alle Ergebnisse in einer einzigen Nachricht zurückgeben. Wer sie einzeln nacheinander zurückschickt, verschenkt den Vorteil und gewöhnt das Modell im weiteren Verlauf daran, eins nach dem anderen anzufordern.

Feste Abläufe gehören nicht in die Schleife. Prüfen, ob eine Bestellnummer das richtige Format hat, einen Betrag runden, ein Datum formatieren: Das kann PHP zuverlässig und kostenlos. Ein Modell, das dafür eine Runde dreht, ist nicht schlauer, nur teurer.

Sparen, ohne kaputtzusparen

Jede der Maßnahmen oben kann die Qualität verschlechtern, und zwar meist leise. Deshalb gilt für Kostenoptimierung dasselbe wie für jede andere Änderung: vorher messen, nachher messen, und zwar beides – Kosten und Ergebnis.

Dafür reicht eine feste Sammlung echter Aufgaben aus dem Betrieb, anonymisiert, mit dem jeweils richtigen Ergebnis. Fünfzig gut gewählte Fälle zeigen mehr als tausend ausgedachte. Jede Änderung läuft gegen diese Sammlung, und verglichen werden Kosten pro erledigter Aufgabe und Erfolgsquote nebeneinander.

Besonders tückisch sind gekürzte Prompts. Ein Systemprompt, der von 2.000 auf 800 Tokens schrumpft, spart bei jedem einzelnen Aufruf – und verliert dabei gern einen Satz, der harmlos aussah. Im Shop war es die Anweisung, vor einer Aussage zur Erstattung die Bestellnummer bestätigen zu lassen. Die Antworten blieben höflich und flüssig, sie bezogen sich nur gelegentlich auf die falsche Bestellung. Ein Test, der nur prüft, ob eine Antwort kommt, findet so etwas nicht.

  • Vor jeder Optimierung eine Ausgangsmessung auf derselben Aufgabensammlung.
  • Kosten pro erledigter Aufgabe und Erfolgsquote immer gemeinsam betrachten, nie einzeln.
  • Für jede Regel im Systemprompt mindestens einen Testfall, der bricht, wenn die Regel fehlt.
  • Nur eine Änderung pro Messung – sonst weißt du nicht, welche gewirkt hat.
  • Nach der Umstellung im Betrieb weitermessen: Echte Anfragen sind vielfältiger als jede Sammlung.

Fazit

Die Kosten einer LLM-Anbindung entstehen selten dort, wo man zuerst sucht. Der Preis pro Token ist eine Eigenschaft des Modells, die Zahl der Runden eine Eigenschaft der ganzen Anwendung – und bei Agenten wiegt die zweite meist schwerer.

Wer pro erledigter Aufgabe misst, trifft andere Entscheidungen: Er kürzt Tool-Ergebnisse mit Rückweg statt blind, gibt dem Modell mit, was der Code schon weiß, hält den Anfang jeder Anfrage stabil, damit der Cache greift, und stellt nicht auf ein billigeres Modell um, bevor die Aufgabensammlung das bestätigt hat.

Und er kann dem Kunden die Frage nach den Kosten ehrlich beantworten. Nicht mit dem Preis pro Million Tokens, sondern mit dem, was ihn eine gelöste Kundenanfrage kostet. Mit dieser Zahl lässt sich gegen die Alternative rechnen – und nur darauf kommt es an.

Fragen zum Thema – oder ein Projekt, das genau daran hängt?

Schreib mir über das Kontaktformular kurz, worum es geht. Ich schaue mir deinen Fall an und sage dir konkret, was sich verbessern lässt – unverbindlich und ohne Verkaufsgespräch.