One Door to the Models, Teil 4: Asynchrone Arbeit abseits des Request-Pfads
Batch-Inferenz ist eine zweite Tür: Datei rein, Job läuft, Datei raus, keine Gateway-Policy dazwischen. Teil 4 hält die Abrechnung trotzdem ehrlich.

Azures Batch-Inference-Pfad sieht überhaupt nicht wie ein API-Aufruf aus. Man lädt eine JSONL-Datei hoch, erstellt einen Job und holt bis zu 24 Stunden später eine Output-Datei ab, es gibt also keine Per-Request-Transaktion, vor die sich eine Gateway-Policy setzen könnte. Jede Garantie, die Teil 3 gebaut hat, Token-Limits, Per-Tenant-Metering, Routing, circuit breaking, gilt für Requests durch API Management. Batch hat per Konstruktion keine davon. Dieser Teil verschiebt die Arbeit, die von Anfang an nie auf dem Request-Pfad hätte liegen sollen, und stellt sich der Tatsache, dass genau das eine zweite Tür zu den Modellen öffnet.
Was nicht auf den Request-Pfad gehört
Die fünf Anwendungen des Unternehmens produzieren drei Arten von Arbeit, die nur deshalb auf dem synchronen Pfad liegen, weil das der einfachste Ort war, sie abzulegen. Der Test ist nicht "ist es langsam", sondern wer wartet.
- Die nächtliche Zusammenfassung von Support-Tickets. Zehntausende Items, kein wartender Mensch, Ergebnisse bis zum Morgenreport. Ein perfekter Batch-Kandidat, der derzeit um dieselbe TPM-Quota konkurriert wie ein Kunde mitten im Gespräch.
- Dokumenten-Ingestion für die Wissenssuche im Retail. Bursty, ausgelöst durch Uploads, tolerant gegenüber Minuten. Eine Queue, kein batch job: Latenz in Minuten ist akzeptabel, Latenz in Stunden funktioniert nicht, weil jemand ein Dokument hochgeladen hat und erwartet, es zu finden.
- Re-Scoring nach einer Prompt-Änderung. Läuft gegen ein Korpus, ganz ohne User, und wird öfter auf halbem Weg abgebrochen, als dass es durchläuft. Batch, mit einer expliziten Abbruch-Story.
Drei Formen, und sie fallen auf genau drei Mechanismen: eine Queue mit Workern für Minuten, ein batch job für Stunden und das synchrone Gateway für alles, dem ein Mensch zusieht. Sie zu vermengen ist der Weg, auf dem ein Marketing-Job einen Kundenservice-Assistenten lahmlegt, der Incident aus Teil 1.
Die Queue, und was nicht hineingehört
Azure Service Bus trägt die Arbeit im Minutenmaßstab. Die Designentscheidungen, auf die es ankommt, drehen sich alle darum, was die Message enthält, nicht darum, welcher Broker es ist.
Der Prompt gehört nicht in die Message. Service Bus begrenzt jede einzelne Message-Property auf 32 KB und den kumulierten Header, User-Properties plus System-Properties, auf 64 KB, und eine Überschreitung wirft eine Serialisierungs-Exception, statt still zu kürzen. Selbst im Body zählt eine Payload über 1 MB doppelt gegen die Größen-Quota der Entity. Das claim-check-Pattern ist die Antwort: Das Dokument geht in den Blob Storage, die Message trägt eine Blob-Referenz, eine Tenant-ID, einen Modell-Alias und eine Correlation-ID. Die Message bleibt klein, die Queue-Tiefe bleibt eine aussagekräftige Metrik, und die Payload liegt bereits dort, von wo der Worker sie streamen will.
Zwei weitere Einschränkungen formen den Worker-Pool. Eine einzelne Queue, ein Topic oder eine Subscription akzeptiert 5.000 gleichzeitige Receive-Requests, bevor weitere Receives mit einem Server-Busy-Fehler abgelehnt werden, was eine Obergrenze für die Receiver-Anzahl ist, nicht für den Durchsatz, und sie wird schneller erreicht, als man erwartet, wenn ein Worker pro Task einen Receiver öffnet. Und ein Namespace erlaubt 5.000 gleichzeitige AMQP-Verbindungen, Connection Pooling im Worker ist also keine Optimierung, sondern ab einer gewissen Größe eine Voraussetzung.
Beim Dead-Lettering unterscheidet sich eine LLM-Queue von einer gewöhnlichen. Eine Message, die fehlschlägt, weil das Modell einen Content-Filter-Block zurückgegeben hat, ist nicht dasselbe wie eine, die fehlschlug, weil der Worker abgestürzt ist, und nur die zweite sollte wiederholt werden. Der Worker schließt die Message ab und protokolliert die Ablehnung als Ergebnis, wenn das Modell mit einem Refusal antwortet, und gibt sie nur bei Infrastrukturfehlern zurück in die Queue. Andernfalls klettert der Delivery Count bei einer Message, die nie Erfolg haben wird, bis ans Limit, und die dead-letter queue füllt sich mit Items, die niemand von echten Fehlern unterscheiden kann.
Die Worker mit KEDA skalieren
Die Worker leben auf dem AKS-Cluster, den Teil 2 provisioniert hat, und sie sollten nicht laufen, wenn die Queue leer ist. KEDA ist als AKS-Add-on verfügbar und skaliert Workloads auf null, mit ScaledObject für Deployments und ScaledJob für job-förmige Arbeit, und die Authentifizierung ist über Microsoft Entra Workload ID vom Workload entkoppelt.
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
Drei Add-on-Einschränkungen entscheiden, ob das beim ersten Versuch funktioniert oder erst beim dritten. Ein ScaledObject darf nicht mit einem Horizontal Pod Autoscaler auf demselben Workload kombiniert werden. KEDA nutzt darunter einen HPA, die beiden konkurrieren also: Existiert der HPA zuerst, schlägt die Erstellung des ScaledObject fehl, und existiert das ScaledObject zuerst, wird der HPA trotzdem erstellt und das Skalierungsverhalten wird seltsam. Pro Cluster ist nur ein externer Metrik-Server erlaubt, das KEDA-Add-on muss also der einzige sein, und mehrere KEDA-Installationen sind nicht unterstützt, was ausschließt, dass ein Team per Helm seine eigene Installation neben der der Plattform betreibt. Und auf AKS Standard gilt: Workload Identity aktivieren, bevor das KEDA-Add-on aktiviert wird; in der falschen Reihenfolge brauchen die KEDA-Operator-Pods einen Neustart, um die richtige Umgebung aufzunehmen.
Ein nützlicher Default: auf Backlog pro Replica skalieren statt auf absolute Queue-Tiefe, und maxReplicaCount aus der Modell-Quota ableiten statt aus der Kapazität des Clusters. Zwanzig Worker, die alle eine 429 bekommen, sind schlechter als fünf, die keine bekommen, und der Queue ist es egal, wie lange eine Message wartet.
Der Batch-Pfad, und warum er eine zweite Tür ist
Arbeit im Stundenmaßstab geht an ein Global-Batch-Modell-Deployment, und die Form dieser API ist der Punkt dieses Teils. Es gibt keinen Request, den man abfangen könnte. Eine Datei wird hochgeladen, ein Job wird erstellt, und eine Output-Datei erscheint, wenn der Job abgeschlossen ist. Die Mechanik ist spezifisch genug, um sie präzise zu benennen.
Der Input ist JSONL, ein Request-Objekt pro Zeile, und jede Zeile trägt eine 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": "..."}]}}
Die Antworten kommen nicht in der Reihenfolge zurück, die die Datei definiert hat, weshalb custom_id Pflicht ist statt Komfort: Sie ist der einzige Weg, eine Antwort wieder ihrem Input zuzuordnen. Das model-Attribut muss das Global-Batch-Deployment benennen, und derselbe Deployment-Name muss auf jeder Zeile stehen. Ein zweites Deployment anzusteuern bedeutet eine zweite Datei und einen zweiten Job, was aus "route diesen Batch zu dem Modell, das heute günstiger ist" eine Entscheidung zum Submission-Zeitpunkt macht statt einer Routing-Entscheidung. Microsofts eigene Empfehlung lautet, große Dateien einzureichen statt vieler kleiner.
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"}},
)
Der Job durchläuft dann validating, in_progress, finalizing und completed, trägt ein expires_at 24 Stunden nach der Erstellung sowie laufende request_counts für completed, failed und total. Das Completion Window beträgt 24 Stunden, und ein Job, der darin nicht fertig wird, läuft ab, statt weiterzulaufen. Auch die Output-Dateien laufen ab, mit einem Fenster, das sich zwischen 14 und 30 Tagen setzen lässt; eine Pipeline, die annimmt, die Ergebnisse lägen nächstes Quartal noch da, ist ein Datenverlust, der nur auf sein Datum wartet.
Auch Kapazität funktioniert hier anders. Batch jobs verbrauchen eine enqueued-token-Quota, und ein Job, der groß genug ist, sie zu überschreiten, wird abgelehnt, statt hinter dem vorherigen eingereiht zu werden. Bestimmte Regionen unterstützen inzwischen ein Fail-fast-Verhalten, mit dem sich mehrere batch jobs mit exponentiellem Backoff einreihen lassen, sodass das Ende des einen automatisch den nächsten startet. Ohne das ist die Retry-Schleife Aufgabe des Einreichers, und sie gehört in die Control Plane statt in jede einzelne Anwendung.
Die Abrechnung ehrlich halten
Hier kommt der unbequeme Teil. Der Batch-Pfad läuft nicht durch API Management, llm-token-limit drosselt ihn also nicht, llm-emit-token-metric misst ihn nicht, und die Per-Tenant-Attribution, die Teil 5 gleich bauen wird, hat einen blinden Fleck von der Größe des größten Workloads im Unternehmen.
Zwei Antworten, und der Unterschied zwischen ihnen verdient eine bewusste Entscheidung statt einer per Default.
- Anwendungen ihre batch jobs direkt einreichen lassen und eine zweite, ungemessene Tür akzeptieren. Am einfachsten, und es führt still genau das Problem wieder ein, für das Teil 1 geschrieben wurde: eine Rechnung, die niemand zuordnen kann.
- Die Control Plane zum einzigen Batch-Einreicher machen. Eine Anwendung schickt einen Batch-Request an die Control Plane, die den Tenant validiert, den Modell-Alias auf ein Global-Batch-Deployment auflöst, das JSONL schreibt, den Job einreicht, die Einreichung dem Tenant zuschreibt, bis zum Abschluss pollt und den Output zurückgibt. Das Gateway bleibt die einzige Tür für synchronen Traffic, und die Control Plane ist die einzige Tür für asynchronen.
Die zweite Antwort ist mehr Arbeit, und sie ist die, die die Prämisse der Serie wahr hält. Sie stellt die Abrechnung außerdem auf festeren Boden als der Streaming-Fall aus Teil 3: Ein abgeschlossener batch job meldet seine eigenen request_counts, und die Output-Datei trägt usage pro Antwort, Batch-Ausgaben sind also exakt zuzuordnen, exakter als der gestreamte Traffic auf dem synchronen Pfad. Eine angenehme Umkehrung, die man kennen sollte, bevor jemand annimmt, async hieße approximativ.
Fehlerbilder, die man im Blick behalten sollte
- Die Retry-Schleife, die nicht erfolgreich sein kann. Ein Content-Filter-Refusal ist ein Ergebnis, kein Fehler. Es zu wiederholen verbrennt Quota, treibt den Delivery Count hoch und endet in einer dead-letter queue voller Messages, die korrekt beantwortet wurden.
- Worker, die über die Quota hinaus skaliert sind. KEDA skaliert allein auf Basis des Backlogs fröhlich bis
maxReplicaCount. Übersteigt das, was die TPM des Modell-Deployments erlaubt, produzieren die zusätzlichen Replicas 429er, und die Queue leert sich kein bisschen schneller. - Ein batch job, der still abläuft. Das 24-Stunden-Fenster endet in einem
expired-Status, nicht in einer Exception in irgendjemandes Code. Ohne Alert auf den Job-Status fehlt der Morgenreport einfach, und die erste Person, die es merkt, ist die, die ihn liest. - Output-Dateien, die altern. Eine Ergebnisdatei hat ein Ablaufdatum zwischen 14 und 30 Tagen. Alles, was aufbewahrt werden muss, wird beim Einreichen in Storage kopiert, den die Plattform besitzt, nicht später.
- Die zweite Tür, aus Bequemlichkeit wieder geöffnet. Ein Team mit direktem Zugriff auf das Global-Batch-Deployment macht das Attributionsmodell zunichte. Die Netzwerkregel und der Alert aus Teil 1 gelten auch hier, und das ist der Pfad, den sie am ehesten verfehlen.
Was Teil 5 erbt
Synchroner Traffic durch das Gateway, Arbeit im Minutenmaßstab auf einer Queue mit Workern, die auf null skalieren, und Arbeit im Stundenmaßstab, von der Control Plane gegen ein Global-Batch-Deployment eingereicht. Drei Pfade, drei Latenzprofile und drei verschiedene Qualitäten von Usage-Daten, die dieselbe Frage füttern: Welches Team hat was ausgegeben. Diese Frage kommt als Nächstes, zusammen mit dem Identitätsmodell, das "welches Team" überhaupt beantwortbar macht.
Weiterlesen
- Teil 5, Identität, Quota und Chargeback, wo die drei Usage-Signale dieser Pfade zu einer Zahl pro Team werden und ein Azure-Monitor-Limit entscheidet, wie sie gebaut wird.
- Teil 3, Die Provider-Abstraktion und Streaming, die synchrone Hälfte dieses Bildes, und die Garantien pro Transport, um die dieser Teil herumarbeitet.
- Batch Inference at Half Price, derselbe Tausch von Latenz gegen Kosten von der AWS-Seite, mit der Rechnung, wann er sich lohnt.
Für die Infrastruktur- und Plattform-Seite, so etwas in diesem Maßstab zu betreiben, gibt es die Field Notes auf ercan.cloud, und der Hub liegt auf ercanermis.com.
Referenzen
Weiteres von Ercan
Zwei weitere Seiten, gleicher Autor, anderes Terrain.
Cloud, AWS, EKS, Terraform, Platform Engineering.
Praxisnotizen aus Produktionssystemen. EKS, IAM, Terraform im Organisationsmaßstab, Observability, Kostenoptimierung.
Besuchen ercan.cloud →Die Drehscheibe. Über mich, Beratung, Kontakt.
Persönliche Drehscheibe für beide Schreibspuren. Wer ich bin, wie die Beratung funktioniert, wie Sie mich erreichen.
Besuchen ercanermis.com →