Μετάβαση στο περιεχόμενο
14/30Κεφάλαιο 14 από 30

Η πρώτη σας παραγωγική κλήση 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. Δεν μπορείτε να του ζητήσετε ένα 429 σε επιλεγμένη στιγμή, ή ένα socket που δέχεται τη σύνδεσή σας και δεν απαντά ποτέ, ή ένα stream που σταματά στη μέση μιας λέξης — και θα πληρώνατε για κάθε πείραμα, ενώ τα ενδιαφέροντα πειράματα είναι αυτά που τρέχετε εκατό φορές.

Άρα το πρώτο πρόγραμμα σε αυτό το μισό του μαθήματος δεν είναι client. Είναι ένας εχθρικός server: σαράντα γραμμές απλού Node που μιλούν το ίδιο wire protocol με ένα chat completions endpoint και συμπεριφέρονται άσχημα κατά παραγγελία. Κάθε αριθμός σε αυτό το κεφάλαιο βγήκε από αυτόν.

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);

Τέσσερις εχθρικές συμπεριφορές, από μία γραμμή η καθεμία: το /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

Τρέξτε το, και το υπόλοιπο κεφάλαιο είναι μέτρηση.

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]

Το request body, και το key που δεν φεύγει ποτέ από τον server

Σύνδεσμος στην ενότητα: Το request body, και το key που δεν φεύγει ποτέ από τον server

Ένα chat request είναι μια λίστα από messages, το καθένα με έναν ρόλο. Αυτή η λίστα είναι ολόκληρη η κατάσταση του μοντέλου: δεν υπάρχει μνήμη ανάμεσα στις κλήσεις, και ό,τι θέλετε να γνωρίζει το μοντέλο πρέπει να βρίσκεται μέσα στο array που στέλνετε αυτή τη φορά. Το κεφάλαιο 15 αφορά το τι να βάλετε μέσα και το κεφάλαιο 16 το τι κοστίζει, οπότε εδώ είναι απλώς το σχήμα.

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,
};

Αυτοί οι ρόλοι δεν είναι διακόσμηση. Γίνονται 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.

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

Οι δύο αριθμοί είναι ίδιοι, και αυτό είναι όλο το πρόβλημα. Για 791 ms ο χρήστης έχει ένα spinner, και ούτε μία λέξη δεν ήταν διαθέσιμη νωρίτερα — ο server είχε την απάντηση, byte προς byte, και επέλεξε να μην πει τίποτα.

Δεύτερον, με streaming. Ίδιος server, ίδια απάντηση, ίδια συνολική δουλειά. Η διαφορά είναι ένας 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);
      }
    }
  }
}

Τρεις λεπτομέρειες εκεί είναι φέρουσες και οι περισσότερες πρώτες προσπάθειες παραλείπουν και τις τρεις. Το buffer υπάρχει επειδή ένα network chunk δεν έχει καμία σχέση με ένα event: ένα read() μπορεί να επιστρέψει μισό event, ή δυόμισι. Το flag { stream: true } υπάρχει επειδή ένας multi-byte UTF-8 χαρακτήρας μπορεί να κοπεί σε δύο chunks, και χωρίς αυτό ένα τονισμένο γράμμα γίνεται τυχαία replacement character. Και τα events χωρίζονται με κενή γραμμή, όχι με newline, γι’ αυτό το loop ψάχνει το \n\n.

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

Δώδεκα φορές πιο γρήγορα μέχρι την πρώτη λέξη, και δύο χιλιοστά πιο αργά μέχρι την τελευταία. Το streaming δεν κάνει τίποτα πιο γρήγορο. Αλλάζει αυτό που κάνει ο χρήστης μέσα στα ίδια 790 ms: διαβάζει αντί να περιμένει. Αυτό είναι όλο το όφελος, είναι τεράστιο, και είναι ο λόγος που κάθε chat product κάνει streaming.

Τρίτον, με είκοσι clients ταυτόχρονα. Ο mock provider εξυπηρετεί τρία requests κάθε φορά. Πυροδοτήστε είκοσι:

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

Είκοσι απαντήσεις, εβδομήντα τέσσερα requests, πενήντα τέσσερις απορρίψεις. Κανείς δεν έχασε τίποτα, κάθε client πήρε το ίδιο κείμενο, και το μόνο ορατό κόστος ήταν χρόνος. Αυτό είναι μια retry policy που λειτουργεί. Το υπόλοιπο αυτού του κεφαλαίου αφορά τους τρεις τρόπους με τους οποίους μπορεί αντί γι’ αυτό να αποτύχει.

