Zum Inhalt springen
14/30Kapitel 14 von 30

Dein erster LLM-Call in Production: Streaming, Retries, Timeouts

Baue einen Provider, der dich anlügt: 429s, hängende Sockets, halbierte Streams. Full jitter: 2,2 Sekunden statt 226.

Auf dieser Seite

Kapitel 13 endete mit einer Stoppuhr an einem Modell, das du anfassen konntest. Die Gewichte lagen in deinem Speicher, der KV cache gehörte dir zum Aktivieren oder Deaktivieren, und die Zahl, die herauskam — time to first token — war eine Eigenschaft deiner Hardware.

Jetzt stell dieses Modell hinter einen Port, wie es jedes Produkt tut, und lies dieselbe Zahl noch einmal. Es ist immer noch time to first token, aber sie ist keine Eigenschaft von irgendetwas mehr, das du kontrollierst. Sie enthält jetzt einen TLS-Handshake, eine Warteschlange beim Provider, einen Rate Limiter und die Möglichkeit, dass überhaupt nie ein token ankommt.

Dieser letzte Satzteil ist das Kapitel. Der Code, den du gleich schreibst, berechnet nichts. Er öffnet eine Verbindung, wartet, parst, was ankommt, entscheidet, was zu tun ist, wenn nichts ankommt, entscheidet erneut, wenn das, was ankommt, ein Fehler ist, und bricht sich selbst ab, wenn der Nutzer seine Meinung ändert. Jede dieser Entscheidungen betrifft Zustand über Zeit, und jede hat eine falsche Antwort, die ausgeliefert wird und Geld kostet.

So sieht das Problem aus, gemessen, alles davon in diesem Kapitel:

was passiert istwas ein sorgloser Client tutwas es kostet
der Server hat den Socket akzeptiert und nie geantwortetwartet300,8 s, bevor Node von allein aufgibt
der Schlüssel war falsch (401)versucht es fünfmal erneut6.325 ms Verzögerung, dann dieselbe 401
hundert Clients treffen gleichzeitig das Rate Limitalle versuchen es nach demselben Zeitplan erneut226 s zum Leeren, gegenüber 2,2 s
die Anfrage ist abgelaufen und wurde erneut gesendetsendet sie erneutder Provider generiert — und berechnet — die Antwort zweimal
die Verbindung bricht mitten in der Antwort abzeigt den Teiltextnicht von einer korrekt kurzen Antwort zu unterscheiden

Nichts davon ist ein Modellierungsproblem. Alles davon steckt in den ersten hundert Zeilen jedes LLM-Produkts, das je geschrieben wurde.

Lies die Tabelle noch einmal und frag dich, welche Art Programm sie beschreibt. Es hält vierzig Sekunden lang eine Verbindung offen. Es muss über einen Button abbrechbar sein. Es sammelt eine Teilantwort, die gültig zum Anzeigen und ungültig zum Speichern ist. Und es läuft in einem Serverprozess oder auf einem Edge Worker, neben dem Teil, der die Antwort rendert, und hält dabei einen Socket.

Das ist kein Notebook. Nicht, dass Python das nicht könnte — es kann es, und Leute tun es —, sondern dass alles, was die vorherigen dreizehn Kapitel aufgebaut haben, von anderer Art war. Kapitel 1 bis 13 hielten Gewichte, Gradienten, logits und Tokenizer-Bytes. Ab hier hält der Code eine Verbindung, einen retry, einen Abbruch, angesammelten Zustand und später einen Berechtigungs-prompt. Der Kurs wechselt genau an der Nahtstelle die Sprache, an der sich das Objekt ändert.

Also die Regel, einmal aufgeschrieben:

Wenn der Code Gewichte, Gradienten, logits oder Tokenizer-Bytes in den Händen hält, ist er Python. Wenn er eine Verbindung hält, retries ausführt, abbricht, Zustand ansammelt und um Erlaubnis fragt, ist er TypeScript.

Es gibt nur eine einzige Nahtstelle, und sie liegt hier, zwischen Kapitel 13 und Kapitel 14. Drei unabhängige Kriterien setzen sie hier.

