Aller au contenu
14/30Chapitre 14 sur 30

Votre premier appel LLM en production : streaming, retries, timeouts

Créez un fournisseur hostile — 429, sockets bloquées, streams coupés — et mesurez votre client. Full jitter : 2,2 s contre 226.

Dans cet article

Chapitre 13 se terminait avec un chronomètre sur un modèle que vous pouviez toucher. Les poids étaient dans votre mémoire, le KV cache était à vous, à activer ou désactiver, et le nombre obtenu — le temps jusqu’au premier token — était une propriété de votre matériel.

Placez maintenant ce modèle derrière un port, comme le fait tout produit, et relisez le même nombre. C’est toujours le temps jusqu’au premier token, mais ce n’est plus une propriété de quoi que ce soit que vous contrôlez. Il inclut désormais une négociation TLS, une file d’attente chez le fournisseur, un limiteur de débit, et la possibilité qu’aucun token n’arrive jamais.

Cette dernière proposition est le chapitre. Le code que vous allez écrire ne calcule rien. Il ouvre une connexion, attend, analyse ce qui arrive, décide quoi faire quand rien n’arrive, décide à nouveau quand ce qui arrive est une erreur, et s’annule lorsque l’utilisateur change d’avis. Chacune de ces choses est une décision sur l’état dans le temps, et chacune a une mauvaise réponse qui part en production et coûte de l’argent.

Voici la forme du problème, mesurée, entièrement dans ce chapitre :

ce qui s’est passéce que fait un client négligentce que ça coûte
le serveur a accepté la socket et n’a jamais réponduattend300,8 s avant que Node abandonne tout seul
la clé était mauvaise (401)retente cinq fois6 325 ms de délai, puis le même 401
cent clients atteignent ensemble la limite de débittous retry selon le même calendrier226 s pour résorber, contre 2,2 s
la requête a expiré et a été renvoyéela renvoiele fournisseur génère — et facture — la réponse deux fois
la connexion a coupé au milieu de la réponseaffiche le texte partielimpossible à distinguer d’une réponse courte correcte

Aucun de ces problèmes n’est un problème de modélisation. Tous se trouvent dans les cent premières lignes de tout produit LLM jamais écrit.

Relisez ce tableau et demandez-vous quel type de programme il décrit. Il garde une connexion ouverte pendant quarante secondes. Il doit être annulable depuis un bouton. Il accumule une réponse partielle qui est valide à afficher et invalide à enregistrer. Et il s’exécute dans un processus serveur ou dans un edge worker, à côté de ce qui rend la réponse, en tenant une socket.

Ce n’est pas un notebook. Ce n’est pas que Python ne puisse pas le faire — il le peut, et des gens le font —, c’est que tout ce que les treize chapitres précédents ont construit était d’une autre nature. Les chapitres 1 à 13 tenaient des poids, des gradients, des logits et des octets de tokenizer. À partir d’ici, le code tient une connexion, un retry, une annulation, un état accumulé et, plus tard, un prompt d’autorisation. Le cours change de langage exactement à la jointure où l’objet change.

Donc la règle, écrite une fois :

Si le code a des poids, des gradients, des logits ou des octets de tokenizer entre les mains, c’est Python. S’il tient une connexion, retries, annule, accumule de l’état et demande une autorisation, c’est TypeScript.

La jointure est unique et elle tombe ici, entre le chapitre 13 et le chapitre 14. Trois critères indépendants la placent ici.

