Sari la conținut
14/30Capitolul 14 din 30

Primul tău apel LLM în producție: streaming, retry-uri, timeout-uri

Construiește un provider care te minte — 429, socket-uri blocate, stream-uri tăiate — și măsoară ce face clientul.

Pe această pagină

Capitolul 13 s-a încheiat cu un cronometru pe un model pe care îl puteai atinge. Greutățile erau în memoria ta, KV cache era al tău, de activat sau dezactivat, iar numărul care ieșea — timpul până la primul token — era o proprietate a hardware-ului tău.

Acum pune acel model în spatele unui port, așa cum face orice produs, și citește din nou același număr. Este tot timpul până la primul token, dar nu mai este o proprietate a nimic din ce controlezi. Acum include un TLS handshake, o coadă la provider, un limitator de rată și posibilitatea ca niciun token să nu sosească vreodată.

Acea ultimă propoziție este capitolul. Codul pe care urmează să îl scrii nu calculează nimic. Deschide o conexiune, așteaptă, parsează ce sosește, decide ce face când nu sosește nimic, decide din nou când ceea ce sosește este o eroare și se anulează singur când utilizatorul se răzgândește. Fiecare dintre acestea este o decizie despre stare în timp, iar fiecare are un răspuns greșit care ajunge în producție și costă bani.

Iată forma problemei, măsurată, toată în acest capitol:

ce s-a întâmplatce face un client neglijentcât costă
serverul a acceptat socket-ul și nu a răspuns niciodatăașteaptă300,8 s înainte ca Node să renunțe singur
cheia era greșită (401)încearcă din nou de cinci ori6.325 ms de întârziere, apoi același 401
o sută de clienți lovesc limita de rată împreunătoți reîncearcă după același program226 s ca să se golească, față de 2,2 s
request-ul a expirat și a fost retrimisîl retrimiteprovider-ul generează — și facturează — răspunsul de două ori
conexiunea a căzut la mijlocul răspunsuluiafișează textul parțialimposibil de distins de un răspuns scurt corect

Niciuna dintre acestea nu este o problemă de modelare. Toate sunt în primele o sută de linii ale oricărui produs LLM scris vreodată.

Citește din nou tabelul și întreabă-te ce fel de program descrie. Ține o conexiune deschisă patruzeci de secunde. Trebuie să poată fi anulat dintr-un buton. Acumulează un răspuns parțial care este valid de afișat și invalid de salvat. Și rulează într-un proces de server sau într-un edge worker, lângă lucrul care randează răspunsul, ținând un socket.

Acesta nu este un notebook. Nu pentru că Python nu poate face asta — poate, iar oamenii o fac — ci pentru că tot ce au construit cele treisprezece capitole anterioare era de altă natură. Capitolele 1–13 au ținut weights, gradients, logits și bytes de tokenizer. De aici, codul ține o conexiune, un retry, o anulare, stare acumulată și, mai târziu, un prompt de permisiune. Cursul schimbă limbajul exact la cusătura unde obiectul se schimbă.

Așadar regula, scrisă o singură dată:

Dacă codul are în mâini weights, gradients, logits sau bytes de tokenizer, este Python. Dacă ține o conexiune, face retries, anulează, acumulează stare și cere permisiune, este TypeScript.

Cusătura este una singură și cade aici, între Capitolul 13 și Capitolul 14. Trei criterii independente o pun aici.

