Timeouts Fiabilité AbortController LLM API Streaming

Une stratégie de timeout d'API LLM qui survit aux générations lentes

Pourquoi un timeout fixe unique casse les appels LLM, et comment définir des délais de connexion, de premier jeton et d'inactivité, streamer pour rester actif, et annuler proprement avec AbortController.

AveMujica API 10 min de lecture

Une requête vers un modèle de chat qui répond habituellement en trois secondes peut occasionnellement prendre quatre-vingt-dix secondes. Fixez un timeout brutal et unique de 30 secondes sur tout le client et vous couperez les générations lentes-mais-correctes tout en ne faisant rien contre celles qui sont réellement bloquées. La solution n'est pas un nombre plus grand — ce sont plusieurs délais qui mesurent chacun une chose différente : combien de temps vous attendez pour vous connecter, combien de temps vous attendez le premier jeton, et combien de silence vous tolérez une fois que les jetons circulent. Cet article montre comment construire ce timeout en couches, pourquoi le streaming change le calcul, et comment annuler le travail à l'instant où un délai expire ou où un utilisateur dit stop.

La latence des LLM est inhabituelle, et c'est la racine du problème. Un appel REST classique a une distribution de latence resserrée autour d'une moyenne, si bien qu'un timeout fixe fixé à quelques multiples au-dessus de la moyenne est sûr. La latence de génération d'un modèle est à queue lourde et dépend de choses que le client ne peut pas voir : la longueur de sortie que le modèle choisit, le fait qu'il s'agisse ou non d'un modèle de raisonnement dépensant des jetons cachés avant d'écrire un mot, la mise en file d'attente en amont, et le routage à froid. Un timeout calibré sur la médiane ampute la queue ; un timeout calibré sur la queue laisse les requêtes réellement bloquées retenir des connexions et du quota bien trop longtemps.

Séparer les délais qu'un timeout tente de couvrir

« Timeout » est un seul mot pour au moins quatre limites indépendantes. Les nommer séparément est toute l'astuce.

  • Timeout de connexion — le plafond pour établir la connexion TCP/TLS. Il doit être court (quelques secondes) ; une connexion lente est un problème de réseau ou de DNS, pas un modèle lent.
  • Délai jusqu'au premier jeton (TTFT) — combien de temps vous attendez que le modèle commence à répondre. Sur un modèle de raisonnement, cela peut légitimement atteindre des dizaines de secondes, car le modèle peut réfléchir avant de produire quoi que ce soit.
  • Timeout d'inactivité / inter-jetons — une fois qu'un flux a démarré, l'écart maximal que vous tolérez entre les fragments. Un flux sain envoie des jetons en continu ; un long silence signifie que l'amont a calé.
  • Délai total / horloge murale — une limite absolue sur la requête entière pour qu'un cas pathologique ne puisse pas s'exécuter sans borne.

Un timeout fixe unique regroupe ces quatre limites en un seul nombre et n'en sert donc aucune correctement. La fiabilité des longues générations vient de la définition d'un TTFT et d'un budget total généreux tout en gardant le timeout d'inactivité serré, de sorte que vous échouez vite face à un blocage mais restez patient pendant le vrai travail.

Le streaming transforme un timeout total dur en un timeout d'inactivité souple

Le changement le plus efficace que vous puissiez faire est de streamer. Lorsque vous envoyez stream: true, le modèle renvoie les jetons via des Server-Sent Events au lieu de mettre en tampon la réponse entière, et vous cessez de mesurer « la réponse entière a-t-elle fini en N secondes » pour commencer à mesurer « le fragment suivant est-il arrivé en N secondes ». Cela inverse le problème en votre faveur : une réponse de cinq mille jetons ne risque plus une coupure sur le temps total, car chaque fragment réinitialise l'horloge d'inactivité. Si vous n'avez pas encore construit de client de streaming, le streaming des réponses LLM avec SSE couvre le format de transport et le parsing ; ici, seul le comportement de timing qu'il débloque nous intéresse.

La logique de timeout d'inactivité sur un flux fetch ressemble à ceci — un chien de garde qui est réinitialisé à chaque fragment et ne se déclenche que pendant un vrai silence :

async function streamWithIdleTimeout(url, body, { idleMs = 20000 } = {}) {
  const controller = new AbortController();
  let timer = setTimeout(() => controller.abort(new DOMException("idle", "TimeoutError")), idleMs);

  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: "Bearer AVE_GATEWAY_API_KEY" },
    body: JSON.stringify({ ...body, stream: true }),
    signal: controller.signal,
  });

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      clearTimeout(timer);                       // a chunk arrived: reset the watchdog
      timer = setTimeout(() => controller.abort(new DOMException("idle", "TimeoutError")), idleMs);
      process.stdout.write(decoder.decode(value, { stream: true }));
    }
  } finally {
    clearTimeout(timer);
  }
}

