Le chemin d'inférence batch d'Azure ne ressemble pas du tout à un appel d'API. On téléverse un fichier JSONL, on crée un job, et on récupère un fichier de sortie jusqu'à 24 heures plus tard, ce qui signifie qu'il n'existe aucune transaction par requête devant laquelle une politique de gateway pourrait se placer. Toutes les garanties construites par la partie 3, limites de tokens, mesure par tenant, routage, circuit breaking, s'appliquent aux requêtes qui passent par API Management. Le batch n'en a aucune, par construction. Cette partie déplace le travail qui n'aurait jamais dû se trouver sur le chemin de requête, et affronte le fait que ce déplacement ouvre une seconde porte vers les modèles.

Ce qui n'a rien à faire sur le chemin de requête

Les cinq applications de l'entreprise produisent trois types de travail qui ne sont sur le chemin synchrone que parce que c'était l'endroit le plus facile où les mettre. Le test n'est pas « est-ce lent », c'est qui attend.

  • La synthèse nocturne des tickets de support. Des dizaines de milliers d'éléments, aucun humain qui attend, des résultats nécessaires pour le rapport du matin. Un candidat batch parfait qui, aujourd'hui, se dispute le même quota TPM qu'un client en pleine conversation.
  • L'ingestion de documents pour la recherche de connaissances retail. Par rafales, déclenchée par des uploads, tolérante à quelques minutes. Une queue, pas un batch job : une latence en minutes convient, une latence en heures ne fonctionne pas, parce que quelqu'un a téléversé un document en s'attendant à le retrouver.
  • Le re-scoring après un changement de prompt. Tourne sur un corpus, sans aucun utilisateur, et se fait annuler à mi-parcours plus souvent qu'il ne se termine. Du batch, avec une histoire d'annulation explicite.

Trois formes, et elles correspondent exactement à trois mécanismes : une queue avec des workers pour les minutes, un batch job pour les heures, et la gateway synchrone pour tout ce qu'un humain regarde. Les confondre, c'est ainsi qu'un job marketing fait tomber un assistant de service client, l'incident de la partie 1.

La queue, et ce qu'il ne faut pas y mettre

Azure Service Bus porte le travail à l'échelle des minutes. Les décisions de conception qui comptent portent toutes sur le contenu du message plutôt que sur le choix du broker.

Ne mettez pas le prompt dans le message. Service Bus plafonne chaque propriété individuelle de message à 32 KB et le cumul de l'en-tête, propriétés utilisateur plus propriétés système, à 64 KB, et le dépassement lève une exception de sérialisation au lieu de tronquer en silence. Même dans le corps, une payload au-delà de 1 MB est comptée deux fois contre le quota de taille de l'entité. Le pattern claim-check est la réponse : le document va dans Blob Storage, le message porte une référence de blob, un ID de tenant, un alias de modèle et un ID de corrélation. Le message reste petit, la profondeur de la queue reste une métrique qui a du sens, et la payload est déjà là où le worker veut la streamer.

Deux contraintes de plus façonnent le pool de workers. Une seule queue, un topic ou une subscription accepte 5 000 requêtes de réception concurrentes avant de rejeter les suivantes avec une erreur server busy, ce qui est un plafond sur le nombre de receivers plutôt que sur le débit, et un worker qui ouvre un receiver par tâche l'atteint plus vite qu'on ne le croit. Et un namespace autorise 5 000 connexions AMQP concurrentes, donc le pooling de connexions dans le worker n'est pas une optimisation, c'est une exigence à l'échelle.

Le dead-lettering est l'endroit où une queue LLM diffère d'une queue ordinaire. Un message qui échoue parce que le modèle a renvoyé un blocage du filtre de contenu n'est pas le même qu'un message qui a échoué parce que le worker a planté, et seul le second doit être retenté. Le worker complète le message et enregistre le refus comme un résultat quand le modèle répond par un refus, et ne l'abandonne que pour les défaillances d'infrastructure. Sinon, le compteur de livraisons grimpe jusqu'à sa limite sur un message qui ne réussira jamais, et la dead-letter queue se remplit d'éléments que personne ne peut distinguer des vraies défaillances.

Le scaling des workers avec KEDA