Unu: ecosistemul, numărat. Tot ce citează jumătatea stângă a acestui curs este Python, iar în cele douăsprezece cursuri auditate pentru această programă nu există niciun precedent în care backpropagation să fie predat în alt limbaj: micrograd (17,4K stele), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). A scrie Capitolul 5 în TypeScript ar rupe legătura cu acele surse, iar legăturile sunt jumătate din valoarea unui capitol care există ca să fie citat, nu ca să rankeze. Pe partea aceasta, aritmetica se inversează: pachetul ai de la Vercel are 89,4M descărcări pe lună și livrează chiar lucrul în sine — o buclă de tool calling agent, exportată ca ToolLoopAgent — deci conceptul la care ajunge acest curs în Capitolul 23 are implementarea de referință în TypeScript, chiar dacă, după cum măsoară acel capitol, nimeni nu s-a pus de acord asupra unui nume pentru el; Mastra are 27,7K stele; iar SDK-urile Anthropic, generate dintr-o singură specificație, declară 202 endpoint-uri în TypeScript față de 201 în Python — paritate, nu un port de curtoazie.

Doi: sursa normativă a MCP. Schema specificației Model Context Protocol este un fișier schema.ts. A preda protocolul din Capitolul 26 în alt limbaj înseamnă a preda o traducere a documentului său fondator.

Trei: cererea din căutare, cu o corecție la presupunerea evidentă. machine learning python este cea mai saturată expresie de pe internet; ai agent typescript are propriul tail sănătos. Dar „ecosistemul MCP este în mare parte TypeScript” este adevărat doar în funcție de cum numeri: registrul oficial listează 8.275 de servere pe npm față de 3.603 pe PyPI, în timp ce la descărcări câștigă Python — 287M pe lună pentru mcp plus 72M pentru fastmcp față de 195M pentru @modelcontextprotocol/sdk. MCP este singurul teritoriu cu adevărat bilingv de aici, motiv pentru care Capitolul 27 scrie același server de două ori, în loc să se prefacă.

Afișează detaliile

Cele cinci excepții declarate, ca regula să fie regulă, nu slogan.

Capitolele 17, 20 și 29 au un al doilea panou în Python: implementarea top-p sampling cere să ai vectorul de probabilități în mână, iar un HTTP API nu ți-l dă niciodată; a calcula onest prețul unui fine-tune înseamnă să rulezi unul, iar un adaptor LoRA are o duzină de linii de nn.Module; iar lm-eval-harness, HELM, SWE-bench și τ-bench sunt Python, deci un evaluation harness în TypeScript ar fi imaginea în oglindă a greșelii cu backpropagation. Capitolul 27 este bilingv, din motivul măsurat mai sus. Capitolul 28 este Markdown, pentru că un agent skill este un fișier SKILL.md, iar a-i da un limbaj de programare ar însemna să nu fi înțeles formatul.

Cele treisprezece capitole Python nu sunt aruncate. Ce se află de cealaltă parte a portului este ceea ce au construit, iar ultima secțiune de aici conectează un client la asta.

Nu poți învăța nimic din toate acestea împotriva unui provider real. Nu poți să îi ceri un 429 la un moment ales, sau un socket care îți acceptă conexiunea și nu răspunde niciodată, sau un stream care se oprește la mijlocul unui cuvânt — și ai plăti pentru fiecare experiment, când experimentele interesante sunt cele pe care le rulezi de o sută de ori.

Așadar primul program din această jumătate a cursului nu este un client. Este un server ostil: patruzeci de linii de Node simplu care vorbesc același protocol pe fir ca un endpoint de chat completions și se poartă greșit la cerere. Fiecare număr din acest capitol a ieșit din el.

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

Patru comportamente ostile, câte o linie fiecare: /hang acceptă socket-ul și nu scrie niciodată în el; /401 refuză cheia; verificarea de capacitate produce un 429 real cu un header Retry-After real după ce trei request-uri sunt deja în zbor; iar ?cut=N abandonează răspunsul la jumătate, fie resetând socket-ul, fie — cu &how=close — închizându-l ordonat, lucru care se dovedește că contează enorm. Restul este un stream Server-Sent Events real: un obiect JSON per linie data:, o linie goală între evenimente, șirul [DONE] la final.1

Rulează-l, iar restul capitolului este măsurare.

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]

Corpul request-ului și cheia care nu părăsește niciodată serverul

