Przejdź do treści głównej

Wsadowe generowanie obrazów i wideo bez załamania kolejki

Projekt produkcyjny: współbieżność, backpressure, idempotentność, sprawiedliwe szeregowanie, ponowienia, ochrona kosztów i obserwowalność.

Autor
SpicyAPI Engineering
Opublikowano
Czas czytania
19 min czytania
Zaktualizowano
Operator przy monitorach nadzoruje oddzielne taśmociągi dla obrazów i wideo w ilustrowanej serwerowni

Ilustracja: osobne kolejki obrazów i wideo pozwalają każdemu obciążeniu posuwać się we własnym tempie.

Generator wsadowy zwykle zawodzi w najmniej ciekawym momencie: nie podczas generowania jednego obrazu, ale wtedy, gdy klient importuje 20 000 wierszy, a aplikacja zamienia je wszystkie naraz w aktywne żądania.

API może być zdrowe, a każdy payload poprawny. System i tak się załamuje: wyczerpują się połączenia z bazą danych, callbacki napływają szybciej, niż możesz je zatwierdzać, jeden duży tenant blokuje wszystkich pozostałych, ponowienie dubluje drogie wideo, a skok ruchu zamienia się w rachunek.

Trzymaj oczekującą pracę w trwałej kolejce, ograniczaj zadania w toku i ponawiaj tylko wtedy, gdy wiesz, czy zdalne zadanie już istnieje. Większy Promise.all nie robi nic z tych rzeczy.

Jeden fakt kształtuje wszystko poniżej: SpicyAPI nie ma endpointu zbiorczego ani wsadowego. Każdy wiersz to osobne wywołanie createTask z własnym zadaniem, własnym holdem i własnym rozliczeniem, a przyjętego zadania nie można anulować. Kolejka, limit współbieżności i przycisk stopu mieszkają więc w Twojej aplikacji.

Najpierw wypróbuj w Playground

Otwórz Playground z-image. Napisz reprezentatywny Prompt, wybierz Aspect ratio, ustaw Prompt expansion tak, jak zamierzasz uruchamiać wsad, kliknij Run model i sprawdź Output. Skaluj dopiero wtedy, gdy ten pojedynczy wynik jest tym, czego chcesz; scheduler mnoży żądanie razem z jego błędami.

Krótka odpowiedź

  • Trzymaj zaległości we własnej trwałej kolejce, a nie w żądaniu HTTP ani w promise w pamięci.
  • Ogranicz zadania w toku samodzielnie. Limit żądań platformy (1000 żądań na 10 sekund, osobno dla odczytów i zapisów) nie ogranicza, ile przyjętych zadań działa naraz; robią to Twoje saldo i limity wydatków. Wybierz liczbę, osobno dla każdej klasy mediów, i utrzymuj ją, aż zadanie będzie terminalne.
  • Przyjmuj według wieku kolejki, nie jej długości, i ustal koszt całego wsadu przed pierwszym dispatchem.
  • Jeden zapisany klucz idempotentności na każdy logiczny wynik, zawsze wysyłany z tym samym kluczem API; po 24 godzinach nie polegaj już na nim.
  • Ponawiaj według kodu błędu: błędy pojemności i transportu — tak; nieprawidłowe dane wejściowe, puste saldo i limity wydatków — nie.
  • Round-robin między tenantami oraz kill switch dispatchu, który zostawia obsługę zakończeń w działaniu.

Trzy limity i kto ustala każdy z nich

Traktuj zgłoszenie, dispatch i zakończenie jako osobne etapy:

wsad klienta

│

▼

przyjęcie + kontrola kosztów ──► trwała kolejka ──► ograniczeni workerzy ──► createTask

│

zakończenie przez polling / webhook

│

kopiowanie wyniku ──► magazyn wyników + zdarzenie dla klienta

LimitCo ograniczaKto ustala go w SpicyAPI
PrzyjęcieIle zaległości trzymasz i obiecujesz zrealizowaćTy
Tempo zgłaszaniaWywołania createTask na sekundęPlatforma: 1000 zapisów na 10 sekund na konto, token bucket około 100 żądań na sekundę, z X-RateLimit-Remaining w każdej odpowiedzi i Retry-After przy 429
Zadania w tokuPrzyjęte, niezakończone zadaniaGłównie Ty. Platforma ogranicza je tylko przez środki blokowane przez każde zadanie, limity wydatków klucza, zespołu i platformy (40202) oraz pojemność modelu (50301)