Les workers vivent sur le cluster AKS provisionné par la partie 2, et ils ne devraient pas tourner quand la queue est vide. KEDA est disponible comme add-on AKS et scale les workloads jusqu'à zéro, en pilotant ScaledObject pour les deployments et ScaledJob pour le travail en forme de job, avec une authentification découplée du workload via Microsoft Entra Workload ID.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: ingest-worker
spec:
  scaleTargetRef:
    name: ingest-worker
  minReplicaCount: 0
  maxReplicaCount: 20
  pollingInterval: 15
  cooldownPeriod: 120
  triggers:
    - type: azure-servicebus
      metadata:
        queueName: doc-ingest
        messageCount: "20"      # target backlog per replica
      authenticationRef:
        name: keda-workload-identity

Trois limitations de l'add-on décident si cela fonctionne du premier coup ou du troisième. N'associez pas un ScaledObject à un Horizontal Pod Autoscaler sur le même workload. KEDA utilise un HPA en dessous, donc les deux se disputent : si le HPA existe en premier, la création du ScaledObject échoue, et si le ScaledObject existe en premier, le HPA est créé quand même et le comportement de scaling devient étrange. Un seul serveur de métriques externes est autorisé par cluster, donc l'add-on KEDA doit être le seul et les installations KEDA multiples ne sont pas supportées, ce qui exclut qu'une équipe installe la sienne via Helm à côté de celle de la plateforme. Et sur AKS Standard, activez la workload identity avant d'activer l'add-on KEDA ; fait dans le mauvais ordre, les pods de l'opérateur KEDA ont besoin d'un redémarrage pour récupérer le bon environnement.

Un défaut utile : scaler sur le backlog par replica plutôt que sur la profondeur absolue de la queue, et fixer maxReplicaCount à partir du quota du modèle plutôt que de la capacité du cluster. Vingt workers qui reçoivent chacun un 429 valent moins que cinq qui n'en reçoivent pas, et la queue se moque du temps qu'un message attend.

Le chemin batch, et pourquoi c'est une seconde porte

Le travail à l'échelle des heures va vers un déploiement de modèle Global-Batch, et la forme de cette API est le sujet de cette partie. Il n'y a pas de requête à intercepter. Un fichier est téléversé, un job est créé, et un fichier de sortie apparaît quand le job se termine. La mécanique est assez spécifique pour mériter d'être énoncée précisément.

L'entrée est du JSONL, un objet de requête par ligne, et chaque ligne porte un custom_id :

{"custom_id": "ticket-88412", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "batch-summarize", "messages": [{"role": "system", "content": "Summarize the ticket in two sentences."}, {"role": "user", "content": "..."}]}}
{"custom_id": "ticket-88413", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "batch-summarize", "messages": [{"role": "system", "content": "Summarize the ticket in two sentences."}, {"role": "user", "content": "..."}]}}

Les réponses ne sont pas renvoyées dans l'ordre défini par le fichier, et c'est pourquoi custom_id est obligatoire plutôt que simplement commode : c'est le seul moyen de rattacher une réponse à son entrée. L'attribut model doit nommer le déploiement Global Batch, et le même nom de déploiement doit apparaître sur chaque ligne. Cibler un second déploiement signifie un second fichier et un second job, ce qui fait de « router ce batch vers le modèle le moins cher aujourd'hui » une décision au moment de la soumission plutôt qu'une décision de routage. La recommandation de Microsoft elle-même est de soumettre de gros fichiers plutôt que beaucoup de petits.

batch = client.batches.create(
    input_file_id=file_id,
    endpoint="/chat/completions",
    completion_window="24h",
    # 1209600 to 2592000 seconds, 14 to 30 days, before the output file expires
    extra_body={"output_expires_after": {"seconds": 1209600, "anchor": "created_at"}},
)

Le job traverse ensuite validating, in_progress, finalizing et completed, et porte un expires_at fixé 24 heures après la création, ainsi qu'un request_counts courant des requêtes complétées, échouées et totales. La fenêtre de complétion est de 24 heures, et un job qui ne se termine pas dedans expire au lieu de continuer. Les fichiers de sortie expirent eux aussi, sur une fenêtre réglable entre 14 et 30 jours, donc un pipeline qui suppose que les résultats seront encore là le trimestre prochain est une perte de données en devenir.

