Zum Inhalt
wannacode

Kosten und Latenz: Budget, Cache, ein kleineres Modell

Kurz gesagt: Die Kosten eines Sprachmodells sind Tokens × Preis, getrennt für Eingabe und Ausgabe; ein Token-Budget, ein Antwort-Cache und ein kleineres Modell senken die Rechnung, und der Cache und das kleinere Modell senken auch die Antwortzeit.

Problem

Die Rechnung am Monatsende

Der Support-Assistent arbeitet seit einem Monat. Er antwortet richtig, und das Testset aus der Lektion über die Evaluation besteht. Dann kommen zwei Beschwerden: Die Rechnung für das Modell ist höher als erwartet, und Kunden warten etwa eine Sekunde auf eine Antwort.

Hier ist ein Aufruf, wie der Assistent ihn macht. Die Token-Preise sind Beispiele, in Euro pro Million Tokens:

String question = "Wie viel kostet die Lieferung?";
Response r = CLIENT.create(LARGE, 1024, PROMPT + " " + ALL_DOCS, List.of(new Message("user", question)));
ModelInfo m = MODELS.get(LARGE);
double euro = cost(m, r.usage());
double input = r.usage().inputTokens() * m.inPrice() / 1_000_000;
System.out.printf("Eingabe: %d Tokens, Ausgabe: %d Tokens%n", r.usage().inputTokens(), r.usage().outputTokens());
System.out.printf("Kosten: %.5f € (Anteil der Eingabe %.0f%%)%n", euro, 100 * input / euro);
System.out.printf("Beispielzeit: %d ms%n", latency(m, r.usage()));
Ausgabe
Eingabe: 350 Tokens, Ausgabe: 40 Tokens
Kosten: 0.00204 € (Anteil der Eingabe 69%)
Beispielzeit: 1235 ms

Die Frage „Wie viel kostet die Lieferung?“ hat 10 Tokens, und die Eingabe hat 350. Ein Teil der Rechnung ist die Antwort, der größere Teil ist die Eingabe: Der System-Prompt und alle Dokumente des Shops reisen bei jeder Frage mit. Und im Log ist die Hälfte der Fragen gleich: wie viel die Lieferung kostet, wann der Support geöffnet ist.

Die Idee

Wohin das Geld und die Zeit gehen

Ein Modell wird nach Tokens bezahlt (die Lektion über Tokens), getrennt für die Eingabe und für die Ausgabe:

  • die Eingabe ist alles, was du schickst: der System-Prompt, die Dokumente, der Verlauf des Gesprächs, die Frage. Die API hat kein Gedächtnis, deshalb wird das jedes Mal alles geschickt (die Lektion über den Kontext);
  • die Ausgabe ist die Antwort. Ein Ausgabe-Token kostet meist ein Mehrfaches eines Eingabe-Tokens, hier das Vierfache.

Mit der Zeit ist es ähnlich, nur mit anderem Gewicht. Das Modell liest die Eingabe schnell und schreibt die Antwort Token für Token. Eine lange Antwort ist also langsam, und eine lange Eingabe ist vor allem teuer.

Daraus ergeben sich die drei Auswege, die die Lektion mit einem Programm misst:

  1. ein Token-Budget: weniger Eingabe (nur das, was nötig ist) und eine Obergrenze für die Ausgabe;
  2. ein Cache: Die gleiche Frage erreicht das Modell nicht;
  3. ein kleineres Modell für die einfachen Fragen: ein billigeres und schnelleres Token.

Ein Token-Budget ist eine im Voraus geplante Obergrenze für die Tokens eines Aufrufs, so wie du den Speicher oder die Antwortzeit einer Anfrage planst.

Sieh es dir an

Der Assistent im Code

Der falsche Client ist derselbe wie in der Lektion über die Evaluation: die Form der echten API, aufgezeichnete Antworten und grobe Tokens (jedes Wort sind 2 Tokens). Die Antworten sind nach einem Wort in der Frage aufgezeichnet:

