Zum Hauptinhalt springen

Batch-Generierung von Bildern und Videos ohne Queue-Kollaps

Produktionsentwurf für Parallelität, Backpressure, Idempotenz, faires Scheduling, Retries, Kostenschutz und Observability bei großen Medien-Batches.

Autor
SpicyAPI Engineering
Erschienen
Lesedauer
21 Min. Lesezeit
Aktualisiert
Ein Operator vor Monitoren überblickt getrennte Förderbänder für Bilder und Videos in einem illustrierten Serverraum

Illustration: Getrennte Bild- und Video-Queues lassen jede Last in ihrem eigenen Tempo vorankommen.

Ein Batch-Generator scheitert meist im unspektakulärsten Moment: nicht beim Erzeugen eines einzelnen Bildes, sondern wenn ein Kunde 20.000 Zeilen importiert und die Anwendung alle auf einmal in laufende Requests verwandelt.

Die API kann gesund und jede Payload gültig sein. Das System bricht trotzdem zusammen: Datenbankverbindungen sind ausgeschöpft, Callbacks treffen schneller ein, als Sie sie committen können, ein großer Tenant blockiert alle anderen, ein Retry verdoppelt ein teures Video, und eine Lastspitze wird zur Rechnung.

Halten Sie wartende Arbeit in einer dauerhaften Queue, begrenzen Sie die Tasks in flight und wiederholen Sie nur, wenn Sie wissen, ob schon ein Remote-Task existiert. Ein größeres Promise.all leistet nichts davon.

Eine Tatsache prägt alles Folgende: SpicyAPI hat keinen Bulk- oder Batch-Endpunkt. Jede Zeile ist ein eigener createTask-Aufruf mit eigenem Task, eigenem Hold und eigener Abrechnung, und ein akzeptierter Task kann nicht abgebrochen werden. Die Queue, das Parallelitätslimit und der Stopp-Knopf liegen daher alle in Ihrer Anwendung.

Zuerst im Playground ausprobieren

Öffnen Sie den z-image Playground. Schreiben Sie einen repräsentativen Prompt, wählen Sie das Aspect ratio, stellen Sie Prompt expansion so ein, wie Sie den Batch fahren wollen, klicken Sie auf Run model und prüfen Sie den Output. Skalieren Sie erst hoch, wenn dieses einzelne Ergebnis stimmt; ein Scheduler vervielfacht die Anfrage samt ihren Fehlern.

Die kurze Antwort

  • Halten Sie den Rückstand in Ihrer eigenen dauerhaften Queue, nicht in einem HTTP-Request oder einem In-Memory-Promise.
  • Begrenzen Sie die Tasks in flight selbst. Das Rate-Limit der Plattform (1.000 Requests pro 10 Sekunden, getrennt für Lese- und Schreibzugriffe) begrenzt nicht, wie viele akzeptierte Tasks gleichzeitig laufen; das tun Ihr Guthaben und Ihre Ausgabenlimits. Wählen Sie pro Medienklasse eine Zahl und halten Sie sie, bis der Task terminal ist.
  • Lassen Sie nach Queue-Alter zu, nicht nach Queue-Länge, und legen Sie die Kosten des ganzen Batches vor dem ersten Dispatch fest.
  • Ein gespeicherter Idempotency-Key pro logischer Ausgabe, immer mit demselben API-Key gesendet und nie über 24 Stunden hinaus vertraut.
  • Retry nach Fehlercode: Kapazitäts- und Transportfehler ja; ungültige Eingaben, leeres Guthaben und Ausgabenlimits nein.
  • Round-Robin über Tenants, plus ein Kill-Switch für den Dispatch, der die Verarbeitung der Abschlüsse weiterlaufen lässt.

Drei Limits und wer sie setzt

Behandeln Sie Einreichung, Dispatch und Abschluss als getrennte Stufen:

Client-Batch

│

▼

Zulassung + Kostenprüfung ──► dauerhafte Queue ──► begrenzte Worker ──► createTask

│

Polling / Webhook-Abschluss

│

Output kopieren ──► Ergebnisspeicher + Kundenereignis

LimitWas es begrenztWer es auf SpicyAPI setzt
ZulassungWie viel Rückstand Sie halten und zu erledigen versprechenSie
EinreichungsratecreateTask-Aufrufe pro SekundePlattform: 1.000 Schreibzugriffe pro 10 Sekunden je Konto, ein Token Bucket von etwa 100 Requests pro Sekunde, mit X-RateLimit-Remaining in jeder Antwort und Retry-After bei einem 429
Tasks in flightAkzeptierte, unfertige TasksGrößtenteils Sie. Die Plattform begrenzt sie nur über die Mittel, die jeder Task hält, die Ausgabenlimits von Key, Team und Plattform (40202) und die Modellkapazität (50301)