Eins: das Ökosystem, gezählt. Alles, was die linke Hälfte dieses Kurses zitiert, ist Python, und über die zwölf für diesen Lehrplan geprüften Kurse hinweg gibt es kein einziges Beispiel, in dem backpropagation in einer anderen Sprache gelehrt wird: micrograd (17,4K Stars), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Kapitel 5 in TypeScript zu schreiben, würde die Verbindung zu diesen Quellen kappen, und die Links sind der halbe Wert eines Kapitels, das existiert, um referenziert zu werden, nicht um zu ranken. Auf dieser Seite dreht sich die Rechnung um: Vercels ai-Paket liegt bei 89,4 Mio. Downloads pro Monat und liefert die Sache selbst aus — eine tool-calling agent loop, exportiert als ToolLoopAgent —, sodass das Konzept, das dieser Kurs in Kapitel 23 erreicht, seine Referenzimplementierung in TypeScript hat, obwohl sich, wie dieses Kapitel misst, niemand auf einen Namen dafür geeinigt hat; Mastra liegt bei 27,7K Stars; und Anthropics SDKs, aus einer Spezifikation generiert, deklarieren 202 Endpunkte in TypeScript gegenüber 201 in Python — Parität, kein höflicher Port.

Zwei: die normative Quelle von MCP. Das Schema der Model Context Protocol-Spezifikation ist eine schema.ts-Datei. Das Protokoll aus Kapitel 26 in einer anderen Sprache zu lehren, heißt, eine Übersetzung seines Gründungsdokuments zu lehren.

Drei: Suchnachfrage, mit einer Korrektur der naheliegenden Vermutung. machine learning python ist die am stärksten gesättigte Phrase im Internet; ai agent typescript hat seinen eigenen gesunden Long Tail. Aber „das MCP-Ökosystem ist hauptsächlich TypeScript“ stimmt nur je nachdem, wie du zählst: Die offizielle Registry listet 8.275 Server auf npm gegenüber 3.603 auf PyPI, während Python nach Downloads gewinnt — 287 Mio. pro Monat für mcp plus 72 Mio. für fastmcp gegenüber 195 Mio. für @modelcontextprotocol/sdk. MCP ist hier das eine wirklich zweisprachige Gebiet, weshalb Kapitel 27 denselben Server zweimal schreibt, statt so zu tun, als wäre es anders.

Details anzeigen

Die fünf erklärten Ausnahmen, damit die Regel eine Regel ist und kein Slogan.

Kapitel 17, 20 und 29 enthalten ein zweites Panel in Python: top-p sampling zu implementieren braucht den Wahrscheinlichkeitsvektor in deiner Hand, und eine HTTP API gibt dir nie einen; den Preis für einen fine-tune ehrlich zu bestimmen heißt, einen auszuführen, und ein LoRA-Adapter sind ein Dutzend Zeilen nn.Module; und lm-eval-harness, HELM, SWE-bench und τ-bench sind Python, also wäre ein Evaluierungs-harness in TypeScript das Spiegelbild des backpropagation-Fehlers. Kapitel 27 ist aus dem oben gemessenen Grund zweisprachig. Kapitel 28 ist Markdown, weil ein agent skill eine SKILL.md-Datei ist und ihm eine Programmiersprache zu geben bedeuten würde, das Format nicht verstanden zu haben.

Die dreizehn Python-Kapitel werden nicht weggeworfen. Auf der anderen Seite des Ports liegt, was sie gebaut haben, und der letzte Abschnitt hier verbindet einen Client damit.

Das kannst du nicht an einem echten Provider lernen. Du kannst ihn nicht zu einem gewählten Zeitpunkt um eine 429 bitten, oder um einen Socket, der deine Verbindung annimmt und nie antwortet, oder um einen Stream, der mitten in einem Wort stoppt — und du würdest für jedes Experiment bezahlen, obwohl die interessanten Experimente die sind, die du hundertmal laufen lässt.

Das erste Programm in dieser Hälfte des Kurses ist also kein Client. Es ist ein feindlicher Server: vierzig Zeilen schlichtes Node, die dasselbe Wire Protocol sprechen wie ein Chat-Completions-Endpunkt und sich auf Befehl danebenbenehmen. Jede Zahl in diesem Kapitel stammt daraus.

mock-provider.mjsJS
import { createServer } from "node:http";

const WORDS = "A tide gauge is a device that measures sea level over time .".split(" ");
const CAPACITY = 3;                      // how many requests it will serve at once
let inflight = 0;

const sse = (res, obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);

createServer(async (req, res) => {
  const url = new URL(req.url, "http://x");

  if (url.pathname === "/hang") return;                        
  if (url.pathname === "/401") { res.writeHead(401); return res.end("{}"); }

  if (inflight >= CAPACITY) {                                  
    res.writeHead(429, { "retry-after": "1" });                
    return res.end(JSON.stringify({ error: { type: "rate_limit_error" } }));
  }
  inflight++;

  const cut = Number(url.searchParams.get("cut") ?? -1);       // abandon after N chunks
  const how = url.searchParams.get("how");                     // "close" = orderly, else reset
  const max = Number(url.searchParams.get("max_tokens") ?? 999);
  const delay = Number(url.searchParams.get("delay") ?? 60);   // ms per token

  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
  for (let i = 0; i < Math.min(WORDS.length, max); i++) {
    if (i === cut) {                                           
      how === "close" ? res.end() : res.destroy();             
      inflight--; return;                                      
    }
    await new Promise((r) => setTimeout(r, delay));
    sse(res, { choices: [{ delta: { content: (i ? " " : "") + WORDS[i] }, finish_reason: null }] });
  }
  sse(res, { choices: [{ delta: {}, finish_reason: max < WORDS.length ? "length" : "stop" }] });
  res.write("data: [DONE]\n\n");
  inflight--;
  res.end();
}).listen(8787);