Remarquez que le budget d'inactivité (20 secondes de silence) est découplé du temps total. La requête peut s'exécuter pendant des minutes tant qu'elle continue de produire des jetons, mais un vrai blocage se déclenche dans une seule fenêtre d'inactivité. C'est exactement le comportement qu'un timeout total fixe ne peut pas exprimer.

Annuler avec AbortController, et lire le nom de l'erreur

Chaque timeout a besoin d'un mécanisme pour réellement arrêter le travail, et dans le navigateur et les runtimes modernes, ce mécanisme est AbortController. Vous passez son signal à fetch, et l'appel à abort() détruit la connexion. Pour un simple plafond d'horloge murale sans construire un contrôleur à la main, AbortSignal.timeout(ms) renvoie un signal qui s'annule lui-même après le nombre de millisecondes donné :

// Hard 60s cap on a non-streaming call:
const res = await fetch(url, { method: "POST", signal: AbortSignal.timeout(60000), /* ... */ });

Les deux chemins d'annulation signalent des erreurs différentes, et les distinguer est ce qui vous permet de décider s'il faut réessayer. Un controller.abort() manuel rejette le fetch avec un DOMException nommé AbortError ; un signal issu de AbortSignal.timeout() rejette avec un nommé TimeoutError. Pour combiner « l'utilisateur a appuyé sur stop » avec « le délai dur », fusionnez les signaux avec AbortSignal.any([userSignal, AbortSignal.timeout(60000)]), puis branchez sur err.name :

try {
  await callModel();
} catch (err) {
  if (err.name === "TimeoutError") retryOrEscalate();   // our deadline fired
  else if (err.name === "AbortError") stopQuietly();     // the user cancelled
  else throw err;                                         // a real failure
}

Traiter une annulation utilisateur comme un échec à réessayer est un bug classique : vous relancez une requête que l'utilisateur a explicitement tuée, vous payez pour les jetons et vous polluez vos métriques. Branchez sur le nom.

Le côté serveur a ses propres timeouts, et ils l'emportent

Si votre client parle à une passerelle ou à votre propre backend plutôt que directement au fournisseur, ce saut impose ses propres délais, et le plus court le long du chemin décide du résultat. Un échec courant est un client attendant patiemment cinq minutes pendant qu'un proxy intermédiaire coupe la connexion à soixante secondes, ou le timeout d'inactivité d'un équilibreur de charge fermant une réponse en streaming qui « traîne » parce que la mise en tampon de la réponse a retardé le premier octet au-delà de la limite. Trois règles côté serveur empêchent que cela morde :

  • Propagez l'annulation vers l'amont. Lorsque le client se déconnecte, annulez la requête que vous avez faite au fournisseur. L'annulation est au mieux : le fournisseur peut continuer à générer et à facturer brièvement après la déconnexion, mais propager l'interruption est la seule façon d'espérer arrêter la dépense pour des jetons que personne ne lira. C'est le miroir serveur d'AbortController.
  • Ne mettez pas en tampon les réponses streamées. Un proxy qui accumule le corps avant de le transmettre retarde le premier octet et peut déclencher un timeout d'inactivité en amont qui ne se serait jamais déclenché sur un flux vidé au fil de l'eau.
  • Confiez le délai au runtime. En Go, un délai de context.Context est le vecteur idiomatique ; lorsqu'il expire, la requête HTTP sortante est annulée et les ressources sont libérées. Chaque langage a un équivalent — l'important est que le timeout atteigne le socket, pas seulement l'enveloppe.

Une passerelle est un endroit naturel pour imposer une politique cohérente, car elle termine déjà les connexions client et détient les identifiants du fournisseur. Enregistrer chaque amont derrière elle permet à un seul délai et à un seul chemin d'annulation de s'appliquer quel que soit le fournisseur qui sert la requête ; les jetons réellement consommés avant une interruption sont enregistrés dans ses journaux d'usage, de sorte qu'un appel annulé reste comptabilisé honnêtement. Gardez toutefois à l'esprit que le compte local est au mieux : lorsque l'usage final de l'amont n'arrive jamais, il peut être estimé. Pour tout ce qui touche à la facturation, rapprochez-le donc de l'usage propre du fournisseur.

Timeouts et réessais forment un seul système, pas deux