Un : l’écosystème, compté. Tout ce que cite la moitié gauche de ce cours est en Python, et parmi les douze cours audités pour ce programme, il n’existe pas un seul précédent où la backpropagation est enseignée dans un autre langage : micrograd (17,4K étoiles), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Écrire le chapitre 5 en TypeScript romprait le lien avec ces sources, et ces liens représentent la moitié de la valeur d’un chapitre fait pour être référencé plutôt que pour ranker. De ce côté-ci, l’arithmétique s’inverse : le package ai de Vercel atteint 89,4M téléchargements par mois et livre la chose elle-même — une boucle de tool calling agent, exportée sous le nom ToolLoopAgent — si bien que le concept auquel ce cours arrive au chapitre 23 a son implémentation de référence en TypeScript, même si, comme le mesure ce chapitre, personne ne s’est mis d’accord sur son nom ; Mastra est à 27,7K étoiles ; et les SDK d’Anthropic, générés depuis une seule spécification, déclarent 202 endpoints en TypeScript contre 201 en Python — parité, pas portage de courtoisie.

Deux : la source normative de MCP. Le schéma de la spécification Model Context Protocol est un fichier schema.ts. Enseigner le protocole du chapitre 26 dans un autre langage revient à enseigner une traduction de son document fondateur.

Trois : la demande de recherche, avec une correction de l’hypothèse évidente. machine learning python est l’expression la plus saturée d’Internet ; ai agent typescript a sa propre longue traîne en bonne santé. Mais dire que « l’écosystème MCP est surtout TypeScript » n’est vrai que selon la façon de compter : le registre officiel liste 8 275 serveurs sur npm contre 3 603 sur PyPI, tandis qu’en téléchargements Python gagne — 287M par mois pour mcp plus 72M pour fastmcp contre 195M pour @modelcontextprotocol/sdk. MCP est le seul territoire réellement bilingue ici, ce qui explique pourquoi le chapitre 27 écrit le même serveur deux fois au lieu de faire semblant.

Afficher les détails

Les cinq exceptions déclarées, pour que la règle soit une règle et non un slogan.

Les chapitres 17, 20 et 29 comportent un second panneau en Python : implémenter l’échantillonnage top-p exige d’avoir le vecteur de probabilités en main, et une HTTP API ne vous en donne jamais ; chiffrer honnêtement un fine-tune signifie en exécuter un, et un adaptateur LoRA tient en une douzaine de lignes de nn.Module ; et lm-eval-harness, HELM, SWE-bench et τ-bench sont en Python, donc un harness d’évaluation en TypeScript serait l’image miroir de l’erreur de backpropagation. Le chapitre 27 est bilingue, pour la raison mesurée ci-dessus. Le chapitre 28 est Markdown, parce qu’une agent skill est un fichier SKILL.md et lui donner un langage de programmation voudrait dire ne pas avoir compris le format.

Les treize chapitres Python ne sont pas jetés. Ce qui se trouve de l’autre côté du port est ce qu’ils ont construit, et la dernière section ici y connecte un client.

Vous ne pouvez rien apprendre de cela face à un vrai fournisseur. Vous ne pouvez pas lui demander un 429 à un moment choisi, ni une socket qui accepte votre connexion et ne répond jamais, ni un stream qui s’arrête au milieu d’un mot — et vous paieriez chaque expérience, alors que les expériences intéressantes sont celles que vous exécutez cent fois.

Le premier programme de cette moitié du cours n’est donc pas un client. C’est un serveur hostile : quarante lignes de Node pur qui parlent le même protocole filaire qu’un endpoint de chat completions et se comportent mal à la demande. Tous les nombres de ce chapitre en proviennent.

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

Quatre comportements hostiles, une ligne chacun : /hang accepte la socket et n’y écrit jamais ; /401 refuse la clé ; la vérification de capacité produit un vrai 429 avec un vrai header Retry-After dès que trois requêtes sont déjà en vol ; et ?cut=N abandonne la réponse à mi-chemin, soit en réinitialisant la socket, soit — avec &how=close — en la fermant proprement, ce qui s’avère compter énormément. Le reste est un vrai stream Server-Sent Events : un objet JSON par ligne data:, une ligne vide entre les événements, la chaîne [DONE] à la fin.1

Lancez-le, et le reste du chapitre devient une mesure.

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]

Le corps de requête, et la clé qui ne quitte jamais le serveur