Vier feindliche Verhaltensweisen, je eine Zeile: /hang akzeptiert den Socket und schreibt nie hinein; /401 weist den Schlüssel ab; der Capacity-Check erzeugt eine echte 429 mit echtem Retry-After-Header, sobald bereits drei Requests in flight sind; und ?cut=N bricht die Antwort auf halber Strecke ab, entweder durch Zurücksetzen des Sockets oder — mit &how=close — durch ordentliches Schließen, was sich als sehr wichtig herausstellt. Der Rest ist ein echter Server-Sent Events-Stream: ein JSON-Objekt pro data:-Zeile, eine Leerzeile zwischen Events, der String [DONE] am Ende.1

Starte ihn, und der Rest des Kapitels ist Messung.

terminalBASH
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"
TEXT
data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}

data: {"choices":[{"delta":{},"finish_reason":"length"}]}

data: [DONE]

Der Request Body und der Schlüssel, der den Server nie verlässt

Link zum Abschnitt: Der Request Body und der Schlüssel, der den Server nie verlässt

Ein Chat-Request ist eine Liste von Nachrichten, jede mit einer Rolle. Diese Liste ist der gesamte Zustand des Modells: Es gibt kein Gedächtnis zwischen Calls, und alles, was das Modell wissen soll, muss in dem Array stehen, das du dieses Mal sendest. Kapitel 15 handelt davon, was hineingehört, und Kapitel 16 davon, was es kostet; hier geht es nur um die Form.

call.tsTS
const body = {
  model: "gpt-4.1-mini",
  messages: [
    { role: "system", content: "You explain instruments in one sentence." },
    { role: "user", content: "What is a tide gauge?" },
  ],
  stream: true,
  max_tokens: 200,
};

Diese Rollen sind keine Dekoration. Sie werden in das Chat-Template aus Kapitel 11 gerendert, bevor das Modell ein einziges token sieht, weshalb die falsche Rolle die Antwort still verschlechtert, statt einen Fehler auszulösen.

Eine Regel ohne Ausnahmen: Der API-Schlüssel wandert nie zum Client. Nicht in einer für den Browser präfixten Umgebungsvariable, nicht in einer Build-Time-Konstante, nicht „vorübergehend“. Ein Schlüssel in einem Bundle ist innerhalb weniger Tage ein Schlüssel auf der Rechnung von jemand anderem. Der Browser spricht mit deinem Server, dein Server hält den Schlüssel und spricht mit dem Provider — und weil dein Server in der Mitte sitzt, ist er auch der einzige Ort, der messen kann, was jeder Nutzer ausgibt; genau dort muss die Buchhaltung aus Kapitel 16 leben.

Jetzt das Experiment, auf dem das Kapitel aufbaut. Eine Frage, ein Mock-Provider, der dreizehn tokens mit je 60 ms erzeugt, drei Arten zu fragen.

Erstens, ohne Streaming. Der Client sendet den Request und wartet auf den gesamten JSON-Body.

TEXT
blocking   first visible =  791 ms   complete =  791 ms   finish_reason = stop

Die beiden Zahlen sind gleich, und das ist das ganze Problem. 791 ms lang sieht der Nutzer einen Spinner, und kein einziges Wort war früher verfügbar — der Server hatte die Antwort, Byte für Byte, und entschied sich, nichts zu sagen.

Zweitens, mit Streaming. Derselbe Server, dieselbe Antwort, dieselbe Gesamtarbeit. Der Unterschied ist ein Parser.

sse.tsTS
export async function* readSSE(res: Response) {
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });   
    let sep: number;
    while ((sep = buffer.indexOf("\n\n")) !== -1) {       
      const event = buffer.slice(0, sep);
      buffer = buffer.slice(sep + 2);
      for (const line of event.split("\n")) {
        if (!line.startsWith("data:")) continue;
        const payload = line.slice(5).trim();
        if (payload === "[DONE]") return;
        yield JSON.parse(payload);
      }
    }
  }
}