Limit żądań reguluje zgłaszanie, nie współbieżność: przyjęte zadania nie pobierają już z kubełka żądań w trakcie generowania.

Przy 5 zakończeniach na sekundę tworzysz około 5 zadań na sekundę, czyli drobny ułamek kubełka 100 na sekundę. Dla większości wsadów limit zgłaszania jest bezpiecznikiem, a nie Twoim hamulcem; wcześniej zatrzymają Cię pieniądze blokowane przez zadania w toku i Twoje własne limity. Polling używa osobnego kubełka odczytu, więc rozszalała pętla odpytywania nie zablokuje createTask, ale może zacząć zwracać 429 na recordInfo i zostawić Cię po omacku. Czytaj nagłówek zamiast na sztywno wpisywać wartości domyślne.

Inne API mogą narzucać osobne limity współbieżności i kolejki oczekujących; przewodnik po współbieżności Leonardo je rozróżnia. SpicyAPI nie dokumentuje liczbowego limitu zadań w toku, więc skonfiguruj własny, zamiast polegać na limicie tempa żądań.

Wyznacz limit zadań w toku z czasu i pieniędzy

Dla jednej stabilnej klasy obciążenia prawo Little'a wiąże średnią liczbę zadań w systemie z tempem zakończeń i średnim czasem, jaki każde zadanie tam spędza:

średnia liczba zadań w toku ≈ zakończenia na sekundę × średnia liczba sekund od przyjęcia do stanu terminalnego

Jeśli zadania obrazowe trwają średnio 8 sekund, a potrzebujesz 5 na sekundę:

5 zadań/s × 8 s = 40 równoczesnych zadań obrazowych

Traktuj 40 jako średnie zajęcie, nie gwarancję. Początkowy limit 50 daje 25 % zapasu; podnoś go tylko dopóki wiek kolejki, błędy i wydatki są w normie. Do ogonów rozkładu i alarmów na wiek kolejki używaj p95 i p99. Mierz czas zadania z createdAt i completedAt w recordInfo, osobno dla modelu i ustawień.

Wideo należy do własnej puli. Jeśli klipy Seedance 2.0 trwają w Twoim obciążeniu średnio 70 sekund, a chcesz 0,2 na sekundę:

0,2 zadania/s × 70 s = 14 równoczesnych zadań wideo

Średnie 8 i 70 sekund to założenia do wymiarowania, a nie obiecane czasy generowania.

Przy jednej wspólnej puli 50 fala długich wideo może zająć każdy slot, podczas gdy szybkie obrazy czekają. Przy 50 slotach na obrazy i 14 na wideo obie klasy posuwają się naprzód. Z-Image i Seedream 5.0 Pro również mogą mieć osobne pule, jeśli różnią się czasem trwania lub priorytetem.

Teraz sprawdź limit pod kątem pieniędzy. Każde przyjęte zadanie blokuje swój estimatedCost do czasu rozliczenia, a ten hold wlicza się do limitu każdego klucza i zespołu. Według publicznych cen katalogowych:

W tokuZablokowane naraz
50 obrazów Seedream 5.0 Pro w 1K (po 0,036 $)1,80 $
14 klipów tekst-na-wideo Seedance 2.0, 5 s w 720p (0,152 $ za sekundę, po 0,76 $)10,64 $

Saldo, które nie pokrywa tych holdów, sprawia, że nowe zadania kończą się błędem 40201, choćby Twój scheduler był świetny. Ważniejsze są łączne wydatki: 20 000 obrazów Seedream 5.0 Pro w 1K to 720 $, a 20 000 obrazów Z-Image po 0,01 $ z wyłączonym Prompt expansion to 200 $. Ceny się zmieniają, więc czytaj je z aktualnej strony modelu lub z wyceny. Limit, który zatrzyma zły wsad, to ten, który ustawisz przed jego startem: ustaw dzienny limit wydatków na kluczu produkcyjnym przed dispatchem, dobrany do maksimum, które możesz stracić w zły dzień. Pusty limit oznacza brak ograniczenia; limity zespołu również mogą zatrzymać nowe zadania kodem 40202.