Lien vers la section : Le corps de requête, et la clé qui ne quitte jamais le serveur

Une requête de chat est une liste de messages, chacun avec un rôle. Cette liste est l’état entier du modèle : il n’y a pas de mémoire entre les appels, et tout ce que vous voulez que le modèle sache doit se trouver dans le tableau que vous envoyez cette fois-ci. Le chapitre 15 porte sur ce qu’il faut y mettre et le chapitre 16 sur ce que cela coûte ; ici, il ne s’agit donc que de la forme.

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

Ces rôles ne sont pas décoratifs. Ils sont rendus dans le template de chat du chapitre 11 avant que le modèle voie un seul token, ce qui explique pourquoi envoyer le mauvais rôle dégrade silencieusement la réponse au lieu de lever une erreur.

Une règle sans exception : la clé API ne va jamais au client. Pas dans une variable d’environnement préfixée pour le navigateur, pas dans une constante de build, pas « temporairement ». Une clé dans un bundle devient en quelques jours une clé sur la facture de quelqu’un d’autre. Le navigateur parle à votre serveur, votre serveur détient la clé et parle au fournisseur — et puisque votre serveur est au milieu, c’est aussi le seul endroit qui peut mesurer ce que chaque utilisateur dépense, là où doit vivre la comptabilité du chapitre 16.

Voici maintenant l’expérience sur laquelle repose le chapitre. Une question, un fournisseur mock qui produit treize tokens à 60 ms chacun, trois façons de la poser.

D’abord, sans streaming. Le client envoie la requête et attend tout le corps JSON.

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

Les deux nombres sont les mêmes, et c’est tout le problème. Pendant 791 ms, l’utilisateur a un spinner, et pas un mot n’était disponible plus tôt — le serveur avait la réponse, octet par octet, et a choisi de ne rien dire.

Ensuite, avec streaming. Même serveur, même réponse, même travail total. La différence est 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);
      }
    }
  }
}

Trois détails ici portent toute la charge, et la plupart des premières tentatives sautent les trois. Le buffer existe parce qu’un chunk réseau n’a aucune relation avec un événement : un read() peut renvoyer la moitié d’un événement, ou deux et demi. Le flag { stream: true } existe parce qu’un caractère UTF-8 multi-octets peut être coupé entre deux chunks, et sans lui une lettre accentuée devient au hasard un caractère de remplacement. Et les événements sont séparés par une ligne vide, pas par un retour à la ligne, raison pour laquelle la boucle cherche \n\n.

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

Douze fois plus rapide jusqu’au premier mot, et deux millisecondes plus lent jusqu’au dernier. Le streaming ne rend rien plus rapide. Il change ce que fait l’utilisateur pendant les mêmes 790 ms : lire au lieu d’attendre. C’est tout le bénéfice, il est énorme, et c’est la raison pour laquelle tous les produits de chat streament.

Troisièmement, avec vingt clients à la fois. Le fournisseur mock sert trois requêtes à la fois. Lancez-en vingt :

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

Vingt réponses, soixante-quatorze requêtes, cinquante-quatre rejets. Personne n’a rien perdu, chaque client a reçu le même texte, et le seul coût visible a été le temps. C’est une politique de retry qui fonctionne. Le reste de ce chapitre explique les trois façons dont elle peut échouer à la place.

Avant les échecs, le champ que presque tout le monde ignore au premier passage. Chaque stream se termine par un événement portant finish_reason. stop signifie que le modèle a décidé qu’il avait fini. length signifie qu’il a atteint le plafond de tokens, donc la réponse est tronquée au milieu d’une phrase et ce n’est pas la faute du modèle. Les chapitres suivants ajoutent tool_calls (chapitre 18) et des filtres de contenu.

Regardez maintenant deux fins qu’un client naïf ne peut pas distinguer. Même serveur, même délai, une réponse tronquée par max_tokens et une où la connexion est fermée proprement après cinq tokens :

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"