La capacité fonctionne différemment ici aussi. Les batch jobs consomment un quota de tokens enqueued, et un job assez gros pour le dépasser est rejeté au lieu d'être mis en attente derrière le précédent. Certaines régions supportent désormais un comportement fail-fast qui permet de mettre plusieurs batch jobs en file avec un backoff exponentiel, de sorte que la fin de l'un lance automatiquement le suivant. Sans cela, la boucle de retry est le travail du soumetteur, et elle appartient au control plane plutôt qu'à chaque application.

Garder les comptes honnêtes

Voici la partie inconfortable. Le chemin batch ne traverse pas API Management, donc llm-token-limit ne le limite pas, llm-emit-token-metric ne le mesure pas, et l'attribution par tenant que la partie 5 s'apprête à construire a un angle mort de la taille du plus gros workload de l'entreprise.

Deux réponses, et la différence entre elles mérite une décision délibérée plutôt qu'un choix par défaut.

  • Laisser les applications soumettre les batch jobs directement et accepter une seconde porte non mesurée. Le plus simple, et cela réintroduit en silence exactement le problème qui a motivé la partie 1, une facture que personne ne peut attribuer.
  • Faire du control plane le seul soumetteur de batch. Une application poste une requête de batch au control plane, qui valide le tenant, résout l'alias de modèle vers un déploiement Global Batch, écrit le JSONL, soumet le job, enregistre la soumission au compte du tenant, sonde jusqu'à la complétion et rend la sortie. La gateway reste la seule porte pour le trafic synchrone, et le control plane est la seule porte pour le trafic asynchrone.

La seconde demande plus de travail, et c'est celle qui garde vraie la prémisse de la série. Elle pose aussi la comptabilité sur un sol plus ferme que le cas du streaming de la partie 3 : un batch job terminé rapporte son propre request_counts et le fichier de sortie porte l'usage par réponse, donc la dépense batch est exactement attribuable, davantage que le trafic streamé du chemin synchrone. C'est une inversion plaisante à connaître avant que quelqu'un ne suppose qu'asynchrone veut dire approximatif.

Modes de défaillance à surveiller

  • La boucle de retry qui ne peut pas réussir. Un refus du filtre de contenu est un résultat, pas une défaillance. Le retenter brûle du quota, gonfle le compteur de livraisons et finit dans une dead-letter queue pleine de messages auxquels le modèle avait correctement répondu.
  • Des workers scalés au-delà du quota. KEDA scale volontiers jusqu'à maxReplicaCount sur le seul backlog. Si cela dépasse ce que le TPM du déploiement de modèle autorise, les replicas supplémentaires génèrent des 429 et la queue ne se vide pas plus vite.
  • Un batch job qui expire en silence. La fenêtre de 24 heures se termine par un statut expired, pas par une exception dans le code de qui que ce soit. Sans alerte sur le statut du job, le rapport du matin est simplement absent et la première personne à s'en apercevoir est celle qui le lit.
  • Des fichiers de sortie qui disparaissent avec l'âge. Un fichier de résultats a une expiration entre 14 et 30 jours. Tout ce qui doit être conservé est copié vers un stockage que la plateforme possède, à la soumission, pas plus tard.
  • La seconde porte rouverte par commodité. Une équipe avec un accès direct au déploiement Global Batch défait le modèle d'attribution. La règle réseau et l'alerte de la partie 1 s'appliquent ici aussi, et c'est le chemin qu'elles ont le plus de chances de manquer.

Ce dont hérite la partie 5

Le trafic synchrone par la gateway, le travail à l'échelle des minutes sur une queue avec des workers qui scalent jusqu'à zéro, et le travail à l'échelle des heures soumis par le control plane vers un déploiement Global Batch. Trois chemins, trois profils de latence et trois qualités différentes de données d'usage qui alimentent la même question : quelle équipe a dépensé quoi. Cette question vient ensuite, avec le modèle d'identité sans lequel « quelle équipe » n'a même pas de réponse.

À lire ensuite

Pour le volet infrastructure et plateforme de l'exploitation de tout cela à grande échelle, les notes de terrain sont sur ercan.cloud, et le hub se trouve sur ercanermis.com.

Références