Das Rate-Limit steuert die Einreichung, nicht die Parallelität: Akzeptierte Tasks beziehen während der Generierung nichts mehr aus dem Request-Bucket.

Bei 5 Abschlüssen pro Sekunde erzeugen Sie etwa 5 Tasks pro Sekunde, einen kleinen Bruchteil des Buckets von 100 pro Sekunde. Für die meisten Batches ist das Einreichungslimit eine Sicherung, keine Drossel; als Erstes setzen das Geld, das Tasks in flight halten, und Ihre eigenen Limits Grenzen. Das Polling nutzt den separaten Lese-Bucket, sodass eine außer Kontrolle geratene Poll-Schleife createTask nicht blockieren kann, wohl aber 429 bei recordInfo auslösen und Sie blind machen kann. Lesen Sie den Header, statt die Standardwerte fest zu codieren.

Andere APIs können getrennte Limits für Parallelität und wartende Queue vorgeben; Leonardos Concurrency-Leitfaden unterscheidet sie. SpicyAPI hat keine dokumentierte Obergrenze für Tasks in flight, richten Sie daher Ihre eigene ein, statt sich auf das Request-Rate-Limit zu verlassen.

Das Limit für Tasks in flight aus Zeit und Geld ableiten

Für eine stabile Workload-Klasse setzt das Little'sche Gesetz die durchschnittliche Zahl der Tasks im System in Beziehung zur Abschlussrate und zur mittleren Zeit, die jeder Task dort verbringt:

durchschnittliche Tasks in flight ≈ Abschlüsse pro Sekunde × mittlere Sekunden von der Annahme bis zum terminalen Zustand

Wenn Bild-Tasks im Schnitt 8 Sekunden dauern und Sie 5 pro Sekunde brauchen:

5 Jobs/s × 8 s = 40 gleichzeitige Bild-Tasks

Verstehen Sie 40 als mittlere Auslastung, nicht als Garantie. Ein Startlimit von 50 gibt 25 % Spielraum; erhöhen Sie es nur, solange Queue-Alter, Fehler und Ausgaben im grünen Bereich bleiben. Nutzen Sie p95 und p99 für die Ausreißer und für Alarme auf das Queue-Alter. Messen Sie die Task-Dauer aus createdAt und completedAt in recordInfo, je Modell und Einstellung.

Video gehört in einen eigenen Pool. Wenn Seedance 2.0-Clips in Ihrer Workload im Schnitt 70 Sekunden brauchen und Sie 0,2 pro Sekunde wollen:

0,2 Jobs/s × 70 s = 14 gleichzeitige Video-Tasks

Die Durchschnitte von 8 und 70 Sekunden sind Annahmen für die Dimensionierung, keine zugesagten Generierungszeiten.

Teilen sich beide einen Pool von 50, kann eine Welle langer Videos jeden Slot belegen, während schnelle Bilder warten. Mit 50 Bild-Slots und 14 Video-Slots kommen beide Klassen voran. Z-Image und Seedream 5.0 Pro können ebenfalls eigene Pools bekommen, wenn sich Dauer oder Priorität unterscheiden.

Prüfen Sie das Limit nun gegen das Geld. Jeder akzeptierte Task hält seine estimatedCost, bis er abgerechnet ist, und der Hold zählt für jedes Key- und Team-Limit. Mit öffentlichen Katalogpreisen:

In flightGleichzeitig gehalten
50 Seedream 5.0 Pro-Bilder in 1K (je $0.036)$1.80
14 Seedance 2.0 Text-zu-Video-Clips, 5 s in 720p ($0.152 pro Sekunde, je $0.76)$10.64

Ein Guthaben, das diese Holds nicht deckt, lässt neue Tasks mit 40201 fehlschlagen, egal wie gut Ihr Scheduler ist. Wichtiger sind die Gesamtausgaben: 20.000 Seedream 5.0 Pro-Bilder in 1K kosten $720 und 20.000 Z-Image-Bilder zu $0.01 ohne Prompt expansion $200. Preise ändern sich, lesen Sie sie daher von der aktuellen Modellseite oder aus einem Quote. Das Limit, das einen schlechten Batch stoppt, ist das, das Sie vor dem Start setzen: Legen Sie vor dem Dispatch ein tägliches Ausgabenlimit für den Produktions-Key fest, bemessen nach dem Höchstbetrag, den Sie an einem schlechten Tag verlieren können. Ein leeres Limit bedeutet kein Limit; auch Team-Limits können neue Tasks mit 40202 stoppen.

Die Abschätzung der Abarbeitungszeit ist die andere Hälfte:

Abarbeitungszeit ≈ Jobs in der Queue ÷ dauerhaft mögliche Abschlüsse pro Sekunde

18.000 Bilder in der Queue bei 5 pro Sekunde sind eine Stunde Arbeit. Versprechen Sie 15 Minuten bis zum Start, muss die Zulassung deutlich vor 18.000 bremsen. Ein wachsender Rückstand ist kein Beharrungszustand, verlassen Sie sich daher mehr auf das beobachtete Queue-Alter als auf die Formeln.

Backpressure beginnt vor dem Dispatch

Backpressure heißt, dass das nachgelagerte System den Produzenten ausbremsen kann. Ohne sie nimmt die Queue endlos Arbeit auf, und der Kunde erfährt erst Stunden später von der Überlast.

Verwenden Sie zwei Zulassungsschwellen auf das Queue-Alter, das Alter des ältesten bereiten Eintrags. Die Länge täuscht, wenn Einträge unterschiedlich teuer sind; das Alter zeigt direkt, ob die zugesagte Wartezeit eingehalten wird.

  • Weiche Schwelle: den Batch annehmen, eine verzögerte Schätzung zurückgeben, den Produzenten bremsen.
  • Harte Schwelle: neue Arbeit mit einer wiederholbaren Produktantwort zurückstellen oder ablehnen, bevor ein Remote-Task existiert.

Vergleichen Sie Geldbeträge in exakten Dezimaleinheiten statt in binärem Fließkomma. Committen Sie die Reservierung atomar mit der Batch-Zulassung in Ihrem Queue-Adapter, damit gleichzeitige Einreichungen nicht dasselbe Restbudget verbrauchen.

Machen Sie nicht jede interne Überlast zu einem API-429 für Ihre eigenen Kunden. Ist die Arbeit dauerhaft gespeichert, geben Sie eine akzeptierte Batch-ID in einem verzögerten Zustand zurück. Lehnen Sie nur Arbeit ab, die Sie nicht verantwortungsvoll halten können, und nennen Sie dabei einen Wiederholungszeitpunkt und machen Sie die Batch-Einreichung selbst idempotent.

Zwei Details dieser API entscheiden, wie lange ein Eintrag sicher in Ihrer Queue warten darf:

  • Referenzdateien laufen nach einem Tag ab. Eine spicy://-URI aus einem Upload gilt bis zum expiresAt in ihrer Commit-Antwort (standardmäßig einen Tag), und ein Retry verwendet die ursprüngliche Eingabe, sodass ein Retry danach 400 liefert. Halten Sie eine öffentliche HTTPS-URL oder Ihre eigene Quell-ID in der Queue und laden Sie erst beim Dispatch hoch.
  • Quotes gelten fünf Minuten. Wenn Sie den Preis sperren wollen, holen Sie den Quote beim Dispatch und senden Sie quoteId mit expectedCost an createTask; ein geänderter Preis scheitert dann mit 40901, statt einen neuen Betrag zu berechnen. Quotes sind kostenlos, zählen aber gegen den Lese-Bucket, daher ist die Zulassung der falsche Ort, 20.000 Zeilen zu quoten.

Idempotenz hat zwei Ebenen

Zwei verschiedene Duplikate gilt es zu verhindern: der Kunde, der denselben Batch zweimal einreicht, und Ihr Dispatcher, der nicht weiß, ob ein Create gelungen ist. Geben Sie dem Batch eine stabile Identität und erzeugen und speichern Sie dann für jeden neuen logischen Eintrag eine UUID. Die Wiederherstellung verwendet den gespeicherten Key dieses Eintrags erneut.

Legen Sie in Ihrer Datenbank eine Unique-Constraint auf diesen Key an. Schreiben Sie den Eintrag als „Intent“, bevor Sie die API aufrufen, senden Sie den Key als Idempotency-Key und speichern Sie die zurückgegebene taskId in derselben Zeile. Nach einem Absturz verwendet der nächste Worker den gespeicherten Key erneut, wenn die Annahme noch unbekannt ist. Wer den Intent erst nach dem Senden schreibt, lässt ein Fenster offen, in dem ein Neustart dieselbe Ausgabe zweimal kauft.