Oszacowanie czasu opróżnienia to druga połowa:

czas opróżnienia ≈ zadania w kolejce ÷ trwałe zakończenia na sekundę

18 000 obrazów w kolejce przy 5 na sekundę to godzina pracy. Jeśli obiecujesz start w ciągu 15 minut, przyjmowanie musi zwolnić na długo przed 18 000. Rosnące zaległości nie są stanem ustalonym, więc bardziej niż równaniom ufaj obserwowanemu wiekowi kolejki.

Backpressure zaczyna się przed dispatchem

Backpressure oznacza, że system w dole strumienia może zmusić producenta do zwolnienia. Bez niego kolejka w nieskończoność wchłania pracę, a klient dowiaduje się o przeciążeniu po godzinach.

Użyj dwóch progów przyjmowania opartych na wieku kolejki, czyli wieku najstarszego gotowego elementu. Długość myli, gdy elementy różnią się kosztem; wiek bezpośrednio mówi, czy obiecany czas oczekiwania jest dotrzymywany.

  • Próg miękki: przyjmij wsad, zwróć opóźnione oszacowanie, spowolnij producenta.
  • Próg twardy: odłóż lub odrzuć nową pracę odpowiedzią produktu, którą można ponowić, zanim powstanie jakiekolwiek zdalne zadanie.

Porównuj pieniądze w dokładnych jednostkach dziesiętnych, a nie w binarnym zmiennym przecinku. Zatwierdzaj rezerwację atomowo razem z przyjęciem wsadu w adapterze kolejki, aby równoległe zgłoszenia nie zużyły tego samego pozostałego budżetu.

Nie zamieniaj każdego wewnętrznego przeciążenia w API 429 dla własnych klientów. Jeśli praca jest trwale zapisana, zwróć przyjęty identyfikator wsadu w stanie opóźnionym. Odrzucaj tylko pracę, której nie możesz odpowiedzialnie przechować, a wtedy podaj czas ponowienia i spraw, by samo zgłoszenie wsadu było idempotentne.

Dwa szczegóły tego API decydują, jak długo element może bezpiecznie czekać w Twojej kolejce:

  • Pliki referencyjne wygasają po jednym dniu. URI spicy:// z uploadu jest ważne do expiresAt z jego odpowiedzi commit (domyślnie jeden dzień), a ponowienie używa pierwotnych danych wejściowych, więc ponowienie po tym czasie zwraca 400. Trzymaj w kolejce publiczny URL HTTPS lub własny identyfikator źródła i wysyłaj plik w chwili dispatchu.
  • Wyceny ważne są pięć minut. Jeśli chcesz zablokować cenę, wyceniaj przy dispatchu i wyślij quoteId z expectedCost do createTask; zmieniona cena kończy się wtedy błędem 40901 zamiast obciążenia nową kwotą. Wyceny są darmowe, ale liczą się do kubełka odczytu, więc wycenianie 20 000 wierszy przy przyjmowaniu to złe miejsce.

Idempotentność ma dwie warstwy

Trzeba zapobiec dwóm różnym duplikatom: klientowi zgłaszającemu ten sam wsad dwa razy oraz Twojemu dispatcherowi, który nie wie, czy tworzenie się udało. Nadaj wsadowi stabilną tożsamość, a następnie generuj i zapisuj UUID dla każdego nowego elementu logicznego. Odzyskiwanie używa ponownie zapisanego klucza tego elementu.

Utrzymuj w bazie danych ograniczenie unikalności na tym kluczu. Zapisz element jako „intencję” przed wywołaniem API, wyślij klucz jako Idempotency-Key i zapisz zwrócony taskId w tym samym wierszu. Po awarii następny worker używa ponownie zapisanego klucza, jeśli przyjęcie jest wciąż nieznane. Zapisanie intencji dopiero po wysłaniu zostawia okno, w którym restart może kupić ten sam wynik dwa razy.