Link către secțiunea: Corpul request-ului și cheia care nu părăsește niciodată serverul

Un request de chat este o listă de mesaje, fiecare cu un rol. Acea listă este întreaga stare a modelului: nu există memorie între apeluri, iar orice vrei ca modelul să știe trebuie să fie în array-ul pe care îl trimiți de data aceasta. Capitolul 15 este despre ce pui în el, iar Capitolul 16 este despre cât costă, deci aici contează doar forma.

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

Acele roluri nu sunt decor. Sunt randate în chat template-ul din Capitolul 11 înainte ca modelul să vadă un singur token, motiv pentru care trimiterea rolului greșit degradează răspunsul în tăcere, în loc să ridice o eroare.

O regulă fără excepții: cheia API nu călătorește niciodată la client. Nu într-o variabilă de mediu prefixată pentru browser, nu într-o constantă de build, nu „temporar”. O cheie într-un bundle este o cheie pe nota de plată a altcuiva în câteva zile. Browserul vorbește cu serverul tău, serverul tău ține cheia și vorbește cu provider-ul — și, pentru că serverul tău este la mijloc, este și singurul loc care poate măsura cât cheltuie fiecare utilizator, adică locul unde trebuie să trăiască contabilitatea din Capitolul 16.

Acum experimentul pe care este construit capitolul. O întrebare, un provider mock care produce treisprezece token la 60 ms fiecare, trei feluri de a întreba.

Mai întâi, fără streaming. Clientul trimite request-ul și așteaptă întregul corp JSON.

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

Cele două numere sunt aceleași, iar aceasta este întreaga problemă. Timp de 791 ms utilizatorul are un spinner și niciun cuvânt nu era disponibil mai devreme — serverul avea răspunsul, byte cu byte, și a ales să nu spună nimic.

Apoi, cu streaming. Același server, același răspuns, aceeași muncă totală. Diferența este un 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);
      }
    }
  }
}

Trei detalii de acolo sunt portante, iar majoritatea primelor încercări le sar pe toate trei. buffer există pentru că un chunk de rețea nu are nicio relație cu un eveniment: un read() poate returna jumătate de eveniment, sau două și jumătate. Flag-ul { stream: true } există pentru că un caracter UTF-8 multi-byte poate fi împărțit între două chunk-uri, iar fără el o literă cu accent devine aleatoriu un caracter de înlocuire. Iar evenimentele sunt separate printr-o linie goală, nu printr-un newline, motiv pentru care bucla caută \n\n.

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

De douăsprezece ori mai rapid până la primul cuvânt și cu două milisecunde mai lent până la ultimul. Streaming nu face nimic mai rapid. Schimbă ce face utilizatorul în aceleași 790 ms: citește în loc să aștepte. Acesta este întregul beneficiu, este enorm și este motivul pentru care fiecare produs de chat face streaming.

În al treilea rând, cu douăzeci de clienți simultan. Provider-ul mock servește trei request-uri odată. Lansează douăzeci:

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

Douăzeci de răspunsuri, șaptezeci și patru de request-uri, cincizeci și patru de respingeri. Nimeni nu a pierdut nimic, fiecare client a primit același text, iar singurul cost vizibil a fost timpul. Asta este o politică de retry care funcționează. Restul capitolului este despre cele trei feluri în care poate eșua.

finish_reason și două finaluri care arată la fel

Link către secțiunea: finish_reason și două finaluri care arată la fel

Înainte de eșecuri, câmpul pe care aproape toată lumea îl ignoră la prima trecere. Fiecare stream se termină cu un eveniment care poartă finish_reason. stop înseamnă că modelul a decis că a terminat. length înseamnă că a lovit plafonul de token, deci răspunsul este trunchiat la mijlocul propoziției și nu este vina modelului. Capitolele următoare adaugă tool_calls (Capitolul 18) și filtre de conținut.

