Il percorso di batch inference di Azure non assomiglia affatto a una chiamata API. Carichi un file JSONL, crei un job e raccogli un file di output fino a 24 ore dopo, il che significa che non esiste una transazione per richiesta davanti a cui una policy del gateway possa mettersi. Ogni garanzia costruita dalla Parte 3, limiti di token, metering per tenant, routing, circuit breaking, si applica alle richieste che passano per API Management. Il batch non ne ha nessuna, per costruzione. Questa parte sposta il lavoro che non avrebbe mai dovuto stare sul percorso della richiesta, e affronta il fatto che farlo apre una seconda porta verso i modelli.

Cosa sta fuori dal percorso della richiesta

Le cinque applicazioni dell'azienda producono tre tipi di lavoro che stanno sul percorso sincrono solo perché era il posto più comodo dove metterli. Il test non è "è lento", è chi sta aspettando.

  • Summarization notturna dei ticket di supporto. Decine di migliaia di elementi, nessun umano in attesa, risultati necessari per il report del mattino. Un candidato perfetto per il batch che oggi compete per la stessa quota TPM di un cliente a metà conversazione.
  • Ingestion di documenti per la knowledge search del retail. A burst, innescata dagli upload, tollerante ai minuti. Una queue, non un batch job: una latenza di minuti va bene, una latenza di ore no, perché qualcuno ha caricato un documento aspettandosi di trovarlo.
  • Re-scoring dopo un cambio di prompt. Gira contro un corpus, nessun utente, e viene cancellato a metà più spesso di quanto arrivi in fondo. Batch, con una storia di cancellazione esplicita.

Tre forme, e mappano esattamente su tre meccanismi: una queue con worker per i minuti, un batch job per le ore, e il gateway sincrono per tutto ciò che un umano sta guardando. Confonderli è il modo in cui un job di marketing butta giù un assistente di customer service, che è l'incidente della Parte 1.

La queue, e cosa non metterci dentro

Azure Service Bus trasporta il lavoro su scala di minuti. Le decisioni di design che contano riguardano tutte cosa contiene il messaggio, non quale broker sia.

Non mettere il prompt nel messaggio. Service Bus limita ogni singola property del messaggio a 32 KB e il cumulativo dell'header, user property più system property, a 64 KB, e superarlo solleva un'eccezione di serializzazione invece di troncare in silenzio. Anche dentro il body, un payload sopra 1 MB conta doppio contro la quota di dimensione dell'entità. Il pattern claim-check è la risposta: il documento va su Blob Storage, il messaggio porta un riferimento al blob, un tenant ID, un alias di modello e un correlation ID. Il messaggio resta piccolo, la profondità della queue resta una metrica significativa, e il payload è già dove il worker vuole leggerlo in streaming.

Altri due vincoli danno forma al pool di worker. Una singola queue, topic o subscription accetta 5.000 receive request concorrenti prima di rifiutare ulteriori receive con un errore di server busy, che è un tetto sul numero di receiver e non sul throughput, e viene raggiunto più in fretta di quanto ci si aspetti da un worker che apre un receiver per task. E un namespace consente 5.000 connessioni AMQP concorrenti, quindi il connection pooling nel worker non è un'ottimizzazione, è un requisito su scala.

Il dead-lettering è dove una queue per LLM differisce da una ordinaria. Un messaggio fallito perché il modello ha restituito un blocco del content filter non è uguale a uno fallito perché il worker è crashato, e solo il secondo va ritentato. Il worker completa il messaggio e registra il rifiuto come risultato quando il modello risponde con un rifiuto, e lo abbandona solo per fallimenti infrastrutturali. Altrimenti il delivery count sale fino al limite su un messaggio che non avrà mai successo, e la dead-letter queue si riempie di elementi che nessuno riesce a distinguere dai fallimenti veri.

Scalare i worker con KEDA

I worker vivono sul cluster AKS che la Parte 2 ha provisionato, e non dovrebbero girare quando la queue è vuota. KEDA è disponibile come add-on di AKS e scala i workload a zero, pilotando ScaledObject per i deployment e ScaledJob per il lavoro a forma di job, con l'autenticazione disaccoppiata dal workload tramite 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

Tre limitazioni dell'add-on decidono se questo funziona al primo tentativo o al terzo. Non abbinare uno ScaledObject a un Horizontal Pod Autoscaler sullo stesso workload. KEDA usa un HPA sotto il cofano, quindi i due competono: se l'HPA esiste per primo, la creazione dello ScaledObject fallisce, e se lo ScaledObject esiste per primo, l'HPA viene creato comunque e il comportamento di scaling diventa strano. È consentito un solo external metric server per cluster, quindi l'add-on KEDA deve essere l'unico e installazioni multiple di KEDA non sono supportate, il che esclude un team che si installa la propria via Helm accanto a quella della piattaforma. E su AKS Standard, abilita la workload identity prima di abilitare l'add-on KEDA; fatto nell'ordine sbagliato, i pod dell'operator KEDA hanno bisogno di un restart per raccogliere l'ambiente giusto.

Un default utile: scala sul backlog per replica invece che sulla profondità assoluta della queue, e imposta maxReplicaCount a partire dalla quota del modello invece che dalla capacità del cluster. Venti worker che ricevono ciascuno un 429 sono peggio di cinque che non lo ricevono, e alla queue non importa quanto a lungo un messaggio aspetta.