Co dodaje dokumentacja idempotentności i co musi uwzględnić kolejka:

  • Klucz jest ważny 24 godziny od pierwszego przyjętego żądania; powtórki go nie przedłużają. Potem ten sam klucz tworzy nowe, płatne zadanie. Zanim odtworzysz stary element z kolejki lub dead-letter, sprawdź jego zapisany taskId, zamiast ufać kluczowi.
  • Klucz należy do tego samego konta i tego samego klucza API oraz do dokładnie tego modelu, input i callBackUrl. Zmiana czegokolwiek zwraca 409. Siostrzany klucz Twojego konta również dostaje 409 i nie może odczytać zadania: tylko klucz, który utworzył zadanie, może je odczytać lub ponowić, więc używaj jednego klucza do tworzenia, odpytywania i ponawiania we flocie workerów.
  • Powtórka zwraca pierwotne zadanie w jego bieżącym stanie, bez znacznika, że była powtórką.
  • Rzadki wyścig z tym samym kluczem zwraca 409 „Idempotency key conflict, please retry shortly”. Ponów raz po kilkuset milisekundach; dwa pozostałe komunikaty 409 nigdy się nie powiodą.

Dispatcher, który trzyma limit do końca zadania

Zapisz po jednym obiekcie w wierszu w batch.jsonl, na przykład {"model": "alibaba/z-image-turbo/text-to-image", "input": {"prompt": "<one row's prompt>"}}, używając danych wejściowych akceptowanych przez wybrany model. Ustaw SPICY_API_KEY, BATCH_BUDGET_USD i opcjonalnie BATCH_WORKERS (domyślnie 2). Uruchom ten przykład w Pythonie i zatwierdź limit wsadu. Utrzymuje każdego workera zajętego, dopóki jego zadanie nie jest terminalne, rozliczone i nie przygotowuje już wyniku.

Zachowaj folder batch-run na potrzeby odzyskiwania i nie zmieniaj kolejności jego wierszy źródłowych. Ten sam folder przechowuje rozliczone wydatki i rezerwacje między restartami. Uruchamiaj tylko jednego koordynatora na folder.

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)))

Dokumentacja integracji produkcyjnej omawia wdrożenie. Ten przykład izoluje nieprawidłowe wiersze, wstrzymuje nowe przyjmowanie przy problemach z saldem lub niepewnych błędach i ogranicza przejściowe ponowienia. Zadania już przyjęte są dalej zbierane po wstrzymaniu przyjmowania. Celowo nie wykonuje automatycznych płatnych ponowień; wiersz zakończony błędem pozostaje zapisany do jawnej nowej próby po rozliczeniu. Uruchamiaj jeden taki proces na klasę mediów i ustaw round-robin po tenantach przed plikiem.

Sprawiedliwe szeregowanie: chroń małych tenantów

Kto pierwszy, ten lepszy jest sprawiedliwe wobec zadań, nie wobec klientów. Jeśli tenant A kolejkuje 100 000 elementów na minutę, zanim tenant B zakolejkuje dziesięć, B czeka za wszystkimi elementami A. Prowadź kolejkę na tenanta i bierz po jednym elemencie na tenanta w każdej rundzie:

runda 1: A1, B1, C1

runda 2: A2, B2, C2

Wagi mogą odzwierciedlać wykupioną pojemność, ale daj każdemu aktywnemu tenantowi minimalny udział. Ogranicz też zadania w toku na tenanta: limit globalny chroni Twoje konto, limit tenanta powstrzymuje hałaśliwych sąsiadów. W obrębie tenanta pasy priorytetów (interaktywne podglądy, zwykłe wsady, backfille) warto dodać dopiero wtedy, gdy opóźnienie podglądów jest realną skargą. Jeśli to zrobisz, postarzaj priorytet wraz z czasem w kolejce, bo inaczej stały strumień podglądów zagłodzi backfill na zawsze. Wszystko na jednym koncie dzieli te same dwa kubełki żądań i to samo saldo, więc sprawiedliwość między tenantami jest Twoim zadaniem, nie API.

Klasyfikuj błędy, zanim ponowisz

„Ponów trzy razy” to nie polityka. Właściwe działanie zależy od tego, co wiesz i czy zadanie może już istnieć. Ta tabela używa udokumentowanych kodów; wartości code są w treści odpowiedzi, a błędy zadań przychodzą jako state: "failed" z errorCode w udanym wywołaniu recordInfo (code: 200).