Acum urmărește două finaluri pe care un client naiv nu le poate deosebi. Același server, aceeași întârziere, unul trunchiat de max_tokens și unul în care conexiunea este închisă curat după cinci 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"

Citește cu atenție primele două rânduri. Text identic. Număr de chunk-uri identic. Nicio excepție în niciun caz. Bucla for await s-a terminat normal de ambele ori, pentru că din punctul de vedere al reader-ului corpul s-a încheiat, iar asta este tot ce poate face un corp. Singura diferență din întreaga observație este că unul poartă finish_reason: "length", iar celălalt nu poartă nimic.

Așadar regula nu este „prinde erorile în timpul streaming”. Este:

Un stream care se termină fără finish_reason nu s-a terminat. S-a oprit.

Tratează întotdeauna un finish_reason lipsă ca eșec și nu persista niciodată acel text ca răspuns complet. Al treilea rând arată cazul mai ușor — un socket distrus aruncă într-adevăr o eroare și pierde și chunk-ul care era în zbor, motiv pentru care textul este cu un cuvânt mai scurt decât cele două de mai sus.

Cinci coduri de status care sunt cinci probleme diferite

Link către secțiunea: Cinci coduri de status care sunt cinci probleme diferite

Cel mai scump obicei al unui produs nou este un singur bloc catch pentru tot ce returnează provider-ul. Aceste coduri nu sunt variații ale lui „a eșuat”. Sunt cinci instrucțiuni, iar patru dintre ele se contrazic.

statusce înseamnăce faciaștepți?
400request-ul tău este malformat — JSON greșit, câmp necunoscut, context prea lungrepari codulniciodată
401cheia este greșită, lipsește sau a fost revocatărepari deployment-ulniciodată
429limită de rată: prea multe request-uri sau prea mulți token pe minutretryRetry-After, apoi backoff
500provider-ul s-a stricatretrybackoff
503provider-ul este supraîncărcat — este pornit, dar plinretrybackoff și reduci încărcarea

Linia care contează este între 4xx și restul. Un 400 sau un 401 returnează exact același răspuns dacă îl trimiți de o mie de ori, pentru că nimic nu se schimbă la niciun capăt între încercări. A-l retrimite nu este prudență, este o întârziere cu pași suplimentari. Măsurat: un client care face șase încercări — cinci retries cu exponential backoff — și unul care citește mai întâi codul.

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

Șase secunde de spinner ca să ajungi la un răspuns disponibil în patru milisecunde. Și aceasta este versiunea blândă: retries într-un produs sunt de obicei imbricate — un client HTTP care face retry în interiorul unui job runner care face retry în interiorul unei cozi cu propria redelivrare — deci șase secunde devin șase minute în care un deployment permanent stricat arată ca unul lent.

Triajul are nouă linii și aparține unui singur loc:

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
}

Încă două pentru lista ta: 402, pe care unii provideri îl folosesc pentru „nu mai ai credit” și care cere un ecran cu link de cumpărare, nu un retry, și 529 sau echivalentele specifice furnizorilor, care se comportă ca 503.

A face retry este ușor. Când faci retry este partea cu un răspuns corect măsurabil.

Exponential backoff este standardul: aștepți o întârziere de bază, o dublezi după fiecare eșec, te oprești la un plafon. Există pentru că un server supraîncărcat se înrăutățește dacă utilizatorii care tocmai au eșuat se întorc imediat.

Problema este că toată lumea dublează din același punct de pornire. Dacă o sută de clienți lovesc o limită în același moment — și o vor face, pentru că asta este un vârf de trafic — atunci toți cei o sută așteaptă 200 ms, toți reîncearcă împreună, toți eșuează împreună și toți așteaptă 400 ms. Programul de retry i-a sincronizat. Aceasta este o thundering herd, iar aleatoriul este rezolvarea.2

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