Lisez attentivement les deux premières lignes. Texte identique. Nombre de chunks identique. Aucune exception dans les deux cas. La boucle for await s’est terminée normalement les deux fois, parce que du point de vue du lecteur le corps s’est terminé, et c’est tout ce qu’un corps peut faire. La seule différence dans toute l’observation est que l’un porte finish_reason: "length" et l’autre ne porte rien du tout.

La règle n’est donc pas « attraper les erreurs pendant le streaming ». C’est :

Un stream qui se termine sans finish_reason ne s’est pas terminé. Il s’est arrêté.

Traitez toujours un finish_reason manquant comme un échec, et ne persistez jamais ce texte comme une réponse terminée. La troisième ligne montre le cas plus facile — une socket détruite lève bien une exception, et elle perd aussi le chunk qui était en vol, ce qui explique pourquoi le texte a un mot de moins que les deux précédents.

Cinq codes de statut qui sont cinq problèmes différents

Lien vers la section : Cinq codes de statut qui sont cinq problèmes différents

L’habitude la plus coûteuse d’un nouveau produit consiste à avoir un seul bloc catch pour tout ce que renvoie le fournisseur. Ces codes ne sont pas des variations de « ça a échoué ». Ce sont cinq instructions, et quatre d’entre elles se contredisent.

statutce que cela signifiequoi faireattendre ?
400votre requête est mal formée — JSON invalide, champ inconnu, context trop longcorriger le codejamais
401la clé est mauvaise, absente ou révoquéecorriger le déploiementjamais
429limite de débit : trop de requêtes, ou trop de tokens, par minuteretryRetry-After, puis backoff
500le fournisseur a casséretrybackoff
503le fournisseur est surchargé — il est en ligne, il est pleinretrybackoff, et délester

La ligne qui compte passe entre les 4xx et le reste. Un 400 ou un 401 renvoie exactement la même réponse si vous l’envoyez mille fois, parce que rien ne change d’une tentative à l’autre ni d’un côté ni de l’autre. Le retry n’est pas de la prudence, c’est un délai avec des étapes en plus. Mesure : un client qui fait six tentatives — cinq retries avec backoff exponentiel —, et un autre qui lit d’abord le code.

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

Six secondes de spinner pour atteindre une réponse qui était disponible en quatre millisecondes. Et c’est la version douce : les retries dans un produit sont généralement imbriqués — un client HTTP qui retry dans un job runner qui retry, lui-même dans une queue avec sa propre redistribution — si bien que six secondes deviennent six minutes pendant lesquelles un déploiement définitivement cassé ressemble à un déploiement lent.

Le triage fait neuf lignes et doit vivre à un seul endroit :

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
}

Deux autres pour votre liste : 402, que certains fournisseurs utilisent pour dire « vous n’avez plus de crédits » et qui exige un écran avec un lien pour en acheter davantage plutôt qu’un retry, et 529 ou ses équivalents spécifiques à certains vendeurs, qui se comportent comme 503.

Backoff, et ce que le jitter achète réellement

Lien vers la section : Backoff, et ce que le jitter achète réellement

Retry est facile. Retry quand est la partie qui a une bonne réponse mesurable.

Le backoff exponentiel est le standard : attendre un délai de base, le doubler après chaque échec, s’arrêter à un plafond. Il existe parce qu’un serveur surchargé empire si les clients qui viennent d’échouer reviennent immédiatement.

Le problème, c’est que tout le monde double depuis le même point de départ. Si cent clients atteignent une limite au même moment — et ils le feront, parce que c’est ce qu’est un pic de trafic — alors les cent attendent 200 ms, les cent retry ensemble, les cent échouent ensemble, et les cent attendent 400 ms. Le calendrier de retry les a synchronisés. C’est un thundering herd, et l’aléatoire est la correction.2

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

Ce changement unique — choisir uniformément dans l’intervalle au lieu d’en prendre la borne supérieure — s’appelle full jitter. C’est un appel à Math.random(), et cela vaut la peine d’être mesuré plutôt que cru :

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