GdzieSygnałPonowić?Co zrobić
TworzenieTimeout, zerwane połączenie, nieczytelna treśćTakWynik nieznany. Ten sam klucz, ten sam klucz API, backoff z jitterem
Tworzenie429 z Retry-AfterTakOdczekaj podaną liczbę sekund, dodaj jitter, zmniejsz tempo dispatchu. Nie traktuj tego jako dowodu, że wysyłasz zbyt szybko
Upload429 bez Retry-After (oczekujące uploady)Bez natychmiastowego ponowieniaDokończ oczekujące uploady lub poczekaj na wygaśnięcie ich ticketów, zanim zażądasz kolejnych
Tworzenie500, 503, 50301 (model niedostępny)Tak, z ograniczeniemBackoff z terminem; 50301 może też oznaczać brak aktywnej ceny, więc odśwież katalog
Tworzenie409 wyścig z tym samym kluczemRazKilkaset ms, ten sam klucz. Inne 409 oznaczają zmieniony payload lub inny klucz: zatrzymaj się
Tworzenie400, 40003, 40004NieNieprawidłowe pole, nieobsługiwana kombinacja lub niezgodność typu/rozmiaru pliku; popraw element, pokaż komunikat
Tworzenie40201 saldo, 40202 limit wydatkówNieWstrzymaj dispatch i zaalarmuj. Ponowienie nie zmieni salda ani limitu
Tworzenie401, 403, 40301NieUprawnienia klucza lub modelu: wstrzymaj integrację, zaalarmuj operatora
Zadanierate_limited, upstream_unavailable, upstream_failedTakDopiero po settled: true: POST /api/v1/jobs/retry z kluczem powiązanym z zadaniem źródłowym; tworzy nowe płatne zadanie. Wcześniejsze ponowienie zwraca 409
Zadanietimeout, generation_failedMożeCzęsto kończy się tak samo. Spróbuj krótszego czasu trwania lub niższej rozdzielczości albo odpuść
Zadanieinvalid_request, unsupported_combination, content_rejectedNieTe same dane wejściowe znów zawiodą. Zmień dane wejściowe lub wybierz inny model
Zadanieinvalid_assetNowa rewizjaPliku referencyjnego już nie ma: wyślij go ponownie, zaktualizuj dane wejściowe i wyceń nowe zadanie
LokalnieCallback odebrany, Twoja transakcja się nie powiodłaTakPonów swój handler, nie tworzenie; deduplikuj po ID dostawy lub taskId

Używaj losowego backoffu, aby workerzy nie ponawiali w jednym rytmie.

Przenoś potwierdzone błędy i odrzucone dane wejściowe do dead-letter queue razem z ich klasyfikacją i bezpieczną akcją odtworzenia. Niejednoznaczne tworzenie lub nierozstrzygnięte przyjęte zadanie trzymaj w kolejce uzgadniania z zapisanym kluczem i ID zadania; lokalny termin ponowienia nie dowodzi, że zadanie się nie powiodło. Nieudane zadanie zwykle w całości zwalnia swój hold, ale niektóre płatne wyniki content_rejected są rozliczane według wyceny, więc czytaj settled i cost, zamiast zakładać zwrot.

Ochrona kosztów należy do schedulera

Limity współbieżności chronią maszyny, nie pieniądze. W pełni stabilny worker może mimo to wysłać wsad, na który Cię nie stać. Przed dispatchem sprawdź szacowany koszt względem każdej koperty:

dozwolone = dzienne pozostałe tenanta

∩ miesięczne pozostałe tenanta

∩ pozostałe wsadu projektu

∩ globalne pozostałe na godzinę

Uzgadniaj z rzeczywistym rozliczeniem: createTask zwraca zablokowany estimatedCost, udane zadanie rozlicza się na tę kwotę lub niższą, a settled: true z cost to ostateczne obciążenie. Zwolnij własną rezerwację dla elementów odrzuconych przez API i zachowaj rezerwację dla przyjętych, dopóki settled się nie zmieni. Nie zgaduj na podstawie timeoutu HTTP.

Przydatne zabezpieczenia:

  • maksymalny szacowany koszt na wsad, pokazany człowiekowi, zanim ruszy duży;
  • godzinowy budżet dispatchu na tenanta i alarm globalnej szybkości wydatków;
  • circuit breaker na model lub tenanta;
  • limity kluczy API poniżej salda konta oraz alert niskiego salda;
  • kill switch, który zatrzymuje nowy dispatch, zostawiając przetwarzanie zakończeń w działaniu.