// ein falscher Client: die Form der echten API, die Antworten sind aufgezeichnet
record Message(String role, String content) {}
record Usage(int inputTokens, int outputTokens) {}
record Response(String text, String stopReason, Usage usage) {}
// eine aufgezeichnete Antwort: Modell ("" = jedes), ein Wort in der Frage, Text, den der Prompt braucht
record Recorded(String model, String keyword, String needs, String text) {}
class FakeLlm {
private final List<Recorded> recorded;
FakeLlm(List<Recorded> recorded) {
this.recorded = recorded;
}
// grob: jedes Wort sind 2 Tokens
static int tokens(String text) {
return 2 * text.trim().split("\\s+").length;
}
Response create(String model, int maxTokens, String system, List<Message> messages) {
String question = messages.get(messages.size() - 1).content();
int input = tokens(system);
for (Message m : messages) input += tokens(m.content());
for (Recorded r : recorded) {
boolean forModel = r.model().isEmpty() || r.model().equals(model);
if (forModel && question.contains(r.keyword()) && system.contains(r.needs()))
return reply(r.text(), maxTokens, input);
}
return reply("(keine aufgezeichnete Antwort)", maxTokens, input);
}
// max_tokens schneidet die Antwort ab
private static Response reply(String text, int maxTokens, int input) {
String[] words = text.split(" ");
if (2 * words.length <= maxTokens)
return new Response(text, "end_turn", new Usage(input, 2 * words.length));
int kept = maxTokens / 2;
String cut = String.join(" ", Arrays.copyOf(words, kept));
return new Response(cut, "max_tokens", new Usage(input, 2 * kept));
}
}
static final List<Recorded> RECORDED = List.of(
new Recorded("", "kostet", "", "Die Lieferung an eine Adresse kostet 4,90 €, an eine Filiale 3,50 €, und ab 50 € ist sie kostenlos."),
new Recorded("", "Nachnahme", "", "Ja, per Nachnahme geht es. Die Gebühr beträgt 1,50 €."),
new Recorded("", "zurück", "", "Du kannst die Ware bis 14 Tage nach der Lieferung zurückgeben, wenn sie unbenutzt ist und das Etikett hat."),
new Recorded("", "geöffnet", "", "Der Support ist von Montag bis Freitag von 9 bis 18 Uhr geöffnet."),
new Recorded("", "1042", "", "Bestellung 1042 ist unterwegs und kommt am Donnerstag an."),
new Recorded("", "1077", "", "Bestellung 1077 wird gepackt und geht morgen raus."),
new Recorded("", "beschädigt", "", "Das tut mir leid! Mach ein Foto vom Paket und vom Etikett und schreib uns mit der Bestellnummer. Wir schicken dir ein neues oder erstatten dir das Geld innerhalb von 5 Werktagen."),
new Recorded("", "Tage", "", "Pakete kommen in 2 bis 4 Werktagen an."),
new Recorded("", "Großhandel", "", "Ich weiß es nicht, ich verbinde dich mit einem Kollegen."),
new Recorded("", "Mastercard", "", "Ja, wir akzeptieren Visa und Mastercard."));

Die Dokumente des Shops bestehen aus fünf Abschnitten. Jeder hat Wörter, an denen man ihn erkennt:

// ein Abschnitt der Dokumente und die Wörter, an denen man ihn erkennt
record Doc(List<String> triggers, String text) {}
static final String PROMPT = "Du bist der Support-Assistent eines Online-Shops. Antworte kurz und nur aus den Dokumenten. Steht die Antwort nicht darin, sag: „Ich weiß es nicht, ich verbinde dich mit einem Kollegen.“";
static final List<Doc> DOCS = List.of(
new Doc(List.of("lieferung", "paket"), "Lieferung: Pakete kommen in 2 bis 4 Werktagen an. Die Lieferung an eine Adresse kostet 4,90 €, an eine Filiale des Kuriers 3,50 €, und ab 50 € ist sie kostenlos. Ein Paket wird anhand seiner Nummer verfolgt."),
new Doc(List.of("zurück", "beschädigt"), "Rückgabe: Ware kann bis 14 Tage nach der Lieferung zurückgegeben werden, wenn sie unbenutzt ist und das Etikett hat. Ein beschädigtes Paket wird fotografiert und dem Support gemeldet. Das Geld geht innerhalb von 5 Werktagen auf die Karte zurück."),
new Doc(List.of("zahl", "karte", "mastercard"), "Zahlung: Wir akzeptieren Visa und Mastercard. Die Nachnahme kostet 1,50 € Gebühr. Die Kartenzahlung ist geschützt, und der Betrag wird abgebucht, wenn die Bestellung bestätigt wird."),
new Doc(List.of("bestellung"), "Bestellungen: Der Status einer Bestellung steht im Profil. Der Support prüft eine Bestellung nur anhand ihrer Nummer und fragt nie nach Kartendaten."),
new Doc(List.of("geöffnet", "erreichbar"), "Support: Wir arbeiten von Montag bis Freitag, von 9 bis 18 Uhr. Schreib uns an help@shop.example."));
static final String ALL_DOCS = String.join(" ", DOCS.stream().map(Doc::text).toList());

Die Modelle, die Preise und die Zeiten. Alle Zahlen hier sind Beispiele, und die Zeit kommt aus einer Formel, sie ist nicht gemessen:

// Beispielpreise in € pro 1.000.000 Tokens und Beispielzeit (nicht gemessen)
record ModelInfo(double inPrice, double outPrice, int baseMs, int inputPerMs, int msPerToken) {}
static final String LARGE = "model-large", SMALL = "model-small";
static final Map<String, ModelInfo> MODELS = Map.of(
LARGE, new ModelInfo(4.00, 16.00, 400, 10, 20),
SMALL, new ModelInfo(0.40, 1.60, 150, 40, 5));
static final int CACHE_MS = 5;
// die Kosten eines Aufrufs in €
static double cost(ModelInfo m, Usage u) {
return (u.inputTokens() * m.inPrice() + u.outputTokens() * m.outPrice()) / 1_000_000;
}
// Beispielzeit in ms: Start + Eingabe lesen + Erzeugen
static int latency(ModelInfo m, Usage u) {
return m.baseMs() + u.inputTokens() / m.inputPerMs() + u.outputTokens() * m.msPerToken();
}

Die Antwort für Bestellung 1042 ist aufgezeichnet. Im echten Assistenten kommt der Status von einem Tool (die Lektion über Tools).

1_000_000 ist dasselbe wie 1000000: Die Unterstriche trennen nur die Ziffern. u.inputTokens() / m.inputPerMs() ist eine Ganzzahldivision und lässt den Rest weg.

Probier es aus

Ergänze

Die Kosten sind die Eingabe-Tokens mal der Eingabepreis plus die Ausgabe-Tokens mal …?

return (u.inputTokens() * m.inPrice() + u.outputTokens() * ) / 1_000_000;

Sieh es dir an

Ein Tag

Ein Tag im Support ist eine Stichprobe von 20 Fragen. Manche wiederholen sich, manchmal klein geschrieben oder mit einem Leerzeichen zu viel. Drei betreffen eine bestimmte Bestellung:

List<String> questions = List.of(
"Wie viel kostet die Lieferung?",
"Wann ist der Support geöffnet?",
"Wo ist Bestellung 1042?",
"wie viel kostet die lieferung?",
"Kann ich per Nachnahme zahlen?",
"Bis wann kann ich die Ware zurückgeben?",
"Wie viel kostet die Lieferung?",
"Mein Paket kam beschädigt an, was soll ich tun?",
"Wann ist der Support geöffnet?",
"Wo ist Bestellung 1077?",
"Akzeptiert ihr Mastercard?",
"Wie viel kostet die Lieferung? ",
"Wie viele Tage braucht ein Paket nach Hamburg?",
"Kann ich per Nachnahme zahlen?",
"Verkauft ihr auch im Großhandel?",
"Bis wann kann ich die Ware zurückgeben?",
"Wo ist Bestellung 1042?",
"Wann ist der Support geöffnet?",
"Wie viel kostet die Lieferung an eine Filiale?",
"Wie gebe ich die Ware zurück?");