Cent clients, un serveur qui en sert trois à la fois, tout le reste identique, trois exécutions chacun :

requêtes HTTPrejetspire clientfenêtre de 50 ms la plus chargéetemps total
sans jitter, exécution 149139110 tentatives46 arrivées65,6 s
sans jitter, exécution 278068019 tentatives72 arrivées245,7 s
sans jitter, exécution 377067018 tentatives97 arrivées225,6 s
full jitter, exécution 13242245 tentatives32 arrivées2,2 s
full jitter, exécution 23132136 tentatives31 arrivées2,3 s
full jitter, exécution 33182186 tentatives25 arrivées1,8 s

Deux choses dans ce tableau, et la seconde est la plus importante.

La première est la médiane : 226 secondes contre 2,2, un facteur d’environ cent, avec moins de la moitié des requêtes. La fenêtre de retry la plus chargée explique pourquoi. Sans jitter, jusqu’à 97 des cent clients arrivaient dans le même créneau de 50 millisecondes ; le serveur en prenait trois, donc 94 étaient rejetés et repartaient dormir ensemble, encore synchronisés, pour recommencer avec une attente plus longue. Avec jitter, les mêmes cent se répartissaient dans les mêmes fenêtres par groupes d’environ trente et s’écoulaient presque immédiatement.

La seconde est la variance. Sans jitter : 65,6 s, 245,7 s, 225,6 s. Avec : 2,2, 2,3, 1,8. Un système sans jitter ne se contente pas de mal performer, il performe de façon imprévisible, parce que le résultat est décidé par des accidents microscopiques d’ordonnancement qui choisissent quels trois clients parmi cent synchronisés arrivent en premier. C’est la signature de ce bug en production : un endpoint qui va bien, bien, bien, puis prend quatre minutes, sans qu’aucun de vos changements ne l’explique.

Et le retry le moins cher est celui qui n’a jamais lieu. Placez un verrou de concurrence devant le fournisseur — un compteur qui ne laisse jamais plus de N requêtes en vol — et les mêmes vingt clients qui avaient besoin de 74 requêtes et 7,1 secondes se comportent ainsi :

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

Vingt requêtes pour vingt réponses, zéro rejet, huit fois plus rapide. Un retry est une excuse ; le verrou, c’est ne pas en avoir besoin.

Quand un fournisseur renvoie 429, il vous dit généralement combien de temps attendre dans le header Retry-After.3 Ce nombre n’est pas un conseil : le fournisseur est la seule partie de l’échange qui sait quand sa fenêtre se réinitialise.

L’attente est donc le plus grand des deux : jamais moins que Retry-After, et jamais moins que votre propre backoff non plus, car le header vous dit quand le limiteur vous pardonne, pas quand le serveur a de la place.

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

La trace du client le moins chanceux dans l’exécution à vingt clients montre le header faire son travail. Ses quatre premiers tirages de backoff étaient tous sous une seconde, et tous les quatre ont été remplacés :

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

Deux notes pratiques. Retry-After peut être une date HTTP plutôt qu’un nombre de secondes, donc analysez les deux. Et les fournisseurs limitent le débit sur deux axes à la fois — requêtes par minute et tokens par minute — ce qui explique pourquoi les longs prompts sont rejetés bien en dessous de la limite documentée de requêtes. Le header se ressemble dans les deux cas ; la correction, non.

Demandez /hang au fournisseur mock. Il accepte la connexion, puis ne fait absolument rien : pas de headers, pas de body, pas de fermeture. Ce n’est pas exotique — c’est ce que fait un load balancer lorsque le processus derrière lui est mort sans fermer ses sockets.

Deux clients, une seule différence :

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