Drei Details dort tragen die Last, und die meisten ersten Versuche überspringen alle drei. buffer existiert, weil ein Netzwerk-Chunk keinen Bezug zu einem Event hat: Ein read() kann ein halbes Event zurückgeben oder zweieinhalb. Das { stream: true }-Flag existiert, weil ein mehrbyteiges UTF-8-Zeichen über zwei Chunks geteilt werden kann, und ohne es wird ein Buchstabe mit Akzent zufällig zu einem Replacement Character. Und Events werden durch eine Leerzeile getrennt, nicht durch einen Zeilenumbruch, weshalb die Schleife nach \n\n sucht.

TEXT
streaming  first visible =   65 ms   complete =  793 ms   finish_reason = stop

Zwölfmal schneller bis zum ersten Wort, und zwei Millisekunden langsamer bis zum letzten. Streaming macht nichts schneller. Es ändert, was der Nutzer während derselben 790 ms tut: lesen statt warten. Das ist der ganze Nutzen, er ist enorm, und genau deshalb streamt jedes Chat-Produkt.

Drittens, mit zwanzig Clients gleichzeitig. Der Mock-Provider bedient drei Requests gleichzeitig. Schieß zwanzig ab:

TEXT
jitter=true  clients=20  server capacity=3
HTTP requests made: 74   429s received: 54   200s: 20
wall clock: 7,100 ms
retries per client: 0 0 0 1 1 1 2 2 2 3 4 3 3 5 4 5 4 5 4 5
every answer identical: true

Zwanzig Antworten, vierundsiebzig Requests, vierundfünfzig Ablehnungen. Niemand hat etwas verloren, jeder Client bekam denselben Text, und die einzigen sichtbaren Kosten waren Zeit. So sieht eine retry policy aus, die funktioniert. Der Rest dieses Kapitels handelt von den drei Arten, wie sie stattdessen scheitern kann.

finish_reason und zwei Enden, die gleich aussehen

Link zum Abschnitt: finish_reason und zwei Enden, die gleich aussehen

Vor den Fehlern: das Feld, das fast alle beim ersten Durchgang ignorieren. Jeder Stream endet mit einem Event, das finish_reason trägt. stop heißt, das Modell hat entschieden, dass es fertig ist. length heißt, es hat die token-Obergrenze erreicht, die Antwort ist also mitten im Satz abgeschnitten, und das ist nicht die Schuld des Modells. Spätere Kapitel fügen tool_calls (Kapitel 18) und Content-Filter hinzu.

Jetzt sieh dir zwei Enden an, die ein naiver Client nicht unterscheiden kann. Derselbe Server, dieselbe Verzögerung, eines durch max_tokens gekürzt und eines, bei dem die Verbindung nach fünf tokens sauber geschlossen wird:

TEXT
max_tokens=5           loop ended NORMALLY   chunks=5  finish_reason=length  text="A tide gauge is a"
socket closed cleanly  loop ended NORMALLY   chunks=5  finish_reason=null    text="A tide gauge is a"
socket destroyed       threw TypeError: terminated (UND_ERR_SOCKET)
                                             chunks=4  finish_reason=null    text="A tide gauge is"

Lies die ersten beiden Zeilen sorgfältig. Identischer Text. Identische Chunk-Zahl. In keinem Fall eine Exception. Die for await-Schleife endete beide Male normal, denn aus Sicht des Readers endete der Body, und mehr kann ein Body nicht tun. Der einzige Unterschied in der gesamten Beobachtung ist, dass eines finish_reason: "length" trägt und das andere überhaupt nichts.

Die Regel lautet also nicht „Fehler beim Streaming catchen“. Sie lautet:

Ein Stream, der ohne finish_reason endet, ist nicht beendet. Er hat aufgehört.

Behandle ein fehlendes finish_reason immer als Fehler, und speichere diesen Text nie als abgeschlossene Antwort. Die dritte Zeile zeigt den einfacheren Fall — ein zerstörter Socket wirft tatsächlich, und er verliert auch den Chunk, der gerade in flight war, weshalb der Text ein Wort kürzer ist als die beiden darüber.

Fünf Statuscodes, die fünf verschiedene Probleme sind

Link zum Abschnitt: Fünf Statuscodes, die fünf verschiedene Probleme sind

Die teuerste Gewohnheit eines neuen Produkts ist ein einzelner catch-Block für alles, was der Provider zurückgibt. Diese Codes sind keine Varianten von „es ist fehlgeschlagen“. Sie sind fünf Anweisungen, und vier davon widersprechen einander.

statuswas es bedeutetwas zu tun istwarten?
400dein Request ist fehlerhaft — schlechtes JSON, unbekanntes Feld, context zu langCode reparierennie
401der Schlüssel ist falsch, fehlt oder wurde widerrufenDeployment reparierennie
429Rate Limit: zu viele Requests oder zu viele tokens pro MinuteretryRetry-After, dann backoff
500der Provider ist kaputtretrybackoff
503der Provider ist überlastet — er ist online, er ist vollretrybackoff und Last abwerfen