day geht die Fragen durch und addiert die Kosten und die Zeit. Die drei Flags schalten die drei Auswege ein, und im Moment sind sie aus:

static Totals day(List<String> questions,
boolean budget, boolean cache,
boolean route) {
Map<String, String> answers = new HashMap<>();
double euro = 0;
int ms = 0, hits = 0, onSmall = 0;
for (String question : questions) {
String k = key(question);
// Treffer: kein Modell
if (cache && answers.containsKey(k)) {
hits++;
ms += CACHE_MS;
continue;
}
String model = route ? pickModel(question) : LARGE;
String docs = budget ? relevant(question)
: ALL_DOCS;
int maxTokens = budget ? 200 : 1024;
var msg = List.of(new Message("user", question));
Response r = CLIENT.create(model, maxTokens,
PROMPT + " " + docs, msg);
ModelInfo m = MODELS.get(model);
euro += cost(m, r.usage());
ms += latency(m, r.usage());
if (model.equals(SMALL)) onSmall++;
// eine Bestellung gehört einem Kunden: kein Cache
if (cache && !k.contains("bestellung"))
answers.put(k, r.text());
}
return new Totals(euro, ms, hits, onSmall);
}
static void row(String name, Totals t, int n) {
System.out.printf("%s: %.2f € pro 1000 Fragen, im Schnitt %d ms%n", name, t.euro() / n * 1000, t.ms() / n);
}
static void month(Totals t, int n) {
System.out.printf("Monat bei 3000 Fragen am Tag: %.2f €%n", t.euro() / n * 3000 * 30);
}
Totals all = day(questions, false, false, false);
row("großes Modell, alle Dokumente", all, questions.size());
month(all, questions.size());
Ausgabe
großes Modell, alle Dokumente: 1.88 € pro 1000 Fragen, im Schnitt 1030 ms
Monat bei 3000 Fragen am Tag: 169.27 €

Die Kosten pro 1000 Fragen liegen unter 2 €. Bei 3000 Fragen am Tag sind es im Monat etwa 169 €, und eine Frage wartet im Schnitt etwa eine Sekunde.

Die Idee

Ein Token-Budget

Der erste Ausweg ist die Eingabe. Die Frage zur Lieferung braucht nur den Abschnitt zur Lieferung. relevant nimmt die Abschnitte, deren Wörter in der Frage vorkommen, und wenn es keinen gibt, schickt es alle (die Frage zum Großhandel muss alles sehen, um „Ich weiß es nicht“ zu sagen):

// Budget: nur die Abschnitte, deren Wörter in der Frage vorkommen
static String relevant(String question) {
String low = question.toLowerCase();
List<String> parts = new ArrayList<>();
for (Doc doc : DOCS)
if (doc.triggers().stream().anyMatch(low::contains))
parts.add(doc.text());
return parts.isEmpty() ? ALL_DOCS : String.join(" ", parts);
}

Die zweite Obergrenze ist max_tokens: höchstens 200 Tokens pro Antwort. Sie macht die Antworten nicht kürzer. Sie sagt, wie viel ein Aufruf höchstens kosten kann.

Totals all = day(questions, false, false, false);
row("großes Modell, alle Dokumente", all, questions.size());
Totals budget = day(questions, true, false, false);
row("+ Token-Budget", budget, questions.size());
Ausgabe
großes Modell, alle Dokumente: 1.88 € pro 1000 Fragen, im Schnitt 1030 ms
+ Token-Budget: 1.05 € pro 1000 Fragen, im Schnitt 1010 ms

Die Kosten sinken um fast die Hälfte. Die Zeit bewegt sich kaum: Die Eingabe wird schnell gelesen, und die Antwort ist dieselbe.