Was die Idempotenz-Dokumentation ergänzt und die Queue beachten muss:

  • Ein Key gilt 24 Stunden ab der ersten akzeptierten Anfrage; Replays verlängern ihn nicht. Danach erzeugt derselbe Key einen neuen, abgerechneten Task. Bevor Sie einen alten Queue- oder Dead-Letter-Eintrag erneut abspielen, schlagen Sie seine gespeicherte taskId nach, statt dem Key zu vertrauen.
  • Der Key gehört zum selben Konto und demselben API-Key sowie zu genau diesem Modell, input und callBackUrl. Jede Änderung liefert 409. Ein Schwester-Key Ihres Kontos erhält ebenfalls 409 und kann den Task nicht lesen: Nur der Key, der einen Task erzeugt hat, kann ihn lesen oder wiederholen. Verwenden Sie daher in einer Worker-Flotte einen Key für Create, Poll und Retry.
  • Ein Replay liefert den ursprünglichen Task in seinem aktuellen Zustand, ohne Markierung, dass es ein Replay war.
  • Ein seltenes Rennen mit demselben Key liefert 409 „Idempotency key conflict, please retry shortly“. Wiederholen Sie einmal nach ein paar hundert Millisekunden; die beiden anderen 409-Meldungen gelingen nie.

Ein Dispatcher, der das Limit hält, bis der Task endet

Speichern Sie ein Objekt pro Zeile in batch.jsonl, zum Beispiel {"model": "alibaba/z-image-turbo/text-to-image", "input": {"prompt": "<one row's prompt>"}}, mit den akzeptierten Eingaben des gewählten Modells. Setzen Sie SPICY_API_KEY, BATCH_BUDGET_USD und optional BATCH_WORKERS (Standard 2). Führen Sie dieses Python-Beispiel aus und bestätigen Sie das Batch-Limit. Es hält jeden Worker besetzt, bis sein Task terminal und abgerechnet ist und keinen Output mehr vorbereitet.

Bewahren Sie den Ordner batch-run für die Wiederherstellung auf und ändern Sie die Reihenfolge seiner Quellzeilen nicht. Derselbe Ordner behält abgerechnete Ausgaben und Reservierungen über Neustarts hinweg. Starten Sie nur einen Koordinator pro Ordner.

python
import json
import os
import time
import uuid
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
from pathlib import Path
from threading import Event, Lock

BASE = "https://api.spicyapi.ai/api/v1"
API_KEY = os.environ["SPICY_API_KEY"]
CAP = Decimal(os.environ["BATCH_BUDGET_USD"])
WORKERS = int(os.environ.get("BATCH_WORKERS", "2"))
ROOT = Path(os.environ.get("BATCH_DIR", "batch-run"))
ROOT.mkdir(exist_ok=True, mode=0o700)
lock, stop = Lock(), Event()
reserved = Decimal("0")


class APIError(Exception):
    def __init__(self, status, body):
        self.status, self.code = status, body.get("code")
        super().__init__(f"{status}/{self.code}: {body.get('msg')}")


def call(path, payload=None, key=None):
    headers = {"Authorization": "Bearer " + API_KEY, "Content-Type": "application/json"}
    if key:
        headers["Idempotency-Key"] = key
    for attempt in range(5):
        request = urllib.request.Request(BASE + path,
            data=None if payload is None else json.dumps(payload).encode(), headers=headers)
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                body = json.load(response)
                if body.get("code") != 200:
                    raise APIError(response.status, body)
                return body["data"]
        except urllib.error.HTTPError as error:
            try:
                body = json.load(error)
            except ValueError:
                body = {}
            if error.code != 429 and error.code < 500:
                raise APIError(error.code, body)
            delay = float(error.headers.get("Retry-After") or min(15, 2 ** attempt))
        except (urllib.error.URLError, TimeoutError, ValueError):
            delay = min(15, 2 ** attempt)
        time.sleep(delay)
    raise RuntimeError("Unresolved API call; preserve this row for recovery")


def save(path, row):
    temporary = path.with_suffix(".tmp")
    temporary.write_text(json.dumps(row))
    temporary.chmod(0o600)
    temporary.replace(path)