Il percorso batch, e perché è una seconda porta

Il lavoro su scala di ore va a un model deployment Global-Batch, e la forma di quella API è il punto di questa parte. Non c'è nessuna richiesta da intercettare. Si carica un file, si crea un job, e un file di output appare quando il job completa. La meccanica è abbastanza specifica da valere una descrizione precisa.

L'input è JSONL, un oggetto richiesta per riga, e ogni riga porta 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": "..."}]}}

Le risposte non tornano nell'ordine definito dal file, ed è per questo che custom_id è obbligatorio e non solo comodo: è l'unico modo per ricongiungere una risposta al suo input. L'attributo model deve nominare il deployment Global Batch, e lo stesso nome di deployment deve comparire su ogni riga. Puntare a un secondo deployment significa un secondo file e un secondo job, il che rende "instrada questo batch verso il modello più economico oggi" una decisione al momento della submission e non di routing. La guida di Microsoft stessa è di inviare file grandi invece di tanti file piccoli.

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

Il job attraversa poi validating, in_progress, finalizing e completed, e porta un expires_at 24 ore dopo la creazione insieme a un request_counts progressivo di completate, fallite e totali. La completion window è di 24 ore, e un job che non finisce al suo interno scade invece di continuare. Anche i file di output scadono, su una finestra impostabile tra 14 e 30 giorni, quindi una pipeline che assume che i risultati siano ancora lì il trimestre prossimo è una perdita di dati in attesa di accadere.

Anche la capacità funziona diversamente qui. I batch job consumano una quota di enqueued token, e un job abbastanza grande da superarla viene rifiutato invece di essere accodato dietro il precedente. Alcune region ora supportano un comportamento fail-fast che permette di accodare più batch job con backoff esponenziale, così uno che finisce fa partire automaticamente il successivo. Senza quello, il retry loop è compito di chi fa la submission, e appartiene al control plane invece che a ogni applicazione.

Tenere onesta la contabilità

Ecco la parte scomoda. Il percorso batch non attraversa API Management, quindi llm-token-limit non lo limita, llm-emit-token-metric non lo misura, e l'attribuzione per tenant che la Parte 5 sta per costruire ha un punto cieco grande quanto il workload più grande dell'azienda.

Due risposte, e la differenza tra loro merita una decisione deliberata invece che per default.

  • Lasciare che le applicazioni inviino batch job direttamente e accettare una seconda porta non misurata. La più semplice, e reintroduce in silenzio esattamente il problema per cui la Parte 1 è stata scritta, una fattura che nessuno sa attribuire.
  • Rendere il control plane l'unico submitter di batch. Un'applicazione invia una richiesta batch al control plane, che valida il tenant, risolve l'alias di modello in un deployment Global Batch, scrive il JSONL, invia il job, registra la submission a carico del tenant, fa polling fino al completamento e restituisce l'output. Il gateway resta l'unica porta per il traffico sincrono, e il control plane è l'unica porta per il traffico asincrono.

La seconda è più lavoro ed è quella che mantiene vera la premessa della serie. Mette anche la contabilità su un terreno più solido del caso streaming della Parte 3: un batch job completato riporta i propri request_counts e il file di output porta l'uso per risposta, quindi la spesa batch è attribuibile esattamente, più del traffico in streaming sul percorso sincrono. Un'inversione piacevole che vale la pena conoscere prima che qualcuno assuma che asincrono significhi approssimativo.

Modalità di fallimento da tenere d'occhio

  • Il retry loop che non può avere successo. Un rifiuto del content filter è un risultato, non un fallimento. Ritentarlo brucia quota, gonfia il delivery count e finisce in una dead-letter queue piena di messaggi a cui era stata data la risposta corretta.
  • Worker scalati oltre la quota. KEDA scala allegramente fino a maxReplicaCount sul solo backlog. Se quello supera ciò che il TPM del model deployment consente, le repliche extra generano 429 e la queue non si svuota più in fretta.
  • Un batch job che scade in silenzio. La finestra di 24 ore termina in uno status expired, non in un'eccezione nel codice di qualcuno. Senza un alert sullo stato del job, il report del mattino semplicemente manca e il primo ad accorgersene è chi lo legge.
  • File di output che invecchiano e spariscono. Un file di risultati ha una scadenza tra 14 e 30 giorni. Tutto ciò che va conservato viene copiato su storage di proprietà della piattaforma, alla submission, non dopo.
  • La seconda porta riaperta per comodità. Un team con accesso diretto al deployment Global Batch disfa il modello di attribuzione. La regola di rete e l'alert della Parte 1 valgono anche qui, e questo è il percorso che più probabilmente si perderanno.

Cosa eredita la Parte 5

Traffico sincrono attraverso il gateway, lavoro su scala di minuti su una queue con worker che scalano a zero, e lavoro su scala di ore inviato dal control plane contro un deployment Global Batch. Tre percorsi, tre profili di latenza e tre qualità diverse di dati d'uso che alimentano la stessa domanda: quale team ha speso cosa. Quella domanda è la prossima, insieme al modello di identità che rende "quale team" una domanda a cui si può rispondere.

Leggi questo dopo

Per il lato infrastruttura e piattaforma di come far girare tutto questo su scala, gli appunti tecnici sono su ercan.cloud, e l'hub è su ercanermis.com.

Riferimenti