Die entscheidende Linie verläuft zwischen 4xx und dem Rest. Eine 400 oder 401 gibt exakt dieselbe Antwort zurück, wenn du sie tausendmal sendest, weil sich zwischen den Versuchen an keinem Ende etwas ändert. Sie erneut zu versuchen ist keine Vorsicht, sondern eine Verzögerung mit Extraschritten. Gemessen: ein Client, der sechs Versuche macht — fünf retries mit exponentiellem backoff —, und einer, der zuerst den Code liest.

TEXT
retry everything  ->  6 requests, gave up after 6,325 ms, still HTTP 401
triage first      ->  1 request,  gave up after     4 ms, still HTTP 401

Sechs Sekunden Spinner bis zu einer Antwort, die nach vier Millisekunden verfügbar war. Und das ist die milde Version: retries in einem Produkt sind normalerweise verschachtelt — ein retryender HTTP-Client in einem retryenden Job-Runner in einer Queue mit eigener Redelivery —, sodass sechs Sekunden zu sechs Minuten werden, in denen ein dauerhaft kaputtes Deployment wie ein langsames aussieht.

Die Triage besteht aus neun Zeilen und gehört an genau eine Stelle:

classify.tsTS
export type Verdict = "retry" | "retry-after" | "fatal";

export function classify(status: number): Verdict {
  if (status === 429) return "retry-after";        
  if (status === 408 || status >= 500) return "retry";
  return "fatal";  // 400, 401, 403, 404, 422 — nothing changes by waiting
}

Zwei weitere für deine Liste: 402, das manche Provider für „dein Guthaben ist aufgebraucht“ verwenden und das einen Bildschirm mit Link zum Nachkaufen braucht statt eines retry, und 529 oder vendor-spezifische Äquivalente, die sich wie 503 verhalten.

Erneut versuchen ist einfach. Wann erneut versuchen ist der Teil mit einer messbar richtigen Antwort.

Exponentieller backoff ist der Standard: eine Basisverzögerung warten, sie nach jedem Fehler verdoppeln, bei einer Obergrenze stoppen. Es gibt ihn, weil ein überlasteter Server schlimmer wird, wenn die Clients, die gerade fehlgeschlagen sind, sofort zurückkommen.

Das Problem ist, dass alle vom selben Startpunkt aus verdoppeln. Wenn hundert Clients im selben Moment an ein Limit stoßen — und das werden sie, denn genau das ist ein Traffic-Spike —, dann warten alle hundert 200 ms, alle hundert versuchen es gemeinsam erneut, alle hundert scheitern gemeinsam, und alle hundert warten 400 ms. Der retry-Zeitplan hat sie synchronisiert. Das ist eine thundering herd, und Zufall ist die Lösung.2

sleep=random(0, min(cap, base2n))\text{sleep} = \mathrm{random}\big(0,\ \min(\text{cap},\ \text{base} \cdot 2^{\,n})\big)

Diese eine Änderung — gleichverteilt aus dem Intervall zu ziehen, statt dessen oberen Rand zu nehmen — heißt full jitter. Es ist ein Aufruf von Math.random(), und es lohnt sich, das zu messen statt zu glauben:

backoff.tsTS
export const backoffNaive = (n: number, base = 200, cap = 20_000) =>
  Math.min(cap, base * 2 ** n);

export const backoffFull = (n: number, base = 200, cap = 20_000) =>
  Math.random() * Math.min(cap, base * 2 ** n);      

Hundert Clients, ein Server, der drei gleichzeitig bedient, alles andere identisch, je drei Läufe:

HTTP-RequestsAblehnungenschlechtester Clientvollstes 50-ms-FensterWanduhrzeit
no jitter, Lauf 149139110 Versuche46 Ankünfte65,6 s
no jitter, Lauf 278068019 Versuche72 Ankünfte245,7 s
no jitter, Lauf 377067018 Versuche97 Ankünfte225,6 s
full jitter, Lauf 13242245 Versuche32 Ankünfte2,2 s
full jitter, Lauf 23132136 Versuche31 Ankünfte2,3 s
full jitter, Lauf 33182186 Versuche25 Ankünfte1,8 s

Zwei Dinge stehen in dieser Tabelle, und das zweite ist das wichtige.

Das erste ist der Median: 226 Sekunden gegen 2,2, ungefähr Faktor hundert, mit weniger als der Hälfte der Requests. Das vollste retry-Fenster zeigt warum. Ohne jitter kamen bis zu 97 der hundert Clients im selben 50-Millisekunden-Slot an; der Server hatte drei Plätze, also wurden 94 abgelehnt und gingen gemeinsam schlafen, weiterhin synchronisiert, um es mit längerer Wartezeit wieder zu tun. Mit jitter verteilten sich dieselben hundert über dieselben Fenster in Gruppen von ungefähr dreißig und liefen fast sofort leer.