Acea singură schimbare — alegerea uniformă din interval în loc de capătul lui superior — se numește full jitter. Este un apel la Math.random() și merită măsurată, nu crezută:

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

O sută de clienți, un server care servește trei odată, tot restul identic, câte trei rulări:

request-uri HTTPrespingericel mai prost clientcea mai aglomerată fereastră de 50 mswall clock
fără jitter, rularea 149139110 încercări46 sosiri65,6 s
fără jitter, rularea 278068019 încercări72 sosiri245,7 s
fără jitter, rularea 377067018 încercări97 sosiri225,6 s
full jitter, rularea 13242245 încercări32 sosiri2,2 s
full jitter, rularea 23132136 încercări31 sosiri2,3 s
full jitter, rularea 33182186 încercări25 sosiri1,8 s

Două lucruri sunt în acel tabel, iar al doilea este cel important.

Primul este mediana: 226 de secunde față de 2,2, un factor de aproximativ o sută, cu mai puțin de jumătate din request-uri. Cea mai aglomerată fereastră de retry spune de ce. Fără jitter, până la 97 dintre cei o sută de clienți au sosit în același slot de 50 de milisecunde; serverul avea trei locuri, deci 94 au fost respinși și au adormit împreună, încă sincronizați, ca să repete cu o așteptare mai lungă. Cu jitter, aceeași sută se împrăștie prin aceleași ferestre în grupuri de aproximativ treizeci și se golește aproape imediat.

Al doilea este varianța. Fără jitter: 65,6 s, 245,7 s, 225,6 s. Cu el: 2,2, 2,3, 1,8. Un sistem fără jitter nu doar performează prost, ci performează imprevizibil, pentru că rezultatul este decis de accidente microscopice de scheduling care aleg care trei dintre o sută de clienți sincronizați sosesc primii. Aceasta este semnătura bugului în producție: un endpoint care este ok, ok, ok, apoi durează patru minute, iar nicio schimbare de-a ta nu explică asta.

Iar cel mai ieftin retry este cel care nu se întâmplă niciodată. Pune o poartă de concurență în fața provider-ului — un contor care nu lasă niciodată mai mult de N request-uri în zbor — iar aceiași douăzeci de clienți care au avut nevoie de 74 de request-uri și 7,1 secunde se comportă așa:

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

Douăzeci de request-uri pentru douăzeci de răspunsuri, zero respingeri, de opt ori mai rapid. Un retry este scuza; poarta înseamnă să nu ai nevoie de una.

Când un provider returnează 429, de obicei îți spune cât să aștepți, în header-ul Retry-After.3 Acel număr nu este un sfat: provider-ul este singura parte din schimb care știe când i se resetează fereastra.

Așadar așteptarea este cea mai mare dintre cele două: niciodată mai mică decât Retry-After și niciodată mai mică decât propriul backoff, pentru că header-ul îți spune când te iartă limitatorul, nu când serverul are loc.

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

Urma celui mai ghinionist client din rularea cu douăzeci de clienți arată header-ul făcându-și treaba. Primele lui patru extrageri de backoff au fost toate sub o secundă, iar toate patru au fost suprascrise:

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

Două note practice. Retry-After poate fi o dată HTTP, nu un număr de secunde, deci parsează-le pe ambele. Iar providerii limitează rata pe două axe simultan — request-uri pe minut și token pe minut — motiv pentru care prompt-uri lungi sunt respinse mult sub limita documentată de request-uri. Header-ul arată la fel în ambele cazuri; soluția nu.

Cere provider-ului mock /hang. Acceptă conexiunea și apoi nu mai face absolut nimic: fără headers, fără body, fără close. Nu este exotic — asta face un load balancer când procesul din spatele lui a murit fără să își închidă socket-urile.

Doi clienți, o singură diferență:

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