Πριν από τις αποτυχίες, το πεδίο που σχεδόν όλοι αγνοούν στην πρώτη προσπάθεια. Κάθε stream τελειώνει με ένα event που μεταφέρει finish_reason. Το stop σημαίνει ότι το μοντέλο αποφάσισε πως τελείωσε. Το length σημαίνει ότι χτύπησε το token ceiling, άρα η απάντηση κόβεται στη μέση πρότασης και δεν φταίει το μοντέλο. Μεταγενέστερα κεφάλαια προσθέτουν tool_calls (κεφάλαιο 18) και content filters.

Τώρα δείτε δύο τελειώματα που ένας αφελής client δεν μπορεί να ξεχωρίσει. Ίδιος server, ίδια καθυστέρηση, το ένα truncated από max_tokens και το άλλο όπου η σύνδεση κλείνει καθαρά μετά από πέντε token:

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"

Διαβάστε προσεκτικά τις δύο πρώτες γραμμές. Ίδιο κείμενο. Ίδιος αριθμός 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ποτέ
429rate limit: πάρα πολλά requests, ή πάρα πολλά tokens, ανά λεπτόretryRetry-After, μετά backoff
500ο provider χάλασεretrybackoff
503ο provider είναι overloaded — είναι up, είναι γεμάτοςretrybackoff, και shed load

Η γραμμή που έχει σημασία περνά ανάμεσα στα 4xx και τα υπόλοιπα. Ένα 400 ή ένα 401 επιστρέφει ακριβώς την ίδια απάντηση αν το στείλετε χίλιες φορές, επειδή τίποτα σε καμία άκρη δεν αλλάζει ανάμεσα στις προσπάθειες. Το να κάνετε retry δεν είναι προσοχή, είναι καθυστέρηση με επιπλέον βήματα. Μετρημένο: ένας client που κάνει έξι προσπάθειες — πέντε retries με exponential backoff — και ένας που διαβάζει πρώτα τον κωδικό.

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

Έξι δευτερόλεπτα spinner για να φτάσετε σε μια απάντηση που ήταν διαθέσιμη σε τέσσερα χιλιοστά. Και αυτή είναι η ήπια εκδοχή: τα retries σε ένα προϊόν είναι συνήθως φωλιασμένα — ένας HTTP client που κάνει retry μέσα σε έναν job runner που κάνει retry μέσα σε μια ουρά με δική της redelivery — οπότε τα έξι δευτερόλεπτα γίνονται έξι λεπτά ενός μόνιμα χαλασμένου deployment που μοιάζει αργό.

Το triage είναι εννέα γραμμές και ανήκει σε ένα σημείο:

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
}

Δύο ακόμη για τη λίστα σας: 402, που κάποιοι providers χρησιμοποιούν για «έχετε ξεμείνει από credit» και χρειάζεται μια οθόνη με link για αγορά περισσότερων, όχι retry, και 529 ή τα vendor-specific ισοδύναμά του, που συμπεριφέρονται όπως το 503.

Το retry είναι εύκολο. Το retry πότε είναι το κομμάτι με μετρήσιμη σωστή απάντηση.

Το exponential backoff είναι το standard: περιμένετε μια βασική καθυστέρηση, τη διπλασιάζετε μετά από κάθε αποτυχία, σταματάτε σε ένα ceiling. Υπάρχει επειδή ένας overloaded server γίνεται χειρότερος αν οι clients που μόλις απέτυχαν επιστρέψουν αμέσως.

Το πρόβλημα είναι ότι όλοι διπλασιάζουν από το ίδιο σημείο εκκίνησης. Αν εκατό clients χτυπήσουν ένα limit την ίδια στιγμή — και θα το κάνουν, επειδή αυτό είναι ένα traffic spike — τότε και οι εκατό περιμένουν 200 ms, και οι εκατό κάνουν retry μαζί, και οι εκατό αποτυγχάνουν μαζί, και οι εκατό περιμένουν 400 ms. Το retry schedule τους συγχρόνισε. Αυτό είναι ένα thundering herd, και η τυχαιότητα είναι η λύση.2

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

Αυτή η μία αλλαγή — να επιλέγετε ομοιόμορφα μέσα από το interval αντί να παίρνετε το άνω άκρο του — λέγεται full jitter. Είναι μία κλήση στο Math.random(), και αξίζει να τη μετρήσετε αντί να την πιστέψετε:

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);      

Εκατό clients, ένας server που εξυπηρετεί τρεις κάθε φορά, όλα τα άλλα ίδια, τρεις εκτελέσεις το καθένα:

HTTP requestsαπορρίψειςχειρότερος clientπιο φορτωμένο παράθυρο 50 mswall clock
χωρίς jitter, run 149139110 tries46 arrivals65,6 s
χωρίς jitter, run 278068019 tries72 arrivals245,7 s
χωρίς jitter, run 377067018 tries97 arrivals225,6 s
full jitter, run 13242245 tries32 arrivals2,2 s
full jitter, run 23132136 tries31 arrivals2,3 s
full jitter, run 33182186 tries25 arrivals1,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 δευτερόλεπτα συμπεριφέρονται έτσι:

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