def worker(pair):
    global reserved
    index, line = pair
    if stop.is_set():
        return
    try:
        request = json.loads(line)
        if not isinstance(request.get("model"), str) or not isinstance(request.get("input"), dict):
            raise ValueError("model and input are required")
    except (ValueError, TypeError, AttributeError) as error:
        print(index, "invalid local row:", error)
        return
    path = ROOT / f"{index}.json"
    row = json.loads(path.read_text()) if path.exists() else {
        "request": request, "key": str(uuid.uuid4())}
    if row["request"] != request:
        stop.set()
        raise ValueError("Batch changed; preserve the old folder and use a new one")
    if row.get("settled") or row.get("rejected"):
        return
    accounted = "sentAt" in row
    try:
        if not row.get("taskId"):
            if time.time() - row.get("sentAt", time.time()) >= 23 * 3600:
                raise RuntimeError("Reconcile old acceptance before submitting again")
            if "submission" not in row:
                quote = call("/jobs/quote", request)
                row["hold"] = quote["maxCharge"]
                row["submission"] = {**request, "quoteId": quote["quoteId"],
                                     "expectedCost": quote["estimatedCost"]}
            with lock:
                if stop.is_set():
                    return
                hold = Decimal(row["hold"])
                if not accounted and reserved + hold > CAP:
                    stop.set()
                    print(index, "batch budget reached")
                    return
                if not accounted:
                    reserved += hold
                    accounted = True
            row.setdefault("sentAt", time.time())
            save(path, row)
            accepted = call("/jobs/createTask", row["submission"], row["key"])
            row["taskId"] = accepted["taskId"]
            save(path, row)
        task_id = row["taskId"]
        for _ in range(3000):
            task = call("/jobs/recordInfo?taskId=" + urllib.parse.quote(task_id))
            if task["state"] not in {"queued", "running", "succeeded", "failed", "expired", "canceled"}:
                raise RuntimeError("Unknown state: keep the reservation")
            if task["state"] in {"succeeded", "failed", "expired", "canceled"} and task.get("settled") is True:
                assets = task.get("output", {}).get("assets", [])
                if task["state"] == "succeeded" and any(a.get("pending") for a in assets):
                    time.sleep(5)
                    continue
                row["result"], row["settled"] = task, True
                save(path, row)
                with lock:
                    reserved += Decimal(task["cost"]) - Decimal(row["hold"])
                print(index, task_id, task["state"], task["cost"])
                return
            time.sleep(5)
        raise RuntimeError("Wait interrupted; resume the saved task, not a new generation")
    except APIError as error:
        if error.status == 400 and error.code in {400, 40003, 40004} and not row.get("taskId"):
            row["rejected"] = str(error)
            save(path, row)
            with lock:
                if accounted:
                    reserved -= Decimal(row.get("hold", "0"))
            print(index, "rejected input:", error)
            return
        stop.set()
        print(index, "paused:", error)
    except Exception as error:
        stop.set()
        print(index, "paused:", error)


if CAP <= 0 or not 1 <= WORKERS <= 20:
    raise ValueError("Use a positive budget and 1–20 workers")
# Count settled spend from this batch again on restart.
for path in ROOT.glob("*.json"):
    row = json.loads(path.read_text())
    if row.get("settled"):
        reserved += Decimal(row["result"]["cost"])
    elif row.get("sentAt") and not row.get("rejected"):
        reserved += Decimal(row["hold"])
if input(f"Authorize this batch up to ${CAP}? Type yes: ") != "yes":
    raise SystemExit("No tasks submitted")
rows = Path("batch.jsonl").read_text().splitlines()
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
    list(pool.map(worker, enumerate(rows)))

Die Dokumentation zur Produktionsintegration behandelt das Deployment. Dieses Beispiel isoliert ungültige Zeilen, pausiert die neue Zulassung bei Guthabenproblemen oder unsicheren Fehlern und begrenzt transiente Retries. Bereits akzeptierte Tasks werden nach dem Pausieren der Zulassung weiter eingesammelt. Es führt bewusst keinen automatischen kostenpflichtigen Retry aus; eine fehlgeschlagene Zeile bleibt für einen ausdrücklichen neuen Versuch nach der Abrechnung erfasst. Starten Sie einen solchen Prozess pro Medienklasse und setzen Sie das Tenant-Round-Robin vor die Datei.

Faires Scheduling: kleine Tenants schützen

First-in-first-out ist fair gegenüber Jobs, nicht gegenüber Kunden. Stellt Tenant A 100.000 Einträge pro Minute ein, bevor Tenant B zehn einstellt, wartet B hinter allen von A. Führen Sie eine Queue pro Tenant und nehmen Sie pro Runde einen Eintrag je Tenant:

Runde 1: A1, B1, C1

Runde 2: A2, B2, C2

Gewichte können gekaufte Kapazität abbilden, aber geben Sie jedem aktiven Tenant einen Mindestanteil. Begrenzen Sie außerdem die Tasks in flight je Tenant: Das globale Limit schützt Ihr Konto, das Tenant-Limit dämmt laute Nachbarn ein. Innerhalb eines Tenants lohnen sich Prioritätsspuren (interaktive Vorschauen, normale Batches, Backfills) erst, wenn die Latenz der Vorschauen ein echtes Problem ist. Wenn Sie sie einführen, lassen Sie die Priorität mit der Wartezeit altern, sonst verhungert ein Backfill hinter einem steten Strom von Vorschauen. Alles auf einem Konto teilt dieselben zwei Request-Buckets und dasselbe Guthaben, die Fairness zwischen Tenants ist also Ihre Aufgabe, nicht die der API.

Fehler klassifizieren, bevor Sie wiederholen