Das zweite ist die Varianz. Ohne jitter: 65,6 s, 245,7 s, 225,6 s. Mit jitter: 2,2, 2,3, 1,8. Ein System ohne jitter performt nicht nur schlecht, es performt unvorhersehbar, weil das Ergebnis von mikroskopischen Scheduling-Zufällen bestimmt wird, die auswählen, welche drei von hundert synchronisierten Clients zuerst ankommen. Das ist die Signatur dieses Bugs in Production: ein Endpunkt, der okay ist, okay, okay, und dann vier Minuten braucht, ohne dass eine Änderung von dir es erklärt.

Und der billigste retry ist der, der nie passiert. Setz ein Concurrency Gate vor den Provider — einen Zähler, der nie mehr als N Requests in flight lässt —, und dieselben zwanzig Clients, die 74 Requests und 7,1 Sekunden brauchten, verhalten sich so:

TEXT
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 ms

Zwanzig Requests für zwanzig Antworten, null Ablehnungen, achtmal schneller. Ein retry ist die Entschuldigung; das Gate sorgt dafür, dass du keine brauchst.

Retry-After ist eine Untergrenze, kein Vorschlag

Link zum Abschnitt: Retry-After ist eine Untergrenze, kein Vorschlag

Wenn ein Provider 429 zurückgibt, sagt er dir normalerweise im Retry-After-Header, wie lange du warten sollst.3 Diese Zahl ist kein Rat: Der Provider ist die einzige Partei in diesem Austausch, die weiß, wann sein Fenster zurückgesetzt wird.

Die Wartezeit ist also die größere der beiden: nie weniger als Retry-After und auch nie weniger als dein eigener backoff, denn der Header sagt dir, wann der Limiter dir vergibt, nicht wann der Server Platz hat.

wait.tsTS
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0;   // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt));  

Der Trace des unglücklichsten Clients im Zwanzig-Client-Lauf zeigt, dass der Header seinen Job macht. Seine ersten vier backoff-Ziehungen lagen alle unter einer Sekunde, und alle vier wurden überschrieben:

TEXT
t+   26ms  attempt 0  HTTP 429  -> sleep 1000 ms
t+ 1032ms  attempt 1  HTTP 429  -> sleep 1000 ms
t+ 2034ms  attempt 2  HTTP 429  -> sleep 1000 ms
t+ 3046ms  attempt 3  HTTP 429  -> sleep 1000 ms
t+ 4047ms  attempt 4  HTTP 429  -> sleep 2782 ms
t+ 6852ms  attempt 5  HTTP 200  -> sleep 0 ms

Zwei praktische Hinweise. Retry-After kann ein HTTP-Datum statt einer Sekundenzahl sein, also parse beides. Und Provider begrenzen auf zwei Achsen gleichzeitig — Requests pro Minute und tokens pro Minute —, weshalb lange prompts weit unterhalb des dokumentierten Request-Limits abgelehnt werden. Der Header sieht in beiden Fällen gleich aus; die Lösung nicht.

Frag den Mock-Provider nach /hang. Er akzeptiert die Verbindung und tut dann gar nichts: keine Header, kein Body, kein Close. Das ist nicht exotisch — genau das tut ein Load Balancer, wenn der Prozess dahinter gestorben ist, ohne seine Sockets zu schließen.

Zwei Clients, ein Unterschied:

TEXT
AbortSignal.timeout(5s)   gave up after   5.0 s  (TimeoutError: The operation was aborted due to timeout)
no timeout                gave up after 300.8 s  (TypeError: fetch failed)
                          cause: HeadersTimeoutError UND_ERR_HEADERS_TIMEOUT

Dreihundert Sekunden. Fünf Minuten lang ein offener Socket, ein belegter Request-Slot und ein Nutzer, der auf einen Spinner starrt, endend in einem generischen TypeError, der nichts darüber sagt, was passiert ist. Diese Zahl ist kein Bug: Es ist Nodes Standard-Headers-Timeout, vernünftig für einen generischen HTTP-Client und katastrophal für einen nutzerseitigen Request. Jede Runtime hat so einen Default, die meisten Menschen schlagen ihn nie nach, und der einzige Weg, deinen zu finden, ist, absichtlich einen Socket hängen zu lassen, wie wir es gerade getan haben.

Also: Jeder ausgehende Request bekommt eine explizite Deadline, die du wählst.

deadline.tsTS
const res = await fetch(url, {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
  body: JSON.stringify(payload),
  signal: AbortSignal.timeout(20_000),   
});