Trei sute de secunde. Cinci minute cu un socket ținut deschis, un slot de request ocupat și un utilizator uitându-se la un spinner, terminându-se într-un TypeError generic care nu spune nimic despre ce s-a întâmplat. Numărul nu este un bug: este timeout-ul implicit de headers al Node, rezonabil pentru un client HTTP generic și catastrofal pentru un request orientat către utilizator. Fiecare runtime are un asemenea default, majoritatea oamenilor nu îl caută niciodată, iar singurul mod de a-l afla pe al tău este să agăți intenționat un socket, așa cum tocmai am făcut.

Deci: fiecare request ieșit primește un deadline explicit, ales de tine.

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

Pentru un apel cu streaming, un singur deadline nu este suficient, pentru că există două eșecuri diferite. Primul este stream-ul nu se deschide niciodată: nu sosește niciun eveniment, iar zece până la treizeci de secunde este corect. Al doilea este stream-ul se deschide și apoi stagnează: token au curs și apoi s-au oprit, pentru totdeauna, cu socket-ul încă sănătos. Un timeout de durată totală nu poate deosebi un stream blocat de un răspuns lung corect, deci ce vrei este un idle timeout — un timer resetat de fiecare eveniment, care pornește doar când nu a sosit nimic, să zicem, timp de cincisprezece secunde.

Anularea este același mecanism îndreptat spre o persoană. AbortSignal.timeout și un utilizator care apasă Stop sosesc ambele ca un AbortError, deci combină-le și înregistrează care a declanșat:

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

Abort-ul contează dintr-un motiv dincolo de curățenie: token sunt generați și facturați în timp ce tu nu mai asculți. Capitolul 16 pune un preț pe asta.

Acum eșecul care costă bani, nu timp. Un request expiră pe client, iar mișcarea evidentă este să îl trimiți din nou — dar un timeout nu îți spune nimic despre dacă serverul l-a primit. Foarte des l-a primit și încă lucrează.

Măsurat. Provider-ul mock are nevoie de 780 ms pentru răspuns. Clientul renunță la 300 ms și face retry. Serverul numără câte răspunsuri a generat efectiv, adică ceea ce ar factura:

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

Fără cheie: două generări complete, plătite de două ori, iar clientul nu a primit niciuna. Cu cheie: serverul a recunoscut al doilea request ca fiind același request și a răspuns instant cu răspunsul pe care îl produsese deja, deci retry-ul a evitat și taxa dublă și a fost încercarea care a reușit în cele din urmă.

O idempotency key este un șir unic pe care îl generezi per operațiune logică — nu per încercare — și îl trimiți neschimbat la fiecare retry al ei. Serverul stochează rezultatul lângă cheie și îl redă. Este mecanismul folosit de API-urile de plăți, din același motiv.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");
}

Două limite oneste. Nu fiecare provider acceptă idempotency keys pe completions, iar acolo unde endpoint-ul nu este idempotent, numărul corect de retries pentru un POST care poate să fi rulat deja este zero. Iar un stream care a eșuat la jumătate nu poate fi redat în cazul general: fie îl repornești și plătești din nou, fie păstrezi textul parțial și îl marchezi incomplet. Ce alege produsul tău este o decizie de produs, nu una de networking, și merită luată intenționat.

Clientul scris în acest capitol nu are habar ce se află în spatele portului. Îndreaptă-i base URL-ul spre un provider comercial și face streaming de token dintr-un model cu un trilion de parametri. Îndreaptă-l spre un server construit pe aritmetica din Capitolul 13 — servind modelul pe care l-ai pre-antrenat în Capitolul 10, cu KV cache și weights cuantizate — iar același cod, neschimbat, face streaming de token dintr-un model construit de tine.

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

Acea singură linie este cusătura acestui curs. Pe o parte se află ce au construit primele treisprezece capitole; pe cealaltă, ce construiesc următoarele șaisprezece. Granița este curată pentru că contractul este HTTP și SSE, iar niciuna dintre părți nu știe nimic altceva despre cealaltă.