„Dreimal wiederholen“ ist keine Richtlinie. Die richtige Aktion hängt davon ab, was Sie wissen und ob ein Task schon existieren kann. Diese Tabelle nutzt die dokumentierten Codes; code-Werte stehen im Antwort-Body, und Task-Fehler kommen als state: "failed" mit einem errorCode bei einem erfolgreichen recordInfo-Aufruf (code: 200).

WoSignalRetry?Was zu tun ist
CreateTimeout, abgebrochene Verbindung, unlesbarer BodyJaErgebnis unbekannt. Gleicher Key, gleicher API-Key, Backoff mit Jitter
Create429 mit Retry-AfterJaDie genannten Sekunden warten, Jitter addieren, Dispatch-Rate senken. Nicht als Beweis lesen, dass Sie zu schnell waren
Upload429 ohne Retry-After (ausstehende Uploads)Kein sofortiger RetryAusstehende Uploads abschließen oder warten, bis ihre Tickets ablaufen, bevor Sie weitere anfordern
Create500, 503, 50301 (Modell nicht verfügbar)Ja, begrenztBackoff mit Deadline; 50301 kann auch „kein aktiver Preis“ bedeuten, aktualisieren Sie also den Katalog
Create409 Rennen mit demselben KeyEinmalEin paar hundert ms, gleicher Key. Andere 409 bedeuten geänderte Payload oder anderen Key: stoppen
Create400, 40003, 40004NeinUngültiges Feld, nicht unterstützte Kombination oder Dateityp-/Größenkonflikt; den Eintrag korrigieren, die Meldung anzeigen
Create40201 Guthaben, 40202 AusgabenlimitNeinDispatch pausieren und alarmieren. Ein Retry ändert weder Guthaben noch Limit
Create401, 403, 40301NeinKey- oder Modellberechtigung: Integration pausieren, einen Operator alarmieren
Taskrate_limited, upstream_unavailable, upstream_failedJaErst nach settled: true: POST /api/v1/jobs/retry mit einem an den Quell-Task gebundenen Key; erzeugt einen neuen abgerechneten Task. Ein früherer Retry liefert 409
Tasktimeout, generation_failedVielleichtScheitert oft genauso. Kürzere Dauer oder niedrigere Auflösung versuchen, oder aufgeben
Taskinvalid_request, unsupported_combination, content_rejectedNeinDieselbe Eingabe scheitert erneut. Eingabe ändern oder ein anderes Modell wählen
Taskinvalid_assetNeue RevisionReferenzdatei ist weg: erneut hochladen, Eingabe aktualisieren und einen neuen Task bepreisen
LokalCallback empfangen, Ihre Transaktion schlug fehlJaIhren Handler wiederholen, nicht das Create; nach Delivery-ID oder taskId deduplizieren

Verwenden Sie zufälliges Backoff, damit Worker nicht im Gleichschritt wiederholen.

Verschieben Sie bestätigte Fehler und abgelehnte Eingaben mit ihrer Klassifizierung und der sicheren Replay-Aktion in eine Dead-Letter-Queue. Halten Sie ein mehrdeutiges Create oder einen ungeklärten akzeptierten Task in der Abgleichs-Queue mit gespeichertem Key und Task-ID; eine lokale Retry-Deadline beweist nicht, dass er fehlgeschlagen ist. Ein fehlgeschlagener Task gibt seinen Hold normalerweise vollständig frei, aber manche kostenpflichtigen content_rejected-Ergebnisse werden zum Quote abgerechnet. Lesen Sie daher settled und cost, statt eine Erstattung anzunehmen.

Kostenschutz gehört in den Scheduler

Parallelitätslimits schützen Maschinen, nicht Geld. Ein vollkommen stabiler Worker kann dennoch einen unbezahlbaren Batch dispatchen. Prüfen Sie die geschätzten Kosten vor dem Dispatch gegen jede Obergrenze:

erlaubt = Tenant-Tagesrest

∩ Tenant-Monatsrest

∩ Projekt-Batch-Rest

∩ globaler Stundenrest

Gleichen Sie mit der tatsächlichen Abrechnung ab: createTask liefert die gehaltene estimatedCost, ein erfolgreicher Task wird zu oder unter diesem Betrag abgerechnet, und settled: true mit cost ist die endgültige Belastung. Geben Sie Ihre eigene Reservierung für Einträge frei, die die API abgelehnt hat, und behalten Sie die Reservierung für akzeptierte, bis settled umspringt. Raten Sie nicht anhand eines HTTP-Timeouts.

Nützliche Schutzmaßnahmen:

  • ein Höchstbetrag an geschätzten Kosten pro Batch, einem Menschen angezeigt, bevor ein großer startet;
  • ein stündliches Dispatch-Budget pro Tenant und ein Alarm auf die globale Ausgabengeschwindigkeit;
  • ein Circuit Breaker pro Modell oder Tenant;
  • API-Key-Limits unterhalb des Kontoguthabens und ein Alarm bei niedrigem Guthaben;
  • ein Kill-Switch, der neuen Dispatch stoppt und die Verarbeitung der Abschlüsse weiterlaufen lässt.