Für einen Streaming-Call reicht eine Deadline nicht, weil es zwei verschiedene Fehler gibt. Der erste ist: Der Stream öffnet sich nie: Es kommt überhaupt kein Event an, und zehn bis dreißig Sekunden sind richtig. Der zweite ist: Der Stream öffnet sich und bleibt dann stehen: tokens flossen und hörten dann für immer auf, während der Socket noch gesund ist. Ein Timeout für die Gesamtdauer kann einen festhängenden Stream nicht von einer langen korrekten Antwort unterscheiden; was du willst, ist also ein Idle Timeout — ein Timer, der durch jedes Event zurückgesetzt wird und nur feuert, wenn beispielsweise fünfzehn Sekunden lang nichts angekommen ist.

Cancellation ist dieselbe Mechanik, nur auf einen Menschen gerichtet. AbortSignal.timeout und ein Nutzer, der Stop drückt, kommen beide als AbortError an, also kombiniere sie und zeichne auf, welche ausgelöst hat:

cancel.tsTS
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();

Abbrechen ist aus einem Grund wichtig, der über Ordnung hinausgeht: Die tokens werden generiert und berechnet, während du nicht zuhörst. Kapitel 16 setzt einen Preis darauf.

Jetzt der Fehler, der Geld statt Zeit kostet. Ein Request läuft auf dem Client in einen Timeout, und der naheliegende Schritt ist, ihn erneut zu senden — aber ein Timeout sagt dir nichts darüber, ob der Server ihn erhalten hat. Sehr oft hat er das und arbeitet noch.

Gemessen. Der Mock-Provider braucht 780 ms für die Antwort. Der Client gibt bei 300 ms auf und versucht es erneut. Der Server zählt, wie viele Antworten er tatsächlich generiert hat, also wofür er abrechnen würde:

TEXT
idempotency-key: no    attempt 0: TimeoutError after 300 ms  |  attempt 1: TimeoutError after 300 ms
                       answers generated (and billed): 2

idempotency-key: yes   attempt 0: TimeoutError after 300 ms  |  attempt 1: HTTP 200 (replay) id=cmpl_1
                       answers generated (and billed): 1

Ohne Schlüssel: zwei vollständige Generierungen, zweimal bezahlt, und der Client erhielt keine davon. Mit Schlüssel: Der Server erkannte den zweiten Request als denselben Request und antwortete sofort mit der Antwort, die er bereits erzeugt hatte; der retry vermied also die doppelte Berechnung und war der Versuch, der endlich erfolgreich war.

Ein Idempotency Key ist ein eindeutiger String, den du pro logischer Operation erzeugst — nicht pro Versuch — und bei jedem retry davon unverändert sendest. Der Server speichert das Ergebnis unter diesem Schlüssel und spielt es erneut aus. Es ist derselbe Mechanismus, den Zahlungs-APIs aus demselben Grund verwenden.4

idempotent.tsTS
async function send(url: string, payload: unknown) {
  const key = crypto.randomUUID();      // once per turn, not per attempt

  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(url, {
      method: "POST",
      body: JSON.stringify(payload),
      headers: { "content-type": "application/json", "idempotency-key": key },  
      signal: AbortSignal.timeout(20_000),
    });
    if (res.ok) return res;
    if (classify(res.status) === "fatal") throw new Error(`HTTP ${res.status}`);
    await sleep(backoffFull(attempt));
  }
  throw new Error("out of attempts");
}

Zwei ehrliche Grenzen. Nicht jeder Provider unterstützt Idempotency Keys für Completions, und wenn der Endpunkt nicht idempotent ist, ist die korrekte Zahl von retries für einen POST, der bereits gelaufen sein könnte, null. Und ein Stream, der auf halber Strecke fehlgeschlagen ist, ist im allgemeinen Fall nicht wiederabspielbar: Du startest ihn entweder neu und zahlst erneut, oder du behältst den Teiltext und markierst ihn als unvollständig. Welche dieser Varianten dein Produkt wählt, ist eine Produktentscheidung, keine Netzwerkentscheidung, und sie sollte bewusst getroffen werden.

Der Client, der in diesem Kapitel geschrieben wurde, hat keine Ahnung, was hinter dem Port liegt. Zeig seine Base URL auf einen kommerziellen Provider, und er streamt tokens aus einem Modell mit einer Billion Parametern. Zeig sie auf einen Server, der auf der Arithmetik aus Kapitel 13 gebaut ist — der das Modell ausliefert, das du in Kapitel 10 pretrained hast, mit seinem KV cache und seinen quantisierten Gewichten —, und derselbe Code, unverändert, streamt tokens aus einem Modell, das du gebaut hast.

switch.tsTS
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1";  