Ponieważ przyjętych zadań nie można anulować, zatrzymanie tworzenia to jedyny hamulec: szkody w incydencie kosztowym ogranicza limit zadań w toku, a nie przycisk anulowania. Dalej odbieraj webhooki, odpytuj stare zadania, rozliczaj rezerwacje i kopiuj wyniki.

Zakończenie w skali wsadu

Dostarczanie callBackUrl dla każdego zadania ma timeout 15 sekund; zarejestrowany endpoint konta również może otrzymywać własną dostawę, więc odbiorca powinien weryfikować podpis względem surowej treści, zapisać zdarzenie w tabeli inbox, zwrócić 2xx i resztę zostawić workerowi. Deduplikuj po request_id dostawy v2 i decyduj według data.state, a nie kolejności przybycia. Zachowaj wolny polling jako zapas na pominięte zdarzenia (zobacz dokumentację webhooków).

Kopiuj wyniki do własnego magazynu w miarę kończenia zadań. Linki do wyników działają około 20 minut, a wygenerowane media są przechowywane najwyżej około 14 dni (retencja); przy 5 zakończeniach na sekundę krok kopiowania wchodzi w skład Twojej przepustowości, a wolny uploader staje się wąskim gardłem ukrytym w „zakończonych” zadaniach. Skrócenie retencji może też usunąć wyniki zadań, które już zostały wykonane.

Obserwuj przepływ, nie tylko błędy

Przydatny dashboard odpowiada na cztery pytania: czy przyjmujemy pracę, czy się zaczyna, czy się kończy, czy koszt rozlicza się zgodnie z oczekiwaniami? Śledź według modelu, klasy mediów, poziomu tenanta i wyniku:

SygnałDlaczego ma znaczenie
Wiek najstarszego gotowego elementuBezpośrednia miara czasu oczekiwania klienta i porażki backpressure
Liczba elementów w kolejce, w toku i terminalnychPokazuje, gdzie gromadzi się praca
Przyjęte zadania na sekundę vs. próby utworzeniaOddziela prawdziwą przepustowość od ponowień
Czas zadania p50/p95/p99 (completedAt - createdAt)Podstawa wymiarowania współbieżności i wykrywania zawieszonych zadań
Wyniki terminalne i mieszanka errorCodeDegradacja specyficzna dla modelu lub danych wejściowych
X-RateLimit-RemainingOstrzega przed 429
Saldo zablokowane vs. dostępne (GET /api/v1/chat/credit)Przewiduje 40201, zanim zatrzyma wsad
Koszt zarezerwowany vs. rozliczony, wydatki na godzinę na tenantaWycieki rezerwacji, rozszalałe wsady
Opóźnienie webhooków i odzyskiwanie przez pollingKondycja ścieżki zakończeń

Przeprowadź jeden łańcuch korelacji (batchId, ID elementu, klucz idempotentności, taskId, request_id) przez logi i zdarzenia, a prompty, klucze API i prywatne adresy URL zasobów trzymaj poza etykietami metryk i zwykłymi logami. Alarmuj na objawy: „50 000 elementów w kolejce” może być normalne; „najstarszy element interaktywny ma 12 minut” wymaga działania.

Zacznij od ograniczonego wsadu, potem zwiększaj pojemność

Zwiększaj pojemność tylko dopóki wiek najstarszego elementu, błędy zadań i wydatki mieszczą się w progach; więcej workerów nie naprawi przeciążonej ścieżki zakończeń.

Przed pierwszym płatnym wsadem użyj mocka, aby przerwać workera tuż przed zgłoszeniem i tuż po nim oraz zwrócić 429, nieudane zadania i błędy limitu wydatków. Odzyskiwanie powinno użyć zapisanej intencji, zostawić nieznane zadania nierozstrzygnięte i zatrzymać nowy dispatch bez zatrzymywania zakończeń. Potem uruchom mały zatwierdzony wsad, zanim otworzysz przyjmowanie na duże importy.

Do obsługi zakończeń zobacz „Polling vs webhooks for async generation jobs”; do budżetu wsadu — Per-unit pricing vs subscriptions. Lista kontrolna produkcji obejmuje konfigurację kluczy i testy przed startem.