Un timeout qui se déclenche est un signal pour votre logique de réessai, donc les deux doivent être conçus ensemble ou ils s'affrontent. Deux règles préviennent les pathologies courantes :

  1. Un réessai n'a de sens qu'après un échec rapide. Si votre timeout total est long et que vous réessayez à l'expiration, une seule requête lente devient plusieurs requêtes lentes empilées dos à dos, multipliant la latence et le coût. Gardez le délai par tentative assez serré pour qu'un réessai soit moins coûteux que de continuer à attendre, et plafonnez le nombre de tentatives.
  2. Une requête ayant expiré peut malgré tout avoir été exécutée. Lorsque vous coupez une connexion, le fournisseur a peut-être déjà produit la réponse que vous n'avez jamais lue. Réessayer aveuglément peut doubler la facture ou dupliquer les effets de bord. Associez les timeouts à une stratégie d'idempotence pour qu'un réessai après un timeout soit sûr — les mécanismes sont dans les réessais et l'idempotence des API LLM.

Les timeouts interagissent aussi avec les limites de débit. Une rafale de timeouts suivie de réessais immédiats ressemble, du point de vue de l'amont, à un pic de charge, et peut vous pousser vers des réponses 429. Coordonner les délais avec la contre-pression — mettre en file plutôt que marteler — est traité dans les limites de débit et la contre-pression des API LLM. Lorsqu'un fournisseur entier est lent plutôt qu'une seule requête, le bon geste est souvent de déplacer le trafic ; voir la stratégie de bascule des API LLM.

Les modèles de raisonnement ont besoin d'un budget de patience plus long

Les modèles de raisonnement changent suffisamment le profil de timing pour mériter leurs propres nombres. Parce qu'ils dépensent des jetons de raisonnement cachés avant de produire une sortie visible, le TTFT peut être long même quand le modèle fonctionne parfaitement, et un délai TTFT calibré pour un modèle de chat rapide les tuera prématurément. Deux ajustements aident : augmentez les budgets TTFT et total spécifiquement pour les routes de modèles de raisonnement, et privilégiez le streaming pour qu'une fois les jetons visibles commencés, votre timeout d'inactivité — et non un plafond total — gouverne. Réglez séparément les paramètres de raisonnement et réservez assez de marge pour les jetons cachés en suivant la politique de gestion de la fenêtre de contexte des LLM.

FAQ

Quelle valeur de timeout devrais-je utiliser pour un appel d'API LLM ?

Il n'y a pas de valeur unique, ce qui est tout l'intérêt d'une stratégie en couches. Utilisez un timeout de connexion court (quelques secondes), un délai TTFT généreux dimensionné pour votre famille de modèles la plus lente, un timeout d'inactivité serré pour les réponses streamées (des dizaines de secondes de silence), et un plafond total absolu comme filet de sécurité. Régler un seul nombre vous force à arbitrer entre la justesse de la queue et la détection des blocages ; des délais séparés vous permettent d'avoir les deux.

Comment distinguer un timeout de délai d'une annulation utilisateur ?

Vérifiez le name de l'erreur. Un signal issu de AbortSignal.timeout() rejette avec TimeoutError ; un controller.abort() manuel rejette avec AbortError. Réessayez ou escaladez sur TimeoutError, arrêtez silencieusement sur AbortError, et relancez tout le reste. Confondre les deux vous fait réessayer des requêtes que l'utilisateur a délibérément tuées.

Annuler une requête empêche-t-il réellement le fournisseur de me facturer ?

Seulement si l'annulation atteint le fournisseur, et même alors elle reste au mieux. Dans le navigateur, controller.abort() ferme la connexion à votre serveur ; votre serveur doit alors annuler sa propre requête amont, sinon la génération risque de continuer et vous êtes facturé pour des jetons que personne ne lit. Propager l'annulation à travers chaque saut est ce qui lui donne une chance de devenir un véritable contrôle de coût. Confirmez son effet en recoupant l'usage ou la facture du fournisseur, car votre compte local de jetons peut n'être qu'une estimation.

Pourquoi ma réponse en streaming reste-t-elle bloquée jusqu'à la fin au lieu de streamer ?

Presque toujours une mise en tampon de la réponse dans un intermédiaire. Un reverse proxy ou un framework qui accumule le corps avant de le transmettre annule le streaming et peut aussi déclencher des timeouts d'inactivité en retardant le premier octet. Désactivez la mise en tampon pour les réponses text/event-stream et videz les fragments à mesure qu'ils arrivent.

Scindez une route en quatre délais

Prenez une route de production et scindez son timeout unique en quatre : un timeout de connexion, un délai TTFT, un timeout d'inactivité du flux et un plafond total. Basculez cette route sur stream: true, câblez le chien de garde d'inactivité ci-dessus, puis annulez délibérément une requête en cours de flux. Le nombre de jetons de sortie enregistré localement devrait baisser, ce qui indique utilement que le délai a atteint le socket ; considérez toutefois cela comme un signal, non comme une preuve, et rapprochez-le de l'usage propre du fournisseur lorsque le coût compte, car l'amont peut continuer à facturer brièvement après la déconnexion.

Références