Hier werden die Abschnitte nach Wörtern ausgewählt. Wie du sie nach Bedeutung mit Vektoren auswählst, steht im Modul über die semantische Suche (RAG).

doc.triggers().stream().anyMatch(low::contains) ist wahr, wenn mindestens ein Wort des Abschnitts in der Frage vorkommt. low::contains ist eine kurze Lambda: word -> low.contains(word).

Probier es aus

Sag die Ausgabe voraus

max_tokens ist 10, und die Frage betrifft ein beschädigtes Paket. Was wird ausgegeben?

Response r = CLIENT.create(LARGE, 10, PROMPT, List.of(new Message("user", "Mein Paket kam beschädigt an, was soll ich tun?")));
System.out.println(r.text());
System.out.println(r.stopReason() + ", " + r.usage().outputTokens());
Antwort

Die Idee

Ein Antwort-Cache

Der zweite Ausweg ist die Wiederholung. Wurde dieselbe Frage vor Kurzem gestellt, ist die Antwort dieselbe: Du musst das Modell nicht noch einmal fragen. Das ist Cache-Aside aus der Lektion „Caching“: Du schaust im Cache nach, und bei einem Fehlschlag fragst du das Modell und behältst eine Kopie.

Der Schlüssel ist die Frage in Kleinbuchstaben mit je einem Leerzeichen, sodass „wie viel kostet die lieferung? “ dasselbe ist wie „Wie viel kostet die Lieferung?“:

// der Cache-Schlüssel: Kleinbuchstaben, je ein Leerzeichen
static String key(String question) {
return String.join(" ", question.trim().toLowerCase().split("\\s+"));
}

In day sind das die Zeilen mit answers: Ein Treffer gibt die gespeicherte Antwort in 5 ms und gratis zurück. Eine Frage zu einer Bestellung wird nicht gecacht. Die Antwort gehört einem Kunden, und der Status ändert sich.

Totals all = day(questions, false, false, false);
row("großes Modell, alle Dokumente", all, questions.size());
Totals budget = day(questions, true, false, false);
row("+ Token-Budget", budget, questions.size());
Totals cached = day(questions, true, true, false);
row("+ Cache", cached, questions.size());
System.out.println("aus dem Cache: " + cached.hits() + " von " + questions.size());
Ausgabe
großes Modell, alle Dokumente: 1.88 € pro 1000 Fragen, im Schnitt 1030 ms
+ Token-Budget: 1.05 € pro 1000 Fragen, im Schnitt 1010 ms
+ Cache: 0.69 € pro 1000 Fragen, im Schnitt 637 ms
aus dem Cache: 7 von 20

7 von 20 Fragen kommen aus dem Cache. Jetzt sinkt auch die Zeit, weil ein Treffer nicht auf das Modell wartet.

Probier es aus

Finde den Fehler

Ein Kollege vereinfacht den Cache. Die Kostentests bestehen, und einen Tag später beschwert sich ein Kunde, der Assistent habe ihm den Status einer fremden Bestellung genannt. Welche Zeile hat den Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Ein kleineres Modell für das Einfache

Der dritte Ausweg ist das Modell. Das kleine Modell ist pro Token 10-mal billiger und schreibt schneller. In der Lektion über die Evaluation hat das Testset gezeigt, wo es gut ist: beim Preis der Lieferung, bei der Zahlung, bei der Rückgabe. Und wo nicht: bei der Lieferzeit und bei der Frage außerhalb der Dokumente. Es bekommt also nur die freigegebenen Themen:

// die einfachen Themen: das Testset hat gezeigt, dass das kleine Modell sie schafft
static final List<String> EASY = List.of("kostet", "Nachnahme", "zurück", "Mastercard");
static String pickModel(String question) {
for (String word : EASY)
if (question.contains(word)) return SMALL;
return LARGE;
}
Totals all = day(questions, false, false, false);
row("großes Modell, alle Dokumente", all, questions.size());
Totals budget = day(questions, true, false, false);
row("+ Token-Budget", budget, questions.size());
Totals cached = day(questions, true, true, false);
row("+ Cache", cached, questions.size());
Totals routed = day(questions, true, true, true);
row("+ kleines Modell für das Einfache", routed, questions.size());
System.out.println("beim kleinen Modell: " + routed.onSmall() + " von " + questions.size());
month(all, questions.size());
month(routed, questions.size());
Ausgabe
großes Modell, alle Dokumente: 1.88 € pro 1000 Fragen, im Schnitt 1030 ms
+ Token-Budget: 1.05 € pro 1000 Fragen, im Schnitt 1010 ms
+ Cache: 0.69 € pro 1000 Fragen, im Schnitt 637 ms
+ kleines Modell für das Einfache: 0.41 € pro 1000 Fragen, im Schnitt 418 ms
beim kleinen Modell: 6 von 20
Monat bei 3000 Fragen am Tag: 169.27 €
Monat bei 3000 Fragen am Tag: 36.54 €

Der Monat sinkt von etwa 169 € auf etwa 37 €, und die durchschnittliche Zeit von 1030 auf 418 ms. Die Zahlen stammen aus Beispielpreisen und einer Beispielformel, aber die Reihenfolge der Auswege ist echt.

Sieh es dir an

Die Rechnung, Ausweg für Ausweg

static Totals day(List<String> questions,
boolean budget, boolean cache,
boolean route) {
Map<String, String> answers = new HashMap<>();
double euro = 0;
int ms = 0, hits = 0, onSmall = 0;
for (String question : questions) {
String k = key(question);
// Treffer: kein Modell
if (cache && answers.containsKey(k)) {
hits++;
ms += CACHE_MS;
continue;
}
String model = route ? pickModel(question) : LARGE;
String docs = budget ? relevant(question)
: ALL_DOCS;
int maxTokens = budget ? 200 : 1024;
var msg = List.of(new Message("user", question));
Response r = CLIENT.create(model, maxTokens,
PROMPT + " " + docs, msg);
ModelInfo m = MODELS.get(model);
euro += cost(m, r.usage());
ms += latency(m, r.usage());
if (model.equals(SMALL)) onSmall++;
// eine Bestellung gehört einem Kunden: kein Cache
if (cache && !k.contains("bestellung"))
answers.put(k, r.text());
}
return new Totals(euro, ms, hits, onSmall);
}
Schritt für Schritt, in Worten
  1. Ein Aufruf: Prompt, alle Dokumente und die Frage gehen hinein (350 Tokens), die Antwort kommt heraus (40). Die Dokumente machen den größten Teil der Rechnung aus: 0,00204 €.
  2. Das Token-Budget: nur der Abschnitt, nach dem die Frage fragt, und eine Obergrenze für die Antwort. 144 Tokens Eingabe, 0,00122 € für dieselbe Antwort.
  3. Ein Tag mit 20 Fragen, pro 1000 Fragen: 1,88 € mit allen Dokumenten, 1,05 € mit dem Budget. Die Zeit bewegt sich kaum: Sie besteht vor allem aus der Antwort.
  4. Der Cache: 7 von 20 Fragen sind Wiederholungen und erreichen das Modell nicht. Fragen zu einer Bestellung kommen nie in den Cache.
  5. Das kleine Modell übernimmt die einfachen Themen, die das Testset freigegeben hat: 6 von 20 Fragen. Es ist billiger und schneller.
  6. Von 1,88 € auf 0,41 € pro 1000 Fragen (78 % weniger) und von 1030 auf 418 ms. Die Preise und Zeiten sind Beispiele.

Die Idee

Bei dir ausführen

Die echte Antwort sagt, wie viele Tokens verbraucht wurden. Hier ist die Anfrage mit dem Budget für die Frage zur Lieferung und die Antwort. Der Text und die Zahlen stammen vom falschen Client:

Anfrage
{
"model": "<Name des Modells>",
"max_tokens": 200,
"system": "<der System-Prompt>",
"messages": [
{
"role": "user",
"content": "Wie viel kostet die Lieferung?"
}
]
}
Antwort
{
"content": [
{
"type": "text",
"text": "Die Lieferung an eine Adresse kostet 4,90 €, an eine Filiale 3,50 €, und ab 50 € ist sie kostenlos."
}
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 144,
"output_tokens": 40
}
}

Die Felder sind usage.input_tokens und usage.output_tokens, und stop_reason sagt, ob die Antwort vollständig ist (end_turn) oder die Obergrenze sie abgeschnitten hat (max_tokens). Damit berechnet das Programm dieselbe Rechnung wie hier, nur mit echten Zahlen.

Anbieter liefern offizielle SDKs für Java, C#, Python und JavaScript mit derselben Form: Nachrichten, Rollen, Tokens, Tools. Die genauen Namen der Klassen und Methoden stehen in der Dokumentation des Anbieters. Hier geht die Anfrage über den Standard-HTTP-Client der Sprache, ohne SDK. Die Adresse und das Modell sind Platzhalter:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// nur zur Ansicht: die Adresse und der Header für den Schlüssel stehen in der Doku des Anbieters
static final String ENDPOINT = "https://<anbieter>/...";
static String quote(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
static String ask(String system, String question) throws Exception {
String body = "{\"model\": " + quote("<Name des Modells>")
+ ", \"max_tokens\": 200, \"system\": " + quote(system)
+ ", \"messages\": [{\"role\": \"user\", \"content\": "
+ quote(question) + "}]}";
String key = System.getenv("LLM_API_KEY");
HttpRequest request = HttpRequest.newBuilder(URI.create(ENDPOINT))
.header("content-type", "application/json")
.header("authorization", "Bearer " + key)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
return response.body(); // JSON: content, stop_reason, usage
}
// Java hat keinen JSON-Parser in der Standardbibliothek: usage und stop_reason stehen im ausgegebenen JSON
System.out.println(ask(PROMPT + " " + relevant(question), question));

Dieser Code läuft nicht in der Codeprüfung der Seite. Der Schlüssel steht in der Umgebungsvariablen LLM_API_KEY, nicht im Code (die Lektion „Geheimnisse im Code und in Git“), du brauchst ein Konto bei einem Anbieter, und jeder Lauf kostet Geld.

quote setzt den String in Anführungszeichen und maskiert \ und ". Die Standardbibliothek von Java hat keinen JSON-Parser: Eine JSON-Bibliothek liest die Felder der Antwort.

Die Idee

Wann es weh tut

  • Ein Budget, das abschneidet, was nötig ist. Wenn relevant den Abschnitt mit der Antwort verfehlt, sagt das Modell „Ich weiß es nicht“ oder, schlimmer, erfindet etwas. Ein zu niedriges max_tokens liefert eine halbe Antwort. Jede Änderung am Budget läuft durch das Testset.
  • Ein Cache, der lügt. Der Preis der Lieferung ändert sich, und der Cache liefert den alten. Ein Cache hat ein Verfallsdatum (TTL) und wird geleert, wenn sich die Dokumente ändern. Antworten, die vom Kunden oder von der Zeit abhängen, kommen nicht hinein.
  • Ein kleines Modell ohne Testset. „Es sieht einfach aus“ ist kein Argument. Das kleine Modell übernimmt ein Thema erst, nachdem das Testset es bestanden hat, und das Testset läuft bei jeder Änderung neu.
  • Sparen, das sich nicht lohnt. Bekommt der Assistent 50 Fragen am Tag, sind es einige Euro im Monat. Die Komplexität von Cache und Router kann mehr kosten als das. Miss zuerst.
Genauer, für Neugierige

Den Anfang des Prompts cachen. Viele Anbieter behalten den wiederholten Anfang des Prompts (den System-Prompt und die Dokumente) und berechnen weniger, wenn er noch einmal gelesen wird. Das ist ein anderer Cache: Das Modell antwortet weiterhin, aber die Eingabe ist billiger. Das funktioniert, wenn der Anfang bis zum letzten Zeichen identisch ist, deshalb stehen die veränderlichen Teile (die Frage, der Name des Kunden) am Ende.

Ein Cache nach Bedeutung. „Wie gebe ich die Ware zurück?“ und „Bis wann kann ich die Ware zurückgeben?“ sind verschiedene Schlüssel. Ein Cache mit Vektoren (die Lektion über Vektoren) behandelt sie als eine Frage. Er findet mehr Treffer, und auch falsche: „Kann ich Schuhe zurückgeben?“ und „Kann ich ein Geschenk zurückgeben?“ liegen nah beieinander, und die Antworten können sich unterscheiden.

Eine gestreamte Antwort. Die Antwort kann Wort für Wort ankommen (Streaming). Die Gesamtzeit ist dieselbe, aber der Kunde sieht die ersten Wörter nach etwa hundert ms statt nach einer Sekunde. Das ist Tempo für den Menschen, nicht für die Rechnung.

Batches. Arbeit, auf die kein Kunde wartet (10.000 Bewertungen über Nacht zusammenfassen), kann über einen Batch-Modus laufen: langsamer und billiger.

Die Zeit in der Produktion. Der Durchschnitt versteckt das Ende der Verteilung: 95 % der Antworten können unter einer Sekunde liegen und 5 % über 5 Sekunden. Miss Perzentile, wie in der Lektion „Nichtfunktionale Anforderungen, die du messen kannst“ aus „Software-Engineering“. Kosten und Zeit in der Produktion zu beobachten gehört zu MLOps und wird in ZeroToOps unterrichtet.

Map.of(...) erzeugt eine unveränderliche Map, und String.join(" ", ...) verbindet die Texte mit einem Leerzeichen.

Übung

Welcher Ausweg spart am meisten

Löse es in deinem Editor.

Das Programm wendet die Auswege kumulativ an, du siehst also nicht, wie viel jeder allein bringt. Starte day dreimal, jedes Mal mit nur einem Flag, und gib die Zeilen neben der Zeile ohne Auswege aus:

Ausgabe
großes Modell, alle Dokumente: 1.88 € pro 1000 Fragen, im Schnitt 1030 ms
nur Budget: 1.05 € pro 1000 Fragen, im Schnitt 1010 ms
nur Cache: 1.21 € pro 1000 Fragen, im Schnitt 650 ms
nur kleines Modell: 0.92 € pro 1000 Fragen, im Schnitt 604 ms

Allein ist das kleine Modell am billigsten, und das Budget kommt danach nah heran. Das Budget spart kaum Zeit, und der Cache und das kleine Modell senken sie am stärksten, weil sie nicht darauf warten, dass das große Modell schreibt. Die drei sparen an verschiedenen Stellen, deshalb bringen sie zusammen am meisten.

Weitere Aufgaben

  • Noch ein einfaches Thema. Nimm „geöffnet“ in die einfachen Themen auf. Um wie viel sinken die Kosten? Was musst du tun, bevor du es ausliefern kannst? (Hinweis: die Lektion über die Evaluation.)
  • Eine Obergrenze für den Tag. Hör auf, das große Modell zu fragen, sobald die Kosten für den Tag 0,01 € überschreiten, und antworte „Ich verbinde dich mit einem Kollegen“. Bei wie vielen Fragen gibt es keine Antwort vom Modell?

Test

Teste dich

1. Die Frage hat 10 Tokens, und die Rechnung des Aufrufs gilt für 350 Eingabe-Tokens. Woher kommt der Rest?
2. Das Token-Budget hat die Kosten fast halbiert, und die durchschnittliche Zeit hat sich kaum bewegt. Warum?
3. Welche Frage darf nicht in den Antwort-Cache?
4. Wie wählst du aus, welche Fragen das kleine Modell übernimmt?
5. max_tokens ist 10, und die Antwort braucht 30 Tokens. Was bekommst du?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·