FAQ

Czy SpicyAPI ma endpoint wsadowy lub limit współbieżności?

Nie ma endpointu wsadowego: każdy wiersz to jedno createTask, a udokumentowanego limitu zadań w toku nie ma (limity są w tabeli powyżej; bieżące tempo w X-RateLimit-Limit). Jeśli limit żądań jest za niski na Twój szczyt, poproś wsparcie konsoli o wyższy, opisując szczytowe żądania na sekundę i rozmiar wsadu.

Czy mogę anulować wsad uruchomiony przez pomyłkę?

Nie. Użyj kill switcha dispatchu, aby zatrzymać nowe zadania, podczas gdy obsługa zakończeń działa dalej.

Czy 429 powinno zużywać próbę ponowienia?

Odczekaj Retry-After plus jitter i zachowaj ten sam klucz idempotentności. Powtarzające się ograniczanie wymaga niższego tempa dispatchu. Ogranicz ponowienia zarówno liczbą prób, jak i czasem; jeśli po wyczerpaniu tego budżetu przyjęcie wciąż jest niepewne, uzgodnij zapisaną intencję, zanim utworzysz zastępnik.

Jak zapobiec podwójnym obciążeniom po timeoucie?

Wyślij ponownie z tym samym Idempotency-Key i tym samym kluczem API w ciągu 24 godzin i zapisz taskId, jak tylko go masz. Po 24 godzinach sprawdź zamiast tego zapisany taskId.

Czy sama kolejka wystarczy, by zapobiec przeciążeniu?

Nie. Kolejka przechowuje przeciążenie; backpressure je kontroluje. Nadal potrzebujesz progów przyjmowania opartych na wieku kolejki, limitu zadań w toku i odpowiedzi produktu na sytuację, gdy obiecanego czasu oczekiwania nie da się dotrzymać.

Jaka jest dobra początkowa współbieżność?

Zakończenia na sekundę razy średni czas zadania, osobno dla klasy mediów, plus około 25 % zapasu: 40 dla 5 obrazów na sekundę po 8 sekund każdy. Podnoś ją tylko dopóki wiek kolejki, błędy i wydatki są w normie.

Czy zadania o wysokim priorytecie mogą omijać limity?

Nie. Priorytet wybiera spośród dopuszczalnej pracy i nigdy nie omija limitu globalnego ani budżetów. Daj pracy interaktywnej zarezerwowaną pojemność i postarzaj pracę o niższym priorytecie, aby nie została zagłodzona.

Pozwól Agentowi obsłużyć wywołanie

Użyj Agenta, gdy chcesz, aby przygotował dane wejściowe i zwrócił wynik, podczas gdy Ty zatwierdzasz cenę i decyzje twórcze.

  • CLI: po zapisaniu obiektu input jednego wiersza jako input.json (dla Z-Image: prompt, aspect_ratio, enable_prompt_expansion, output_format, seed) użyj npx @spicyapi/cli tasks create --model alibaba/z-image-turbo/text-to-image --input-file input.json --wait. CLI potwierdza cenę; zachowaj zwrócone ID zadania na potrzeby odzyskiwania.
  • MCP: poproś asystenta o użycie spicyapi_task_create dla alibaba/z-image-turbo/text-to-image, a potem spicyapi_task_wait. Serwer MCP prosi o potwierdzenie ceny przed utworzeniem.
  • Skill: zainstaluj poleceniem npx skills add spicyapi-ai/spicy-skill, a potem poproś $spicyapi, aby wykonał ten sam workflow i zatrzymał się na Twoje zatwierdzenie. Skill korzysta ze skonfigurowanego połączenia CLI lub MCP.

Także w językach English · 日本語 · 한국어 · Deutsch · Français · Español · Português (Brasil) · Русский · Italiano

Rozwiązania

API generowania wideo: porównaj i wdróżPorównaj endpointy wideo pod kątem długości, rozdzielczości, dźwięku i ceny, a potem wyślij to samo żądanie z własnego serwera.

Wypróbuj samodzielnie

Płacisz tylko za to, co uruchomisz — bez planów i subskrypcji.

Zrobione w MiniMax H3 · ByteDance Seedance 2.0 Fast