Η πρώτη σας παραγωγική κλήση LLM: streaming, retries, timeouts
Φτιάξτε έναν provider που λέει ψέματα — 429, κολλημένα sockets, streams που κόβονται — και μετρήστε τι κάνει ο client.
Σε αυτή τη σελίδα
Το κεφάλαιο 13 τελείωσε με ένα χρονόμετρο πάνω σε ένα μοντέλο που μπορούσατε να αγγίξετε. Τα weights ήταν στη μνήμη σας, το KV cache ήταν δικό σας να το ενεργοποιήσετε ή να το απενεργοποιήσετε, και ο αριθμός που προέκυπτε — χρόνος μέχρι το πρώτο token — ήταν ιδιότητα του hardware σας.
Τώρα βάλτε αυτό το μοντέλο πίσω από ένα port, όπως κάνει κάθε προϊόν, και διαβάστε ξανά τον ίδιο αριθμό. Παραμένει χρόνος μέχρι το πρώτο token, αλλά δεν είναι πλέον ιδιότητα κάποιου πράγματος που ελέγχετε. Περιλαμβάνει πλέον ένα TLS handshake, μια ουρά στον provider, έναν rate limiter και την πιθανότητα να μην φτάσει ποτέ κανένα token.
Αυτή η τελευταία πρόταση είναι το κεφάλαιο. Ο κώδικας που πρόκειται να γράψετε δεν υπολογίζει τίποτα. Ανοίγει μια σύνδεση, περιμένει, κάνει parse ό,τι φτάνει, αποφασίζει τι θα κάνει όταν δεν φτάνει τίποτα, αποφασίζει ξανά όταν αυτό που φτάνει είναι σφάλμα και ακυρώνει τον εαυτό του όταν ο χρήστης αλλάζει γνώμη. Καθένα από αυτά είναι μια απόφαση για κατάσταση μέσα στον χρόνο, και καθένα έχει μια λάθος απάντηση που φτάνει σε production και κοστίζει χρήματα.
Αυτή είναι η μορφή του προβλήματος, μετρημένη, όλη μέσα σε αυτό το κεφάλαιο:
| τι συνέβη | τι κάνει ένας απρόσεκτος client | τι κοστίζει |
|---|---|---|
| ο server δέχτηκε το socket και δεν απάντησε ποτέ | περιμένει | 300,8 s πριν το Node τα παρατήσει μόνο του |
| το key ήταν λάθος (401) | κάνει retry πέντε φορές | 6.325 ms καθυστέρηση, και μετά το ίδιο 401 |
| εκατό clients χτυπούν μαζί το rate limit | όλοι κάνουν retry στο ίδιο πρόγραμμα | 226 s για να αδειάσει, αντί για 2,2 s |
| το request έκανε timeout και στάλθηκε ξανά | το στέλνει ξανά | ο provider παράγει — και χρεώνει — την απάντηση δύο φορές |
| η σύνδεση έπεσε στη μέση της απάντησης | δείχνει το μερικό κείμενο | δεν ξεχωρίζει από μια σωστή σύντομη απάντηση |
Κανένα από αυτά δεν είναι πρόβλημα modeling. Όλα βρίσκονται στις πρώτες εκατό γραμμές κάθε LLM προϊόντος που γράφτηκε ποτέ.
Γιατί αυτό το κεφάλαιο αλλάζει γλώσσα
Σύνδεσμος στην ενότητα: Γιατί αυτό το κεφάλαιο αλλάζει γλώσσαΔιαβάστε ξανά τον πίνακα και ρωτήστε τι είδους πρόγραμμα περιγράφει. Κρατάει μια σύνδεση ανοιχτή για σαράντα δευτερόλεπτα. Πρέπει να ακυρώνεται από ένα κουμπί. Συσσωρεύει μια μερική απάντηση που είναι έγκυρη για εμφάνιση και άκυρη για αποθήκευση. Και τρέχει σε ένα server process ή σε έναν edge worker, δίπλα στο πράγμα που κάνει render την απάντηση, κρατώντας ένα socket.
Αυτό δεν είναι notebook. Δεν είναι ότι η Python δεν μπορεί να το κάνει — μπορεί, και πολλοί το κάνουν — είναι ότι όλα όσα έχτισαν τα προηγούμενα δεκατρία κεφάλαια ήταν άλλου είδους. Τα κεφάλαια 1 έως 13 κρατούσαν weights, gradients, logits και tokenizer bytes. Από εδώ και πέρα ο κώδικας κρατά μια σύνδεση, ένα retry, μια ακύρωση, συσσωρευμένη κατάσταση και, αργότερα, ένα prompt άδειας. Το μάθημα αλλάζει γλώσσα ακριβώς στη ραφή όπου αλλάζει το αντικείμενο.
Άρα ο κανόνας, γραμμένος μία φορά:
Αν ο κώδικας έχει weights, gradients, logits ή tokenizer bytes στα χέρια του, είναι Python. Αν κρατά σύνδεση, κάνει retries, ακυρώνει, συσσωρεύει κατάσταση και ζητά άδεια, είναι TypeScript.
Η ραφή είναι μία και πέφτει εδώ, ανάμεσα στο κεφάλαιο 13 και το κεφάλαιο 14. Τρία ανεξάρτητα κριτήρια τη βάζουν εδώ.
Ένα: το οικοσύστημα, μετρημένο. Όλα όσα παραπέμπει το αριστερό μισό αυτού του μαθήματος είναι Python, και στα δώδεκα μαθήματα που ελέγχθηκαν για αυτό το syllabus δεν υπάρχει ούτε ένα προηγούμενο όπου το backpropagation διδάσκεται σε άλλη γλώσσα: micrograd (17,4K stars), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Το να γραφόταν το κεφάλαιο 5 σε TypeScript θα έσπαγε τον σύνδεσμο με αυτές τις πηγές, και οι σύνδεσμοι είναι η μισή αξία ενός κεφαλαίου που υπάρχει για να παραπέμπεται, όχι για να κάνει rank. Σε αυτή την πλευρά η αριθμητική αντιστρέφεται: το πακέτο ai της Vercel έχει 89,4M downloads τον μήνα και παρέχει το ίδιο το πράγμα — ένα tool-calling agent loop, εξαγόμενο ως ToolLoopAgent — άρα η έννοια στην οποία φτάνει αυτό το μάθημα στο κεφάλαιο 23 έχει την reference implementation της σε TypeScript, παρότι, όπως μετρά εκείνο το κεφάλαιο, κανείς δεν έχει συμφωνήσει σε όνομα για αυτήν· το Mastra έχει 27,7K stars· και τα SDKs της Anthropic, παραγόμενα από μία προδιαγραφή, δηλώνουν 202 endpoints σε TypeScript έναντι 201 σε Python — ισοτιμία, όχι ευγενικό port.
Δύο: η κανονιστική πηγή του MCP. Το schema της προδιαγραφής του Model Context Protocol είναι ένα αρχείο schema.ts. Το να διδάξετε το πρωτόκολλο του κεφαλαίου 26 σε άλλη γλώσσα σημαίνει να διδάξετε μια μετάφραση του ιδρυτικού του εγγράφου.
Τρία: ζήτηση αναζήτησης, με διόρθωση στην προφανή εικασία. Το machine learning python είναι η πιο κορεσμένη φράση στο internet· το ai agent typescript έχει τη δική του υγιή ουρά. Αλλά το «το οικοσύστημα MCP είναι κυρίως TypeScript» ισχύει μόνο ανάλογα με το πώς μετράτε: το επίσημο registry απαριθμεί 8.275 servers στο npm έναντι 3.603 στο PyPI, ενώ στα downloads κερδίζει η Python — 287M τον μήνα για το mcp συν 72M για το fastmcp έναντι 195M για το @modelcontextprotocol/sdk. Το MCP είναι η μία πραγματικά δίγλωσση περιοχή εδώ, γι’ αυτό και το κεφάλαιο 27 γράφει τον ίδιο server δύο φορές αντί να προσποιείται.
Εμφάνιση λεπτομερειών
Οι πέντε δηλωμένες εξαιρέσεις, ώστε ο κανόνας να είναι κανόνας και όχι σύνθημα.
Τα κεφάλαια 17, 20 και 29 έχουν δεύτερο πάνελ σε Python: η υλοποίηση top-p sampling χρειάζεται το probability vector στο χέρι σας και ένα HTTP API δεν σας δίνει ποτέ ένα· η ειλικρινής κοστολόγηση ενός fine-tune σημαίνει να τρέξετε ένα, και ένας LoRA adapter είναι δώδεκα γραμμές nn.Module· και τα lm-eval-harness, HELM, SWE-bench και τ-bench είναι Python, άρα ένα evaluation harness σε TypeScript θα ήταν ο καθρέφτης του λάθους του backpropagation. Το κεφάλαιο 27 είναι δίγλωσσο, για τον μετρημένο λόγο παραπάνω. Το κεφάλαιο 28 είναι Markdown, επειδή ένα agent skill είναι αρχείο SKILL.md και το να του δώσετε γλώσσα προγραμματισμού θα σήμαινε ότι δεν έχετε καταλάβει το format.
Τα δεκατρία κεφάλαια Python δεν πετιούνται. Αυτό που βρίσκεται στην άλλη πλευρά του port είναι αυτό που έχτισαν, και η τελευταία ενότητα εδώ συνδέει έναν client με αυτό.
Ένας provider που μπορείτε να σπάσετε
Σύνδεσμος στην ενότητα: Ένας provider που μπορείτε να σπάσετεΔεν μπορείτε να μάθετε τίποτα από αυτά απέναντι σε έναν πραγματικό provider. Δεν μπορείτε να του ζητήσετε ένα 429 σε επιλεγμένη στιγμή, ή ένα socket που δέχεται τη σύνδεσή σας και δεν απαντά ποτέ, ή ένα stream που σταματά στη μέση μιας λέξης — και θα πληρώνατε για κάθε πείραμα, ενώ τα ενδιαφέροντα πειράματα είναι αυτά που τρέχετε εκατό φορές.
Άρα το πρώτο πρόγραμμα σε αυτό το μισό του μαθήματος δεν είναι client. Είναι ένας εχθρικός server: σαράντα γραμμές απλού Node που μιλούν το ίδιο wire protocol με ένα chat completions endpoint και συμπεριφέρονται άσχημα κατά παραγγελία. Κάθε αριθμός σε αυτό το κεφάλαιο βγήκε από αυτόν.
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);Τέσσερις εχθρικές συμπεριφορές, από μία γραμμή η καθεμία: το /hang δέχεται το socket και δεν γράφει ποτέ σε αυτό· το /401 αρνείται το key· ο έλεγχος χωρητικότητας παράγει ένα γνήσιο 429 με γνήσιο header Retry-After μόλις τρία requests είναι ήδη σε εξέλιξη· και το ?cut=N εγκαταλείπει την απάντηση στα μισά, είτε κάνοντας reset το socket είτε — με &how=close — κλείνοντάς το με τακτικό τρόπο, κάτι που αποδεικνύεται ότι έχει μεγάλη σημασία. Τα υπόλοιπα είναι ένα πραγματικό Server-Sent Events stream: ένα JSON object ανά γραμμή data:, μια κενή γραμμή ανάμεσα στα events, το string [DONE] στο τέλος.1
Τρέξτε το, και το υπόλοιπο κεφάλαιο είναι μέτρηση.
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"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]Το request body, και το key που δεν φεύγει ποτέ από τον server
Σύνδεσμος στην ενότητα: Το request body, και το key που δεν φεύγει ποτέ από τον serverΈνα chat request είναι μια λίστα από messages, το καθένα με έναν ρόλο. Αυτή η λίστα είναι ολόκληρη η κατάσταση του μοντέλου: δεν υπάρχει μνήμη ανάμεσα στις κλήσεις, και ό,τι θέλετε να γνωρίζει το μοντέλο πρέπει να βρίσκεται μέσα στο array που στέλνετε αυτή τη φορά. Το κεφάλαιο 15 αφορά το τι να βάλετε μέσα και το κεφάλαιο 16 το τι κοστίζει, οπότε εδώ είναι απλώς το σχήμα.
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,
};Αυτοί οι ρόλοι δεν είναι διακόσμηση. Γίνονται render στο chat template του κεφαλαίου 11 πριν το μοντέλο δει έστω ένα token, γι’ αυτό και η αποστολή λάθος ρόλου υποβαθμίζει σιωπηλά την απάντηση αντί να σηκώσει σφάλμα.
Ένας κανόνας χωρίς εξαιρέσεις: το API key δεν ταξιδεύει ποτέ στον client. Όχι σε environment variable με prefix για browser, όχι σε build-time constant, όχι «προσωρινά». Ένα key μέσα σε bundle είναι key στον λογαριασμό κάποιου άλλου μέσα σε λίγες μέρες. Ο browser μιλά στον server σας, ο server σας κρατά το key και μιλά στον provider — και επειδή ο server σας βρίσκεται στη μέση, είναι επίσης το μόνο μέρος που μπορεί να μετρήσει τι ξοδεύει κάθε χρήστης, που είναι το σημείο όπου πρέπει να ζει η λογιστική του κεφαλαίου 16.
Η ίδια ερώτηση, τρεις φορές
Σύνδεσμος στην ενότητα: Η ίδια ερώτηση, τρεις φορέςΤώρα το πείραμα πάνω στο οποίο χτίζεται το κεφάλαιο. Μία ερώτηση, ένας mock provider που παράγει δεκατρία token στα 60 ms το καθένα, τρεις τρόποι να ρωτήσετε.
Πρώτα, χωρίς streaming. Ο client στέλνει το request και περιμένει ολόκληρο το JSON body.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopΟι δύο αριθμοί είναι ίδιοι, και αυτό είναι όλο το πρόβλημα. Για 791 ms ο χρήστης έχει ένα spinner, και ούτε μία λέξη δεν ήταν διαθέσιμη νωρίτερα — ο server είχε την απάντηση, byte προς byte, και επέλεξε να μην πει τίποτα.
Δεύτερον, με streaming. Ίδιος server, ίδια απάντηση, ίδια συνολική δουλειά. Η διαφορά είναι ένας parser.
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);
}
}
}
}Τρεις λεπτομέρειες εκεί είναι φέρουσες και οι περισσότερες πρώτες προσπάθειες παραλείπουν και τις τρεις. Το buffer υπάρχει επειδή ένα network chunk δεν έχει καμία σχέση με ένα event: ένα read() μπορεί να επιστρέψει μισό event, ή δυόμισι. Το flag { stream: true } υπάρχει επειδή ένας multi-byte UTF-8 χαρακτήρας μπορεί να κοπεί σε δύο chunks, και χωρίς αυτό ένα τονισμένο γράμμα γίνεται τυχαία replacement character. Και τα events χωρίζονται με κενή γραμμή, όχι με newline, γι’ αυτό το loop ψάχνει το \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopΔώδεκα φορές πιο γρήγορα μέχρι την πρώτη λέξη, και δύο χιλιοστά πιο αργά μέχρι την τελευταία. Το streaming δεν κάνει τίποτα πιο γρήγορο. Αλλάζει αυτό που κάνει ο χρήστης μέσα στα ίδια 790 ms: διαβάζει αντί να περιμένει. Αυτό είναι όλο το όφελος, είναι τεράστιο, και είναι ο λόγος που κάθε chat product κάνει streaming.
Τρίτον, με είκοσι clients ταυτόχρονα. Ο mock provider εξυπηρετεί τρία requests κάθε φορά. Πυροδοτήστε είκοσι:
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Είκοσι απαντήσεις, εβδομήντα τέσσερα requests, πενήντα τέσσερις απορρίψεις. Κανείς δεν έχασε τίποτα, κάθε client πήρε το ίδιο κείμενο, και το μόνο ορατό κόστος ήταν χρόνος. Αυτό είναι μια retry policy που λειτουργεί. Το υπόλοιπο αυτού του κεφαλαίου αφορά τους τρεις τρόπους με τους οποίους μπορεί αντί γι’ αυτό να αποτύχει.
finish_reason, και δύο τελειώματα που μοιάζουν ίδια
Σύνδεσμος στην ενότητα: finish_reason, και δύο τελειώματα που μοιάζουν ίδιαΠριν από τις αποτυχίες, το πεδίο που σχεδόν όλοι αγνοούν στην πρώτη προσπάθεια. Κάθε stream τελειώνει με ένα event που μεταφέρει finish_reason. Το stop σημαίνει ότι το μοντέλο αποφάσισε πως τελείωσε. Το length σημαίνει ότι χτύπησε το token ceiling, άρα η απάντηση κόβεται στη μέση πρότασης και δεν φταίει το μοντέλο. Μεταγενέστερα κεφάλαια προσθέτουν tool_calls (κεφάλαιο 18) και content filters.
Τώρα δείτε δύο τελειώματα που ένας αφελής client δεν μπορεί να ξεχωρίσει. Ίδιος server, ίδια καθυστέρηση, το ένα truncated από max_tokens και το άλλο όπου η σύνδεση κλείνει καθαρά μετά από πέντε token:
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"Διαβάστε προσεκτικά τις δύο πρώτες γραμμές. Ίδιο κείμενο. Ίδιος αριθμός chunks. Καμία exception σε καμία περίπτωση. Το loop for await τελείωσε κανονικά και στις δύο, επειδή από τη σκοπιά του reader το body τελείωσε και αυτό είναι το μόνο που μπορεί να κάνει ένα body. Η μόνη διαφορά σε ολόκληρη την παρατήρηση είναι ότι το ένα μεταφέρει finish_reason: "length" και το άλλο δεν μεταφέρει τίποτα.
Άρα ο κανόνας δεν είναι «πιάστε errors όσο κάνετε streaming». Είναι:
Ένα stream που τελειώνει χωρίς
finish_reasonδεν τελείωσε. Σταμάτησε.
Αντιμετωπίστε ένα missing finish_reason ως αποτυχία, πάντα, και μην αποθηκεύετε ποτέ αυτό το κείμενο ως ολοκληρωμένη απάντηση. Η τρίτη γραμμή δείχνει την ευκολότερη περίπτωση — ένα κατεστραμμένο socket πράγματι κάνει throw, και χάνει επίσης το chunk που βρισκόταν σε εξέλιξη, γι’ αυτό και το κείμενο είναι μία λέξη μικρότερο από τα δύο παραπάνω.
Πέντε status codes που είναι πέντε διαφορετικά προβλήματα
Σύνδεσμος στην ενότητα: Πέντε status codes που είναι πέντε διαφορετικά προβλήματαΗ πιο ακριβή συνήθεια ενός νέου προϊόντος είναι ένα block catch για όλα όσα επιστρέφει ο provider. Αυτοί οι κωδικοί δεν είναι παραλλαγές του «απέτυχε». Είναι πέντε οδηγίες, και τέσσερις από αυτές αντιφάσκουν μεταξύ τους.
| status | τι σημαίνει | τι να κάνετε | αναμονή; |
|---|---|---|---|
| 400 | το request σας είναι κακοσχηματισμένο — κακό JSON, άγνωστο πεδίο, context πολύ μεγάλο | διορθώστε τον κώδικα | ποτέ |
| 401 | το key είναι λάθος, λείπει ή έχει ανακληθεί | διορθώστε το deployment | ποτέ |
| 429 | rate limit: πάρα πολλά requests, ή πάρα πολλά tokens, ανά λεπτό | retry | Retry-After, μετά backoff |
| 500 | ο provider χάλασε | retry | backoff |
| 503 | ο provider είναι overloaded — είναι up, είναι γεμάτος | retry | backoff, και shed load |
Η γραμμή που έχει σημασία περνά ανάμεσα στα 4xx και τα υπόλοιπα. Ένα 400 ή ένα 401 επιστρέφει ακριβώς την ίδια απάντηση αν το στείλετε χίλιες φορές, επειδή τίποτα σε καμία άκρη δεν αλλάζει ανάμεσα στις προσπάθειες. Το να κάνετε retry δεν είναι προσοχή, είναι καθυστέρηση με επιπλέον βήματα. Μετρημένο: ένας client που κάνει έξι προσπάθειες — πέντε retries με exponential backoff — και ένας που διαβάζει πρώτα τον κωδικό.
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Έξι δευτερόλεπτα spinner για να φτάσετε σε μια απάντηση που ήταν διαθέσιμη σε τέσσερα χιλιοστά. Και αυτή είναι η ήπια εκδοχή: τα retries σε ένα προϊόν είναι συνήθως φωλιασμένα — ένας HTTP client που κάνει retry μέσα σε έναν job runner που κάνει retry μέσα σε μια ουρά με δική της redelivery — οπότε τα έξι δευτερόλεπτα γίνονται έξι λεπτά ενός μόνιμα χαλασμένου deployment που μοιάζει αργό.
Το triage είναι εννέα γραμμές και ανήκει σε ένα σημείο:
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
}Δύο ακόμη για τη λίστα σας: 402, που κάποιοι providers χρησιμοποιούν για «έχετε ξεμείνει από credit» και χρειάζεται μια οθόνη με link για αγορά περισσότερων, όχι retry, και 529 ή τα vendor-specific ισοδύναμά του, που συμπεριφέρονται όπως το 503.
Backoff, και τι αγοράζει πραγματικά το jitter
Σύνδεσμος στην ενότητα: Backoff, και τι αγοράζει πραγματικά το jitterΤο retry είναι εύκολο. Το retry πότε είναι το κομμάτι με μετρήσιμη σωστή απάντηση.
Το exponential backoff είναι το standard: περιμένετε μια βασική καθυστέρηση, τη διπλασιάζετε μετά από κάθε αποτυχία, σταματάτε σε ένα ceiling. Υπάρχει επειδή ένας overloaded server γίνεται χειρότερος αν οι clients που μόλις απέτυχαν επιστρέψουν αμέσως.
Το πρόβλημα είναι ότι όλοι διπλασιάζουν από το ίδιο σημείο εκκίνησης. Αν εκατό clients χτυπήσουν ένα limit την ίδια στιγμή — και θα το κάνουν, επειδή αυτό είναι ένα traffic spike — τότε και οι εκατό περιμένουν 200 ms, και οι εκατό κάνουν retry μαζί, και οι εκατό αποτυγχάνουν μαζί, και οι εκατό περιμένουν 400 ms. Το retry schedule τους συγχρόνισε. Αυτό είναι ένα thundering herd, και η τυχαιότητα είναι η λύση.2
Αυτή η μία αλλαγή — να επιλέγετε ομοιόμορφα μέσα από το interval αντί να παίρνετε το άνω άκρο του — λέγεται full jitter. Είναι μία κλήση στο Math.random(), και αξίζει να τη μετρήσετε αντί να την πιστέψετε:
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); Εκατό clients, ένας server που εξυπηρετεί τρεις κάθε φορά, όλα τα άλλα ίδια, τρεις εκτελέσεις το καθένα:
| HTTP requests | απορρίψεις | χειρότερος client | πιο φορτωμένο παράθυρο 50 ms | wall clock | |
|---|---|---|---|---|---|
| χωρίς jitter, run 1 | 491 | 391 | 10 tries | 46 arrivals | 65,6 s |
| χωρίς jitter, run 2 | 780 | 680 | 19 tries | 72 arrivals | 245,7 s |
| χωρίς jitter, run 3 | 770 | 670 | 18 tries | 97 arrivals | 225,6 s |
| full jitter, run 1 | 324 | 224 | 5 tries | 32 arrivals | 2,2 s |
| full jitter, run 2 | 313 | 213 | 6 tries | 31 arrivals | 2,3 s |
| full jitter, run 3 | 318 | 218 | 6 tries | 25 arrivals | 1,8 s |
Δύο πράγματα σε αυτόν τον πίνακα, και το δεύτερο είναι το σημαντικό.
Το πρώτο είναι η διάμεσος: 226 δευτερόλεπτα έναντι 2,2, ένας παράγοντας περίπου εκατό, με λιγότερα από τα μισά requests. Το πιο φορτωμένο retry window εξηγεί γιατί. Χωρίς jitter, έως 97 από τους εκατό clients έφτασαν μέσα στο ίδιο slot 50 χιλιοστών· ο server είχε τρεις, άρα 94 απορρίφθηκαν και κοιμήθηκαν μαζί, ακόμη συγχρονισμένοι, για να το ξανακάνουν με μεγαλύτερη αναμονή. Με jitter, οι ίδιοι εκατό απλώθηκαν στα ίδια windows σε ομάδες περίπου τριάντα και άδειασαν σχεδόν αμέσως.
Το δεύτερο είναι η διακύμανση. Χωρίς jitter: 65,6 s, 245,7 s, 225,6 s. Με αυτό: 2,2, 2,3, 1,8. Ένα σύστημα χωρίς jitter δεν αποδίδει απλώς άσχημα, αποδίδει απρόβλεπτα, επειδή το αποτέλεσμα αποφασίζεται από μικροσκοπικά ατυχήματα scheduling που επιλέγουν ποιοι τρεις από τους εκατό συγχρονισμένους clients θα φτάσουν πρώτοι. Αυτή είναι η υπογραφή αυτού του bug σε production: ένα endpoint που είναι μια χαρά, μια χαρά, μια χαρά, και μετά παίρνει τέσσερα λεπτά, χωρίς καμία δική σας αλλαγή να το εξηγεί.
Και το φθηνότερο retry είναι αυτό που δεν συμβαίνει ποτέ. Βάλτε ένα concurrency gate μπροστά από τον provider — έναν counter που δεν αφήνει ποτέ περισσότερα από N requests να είναι in flight — και οι ίδιοι είκοσι clients που χρειάστηκαν 74 requests και 7,1 δευτερόλεπτα συμπεριφέρονται έτσι:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msΕίκοσι requests για είκοσι απαντήσεις, μηδέν απορρίψεις, οκτώ φορές πιο γρήγορα. Ένα retry είναι η συγγνώμη· το gate είναι το να μη χρειαστείτε συγγνώμη.
Το Retry-After είναι πάτωμα, όχι πρόταση
Σύνδεσμος στην ενότητα: Το Retry-After είναι πάτωμα, όχι πρότασηΌταν ένας provider επιστρέφει 429 συνήθως σας λέει πόσο να περιμένετε, στο header Retry-After.3 Αυτός ο αριθμός δεν είναι συμβουλή: ο provider είναι το μόνο μέρος στην ανταλλαγή που ξέρει πότε μηδενίζει το window του.
Άρα η αναμονή είναι η μεγαλύτερη από τις δύο: ποτέ λιγότερο από Retry-After, και ποτέ λιγότερο από το δικό σας backoff επίσης, επειδή το header σας λέει πότε σας συγχωρεί ο limiter και όχι πότε ο server έχει χώρο.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); Το trace του πιο άτυχου client στο twenty-client run δείχνει το header να κάνει τη δουλειά του. Τα πρώτα τέσσερα backoff draws του ήταν όλα κάτω από ένα δευτερόλεπτο, και όλα τα τέσσερα παρακάμφθηκαν:
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Δύο πρακτικές σημειώσεις. Το Retry-After μπορεί να είναι HTTP date αντί για αριθμός δευτερολέπτων, άρα κάντε parse και τα δύο. Και οι providers κάνουν rate-limit σε δύο άξονες ταυτόχρονα — requests ανά λεπτό και tokens ανά λεπτό — γι’ αυτό τα μεγάλα prompts απορρίπτονται πολύ κάτω από το τεκμηριωμένο request limit. Το header μοιάζει ίδιο και στις δύο περιπτώσεις· η λύση δεν είναι.
Το timeout που κανείς δεν διάλεξε
Σύνδεσμος στην ενότητα: Το timeout που κανείς δεν διάλεξεΖητήστε από τον mock provider το /hang. Δέχεται τη σύνδεση, και μετά δεν κάνει απολύτως τίποτα: χωρίς headers, χωρίς body, χωρίς close. Αυτό δεν είναι εξωτικό — είναι αυτό που κάνει ένας load balancer όταν το process πίσω του έχει πεθάνει χωρίς να κλείσει τα sockets του.
Δύο clients, μία διαφορά:
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Τριακόσια δευτερόλεπτα. Πέντε λεπτά με ένα socket ανοιχτό, ένα request slot κατειλημμένο και έναν χρήστη να κοιτάζει spinner, που τελειώνουν σε ένα γενικό TypeError που δεν λέει τίποτα για το τι συνέβη. Αυτός ο αριθμός δεν είναι bug: είναι το default headers timeout του Node, λογικό για έναν γενικό HTTP client και καταστροφικό για ένα user-facing request. Κάθε runtime έχει ένα τέτοιο default, οι περισσότεροι δεν το ψάχνουν ποτέ, και ο μόνος τρόπος να βρείτε το δικό σας είναι να κρεμάσετε επίτηδες ένα socket όπως μόλις κάναμε.
Άρα: κάθε outgoing request παίρνει explicit deadline, επιλεγμένο από εσάς.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});Για μια streaming κλήση ένα deadline δεν αρκεί, επειδή υπάρχουν δύο διαφορετικές αποτυχίες. Η πρώτη είναι το stream δεν ανοίγει ποτέ: δεν φτάνει κανένα event, και δέκα έως τριάντα δευτερόλεπτα είναι σωστά. Η δεύτερη είναι το stream ανοίγει και μετά κολλάει: tokens έρευσαν και μετά σταμάτησαν, για πάντα, με το socket ακόμη υγιές. Ένα total-duration timeout δεν μπορεί να ξεχωρίσει ένα stalled stream από μια μεγάλη σωστή απάντηση, άρα αυτό που θέλετε είναι ένα idle timeout — ένας timer που κάνει reset με κάθε event και ενεργοποιείται μόνο όταν δεν έχει φτάσει τίποτα για, ας πούμε, δεκαπέντε δευτερόλεπτα.
Η ακύρωση είναι ο ίδιος μηχανισμός στραμμένος σε άνθρωπο. Το AbortSignal.timeout και ένας χρήστης που πατά Stop φτάνουν και τα δύο ως AbortError, άρα συνδυάστε τα και καταγράψτε ποιο ενεργοποιήθηκε:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Το abort έχει σημασία για λόγο πέρα από την τάξη: τα tokens παράγονται και χρεώνονται όσο εσείς δεν ακούτε. Το κεφάλαιο 16 βάζει τιμή σε αυτό.
Τι είναι ασφαλές να γίνει retry
Σύνδεσμος στην ενότητα: Τι είναι ασφαλές να γίνει retryΤώρα η αποτυχία που κοστίζει χρήματα αντί για χρόνο. Ένα request κάνει timeout στον client, και η προφανής κίνηση είναι να το στείλετε ξανά — αλλά ένα timeout δεν σας λέει τίποτα για το αν ο server το έλαβε. Πολύ συχνά το έλαβε, και δουλεύει ακόμη.
Μετρημένο. Ο mock provider χρειάζεται 780 ms για την απάντηση. Ο client τα παρατά στα 300 ms και κάνει retry. Ο server μετρά πόσες απαντήσεις παρήγαγε πραγματικά, που είναι αυτό που θα χρέωνε:
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Χωρίς key: δύο πλήρεις generations, πληρωμένες δύο φορές, και ο client δεν έλαβε καμία από τις δύο. Με key: ο server αναγνώρισε το δεύτερο request ως το ίδιο request και απάντησε αμέσως με την απάντηση που είχε ήδη παράγει, άρα το retry απέφυγε και τη διπλή χρέωση και ήταν η προσπάθεια που τελικά πέτυχε.
Ένα idempotency key είναι ένα μοναδικό string που παράγετε ανά λογική λειτουργία — όχι ανά προσπάθεια — και στέλνετε αμετάβλητο σε κάθε retry της. Ο server αποθηκεύει το αποτέλεσμα πάνω στο key και το επαναπαίζει. Είναι ο μηχανισμός που χρησιμοποιούν τα payment APIs, για τον ίδιο λόγο.4
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");
}Δύο ειλικρινή όρια. Δεν υποστηρίζει κάθε provider idempotency keys στα completions, και όπου το endpoint δεν είναι idempotent, ο σωστός αριθμός retries για ένα POST που μπορεί ήδη να έχει τρέξει είναι μηδέν. Και ένα stream που απέτυχε στα μισά δεν είναι replayable στη γενική περίπτωση: είτε το ξεκινάτε ξανά και πληρώνετε ξανά, είτε κρατάτε το μερικό κείμενο και το σημειώνετε ως incomplete. Ποιο από τα δύο κάνει το προϊόν σας είναι product decision, όχι networking decision, και αξίζει να το πάρετε σκόπιμα.
Κλείνοντας τη ραφή
Σύνδεσμος στην ενότητα: Κλείνοντας τη ραφήΟ client που γράφτηκε σε αυτό το κεφάλαιο δεν έχει ιδέα τι βρίσκεται πίσω από το port. Στρέψτε το base URL του σε έναν εμπορικό provider και κάνει streaming tokens από ένα μοντέλο ενός τρισεκατομμυρίου parameters. Στρέψτε το σε έναν server χτισμένο πάνω στην αριθμητική του κεφαλαίου 13 — που εξυπηρετεί το μοντέλο που προεκπαιδεύσατε στο κεφάλαιο 10, με το KV cache του και τα quantized weights του — και ο ίδιος κώδικας, αμετάβλητος, κάνει streaming tokens από ένα μοντέλο που χτίσατε.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Αυτή η μία γραμμή είναι η ραφή αυτού του μαθήματος. Στη μία πλευρά της είναι αυτό που έχτισαν τα πρώτα δεκατρία κεφάλαια· στην άλλη, αυτό που χτίζουν τα επόμενα δεκαέξι. Το όριο είναι καθαρό επειδή το contract είναι HTTP και SSE, και καμία πλευρά δεν ξέρει τίποτα άλλο για την άλλη.
Αξίζει να προσέξετε τι χάσατε περνώντας απέναντι. Πίσω από ένα εμπορικό endpoint δεν ελέγχετε ούτε τα weights, ούτε την υλοποίηση του sampling, ούτε την έκδοση με την οποία μιλάτε, ούτε αν άλλαξε σήμερα το πρωί. Αυτό που ελέγχετε είναι το contract: τα messages που στέλνετε, το deadline που θέτετε, τους κωδικούς που ξεχωρίζετε και τι κάνετε όταν δεν επιστρέφει τίποτα. Αυτή είναι μικρότερη επιφάνεια από αυτή που είχατε στο κεφάλαιο 5, και κάθε κεφάλαιο που απομένει αφορά το πώς να τη χρησιμοποιήσετε καλά.
Πού πάει αυτό στη συνέχεια
Σύνδεσμος στην ενότητα: Πού πάει αυτό στη συνέχειαΈχετε πλέον έναν client που κάνει streaming, τα παρατά στην ώρα του, κάνει retry στα σωστά πράγματα και δεν κάνει ποτέ retry στα λάθος. Αυτό που στέλνει παραμένει ό,τι πληκτρολογήσατε.
Το κεφάλαιο 15 αφορά αυτό το περιεχόμενο, και έρχεται με μια πειθαρχία. Το internet είναι γεμάτο prompting advice — προσφέρετε στο μοντέλο tip, απειλήστε το, πείτε του να πάρει βαθιά ανάσα — και σχεδόν τίποτα από αυτά δεν έρχεται με μέτρηση. Κάποιες από αυτές τις τεχνικές μετακινούν πολύ το output, κάποιες δεν το μετακινούν καθόλου, και τουλάχιστον μία κάνει ένα classification task χειρότερο ενώ κοστίζει περισσότερα tokens. Ποια είναι ποια δεν είναι προφανές διαβάζοντάς τες, και δεν λύνεται με επιχειρηματολογία.
Άρα το επόμενο κεφάλαιο χτίζει ένα bench: εξήντα cases με γνωστές απαντήσεις, τέσσερις παραλλαγές του ίδιου prompt, τρεγμένες παράλληλα μέσα από ακριβώς τον client που μόλις γράψατε, πινακοποιημένες με τα confidence intervals από το κεφάλαιο 4 — επειδή τέσσερις παραλλαγές πάνω σε είκοσι cases δεν ξεχωρίζουν απολύτως τίποτα. Μία πρόταση διέπει ολόκληρο το κεφάλαιο: ένα prompt μετριέται, δεν συζητιέται.
Πηγές και μέθοδος
Σύνδεσμος στην ενότητα: Πηγές και μέθοδοςΚάθε αριθμός παραπάνω προήλθε από τον mock provider, σε Node 22 πάνω από loopback interface, άρα τα latencies είναι πιο καθαρά από ό,τι θα σας δώσει οποιοδήποτε πραγματικό δίκτυο. Αυτό είναι σκόπιμο: καμία από τις αποτυχίες που μετριούνται δεν προκαλείται από το δίκτυο, και ένας εχθρικός server που μπορείτε να επανεκκινήσετε διδάσκει καλύτερα από έναν πραγματικό που πρέπει να πληρώνετε και δεν μπορείτε να σπάσετε.
Παραπομπές
Σύνδεσμος στην ενότητα: Παραπομπές-
Server-Sent Events, WHATWG HTML Living Standard, ενότητα 9.2. Το wire format — πεδία
data:, events χωρισμένα με blank lines,id:καιretry:— ορίζεται εκεί, μαζί με το interfaceEventSource. ΤοEventSourceδεν μπορεί να στείλει request body ή custom headers, γι’ αυτό κάθε LLM client κάνει parse το format με το χέρι πάνω απόfetchαντί να το χρησιμοποιεί. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Η πηγή της διατύπωσης «full jitter» που χρησιμοποιείται παραπάνω, με τις simulations που δείχνουν γιατί η αφελής εκδοχή συγχρονίζει τους clients. Το συνοδευτικό επιχείρημα υπέρ του shedding load αντί του queueing είναι το κεφάλαιο Handling Overload των Beyer, Jones, Petoff και Murphy (eds.), Site Reliability Engineering (O’Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. και Reschke, J. (eds.), HTTP Semantics, RFC 9110, ενότητα 15, ορίζει τις κλάσεις status code· Nottingham, M. και Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), ενότητα 4, ορίζει το 429 Too Many Requests. Το
Retry-Afterείναι το RFC 9110 ενότητα 10.2.3, και δέχεται είτε αριθμό δευτερολέπτων είτε HTTP date. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, διαβάστηκε 7 Σεπτεμβρίου 2026 — η καθαρότερη διατύπωση του contract: ένα key ανά λογική λειτουργία, αποθηκευμένα results που επαναπαίζονται, conflict που επιστρέφεται όσο η πρώτη προσπάθεια είναι ακόμη in flight — και το pattern είναι ανεξάρτητο από provider. Οι κανονιστικές αναφορές για τα request και event shapes που χρησιμοποιούνται εδώ είναιdevelopers.openai.com/api/reference/resources/chatγια streaming, error codes και rate limits, καιplatform.claude.com/docs/en/api/messagesγια το Messages API· τοai-sdk.dev/docsείναι το καλύτερα δουλεμένο παράδειγμα των ίδιων προβληματισμών τυλιγμένων σε library. Όλα διαβάστηκαν την ίδια μέρα. ↩