Trois cents secondes. Cinq minutes avec une socket maintenue ouverte, un slot de requête occupé et un utilisateur qui regarde un spinner, jusqu’à un TypeError générique qui ne dit rien sur ce qui s’est passé. Ce nombre n’est pas un bug : c’est le timeout de headers par défaut de Node, raisonnable pour un client HTTP générique et catastrophique pour une requête visible par l’utilisateur. Chaque runtime a ce genre de défaut, la plupart des gens ne le cherchent jamais, et la seule façon de trouver le vôtre consiste à bloquer volontairement une socket comme nous venons de le faire.

Donc : chaque requête sortante reçoit une échéance explicite, choisie par vous.

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

Pour un appel en streaming, une seule échéance ne suffit pas, car il existe deux échecs différents. Le premier est le stream ne s’ouvre jamais : aucun événement n’arrive du tout, et dix à trente secondes conviennent. Le second est le stream s’ouvre puis cale : les tokens ont circulé puis se sont arrêtés, pour toujours, avec une socket encore saine. Un timeout de durée totale ne peut pas distinguer un stream bloqué d’une longue réponse correcte ; ce que vous voulez est donc un idle timeout — un timer réinitialisé par chaque événement, qui se déclenche seulement quand rien n’est arrivé depuis, disons, quinze secondes.

L’annulation est le même mécanisme pointé vers une personne. AbortSignal.timeout et un utilisateur qui appuie sur Stop arrivent tous deux sous forme de AbortError, alors combinez-les et enregistrez lequel s’est déclenché :

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

Abandonner importe pour une raison qui dépasse la propreté : les tokens sont générés et facturés pendant que vous n’écoutez plus. Le chapitre 16 met un prix là-dessus.

Voici maintenant l’échec qui coûte de l’argent plutôt que du temps. Une requête expire côté client, et le geste évident consiste à l’envoyer de nouveau — mais un timeout ne vous dit rien sur le fait que le serveur l’a reçue ou non. Très souvent, il l’a reçue et travaille encore.

Mesure. Le fournisseur mock a besoin de 780 ms pour la réponse. Le client abandonne à 300 ms et retry. Le serveur compte combien de réponses il a réellement générées, c’est-à-dire ce qu’il facturerait :

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

Sans clé : deux générations complètes, payées deux fois, et le client n’en a reçu aucune. Avec une clé : le serveur a reconnu la seconde requête comme la même requête et a répondu instantanément avec la réponse déjà produite ; le retry a donc évité la double facturation et a été la tentative qui a finalement réussi.

Une clé d’idempotence est une chaîne unique que vous générez par opération logique — pas par tentative — et que vous envoyez inchangée à chaque retry de cette opération. Le serveur stocke le résultat associé à la clé et le rejoue. C’est le mécanisme utilisé par les API de paiement, pour la même raison.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");
}

Deux limites honnêtes. Tous les fournisseurs ne prennent pas en charge les clés d’idempotence sur les completions, et lorsque l’endpoint n’est pas idempotent, le bon nombre de retries pour un POST qui a peut-être déjà été exécuté est zéro. Et un stream qui a échoué à mi-chemin n’est pas rejouable dans le cas général : soit vous le redémarrez et payez de nouveau, soit vous gardez le texte partiel et le marquez incomplet. La décision que prend votre produit est une décision produit, pas réseau, et elle mérite d’être prise volontairement.

Le client écrit dans ce chapitre n’a aucune idée de ce qui se trouve derrière le port. Pointez son URL de base vers un fournisseur commercial et il stream des tokens depuis un modèle de mille milliards de paramètres. Pointez-le vers un serveur construit sur l’arithmétique du chapitre 13 — servant le modèle que vous avez préentraîné au chapitre 10, avec son KV cache et ses poids quantifiés — et le même code, inchangé, stream des tokens depuis un modèle que vous avez construit.

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

Cette ligne unique est la jointure de ce cours. D’un côté se trouve ce que les treize premiers chapitres ont construit ; de l’autre, ce que les seize suivants construisent. La frontière est nette parce que le contrat est HTTP et SSE, et qu’aucun côté ne sait autre chose de l’autre.