Da akzeptierte Tasks nicht abgebrochen werden können, ist das Stoppen der Erzeugung die einzige Bremse: Das Limit für Tasks in flight, kein Abbrechen-Knopf, begrenzt den Schaden bei einem Kostenvorfall. Empfangen Sie weiter Webhooks, pollen Sie alte Tasks, rechnen Sie Reservierungen ab und kopieren Sie Outputs.

Abschluss im Batch-Maßstab

Die Zustellung per callBackUrl je Task bricht nach 15 Sekunden mit einem Timeout ab; auch ein registrierter Konto-Endpunkt kann eine eigene Zustellung erhalten. Der Empfänger sollte daher die Signatur gegen den Roh-Body prüfen, das Ereignis in eine Inbox-Tabelle schreiben, 2xx zurückgeben und den Rest einem Worker überlassen. Deduplizieren Sie nach der v2-Delivery-request_id und entscheiden Sie nach data.state, nicht nach der Ankunftsreihenfolge. Halten Sie ein langsames Polling als Fallback für verpasste Ereignisse bereit (siehe die Webhook-Dokumentation).

Kopieren Sie Outputs in Ihren eigenen Speicher, sobald Tasks fertig werden. Ergebnis-Links halten etwa 20 Minuten, und generierte Medien werden höchstens etwa 14 Tage aufbewahrt (Retention); bei 5 Abschlüssen pro Sekunde ist der Kopierschritt Teil Ihres Durchsatzes, und ein langsamer Uploader wird zum Engpass, der sich in „abgeschlossenen“ Tasks versteckt. Eine kürzere Retention kann auch Ergebnisse von Tasks entfernen, die Sie bereits ausgeführt haben.

Den Fluss beobachten, nicht nur Fehler

Ein nützliches Dashboard beantwortet vier Fragen: Nehmen wir Arbeit an, startet sie, wird sie fertig, wird die Abrechnung wie erwartet verbucht? Verfolgen Sie nach Modell, Medienklasse, Tenant-Stufe und Ergebnis:

SignalWarum es wichtig ist
Alter des ältesten bereiten EintragsDirektes Maß für die Kundenwartezeit und für versagende Backpressure
Zahl der Einträge in Queue, in flight und terminalZeigt, wo sich Arbeit staut
Akzeptierte Tasks pro Sekunde vs. versuchte CreatesTrennt echten Durchsatz von Retries
Task-Dauer p50/p95/p99 (completedAt - createdAt)Bestimmt die Parallelitäts-Dimensionierung und die Erkennung hängender Tasks
Terminale Ergebnisse und errorCode-MixModell- oder eingabespezifische Verschlechterung
X-RateLimit-RemainingWarnt vor einem 429
Gehaltenes vs. verfügbares Guthaben (GET /api/v1/chat/credit)Sagt 40201 voraus, bevor es den Batch anhält
Reservierte vs. abgerechnete Kosten, Ausgaben pro Stunde und TenantVerwaiste Reservierungen, außer Kontrolle geratene Batches
Webhook-Verzögerung und Poll-WiederherstellungenZustand des Abschlusspfads

Führen Sie eine Korrelationskette (batchId, Eintrags-ID, Idempotency-Key, taskId, request_id) durch Logs und Ereignisse und halten Sie Prompts, API-Keys und private Asset-URLs aus Metrik-Labels und Routine-Logs heraus. Alarmieren Sie auf Symptome: „50.000 Einträge in der Queue“ kann normal sein; „der älteste interaktive Eintrag ist 12 Minuten alt“ ist handlungsrelevant.

Mit einem begrenzten Batch beginnen, dann Kapazität erhöhen

Erhöhen Sie die Kapazität nur, solange Alter des ältesten Eintrags, Task-Fehler und Ausgaben innerhalb Ihrer Schwellen bleiben; mehr Worker können einen überlasteten Abschlusspfad nicht beheben.

Unterbrechen Sie vor dem ersten bezahlten Batch mit einem Mock einen Worker unmittelbar vor und nach der Einreichung und lassen Sie 429, fehlgeschlagene Tasks und Ausgabenlimit-Fehler zurückgeben. Die Wiederherstellung sollte den gespeicherten Intent wiederverwenden, unbekannte Tasks ungeklärt lassen und neuen Dispatch stoppen, ohne den Abschluss zu stoppen. Fahren Sie dann einen kleinen freigegebenen Batch, bevor Sie die Zulassung für große Importe öffnen.

