La tua prima chiamata LLM in produzione: streaming, retry, timeout
Costruisci un provider che mente: 429, socket appesi, stream spezzati. Full jitter: 2,2 secondi contro 226.
In questa pagina
Il capitolo 13 si chiudeva con un cronometro puntato su un model che potevi toccare. I pesi erano nella tua memoria, la KV cache era tua da abilitare o disabilitare, e il numero che ne usciva — time to first token — era una proprietà del tuo hardware.
Ora metti quel model dietro una porta, che è ciò che fa ogni prodotto, e rileggi lo stesso numero. È ancora time to first token, ma non è più una proprietà di nulla che tu controlli. Ora include un handshake TLS, una coda dal provider, un rate limiter e la possibilità che non arrivi mai nessun token.
Quest’ultima clausola è il capitolo. Il codice che stai per scrivere non calcola nulla. Apre una connessione, aspetta, analizza ciò che arriva, decide cosa fare quando non arriva nulla, decide di nuovo quando ciò che arriva è un errore, e si annulla quando l’utente cambia idea. Ognuna di queste è una decisione su stato nel tempo, e ognuna ha una risposta sbagliata che finisce in produzione e costa denaro.
Ecco la forma del problema, misurata, tutta in questo capitolo:
| cosa è successo | cosa fa un client poco attento | quanto costa |
|---|---|---|
| il server ha accettato il socket e non ha mai risposto | aspetta | 300,8 s prima che Node si arrenda da solo |
| la chiave era sbagliata (401) | riprova cinque volte | 6.325 ms di ritardo, poi lo stesso 401 |
| cento client colpiscono insieme il rate limit | tutti riprovano con la stessa pianificazione | 226 s per smaltire, contro 2,2 s |
| la richiesta è andata in timeout ed è stata reinviata | la reinvia | il provider genera — e fattura — la risposta due volte |
| la connessione cade a metà risposta | mostra il testo parziale | indistinguibile da una risposta breve corretta |
Nessuno di questi è un problema di modelling. Tutti stanno nelle prime cento righe di ogni prodotto LLM mai scritto.
Perché questo capitolo cambia linguaggio
Link alla sezione: Perché questo capitolo cambia linguaggioRileggi quella tabella e chiediti che tipo di programma descrive. Tiene aperta una connessione per quaranta secondi. Deve poter essere annullato da un pulsante. Accumula una risposta parziale che è valida da mostrare e non valida da salvare. E gira in un processo server o su un edge worker, accanto alla cosa che renderizza la risposta, tenendo un socket.
Non è un notebook. Non è che Python non possa farlo — può, e le persone lo fanno — è che tutto ciò che i tredici capitoli precedenti hanno costruito era di un altro tipo. I capitoli da 1 a 13 tenevano in mano pesi, gradienti, logits e byte del tokenizer. Da qui in poi il codice tiene in mano una connessione, un retry, una cancellazione, stato accumulato e, più avanti, un prompt di autorizzazione. Il corso cambia linguaggio esattamente nel punto di giunzione in cui cambia l’oggetto.
Quindi la regola, scritta una volta:
Se il codice ha in mano pesi, gradienti, logits o byte del tokenizer, è Python. Se tiene una connessione, fa retry, annulla, accumula stato e chiede autorizzazione, è TypeScript.
La giunzione è una sola e cade qui, tra il capitolo 13 e il capitolo 14. Tre criteri indipendenti la collocano qui.
Uno: l’ecosistema, contato. Tutto ciò che la metà sinistra di questo corso cita è Python, e nei dodici corsi analizzati per questo programma non c’è un solo precedente di backpropagation insegnata in un altro linguaggio: micrograd (17,4K star), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Scrivere il capitolo 5 in TypeScript spezzerebbe il legame con quelle fonti, e i legami sono metà del valore di un capitolo che esiste per essere referenziato più che per posizionarsi. Da questo lato l’aritmetica si inverte: il pacchetto ai di Vercel arriva a 89,4M download al mese e include proprio la cosa in sé — un loop di agent con tool calling, esportato come ToolLoopAgent — quindi il concetto a cui questo corso arriva nel capitolo 23 ha la sua implementazione di riferimento in TypeScript, anche se, come misura quel capitolo, nessuno si è accordato su come chiamarlo; Mastra è a 27,7K star; e gli SDK di Anthropic, generati da una sola specifica, dichiarano 202 endpoint in TypeScript contro 201 in Python — parità, non un port di cortesia.
Due: la fonte normativa di MCP. Lo schema della specifica del Model Context Protocol è un file schema.ts. Insegnare il protocollo del capitolo 26 in un altro linguaggio significa insegnare una traduzione del suo documento fondativo.
Tre: la domanda di ricerca, con una correzione all’ipotesi più ovvia. machine learning python è la frase più satura su internet; ai agent typescript ha la sua coda sana. Ma “l’ecosistema MCP è soprattutto TypeScript” è vero solo a seconda di come conti: il registro ufficiale elenca 8.275 server su npm contro 3.603 su PyPI, mentre per download vince Python — 287M al mese per mcp più 72M per fastmcp contro 195M per @modelcontextprotocol/sdk. MCP è l’unico territorio davvero bilingue qui, ed è per questo che il capitolo 27 scrive lo stesso server due volte invece di fare finta.
Mostra dettagli
Le cinque eccezioni dichiarate, così la regola è una regola e non uno slogan.
I capitoli 17, 20 e 29 portano un secondo pannello in Python: implementare il campionamento top-p richiede di avere in mano il vettore di probabilità, e un’API HTTP non te ne dà mai uno; prezzare onestamente un fine-tune significa eseguirne uno, e un adapter LoRA è una dozzina di righe di nn.Module; e lm-eval-harness, HELM, SWE-bench e τ-bench sono Python, quindi un evaluation harness in TypeScript sarebbe l’immagine speculare dell’errore sulla backpropagation. Il capitolo 27 è bilingue, per il motivo misurato sopra. Il capitolo 28 è Markdown, perché una agent skill è un file SKILL.md e darle un linguaggio di programmazione significherebbe non aver capito il formato.
I tredici capitoli Python non vengono scartati. Dall’altra parte della porta c’è ciò che hanno costruito, e l’ultima sezione qui collega un client a quello.
Un provider che puoi rompere
Link alla sezione: Un provider che puoi rompereNon puoi imparare nulla di tutto questo contro un provider reale. Non puoi chiedergli un 429 in un momento scelto, o un socket che accetti la tua connessione e non risponda mai, o uno stream che si fermi a metà parola — e pagheresti ogni esperimento, quando gli esperimenti interessanti sono quelli che esegui cento volte.
Quindi il primo programma in questa metà del corso non è un client. È un server ostile: quaranta righe di Node semplice che parlano lo stesso wire protocol di un endpoint chat completions e si comportano male su richiesta. Ogni numero in questo capitolo viene da lì.
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);Quattro comportamenti ostili, una riga ciascuno: /hang accetta il socket e non ci scrive mai; /401 rifiuta la chiave; il controllo di capacità produce un vero 429 con un vero header Retry-After quando ci sono già tre richieste in corso; e ?cut=N abbandona la risposta a metà, resettando il socket oppure — con &how=close — chiudendolo in modo ordinato, cosa che si rivela molto importante. Il resto è un vero stream Server-Sent Events: un oggetto JSON per ogni riga data:, una riga vuota tra gli eventi, la stringa [DONE] alla fine.1
Eseguilo, e il resto del capitolo è misurazione.
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]Il corpo della richiesta, e la chiave che non lascia mai il server
Link alla sezione: Il corpo della richiesta, e la chiave che non lascia mai il serverUna richiesta chat è una lista di messaggi, ciascuno con un ruolo. Quella lista è l’intero stato del model: non c’è memoria tra una chiamata e l’altra, e qualunque cosa tu voglia che il model sappia deve stare dentro l’array che invii questa volta. Il capitolo 15 parla di cosa metterci e il capitolo 16 di quanto costa, quindi qui è solo la forma.
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,
};Quei ruoli non sono decorazione. Vengono renderizzati nel chat template del capitolo 11 prima che il model veda un solo token, ed è per questo che inviare il ruolo sbagliato degrada silenziosamente la risposta invece di sollevare un errore.
Una regola senza eccezioni: la chiave API non viaggia mai verso il client. Non in una variabile d’ambiente prefissata per il browser, non in una costante di build-time, non “temporaneamente”. Una chiave in un bundle diventa entro pochi giorni una chiave sul conto di qualcun altro. Il browser parla con il tuo server, il tuo server tiene la chiave e parla con il provider — e poiché il tuo server è in mezzo, è anche l’unico posto che può misurare quanto spende ogni utente, ed è lì che deve vivere la contabilità del capitolo 16.
La stessa domanda, tre volte
Link alla sezione: La stessa domanda, tre volteOra l’esperimento su cui è costruito il capitolo. Una domanda, un mock provider che produce tredici tokens a 60 ms ciascuno, tre modi di chiedere.
Primo, senza streaming. Il client invia la richiesta e aspetta l’intero corpo JSON.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopI due numeri sono uguali, e questo è tutto il problema. Per 791 ms l’utente ha uno spinner, e nemmeno una parola era disponibile prima — il server aveva la risposta, byte per byte, e ha scelto di non dire nulla.
Secondo, con streaming. Stesso server, stessa risposta, stesso lavoro totale. La differenza è un 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);
}
}
}
}Tre dettagli lì reggono il carico e quasi tutti i primi tentativi li saltano tutti e tre. buffer esiste perché un network chunk non ha alcuna relazione con un evento: un read() può restituire mezzo evento, oppure due e mezzo. Il flag { stream: true } esiste perché un carattere UTF-8 multi-byte può essere diviso tra due chunk, e senza di esso una lettera accentata diventa a caso un carattere di sostituzione. E gli eventi sono separati da una riga vuota, non da un newline, motivo per cui il loop cerca \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopDodici volte più veloce fino alla prima parola, e due millisecondi più lento fino all’ultima. Lo streaming non rende nulla più veloce. Cambia ciò che l’utente sta facendo durante gli stessi 790 ms: leggere invece di aspettare. È tutto il beneficio, è enorme, ed è il motivo per cui ogni prodotto chat usa lo streaming.
Terzo, con venti client alla volta. Il mock provider serve tre richieste alla volta. Lancialne venti:
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: trueVenti risposte, settantaquattro richieste, cinquantaquattro rifiuti. Nessuno ha perso nulla, ogni client ha ricevuto lo stesso testo, e l’unico costo visibile è stato il tempo. Questa è una policy di retry che funziona. Il resto di questo capitolo riguarda i tre modi in cui può invece fallire.
finish_reason, e due finali che sembrano uguali
Link alla sezione: finish_reason, e due finali che sembrano ugualiPrima dei fallimenti, il campo che quasi tutti ignorano al primo passaggio. Ogni stream termina con un evento che porta finish_reason. stop significa che il model ha deciso di aver finito. length significa che ha raggiunto il limite di token, quindi la risposta è troncata a metà frase e non è colpa del model. I capitoli successivi aggiungono tool_calls (capitolo 18) e filtri sui contenuti.
Ora guarda due finali che un client ingenuo non sa distinguere. Stesso server, stesso ritardo, uno troncato da max_tokens e uno in cui la connessione viene chiusa correttamente dopo cinque tokens:
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"Leggi attentamente le prime due righe. Testo identico. Numero di chunk identico. Nessuna eccezione in entrambi i casi. Il loop for await è terminato normalmente entrambe le volte, perché dal punto di vista del reader il body è finito e questo è tutto ciò che un body può fare. L’unica differenza nell’intera osservazione è che uno porta finish_reason: "length" e l’altro non porta proprio nulla.
Quindi la regola non è “cattura gli errori durante lo streaming”. È:
Uno stream che termina senza un
finish_reasonnon è terminato. Si è fermato.
Tratta sempre un finish_reason mancante come un fallimento, e non persistere mai quel testo come risposta completata. La terza riga mostra il caso più semplice — un socket distrutto genera effettivamente un’eccezione, e perde anche il chunk che era in volo, motivo per cui il testo è più corto di una parola rispetto ai due sopra.
Cinque status code che sono cinque problemi diversi
Link alla sezione: Cinque status code che sono cinque problemi diversiL’abitudine più costosa di un prodotto nuovo è un unico blocco catch per tutto ciò che il provider restituisce. Questi codici non sono variazioni di “è fallito”. Sono cinque istruzioni, e quattro si contraddicono a vicenda.
| status | cosa significa | cosa fare | aspettare? |
|---|---|---|---|
| 400 | la tua richiesta è malformata — JSON non valido, campo sconosciuto, contesto troppo lungo | correggi il codice | mai |
| 401 | la chiave è sbagliata, mancante o revocata | correggi il deployment | mai |
| 429 | rate limit: troppe richieste, o troppi tokens, al minuto | retry | Retry-After, poi backoff |
| 500 | il provider si è rotto | retry | backoff |
| 503 | il provider è sovraccarico — è attivo, è pieno | retry | backoff, e scarica carico |
La linea che conta passa tra 4xx e il resto. Un 400 o un 401 restituisce esattamente la stessa risposta se lo invii mille volte, perché tra i tentativi non cambia nulla da nessuna delle due parti. Ritentarlo non è prudenza, è un ritardo con passaggi extra. Misurato: un client che fa sei tentativi — cinque retry con exponential backoff — e uno che legge prima il codice.
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 401Sei secondi di spinner per arrivare a una risposta che era disponibile in quattro millisecondi. E questa è la versione lieve: i retry in un prodotto di solito sono annidati — un client HTTP che fa retry dentro un job runner che fa retry dentro una coda con la sua redelivery — quindi sei secondi diventano sei minuti di un deployment permanentemente rotto che sembra solo lento.
Il triage è di nove righe e deve stare in un solo posto:
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
}Altri due per la tua lista: 402, che alcuni provider usano per “hai finito i crediti” e che richiede una schermata con un link per comprarne altri invece di un retry, e 529 o i suoi equivalenti specifici del vendor, che si comportano come 503.
Backoff, e cosa compra davvero il jitter
Link alla sezione: Backoff, e cosa compra davvero il jitterFare retry è facile. Fare retry quando è la parte con una risposta corretta misurabile.
Exponential backoff è lo standard: aspetta un ritardo di base, raddoppialo dopo ogni fallimento, fermati a un tetto. Esiste perché un server sovraccarico peggiora se i client che hanno appena fallito tornano subito.
Il problema è che tutti raddoppiano dallo stesso punto di partenza. Se cento client colpiscono un limite nello stesso momento — e succederà, perché questo è un picco di traffico — allora tutti e cento aspettano 200 ms, tutti e cento riprovano insieme, tutti e cento falliscono insieme, e tutti e cento aspettano 400 ms. La pianificazione dei retry li ha sincronizzati. Questo è un thundering herd, e la casualità è la correzione.2
Quel singolo cambiamento — scegliere uniformemente dall’intervallo invece di prenderne l’estremo superiore — si chiama full jitter. È una chiamata a Math.random(), e vale la pena misurarlo invece di crederci:
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); Cento client, un server che ne serve tre alla volta, tutto il resto identico, tre esecuzioni ciascuno:
| richieste HTTP | rifiuti | peggior client | finestra da 50 ms più affollata | wall clock | |
|---|---|---|---|---|---|
| no jitter, run 1 | 491 | 391 | 10 tentativi | 46 arrivi | 65,6 s |
| no jitter, run 2 | 780 | 680 | 19 tentativi | 72 arrivi | 245,7 s |
| no jitter, run 3 | 770 | 670 | 18 tentativi | 97 arrivi | 225,6 s |
| full jitter, run 1 | 324 | 224 | 5 tentativi | 32 arrivi | 2,2 s |
| full jitter, run 2 | 313 | 213 | 6 tentativi | 31 arrivi | 2,3 s |
| full jitter, run 3 | 318 | 218 | 6 tentativi | 25 arrivi | 1,8 s |
Due cose in quella tabella, e la seconda è quella importante.
La prima è la mediana: 226 secondi contro 2,2, un fattore di circa cento, con meno della metà delle richieste. La finestra di retry più affollata spiega perché. Senza jitter, fino a 97 dei cento client sono arrivati nello stesso slot da 50 millisecondi; il server ne aveva tre, quindi 94 sono stati rifiutati e sono andati a dormire insieme, ancora sincronizzati, per rifarlo con un’attesa più lunga. Con jitter gli stessi cento si distribuiscono sulle stesse finestre in gruppi di circa trenta e si smaltiscono quasi subito.
La seconda è la varianza. Senza jitter: 65,6 s, 245,7 s, 225,6 s. Con jitter: 2,2, 2,3, 1,8. Un sistema senza jitter non si limita a performare male, performa in modo imprevedibile, perché l’esito è deciso da incidenti microscopici di scheduling che scelgono quali tre di cento client sincronizzati arrivano per primi. Questa è la firma di questo bug in produzione: un endpoint che va bene, bene, bene, e poi impiega quattro minuti, senza che nessuna tua modifica lo spieghi.
E il retry più economico è quello che non accade mai. Metti un concurrency gate davanti al provider — un contatore che non lascia mai più di N richieste in flight — e gli stessi venti client che avevano richiesto 74 richieste e 7,1 secondi si comportano così:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msVenti richieste per venti risposte, zero rifiuti, otto volte più veloce. Un retry è la scusa; il gate è non averne bisogno.
Retry-After è un minimo, non un suggerimento
Link alla sezione: Retry-After è un minimo, non un suggerimentoQuando un provider restituisce 429 di solito ti dice quanto aspettare, nell’header Retry-After.3 Quel numero non è un consiglio: il provider è l’unica parte dello scambio che sa quando si resetta la sua finestra.
Quindi l’attesa è il maggiore dei due: mai meno di Retry-After, e mai meno nemmeno del tuo backoff, perché l’header ti dice quando il limiter ti perdona e non quando il server ha spazio.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); La traccia del client più sfortunato nella run da venti client mostra l’header che fa il suo lavoro. I suoi primi quattro valori di backoff estratti erano tutti sotto un secondo, e tutti e quattro sono stati sovrascritti:
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 msDue note pratiche. Retry-After può essere una data HTTP invece di un numero di secondi, quindi analizza entrambi. E i provider applicano rate limit su due assi contemporaneamente — richieste al minuto e tokens al minuto — motivo per cui i prompt lunghi vengono rifiutati ben al di sotto del limite di richieste documentato. L’header ha lo stesso aspetto in entrambi i casi; la correzione no.
Il timeout che nessuno ha scelto
Link alla sezione: Il timeout che nessuno ha sceltoChiedi al mock provider /hang. Accetta la connessione, e poi non fa assolutamente nulla: niente header, niente body, niente close. Non è esotico — è ciò che fa un load balancer quando il processo dietro è morto senza chiudere i suoi socket.
Due client, una differenza:
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_TIMEOUTTrecento secondi. Cinque minuti di socket tenuto aperto, uno slot di richiesta occupato e un utente che fissa uno spinner, per finire in un generico TypeError che non dice nulla su ciò che è successo. Quel numero non è un bug: è il timeout di default di Node sugli header, ragionevole per un client HTTP generico e catastrofico per una richiesta rivolta all’utente. Ogni runtime ha un default simile, la maggior parte delle persone non lo cerca mai, e l’unico modo per trovare il tuo è appendere apposta un socket come abbiamo appena fatto.
Quindi: ogni richiesta in uscita riceve una deadline esplicita, scelta da te.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});Per una chiamata in streaming una sola deadline non basta, perché ci sono due fallimenti diversi. Il primo è lo stream non si apre mai: non arriva alcun evento, e da dieci a trenta secondi è corretto. Il secondo è lo stream si apre e poi si blocca: i tokens sono fluiti e poi si sono fermati, per sempre, con il socket ancora sano. Un timeout di durata totale non può distinguere uno stream bloccato da una risposta lunga corretta, quindi ciò che vuoi è un idle timeout — un timer azzerato da ogni evento, che scatta solo quando non arriva nulla per, diciamo, quindici secondi.
La cancellazione è lo stesso meccanismo puntato su una persona. AbortSignal.timeout e un utente che preme Stop arrivano entrambi come un AbortError, quindi combinali e registra quale dei due è scattato:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abortire conta per una ragione oltre all’ordine: i tokens vengono generati e fatturati mentre tu non stai ascoltando. Il capitolo 16 ci mette un prezzo.
Cosa è sicuro ritentare
Link alla sezione: Cosa è sicuro ritentareOra il fallimento che costa denaro invece che tempo. Una richiesta va in timeout sul client, e la mossa ovvia è inviarla di nuovo — ma un timeout non ti dice nulla sul fatto che il server l’abbia ricevuta. Molto spesso l’ha ricevuta, e sta ancora lavorando.
Misurato. Il mock provider ha bisogno di 780 ms per la risposta. Il client rinuncia a 300 ms e ritenta. Il server conta quante risposte ha effettivamente generato, cioè ciò che fatturerebbe:
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): 1Senza chiave: due generazioni complete, pagate due volte, e il client non ne ha ricevuta nessuna. Con una chiave: il server ha riconosciuto la seconda richiesta come la stessa richiesta e ha risposto all’istante con la risposta che aveva già prodotto, quindi il retry ha evitato sia il doppio addebito sia ed è stato il tentativo finalmente riuscito.
Una idempotency key è una stringa unica che generi per ogni operazione logica — non per ogni tentativo — e invii invariata a ogni suo retry. Il server memorizza l’esito associato alla chiave e lo riproduce. È il meccanismo usato dalle API di pagamento, per la stessa ragione.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");
}Due limiti onesti. Non tutti i provider supportano idempotency keys sulle completions, e dove l’endpoint non è idempotente, il numero corretto di retry per un POST che potrebbe essere già stato eseguito è zero. E uno stream fallito a metà non è replayable nel caso generale: o lo riavvii e paghi di nuovo, oppure tieni il testo parziale e lo marchi incompleto. Quale delle due cose faccia il tuo prodotto è una decisione di prodotto, non di networking, e vale la pena prenderla intenzionalmente.
Chiudere la giunzione
Link alla sezione: Chiudere la giunzioneIl client scritto in questo capitolo non ha idea di cosa ci sia dietro la porta. Punta il suo base URL a un provider commerciale e fa streaming di tokens da un model da un trilione di parametri. Puntalo a un server costruito sull’aritmetica del capitolo 13 — che serve il model che hai preaddestrato nel capitolo 10, con la sua KV cache e i suoi pesi quantizzati — e lo stesso codice, invariato, fa streaming di tokens da un model che hai costruito tu.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Quella singola riga è la giunzione di questo corso. Da un lato c’è ciò che i primi tredici capitoli hanno costruito; dall’altro, ciò che costruiscono i prossimi sedici. Il confine è pulito perché il contratto è HTTP e SSE, e nessuna delle due parti sa altro dell’altra.
Vale la pena notare cosa hai perso attraversandolo. Dietro un endpoint commerciale non controlli né i pesi, né l’implementazione del sampling, né la versione con cui stai parlando, né se sia cambiata stamattina. Ciò che controlli è il contratto: i messaggi che invii, la deadline che imposti, i codici che distingui e cosa fai quando non torna nulla. È una superficie più piccola di quella che avevi nel capitolo 5, e ogni capitolo restante riguarda usarla bene.
Dove si va ora
Link alla sezione: Dove si va oraOra hai un client che fa streaming, rinuncia in tempo, fa retry delle cose giuste e non ritenta mai quelle sbagliate. Ciò che invia è ancora qualunque cosa tu abbia digitato.
Il capitolo 15 riguarda quel contenuto, e arriva con una disciplina. Internet è pieno di consigli sul prompting — offri una mancia al model, minaccialo, digli di fare un respiro profondo — e quasi nessuno arriva con una misurazione. Alcune di queste tecniche spostano molto l’output, altre non lo spostano affatto, e almeno una rende un task di classificazione peggiore mentre costa più tokens. Quale sia quale non è ovvio leggendole, e non si risolve discutendo.
Quindi il prossimo capitolo costruisce un bench: sessanta casi con risposte note, quattro varianti dello stesso prompt, eseguite in parallelo attraverso esattamente il client che hai appena scritto, tabulate con gli intervalli di confidenza del capitolo 4 — perché quattro varianti su venti casi non distinguono proprio nulla. Una frase governa tutto il capitolo: un prompt si misura, non si discute.
Fonti e metodo
Link alla sezione: Fonti e metodoOgni numero sopra viene dal mock provider, su Node 22 tramite interfaccia loopback, quindi le latenze sono più pulite di quelle che ti darà qualunque rete reale. È intenzionale: nessuno dei fallimenti misurati è causato dalla rete, e un server ostile che puoi riavviare insegna meglio di uno reale che devi pagare e non puoi rompere.
Riferimenti
Link alla sezione: Riferimenti-
Server-Sent Events, WHATWG HTML Living Standard, sezione 9.2. Il wire format — campi
data:, eventi separati da righe vuote,id:eretry:— è definito lì, insieme all’interfacciaEventSource.EventSourcenon può inviare un request body o header personalizzati, motivo per cui ogni client LLM analizza il formato a mano soprafetchinvece di usarlo. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). La fonte della formulazione “full jitter” usata sopra, con le simulazioni che mostrano perché la versione ingenua sincronizza i client. L’argomento complementare per scaricare carico invece di metterlo in coda è il capitolo Handling Overload di Beyer, Jones, Petoff e Murphy (a cura di), Site Reliability Engineering (O’Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. e Reschke, J. (a cura di), HTTP Semantics, RFC 9110, sezione 15, definisce le classi di status code; Nottingham, M. e Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), sezione 4, definisce 429 Too Many Requests.
Retry-Afterè RFC 9110 sezione 10.2.3, e accetta sia un numero di secondi sia una data HTTP. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, letto il 7 settembre 2026 — la dichiarazione più chiara del contratto: una chiave per operazione logica, risultati memorizzati e riprodotti, un conflitto restituito mentre il primo tentativo è ancora in flight — e il pattern è indipendente dal provider. I riferimenti normativi per le forme di richiesta ed evento usate qui sonodevelopers.openai.com/api/reference/resources/chatper streaming, codici di errore e rate limit, eplatform.claude.com/docs/en/api/messagesper la Messages API;ai-sdk.dev/docsè il miglior esempio sviluppato delle stesse preoccupazioni racchiuse in una libreria. Tutti letti lo stesso giorno. ↩