Il vaut la peine de remarquer ce que vous avez perdu en traversant. Derrière un endpoint commercial, vous ne contrôlez ni les poids, ni l’implémentation de l’échantillonnage, ni la version à laquelle vous parlez, ni le fait qu’elle ait changé ce matin. Ce que vous contrôlez, c’est le contrat : les messages que vous envoyez, l’échéance que vous fixez, les codes que vous distinguez, et ce que vous faites quand rien ne revient. C’est une surface plus petite que celle que vous aviez au chapitre 5, et tous les chapitres restants expliquent comment bien l’utiliser.

Vous avez maintenant un client qui stream, abandonne à temps, retry les bonnes choses et ne retry jamais les mauvaises. Ce qu’il envoie reste ce que vous avez tapé.

Le chapitre 15 porte sur ce contenu, et il vient avec une discipline. Internet est plein de conseils de prompting — offrir un pourboire au modèle, le menacer, lui dire de respirer profondément — et presque aucun n’arrive avec une mesure. Certaines de ces techniques déplacent énormément la sortie, d’autres pas du tout, et au moins une rend une tâche de classification pire tout en coûtant plus de tokens. Laquelle fait quoi n’est pas évident à la lecture, et cela ne se règle pas par débat.

Le chapitre suivant construit donc un banc : soixante cas avec réponses connues, quatre variantes du même prompt, exécutées en parallèle via exactement le client que vous venez d’écrire, tabulées avec les intervalles de confiance du chapitre 4 — parce que quatre variantes sur vingt cas ne distinguent rien du tout. Une phrase gouverne tout le chapitre : un prompt se mesure, il ne se débat pas.


Chaque nombre ci-dessus provient du fournisseur mock, sur Node 22 via une interface loopback, donc les latences sont plus propres que ce que n’importe quel vrai réseau vous donnera. C’est volontaire : aucun des échecs mesurés n’est causé par le réseau, et un serveur hostile que vous pouvez redémarrer enseigne mieux qu’un vrai serveur que vous devez payer et que vous ne pouvez pas casser.

  1. Server-Sent Events, WHATWG HTML Living Standard, section 9.2. Le format filaire — champs data:, événements séparés par des lignes vides, id: et retry: — y est défini, avec l’interface EventSource. EventSource ne peut pas envoyer de corps de requête ni de headers personnalisés, raison pour laquelle chaque client LLM parse le format à la main au-dessus de fetch au lieu de l’utiliser.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). La source de la formulation « full jitter » utilisée ci-dessus, avec les simulations qui montrent pourquoi la version naïve synchronise les clients. L’argument compagnon en faveur du délestage plutôt que de la mise en file d’attente est le chapitre Handling Overload de Beyer, Jones, Petoff et Murphy (dir.), Site Reliability Engineering (O’Reilly, 2016).

  3. Fielding, R., Nottingham, M. et Reschke, J. (dir.), HTTP Semantics, RFC 9110, section 15, définit les classes de codes de statut ; Nottingham, M. et Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), section 4, définit 429 Too Many Requests. Retry-After est la section 10.2.3 de la RFC 9110, et accepte soit un nombre de secondes, soit une date HTTP.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, consulté le 7 septembre 2026 — l’énoncé le plus clair du contrat : une clé par opération logique, résultats stockés et rejoués, conflit renvoyé tant que la première tentative est encore en vol — et le pattern est indépendant du fournisseur. Les références normatives pour les formes de requête et d’événement utilisées ici sont developers.openai.com/api/reference/resources/chat pour le streaming, les codes d’erreur et les limites de débit, et platform.claude.com/docs/en/api/messages pour l’API Messages ; ai-sdk.dev/docs est le meilleur exemple détaillé des mêmes préoccupations encapsulées dans une bibliothèque. Tous consultés le même jour.

Prêt à laisser LIA choisir à votre place ?

Créez avec tous les modèles d'IA au même endroit — commencez gratuitement dès aujourd'hui.