Είκοσι requests για είκοσι απαντήσεις, μηδέν απορρίψεις, οκτώ φορές πιο γρήγορα. Ένα retry είναι η συγγνώμη· το gate είναι το να μη χρειαστείτε συγγνώμη.

Όταν ένας provider επιστρέφει 429 συνήθως σας λέει πόσο να περιμένετε, στο header Retry-After.3 Αυτός ο αριθμός δεν είναι συμβουλή: ο provider είναι το μόνο μέρος στην ανταλλαγή που ξέρει πότε μηδενίζει το window του.

Άρα η αναμονή είναι η μεγαλύτερη από τις δύο: ποτέ λιγότερο από Retry-After, και ποτέ λιγότερο από το δικό σας backoff επίσης, επειδή το header σας λέει πότε σας συγχωρεί ο limiter και όχι πότε ο server έχει χώρο.

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));  

Το trace του πιο άτυχου client στο twenty-client run δείχνει το header να κάνει τη δουλειά του. Τα πρώτα τέσσερα backoff draws του ήταν όλα κάτω από ένα δευτερόλεπτο, και όλα τα τέσσερα παρακάμφθηκαν:

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

Δύο πρακτικές σημειώσεις. Το Retry-After μπορεί να είναι HTTP date αντί για αριθμός δευτερολέπτων, άρα κάντε parse και τα δύο. Και οι providers κάνουν rate-limit σε δύο άξονες ταυτόχρονα — requests ανά λεπτό και tokens ανά λεπτό — γι’ αυτό τα μεγάλα prompts απορρίπτονται πολύ κάτω από το τεκμηριωμένο request limit. Το header μοιάζει ίδιο και στις δύο περιπτώσεις· η λύση δεν είναι.

Ζητήστε από τον mock provider το /hang. Δέχεται τη σύνδεση, και μετά δεν κάνει απολύτως τίποτα: χωρίς headers, χωρίς body, χωρίς close. Αυτό δεν είναι εξωτικό — είναι αυτό που κάνει ένας load balancer όταν το process πίσω του έχει πεθάνει χωρίς να κλείσει τα sockets του.

Δύο clients, μία διαφορά:

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

Τριακόσια δευτερόλεπτα. Πέντε λεπτά με ένα socket ανοιχτό, ένα request slot κατειλημμένο και έναν χρήστη να κοιτάζει spinner, που τελειώνουν σε ένα γενικό TypeError που δεν λέει τίποτα για το τι συνέβη. Αυτός ο αριθμός δεν είναι bug: είναι το default headers timeout του Node, λογικό για έναν γενικό HTTP client και καταστροφικό για ένα user-facing request. Κάθε runtime έχει ένα τέτοιο default, οι περισσότεροι δεν το ψάχνουν ποτέ, και ο μόνος τρόπος να βρείτε το δικό σας είναι να κρεμάσετε επίτηδες ένα socket όπως μόλις κάναμε.

Άρα: κάθε outgoing request παίρνει explicit deadline, επιλεγμένο από εσάς.

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),   
});

Για μια streaming κλήση ένα deadline δεν αρκεί, επειδή υπάρχουν δύο διαφορετικές αποτυχίες. Η πρώτη είναι το stream δεν ανοίγει ποτέ: δεν φτάνει κανένα event, και δέκα έως τριάντα δευτερόλεπτα είναι σωστά. Η δεύτερη είναι το stream ανοίγει και μετά κολλάει: tokens έρευσαν και μετά σταμάτησαν, για πάντα, με το socket ακόμη υγιές. Ένα total-duration timeout δεν μπορεί να ξεχωρίσει ένα stalled stream από μια μεγάλη σωστή απάντηση, άρα αυτό που θέλετε είναι ένα idle timeout — ένας timer που κάνει reset με κάθε event και ενεργοποιείται μόνο όταν δεν έχει φτάσει τίποτα για, ας πούμε, δεκαπέντε δευτερόλεπτα.

Η ακύρωση είναι ο ίδιος μηχανισμός στραμμένος σε άνθρωπο. Το AbortSignal.timeout και ένας χρήστης που πατά Stop φτάνουν και τα δύο ως AbortError, άρα συνδυάστε τα και καταγράψτε ποιο ενεργοποιήθηκε:

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

Το abort έχει σημασία για λόγο πέρα από την τάξη: τα tokens παράγονται και χρεώνονται όσο εσείς δεν ακούτε. Το κεφάλαιο 16 βάζει τιμή σε αυτό.

Τώρα η αποτυχία που κοστίζει χρήματα αντί για χρόνο. Ένα request κάνει timeout στον client, και η προφανής κίνηση είναι να το στείλετε ξανά — αλλά ένα timeout δεν σας λέει τίποτα για το αν ο server το έλαβε. Πολύ συχνά το έλαβε, και δουλεύει ακόμη.