Zur Abschlussbehandlung siehe „Polling vs webhooks for async generation jobs“; für ein Batch-Budget siehe Per-unit pricing vs subscriptions. Die Produktions-Checkliste behandelt Key-Konfiguration und Launch-Tests.

FAQ

Hat SpicyAPI einen Batch-Endpunkt oder ein Parallelitätslimit?

Keinen Batch-Endpunkt: Jede Zeile ist ein createTask, und es gibt keine dokumentierte Obergrenze für Tasks in flight (die Limits stehen in der Tabelle oben; die aktuelle Rate steht in X-RateLimit-Limit). Ist das Rate-Limit für Ihre Spitze zu niedrig, bitten Sie den Console-Support um ein höheres und beschreiben Sie Spitzen-Requests pro Sekunde und Batch-Größe.

Kann ich einen versehentlich gestarteten Batch abbrechen?

Nein. Nutzen Sie den Dispatch-Kill-Switch, um neue Tasks zu stoppen, während die Verarbeitung der Abschlüsse weiterläuft.

Soll ein 429 einen Retry-Versuch verbrauchen?

Warten Sie Retry-After plus Jitter und behalten Sie denselben Idempotency-Key. Wiederholtes Drosseln verlangt eine niedrigere Dispatch-Rate. Begrenzen Sie Retries sowohl nach Versuchen als auch nach Zeit; ist die Annahme nach Ablauf dieses Budgets noch unsicher, gleichen Sie den gespeicherten Intent ab, bevor Sie einen Ersatz erzeugen.

Wie verhindere ich doppelte Abbuchungen nach einem Timeout?

Senden Sie innerhalb von 24 Stunden erneut mit demselben Idempotency-Key und demselben API-Key und speichern Sie die taskId, sobald Sie sie haben. Schlagen Sie nach 24 Stunden stattdessen die gespeicherte taskId nach.

Reicht eine Queue, um Überlast zu verhindern?

Nein. Eine Queue speichert Überlast; Backpressure steuert sie. Sie brauchen weiterhin Zulassungsschwellen auf das Queue-Alter, ein Limit für Tasks in flight und eine Produktantwort für den Fall, dass die zugesagte Wartezeit nicht zu halten ist.

Was ist eine gute Start-Parallelität?

Abschlüsse pro Sekunde mal mittlere Task-Dauer, je Medienklasse, plus etwa 25 % Spielraum: 40 bei 5 Bildern pro Sekunde zu je 8 Sekunden. Erhöhen Sie sie nur, solange Queue-Alter, Fehler und Ausgaben im grünen Bereich bleiben.

Können hochpriorisierte Jobs die Limits umgehen?

Nein. Die Priorität wählt unter zulässiger Arbeit aus und umgeht nie das globale Limit oder die Budgets. Geben Sie interaktiver Arbeit reservierte Kapazität und lassen Sie niedriger priorisierte Arbeit altern, damit sie nicht verhungert.

Einen Agent den Aufruf übernehmen lassen

Nutzen Sie einen Agent, wenn er die Eingaben vorbereiten und das Ergebnis zurückgeben soll, während Sie Preis und kreative Entscheidungen freigeben.

  • CLI: Speichern Sie das input-Objekt einer Zeile als input.json (für Z-Image: prompt, aspect_ratio, enable_prompt_expansion, output_format, seed) und nutzen Sie npx @spicyapi/cli tasks create --model alibaba/z-image-turbo/text-to-image --input-file input.json --wait. Die CLI bestätigt den Preis; behalten Sie die zurückgegebene Task-ID für die Wiederherstellung.
  • MCP: Bitten Sie Ihren Assistenten, spicyapi_task_create für alibaba/z-image-turbo/text-to-image zu verwenden und danach spicyapi_task_wait. Der MCP-Server verlangt vor dem Erstellen eine Preisbestätigung.
  • Skill: Installieren Sie ihn mit npx skills add spicyapi-ai/spicy-skill und bitten Sie dann $spicyapi, denselben Workflow auszuführen und für Ihre Freigabe anzuhalten. Der Skill nutzt eine konfigurierte CLI- oder MCP-Verbindung.

Auch in English · 日本語 · 한국어 · Français · Español · Português (Brasil) · Русский · Italiano · Polski

Lösungen

Video-API: vergleichen, testen, integrierenVergleichen Sie Video-Endpunkte nach Länge, Auflösung, Audio und Preis und senden Sie dieselbe Anfrage anschließend serverseitig.

Selbst ausprobieren

Sie zahlen nur für das, was Sie ausführen – ohne Tarif, ohne Abo.

Erstellt mit MiniMax H3 · ByteDance Seedance 2.0 Fast