Diese eine Zeile ist die Naht dieses Kurses. Auf der einen Seite liegt, was die ersten dreizehn Kapitel gebaut haben; auf der anderen, was die nächsten sechzehn bauen. Die Grenze ist sauber, weil der Vertrag HTTP und SSE ist, und keine Seite irgendetwas anderes über die andere weiß.

Es lohnt sich zu bemerken, was du beim Überschreiten verloren hast. Hinter einem kommerziellen Endpunkt kontrollierst du weder die Gewichte noch die Sampling-Implementierung, noch die Version, mit der du sprichst, noch ob sie sich heute Morgen geändert hat. Was du kontrollierst, ist der Vertrag: die Nachrichten, die du sendest, die Deadline, die du setzt, die Codes, die du unterscheidest, und was du tust, wenn nichts zurückkommt. Das ist eine kleinere Oberfläche, als du in Kapitel 5 hattest, und jedes verbleibende Kapitel handelt davon, sie gut zu nutzen.

Du hast jetzt einen Client, der streamt, rechtzeitig aufgibt, die richtigen Dinge retryt und die falschen nie. Was er sendet, ist immer noch einfach das, was du getippt hast.

Kapitel 15 handelt von diesem Inhalt, und es bringt eine Disziplin mit. Das Internet ist voll von prompting-Ratschlägen — biete dem Modell ein Trinkgeld an, drohe ihm, sag ihm, es soll tief durchatmen —, und fast keiner davon kommt mit einer Messung. Manche dieser Techniken bewegen den Output stark, manche gar nicht, und mindestens eine macht eine Klassifikationsaufgabe schlechter, während sie mehr tokens kostet. Welche welche ist, sieht man ihnen beim Lesen nicht an, und es wird nicht durch Argumente entschieden.

Das nächste Kapitel baut also eine Bench: sechzig Fälle mit bekannten Antworten, vier Varianten desselben prompt, parallel durch genau den Client laufen gelassen, den du gerade geschrieben hast, tabelliert mit den Konfidenzintervallen aus Kapitel 4 — denn vier Varianten über zwanzig Fälle unterscheiden überhaupt nichts. Ein Satz regiert das ganze Kapitel: Ein prompt wird gemessen, nicht debattiert.


Jede Zahl oben stammt vom Mock-Provider, auf Node 22 über ein Loopback-Interface, weshalb die Latenzen sauberer sind, als jedes echte Netzwerk sie liefern wird. Das ist Absicht: Keiner der gemessenen Fehler wird durch das Netzwerk verursacht, und ein feindlicher Server, den du neu starten kannst, lehrt besser als ein echter, für den du bezahlen musst und den du nicht kaputtmachen kannst.

  1. Server-Sent Events, WHATWG HTML Living Standard, Abschnitt 9.2. Das Wire Format — data:-Felder, durch Leerzeilen getrennte Events, id: und retry: — wird dort definiert, zusammen mit dem EventSource-Interface. EventSource kann keinen Request Body und keine Custom Header senden, weshalb jeder LLM-Client das Format von Hand über fetch parst, statt es zu verwenden.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Die Quelle der oben verwendeten „full jitter“-Formulierung, mit den Simulationen, die zeigen, warum die naive Version Clients synchronisiert. Das Begleitargument für das Abwerfen von Last statt deren Einreihung ist das Kapitel Handling Overload in Beyer, Jones, Petoff und Murphy (Hrsg.), Site Reliability Engineering (O'Reilly, 2016).

  3. Fielding, R., Nottingham, M. und Reschke, J. (Hrsg.), HTTP Semantics, RFC 9110, Abschnitt 15, definiert die Statuscode-Klassen; Nottingham, M. und Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), Abschnitt 4, definiert 429 Too Many Requests. Retry-After ist RFC 9110 Abschnitt 10.2.3 und akzeptiert entweder eine Sekundenzahl oder ein HTTP-Datum.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, gelesen am 7. September 2026 — die klarste Darstellung des Vertrags: ein Schlüssel pro logischer Operation, gespeicherte Ergebnisse werden erneut ausgespielt, ein Konflikt wird zurückgegeben, während der erste Versuch noch in flight ist — und das Muster ist provider-unabhängig. Die normativen Referenzen für die hier verwendeten Request- und Event-Formen sind developers.openai.com/api/reference/resources/chat für Streaming, Fehlercodes und Rate Limits sowie platform.claude.com/docs/en/api/messages für die Messages API; ai-sdk.dev/docs ist das beste ausgearbeitete Beispiel derselben Anliegen, verpackt in einer Library. Alle am selben Tag gelesen.

Bereit, LIA die Wahl zu überlassen?

Bau mit jedem KI-Modell an einem Ort — starte heute kostenlos.