Μετρημένο. Ο mock provider χρειάζεται 780 ms για την απάντηση. Ο client τα παρατά στα 300 ms και κάνει retry. Ο server μετρά πόσες απαντήσεις παρήγαγε πραγματικά, που είναι αυτό που θα χρέωνε:

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

Χωρίς key: δύο πλήρεις generations, πληρωμένες δύο φορές, και ο client δεν έλαβε καμία από τις δύο. Με key: ο server αναγνώρισε το δεύτερο request ως το ίδιο request και απάντησε αμέσως με την απάντηση που είχε ήδη παράγει, άρα το retry απέφυγε και τη διπλή χρέωση και ήταν η προσπάθεια που τελικά πέτυχε.

Ένα idempotency key είναι ένα μοναδικό string που παράγετε ανά λογική λειτουργία — όχι ανά προσπάθεια — και στέλνετε αμετάβλητο σε κάθε retry της. Ο server αποθηκεύει το αποτέλεσμα πάνω στο key και το επαναπαίζει. Είναι ο μηχανισμός που χρησιμοποιούν τα payment APIs, για τον ίδιο λόγο.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");
}

Δύο ειλικρινή όρια. Δεν υποστηρίζει κάθε 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 από ένα μοντέλο που χτίσατε.

switch.tsTS
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 που μπορείτε να επανεκκινήσετε διδάσκει καλύτερα από έναν πραγματικό που πρέπει να πληρώνετε και δεν μπορείτε να σπάσετε.

  1. Server-Sent Events, WHATWG HTML Living Standard, ενότητα 9.2. Το wire format — πεδία data:, events χωρισμένα με blank lines, id: και retry: — ορίζεται εκεί, μαζί με το interface EventSource. Το EventSource δεν μπορεί να στείλει request body ή custom headers, γι’ αυτό κάθε LLM client κάνει parse το format με το χέρι πάνω από fetch αντί να το χρησιμοποιεί.

  2. 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).

  3. 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.

  4. 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. Όλα διαβάστηκαν την ίδια μέρα.


Δημιουργήθηκε από

David Vicente Campos

Ιδρυτής της NeuraLIA Labs & συνιδρυτής του MyRealFood

Είμαι μηχανικός πληροφορικής, απόφοιτος του Πανεπιστημίου Λεόν. Συνίδρυσα το MyRealFood, όπου ως CTO έφτιαξα την εφαρμογή που έχουν χρησιμοποιήσει εκατομμύρια άνθρωποι για να τρώνε καλύτερα, και ίδρυσα τη NeuraLIA Labs, όπου δημιουργώ προϊόντα AI. Εδώ γράφω για όσα χρειάστηκε να κατανοήσω στην πορεία, όπως θα ήθελα να μου τα είχε εξηγήσει κάποιος.

Περισσότερα για τον συγγραφέα

Δημοσιεύτηκε από τη NeuraLIA Labs.

Λάβετε νέες αναρτήσεις στα εισερχόμενά σας

Νέα για AI, οδηγοί και ενημερώσεις προϊόντος — ένα σύντομο email όταν δημοσιεύουμε κάτι που αξίζει τον χρόνο σας.

Ευρετήριο μαθήματος

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev13 λεπτά ανάγνωσης

Το μοντέλο AI Jev είναι φτιαγμένο για αποφάσεις, όχι για πρόζα

Το Jev της TypeSafe AI τραβά την προσοχή επειδή αντιμετωπίζει την ευφυΐα στο λογισμικό ως πρόβλημα πιθανοτήτων: επιλέξτε το σωστό κλαδί, προσθέστε βεβαιότητα και αποφύγετε να πληρώνετε ένα LLM για να γράφει κείμενο όταν ο κώδικας χρειάζεται μια απόφαση.

Abstract legal research workspace with documents, search nodes and governance controls.
openai12 λεπτά ανάγνωσης

Το Astra for Law της OpenAI είναι νομικό σύστημα AI, όχι νέο μοντέλο

Το νομικό λανσάρισμα της OpenAI αφορά λιγότερο ένα νέο θεμελιώδες μοντέλο και περισσότερο το σύστημα γύρω από αυτό: ανάκτηση ανά τομέα, αξιόπιστα εργαλεία, δικαιώματα, benchmarks και διαδρομές ελέγχου.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering12 λεπτά ανάγνωσης

Context engineering for long-horizon AI agents

Long-running agents do not fail only because the window is small. They fail when files, tool outputs and stale history crowd out the task the agent was supposed to finish.

Έτοιμοι να αφήσετε τη LIA να επιλέγει;

Δημιουργήστε με κάθε μοντέλο AI σε ένα σημείο — ξεκινήστε δωρεάν σήμερα.