Merită observat ce ai pierdut traversând. În spatele unui endpoint comercial nu controlezi nici weights, nici implementarea de sampling, nici versiunea cu care vorbești, nici dacă s-a schimbat în această dimineață. Ce controlezi este contractul: mesajele pe care le trimiți, deadline-ul pe care îl setezi, codurile pe care le distingi și ce faci când nu se întoarce nimic. Este o suprafață mai mică decât aveai în Capitolul 5, iar fiecare capitol rămas este despre cum o folosești bine.

Ai acum un client care face streaming, renunță la timp, reîncearcă lucrurile corecte și nu le reîncearcă niciodată pe cele greșite. Ce trimite este încă doar ce ai tastat.

Capitolul 15 este despre acel conținut și vine cu o disciplină. Internetul este plin de sfaturi despre prompting — oferă modelului un bacșiș, amenință-l, spune-i să respire adânc — și aproape niciunul nu vine cu o măsurătoare. Unele dintre aceste tehnici mișcă mult output-ul, unele nu îl mișcă deloc, iar cel puțin una face o sarcină de clasificare mai proastă, costând mai mulți token. Care este care nu este evident citindu-le și nu se tranșează prin argument.

Așadar capitolul următor construiește un bench: șaizeci de cazuri cu răspunsuri cunoscute, patru variante ale aceluiași prompt, rulate în paralel prin exact clientul pe care tocmai l-ai scris, tabelate cu intervalele de încredere din Capitolul 4 — pentru că patru variante pe douăzeci de cazuri nu disting absolut nimic. O singură propoziție guvernează întregul capitol: un prompt se măsoară, nu se dezbate.


Fiecare număr de mai sus a venit din provider-ul mock, pe Node 22 peste o interfață loopback, deci latențele sunt mai curate decât ți-ar da orice rețea reală. Este intenționat: niciunul dintre eșecurile măsurate nu este cauzat de rețea, iar un server ostil pe care îl poți reporni te învață mai bine decât unul real pentru care trebuie să plătești și pe care nu îl poți strica.

  1. Server-Sent Events, WHATWG HTML Living Standard, secțiunea 9.2. Formatul pe fir — câmpuri data:, evenimente separate prin linii goale, id: și retry: — este definit acolo, împreună cu interfața EventSource. EventSource nu poate trimite un request body sau headers custom, motiv pentru care fiecare client LLM parsează formatul manual peste fetch în loc să îl folosească.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Sursa formulării full jitter folosite mai sus, cu simulările care arată de ce versiunea naivă sincronizează clienții. Argumentul companion pentru reducerea încărcării în locul punerii ei la coadă este capitolul Handling Overload din Beyer, Jones, Petoff și Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016).

  3. Fielding, R., Nottingham, M. și Reschke, J. (eds.), HTTP Semantics, RFC 9110, secțiunea 15, definește clasele de status code; Nottingham, M. și Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), secțiunea 4, definește 429 Too Many Requests. Retry-After este RFC 9110 secțiunea 10.2.3 și acceptă fie un număr de secunde, fie o dată HTTP.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, citit la 7 septembrie 2026 — cea mai clară formulare a contractului: o cheie per operațiune logică, rezultate stocate redate, conflict returnat cât timp prima încercare este încă în zbor — iar pattern-ul este independent de provider. Referințele normative pentru formele de request și eveniment folosite aici sunt developers.openai.com/api/reference/resources/chat pentru streaming, coduri de eroare și limite de rată, și platform.claude.com/docs/en/api/messages pentru Messages API; ai-sdk.dev/docs este cel mai bun exemplu lucrat al acelorași preocupări împachetate într-o bibliotecă. Toate au fost citite în aceeași zi.

Gata să lași LIA să aleagă?

Construiește cu toate modelele AI într-un singur loc — începe gratuit azi.