Перейти к основному контенту

Пакетная генерация изображений и видео без обвала очереди

Схема для продакшена: параллелизм, обратное давление, идемпотентность, честное планирование, повторы, защита бюджета и наблюдаемость.

Автор
SpicyAPI Engineering
Опубликовано
Время чтения
19 мин чтения
Обновлено
Оператор у мониторов наблюдает за раздельными конвейерами для изображений и видео в нарисованной серверной

Иллюстрация: раздельные очереди для изображений и видео позволяют каждой нагрузке двигаться в своём темпе.

Пакетный генератор обычно ломается в самый неинтересный момент: не при генерации одного изображения, а когда клиент импортирует 20 000 строк, и приложение разом превращает их все в живые запросы.

API может быть исправен, а каждый payload — корректен. Система всё равно рушится: заканчиваются соединения с базой данных, callback-и приходят быстрее, чем вы успеваете их зафиксировать, один крупный тенант блокирует всех остальных, повтор дублирует дорогое видео, а скачок трафика превращается в счёт.

Держите ожидающую работу в надёжной очереди, ограничивайте число задач в полёте и повторяйте запрос только тогда, когда знаете, существует ли уже удалённая задача. Больший Promise.all ничего из этого не делает.

Один факт определяет всё остальное: у SpicyAPI нет массового или пакетного эндпоинта. Каждая строка — это отдельный вызов createTask со своей задачей, своим холдом и своим расчётом, а принятую задачу нельзя отменить. Очередь, лимит параллелизма и кнопка остановки поэтому находятся в вашем приложении.

Сначала попробуйте в Playground

Откройте Playground z-image. Напишите типичный Prompt, выберите Aspect ratio, задайте Prompt expansion так, как собираетесь запускать пакет, нажмите Run model и проверьте Output. Масштабируйте только тогда, когда этот единственный результат вас устраивает: планировщик умножает запрос вместе с его ошибками.

Короткий ответ

  • Держите backlog в собственной надёжной очереди, а не в HTTP-запросе или промисе в памяти.
  • Ограничивайте число задач в полёте сами. Лимит запросов платформы (1 000 запросов за 10 секунд, отдельно для чтения и записи) не ограничивает, сколько принятых задач выполняется одновременно; это делают ваш баланс и лимиты расходов. Выберите число для каждого класса медиа и удерживайте его, пока задача не станет терминальной.
  • Допускайте по возрасту очереди, а не по её длине, и определяйте стоимость всего пакета до первой отправки.
  • Один сохранённый ключ идемпотентности на каждый логический результат, всегда с одним и тем же API-ключом и без доверия к нему после 24 часов.
  • Повторяйте по коду ошибки: ошибки ёмкости и транспорта — да; неверный ввод, пустой баланс и лимиты расходов — нет.
  • Round-robin между тенантами и аварийный выключатель отправки, который не останавливает обработку завершений.

Три лимита и кто задаёт каждый

Рассматривайте отправку, диспетчеризацию и завершение как отдельные этапы:

пакет клиента

│

▼

допуск + проверка стоимости ──► надёжная очередь ──► ограниченные воркеры ──► createTask

│

завершение через polling / webhook

│

копирование результата ──► хранилище результатов + событие клиенту

ЛимитЧто ограничиваетКто задаёт в SpicyAPI
ДопускСколько backlog вы держите и обещаете выполнитьВы
Частота отправкиВызовы createTask в секундуПлатформа: 1 000 записей за 10 секунд на аккаунт, token bucket примерно в 100 запросов в секунду, с X-RateLimit-Remaining в каждом ответе и Retry-After при 429
Задачи в полётеПринятые, но незавершённые задачиВ основном вы. Платформа ограничивает их лишь через средства, которые удерживает каждая задача, лимиты расходов ключа, команды и платформы (40202) и ёмкость модели (50301)

Лимит запросов управляет отправкой, а не параллелизмом: принятые задачи во время генерации больше не берут запросы из бакета.

При 5 завершениях в секунду вы создаёте около 5 задач в секунду — малую долю бакета в 100 в секунду. Для большинства пакетов лимит отправки — это предохранитель, а не ваш тормоз; раньше вас остановят деньги, удерживаемые задачами в полёте, и ваши собственные лимиты. Polling использует отдельный бакет чтения, поэтому убежавший цикл опроса не заблокирует createTask, но может начать получать 429 на recordInfo и оставить вас вслепую. Читайте заголовок, а не зашивайте значения по умолчанию.

Другие API могут вводить отдельные лимиты на параллелизм и на очередь ожидания; руководство по параллелизму Leonardo их различает. У SpicyAPI нет документированного числового предела задач в полёте, поэтому настройте свой, а не полагайтесь на лимит частоты запросов.

Рассчитайте лимит в полёте по времени и деньгам

Для одного устойчивого класса нагрузки закон Литтла связывает среднее число задач в системе с частотой завершений и средним временем, которое каждая задача там проводит:

среднее число задач в полёте ≈ завершений в секунду × среднее число секунд от принятия до терминального состояния

Если задачи с изображениями занимают в среднем 8 секунд, а вам нужно 5 в секунду:

5 задач/с × 8 с = 40 одновременных задач с изображениями

Считайте 40 средней загрузкой, а не гарантией. Стартовый лимит 50 даёт запас 25 %; повышайте его, только пока возраст очереди, ошибки и расходы в норме. Для хвостов и алертов по возрасту очереди используйте p95 и p99. Измеряйте время задачи по createdAt и completedAt в recordInfo, отдельно для каждой модели и настроек.

Видео нужен собственный пул. Если клипы Seedance 2.0 в вашей нагрузке занимают в среднем 70 секунд и вам нужно 0,2 в секунду:

0,2 задачи/с × 70 с = 14 одновременных видеозадач

Средние значения 8 и 70 секунд — допущения для расчёта, а не обещанное время генерации.

В одном общем пуле на 50 всплеск длинных видео может занять все слоты, пока быстрые изображения ждут. С 50 слотами для изображений и 14 для видео двигаются оба класса. Z-Image и Seedream 5.0 Pro тоже могут получить отдельные пулы, если различаются длительность или приоритет.

Теперь сверьте лимит с деньгами. Каждая принятая задача удерживает свою estimatedCost до расчёта, и этот холд учитывается в лимите каждого ключа и команды. По публичным ценам каталога:

В полётеУдерживается одновременно
50 изображений Seedream 5.0 Pro в 1K ($0.036 за штуку)$1.80
14 клипов текст-в-видео Seedance 2.0, 5 с в 720p ($0.152 за секунду, $0.76 за клип)$10.64

Баланс, не покрывающий эти холды, заставит новые задачи завершаться ошибкой 40201, каким бы хорошим ни был ваш планировщик. Важнее общие расходы: 20 000 изображений Seedream 5.0 Pro в 1K обойдутся в $720, а 20 000 изображений Z-Image по $0.01 с выключенным Prompt expansion — в $200. Цены меняются, поэтому берите их с актуальной страницы модели или из котировки. Лимит, который остановит плохой пакет, — тот, что вы задали до его старта: установите суточный лимит расходов на рабочий ключ до отправки, исходя из максимума, который вы можете потерять за плохой день. Пустой лимит означает отсутствие ограничения; лимиты команды тоже могут остановить новые задачи с кодом 40202.

Оценка времени разгрузки — вторая половина:

время разгрузки ≈ задач в очереди ÷ устойчивое число завершений в секунду

18 000 изображений в очереди при 5 в секунду — это час работы. Если вы обещаете начало в течение 15 минут, допуск должен замедляться задолго до 18 000. Растущий backlog — не установившийся режим, поэтому опирайтесь на наблюдаемый возраст очереди больше, чем на формулы.

Обратное давление начинается до отправки

Обратное давление означает, что нижестоящая система может заставить производителя замедлиться. Без него очередь бесконечно принимает работу, а клиент узнаёт о перегрузке спустя часы.

Используйте два порога допуска по возрасту очереди — возрасту самого старого готового элемента. Длина обманывает, когда элементы различаются по стоимости; возраст прямо показывает, выдерживается ли обещанное ожидание.

  • Мягкий порог: принять пакет, вернуть отложенную оценку, замедлить производителя.
  • Жёсткий порог: отложить или отклонить новую работу ответом продукта, допускающим повтор, до создания какой-либо удалённой задачи.

Сравнивайте деньги в точных десятичных единицах, а не в двоичной плавающей точке. Фиксируйте резерв атомарно вместе с допуском пакета в адаптере очереди, чтобы параллельные отправки не потратили один и тот же остаток бюджета.

Не превращайте любую внутреннюю перегрузку в API-429 для ваших собственных клиентов. Если работа надёжно сохранена, верните принятый ID пакета в отложенном состоянии. Отклоняйте только ту работу, которую не можете ответственно удержать, и в этом случае укажите время повтора и сделайте идемпотентной саму отправку пакета.

Две особенности этого API определяют, как долго элемент может безопасно ждать в вашей очереди:

  • Файлы-референсы истекают через сутки. URI spicy:// из загрузки действителен до expiresAt в ответе на commit (по умолчанию один день), а повтор использует исходный ввод, поэтому повтор после этого возвращает 400. Храните в очереди публичный HTTPS-URL или собственный ID источника и загружайте файл в момент отправки.
  • Котировки действуют пять минут. Если нужно зафиксировать цену, запрашивайте котировку при отправке и передавайте quoteId вместе с expectedCost в createTask; изменившаяся цена тогда даёт ошибку 40901, а не списание новой суммы. Котировки бесплатны, но считаются в бакет чтения, поэтому запрашивать их для 20 000 строк при допуске — неверное место.

Идемпотентность в два слоя

Нужно предотвращать два разных дубля: клиент отправляет один и тот же пакет дважды, а ваш диспетчер не знает, удалось ли создание. Дайте пакету стабильную идентичность, а затем генерируйте и сохраняйте UUID для каждого нового логического элемента. При восстановлении используется сохранённый ключ этого элемента.

Держите в базе данных ограничение уникальности на этот ключ. Записывайте элемент как «намерение» до вызова API, отправляйте ключ как Idempotency-Key и сохраняйте возвращённый taskId в той же строке. После сбоя следующий воркер использует сохранённый ключ, если принятие по-прежнему неизвестно. Запись намерения только после отправки оставляет окно, в котором перезапуск может купить один и тот же результат дважды.

Что добавляет документация по идемпотентности и что должна учитывать очередь:

  • Ключ действителен 24 часа с первого принятого запроса; повторы его не продлевают. После этого тот же ключ создаёт новую оплачиваемую задачу. Перед повторным запуском старого элемента из очереди или dead-letter найдите его сохранённый taskId, а не доверяйте ключу.
  • Ключ принадлежит тому же аккаунту и тому же API-ключу, а также точно той же модели, input и callBackUrl. Любое изменение возвращает 409. Соседний ключ вашего аккаунта тоже получает 409 и не может прочитать задачу: читать или повторять задачу может только ключ, который её создал, поэтому используйте один ключ для создания, опроса и повтора во всём парке воркеров.
  • Повтор возвращает исходную задачу в её текущем состоянии, без пометки, что это был повтор.
  • Редкая гонка с тем же ключом возвращает 409 «Idempotency key conflict, please retry shortly». Повторите один раз через несколько сотен миллисекунд; два других сообщения 409 никогда не завершатся успехом.

Диспетчер, который держит лимит до конца задачи

Сохраните по одному объекту на строку в batch.jsonl, например {"model": "alibaba/z-image-turbo/text-to-image", "input": {"prompt": "<one row's prompt>"}}, используя допустимые входные данные выбранной модели. Задайте SPICY_API_KEY, BATCH_BUDGET_USD и при желании BATCH_WORKERS (по умолчанию 2). Запустите этот пример на Python и подтвердите лимит пакета. Он держит каждого воркера занятым, пока его задача не станет терминальной, рассчитанной и не перестанет готовить результат.

Сохраните папку batch-run для восстановления и не меняйте порядок её исходных строк. Эта же папка хранит рассчитанные расходы и резервы между перезапусками. Запускайте только один координатор на папку.

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

Документация по продакшен-интеграции описывает развёртывание. Этот пример изолирует неверные строки, приостанавливает новый допуск при проблемах с балансом или неопределённых ошибках и ограничивает временные повторы. Уже принятые задачи продолжают собираться после приостановки допуска. Автоматических платных повторов он намеренно не делает; строка с ошибкой остаётся записанной для явной новой попытки после расчёта. Запускайте один такой процесс на класс медиа и ставьте round-robin по тенантам перед файлом.

Честное планирование: защитите мелких тенантов

Первым пришёл — первым обслужен: это справедливо к задачам, но не к клиентам. Если тенант A ставит в очередь 100 000 элементов в минуту до того, как тенант B поставит десять, B ждёт за всеми элементами A. Ведите отдельную очередь на тенанта и берите по одному элементу на тенанта за круг:

круг 1: A1, B1, C1

круг 2: A2, B2, C2

Веса могут отражать оплаченную ёмкость, но дайте каждому активному тенанту минимальную долю. Ограничьте и число задач в полёте на тенанта: глобальный лимит защищает ваш аккаунт, лимит тенанта сдерживает шумных соседей. Внутри тенанта приоритетные полосы (интерактивные превью, обычные пакеты, backfill) стоит добавлять, только когда задержка превью становится реальной жалобой. Если добавляете, старите приоритет по времени в очереди, иначе постоянный поток превью навсегда заморит backfill. Всё в одном аккаунте делит те же два бакета запросов и тот же баланс, поэтому справедливость между тенантами — ваша задача, а не API.

Классифицируйте сбои перед повтором

«Повторить три раза» — это не политика. Правильное действие зависит от того, что вы знаете и могла ли уже появиться задача. В этой таблице используются документированные коды; значения code находятся в теле ответа, а сбои задач приходят как state: "failed" с errorCode в успешном вызове recordInfo (code: 200).

ГдеСигналПовторять?Что делать
СозданиеТаймаут, оборванное соединение, нечитаемое телоДаРезультат неизвестен. Тот же ключ, тот же API-ключ, backoff с jitter
Создание429 с Retry-AfterДаПодождать указанное число секунд, добавить jitter, снизить частоту отправки. Не считать это доказательством, что вы шли слишком быстро
Загрузка429 без Retry-After (ожидающие загрузки)Без немедленного повтораЗавершить ожидающие загрузки или дождаться истечения их тикетов, прежде чем запрашивать новые
Создание500, 503, 50301 (модель недоступна)Да, с ограничениемBackoff с дедлайном; 50301 может также означать отсутствие активной цены, поэтому обновите каталог
Создание409 гонка с тем же ключомОдин разНесколько сотен мс, тот же ключ. Другие 409 означают изменённый payload или другой ключ: остановитесь
Создание400, 40003, 40004НетНеверное поле, неподдерживаемая комбинация или несоответствие типа/размера файла; исправьте элемент, покажите сообщение
Создание40201 баланс, 40202 лимит расходовНетПриостановить отправку и оповестить. Повтор не изменит ни баланс, ни лимит
Создание401, 403, 40301НетПрава ключа или модели: приостановить интеграцию, оповестить оператора
Задачаrate_limited, upstream_unavailable, upstream_failedДаТолько после settled: true: POST /api/v1/jobs/retry с ключом, привязанным к исходной задаче; создаёт новую оплачиваемую задачу. Более ранний повтор возвращает 409
Задачаtimeout, generation_failedВозможноЧасто падает так же. Попробуйте меньшую длительность или более низкое разрешение либо откажитесь
Задачаinvalid_request, unsupported_combination, content_rejectedНетТот же ввод снова даст ошибку. Измените ввод или выберите другую модель
Задачаinvalid_assetНовая ревизияФайла-референса больше нет: загрузите заново, обновите ввод и запросите цену новой задачи
ЛокальноCallback получен, ваша транзакция не удаласьДаПовторите свой обработчик, а не создание; дедуплицируйте по ID доставки или taskId

Используйте случайный backoff, чтобы воркеры не повторяли синхронно.

Переносите подтверждённые сбои и отклонённые вводы в dead-letter очередь с их классификацией и безопасным действием для повторного запуска. Неоднозначное создание или неразрешённую принятую задачу держите в очереди сверки с сохранённым ключом и ID задачи; локальный дедлайн повтора не доказывает, что она провалилась. Неудавшаяся задача обычно полностью освобождает холд, но некоторые оплачиваемые исходы content_rejected рассчитываются по котировке, поэтому читайте settled и cost, а не предполагайте возврат.

Защита бюджета — задача планировщика

Лимиты параллелизма защищают машины, а не деньги. Идеально стабильный воркер всё равно может отправить неподъёмный пакет. Перед отправкой проверяйте оценочную стоимость по каждому из ограничений:

разрешено = остаток тенанта за день

∩ остаток тенанта за месяц

∩ остаток пакета проекта

∩ глобальный остаток за час

Сверяйтесь с реальным расчётом: createTask возвращает удержанную estimatedCost, успешная задача рассчитывается на эту сумму или меньше, а settled: true с cost — окончательное списание. Освобождайте собственный резерв для элементов, которые API отклонил, и сохраняйте резерв для принятых, пока settled не изменится. Не гадайте по HTTP-таймауту.

Полезные защиты:

  • максимальная оценочная стоимость на пакет, показываемая человеку перед стартом крупного пакета;
  • почасовой бюджет отправки на тенанта и алерт на глобальную скорость расходов;
  • circuit breaker на модель или тенанта;
  • лимиты API-ключей ниже баланса аккаунта и алерт о низком балансе;
  • аварийный выключатель, который останавливает новую отправку, оставляя обработку завершений работающей.

Поскольку принятые задачи нельзя отменить, остановка создания — единственный тормоз: ущерб при инциденте со стоимостью ограничивает лимит задач в полёте, а не кнопка отмены. Продолжайте принимать webhook-и, опрашивать старые задачи, рассчитывать резервы и копировать результаты.

Завершение в масштабе пакета

Доставка callBackUrl для каждой задачи истекает по тайм-ауту через 15 секунд; зарегистрированный эндпоинт аккаунта тоже может получать собственную доставку, поэтому получатель должен проверять подпись по сырому телу, записывать событие в таблицу inbox, возвращать 2xx и оставлять остальное воркеру. Дедуплицируйте по request_id доставки v2 и решайте по data.state, а не по порядку прихода. Держите медленный polling как запасной вариант для пропущенных событий (см. документацию по webhook-ам).

Копируйте результаты в собственное хранилище по мере завершения задач. Ссылки на результаты живут около 20 минут, а сгенерированные медиа хранятся не более 14 дней (retention); при 5 завершениях в секунду шаг копирования входит в вашу пропускную способность, а медленный загрузчик становится узким местом, прячущимся в «завершённых» задачах. Сокращение retention также может удалить результаты задач, которые вы уже выполнили.

Наблюдайте за потоком, а не только за ошибками

Полезный дашборд отвечает на четыре вопроса: принимаем ли мы работу, начинается ли она, завершается ли, рассчитывается ли стоимость, как ожидалось? Отслеживайте по модели, классу медиа, уровню тенанта и исходу:

СигналПочему важен
Возраст самого старого готового элементаПрямая мера ожидания клиента и сбоя обратного давления
Число элементов в очереди, в полёте и терминальныхПоказывает, где накапливается работа
Принятые задачи в секунду vs. попытки созданияОтделяет реальную пропускную способность от повторов
Длительность задачи p50/p95/p99 (completedAt - createdAt)Определяет расчёт параллелизма и обнаружение зависших задач
Терминальные исходы и состав errorCodeДеградация, связанная с моделью или вводом
X-RateLimit-RemainingПредупреждает до 429
Удержанный vs. доступный баланс (GET /api/v1/chat/credit)Предсказывает 40201, пока он не остановил пакет
Зарезервированная vs. рассчитанная стоимость, расходы в час по тенантамУтёкшие резервы, убежавшие пакеты
Задержка webhook-ов и восстановления через pollingСостояние пути завершения

Пронесите одну цепочку корреляции (batchId, ID элемента, ключ идемпотентности, taskId, request_id) через логи и события и не помещайте промпты, API-ключи и приватные URL ресурсов в метки метрик и обычные логи. Оповещайте по симптомам: «50 000 элементов в очереди» может быть нормой; «самому старому интерактивному элементу 12 минут» требует действий.

Начните с ограниченного пакета, затем наращивайте ёмкость

Наращивайте ёмкость, только пока возраст самого старого элемента, сбои задач и расходы остаются в рамках порогов; больше воркеров не исправит перегруженный путь завершения.

Перед первым платным пакетом с помощью мока прервите воркер сразу до и сразу после отправки и верните 429, неудавшиеся задачи и ошибки лимита расходов. Восстановление должно использовать сохранённое намерение, оставлять неизвестные задачи неразрешёнными и останавливать новую отправку, не останавливая завершение. Затем запустите небольшой одобренный пакет, прежде чем открывать допуск для крупных импортов.

По обработке завершений см. «Polling vs webhooks for async generation jobs»; по бюджету пакета — Per-unit pricing vs subscriptions. Продакшен-чеклист охватывает настройку ключей и тесты перед запуском.

FAQ

Есть ли у SpicyAPI пакетный эндпоинт или лимит параллелизма?

Пакетного эндпоинта нет: каждая строка — это один createTask, а документированного предела задач в полёте нет (лимиты указаны в таблице выше; текущая частота — в X-RateLimit-Limit). Если лимит запросов слишком низок для вашего пика, попросите поддержку консоли о более высоком, описав пиковые запросы в секунду и размер пакета.

Можно ли отменить пакет, запущенный по ошибке?

Нет. Используйте аварийный выключатель отправки, чтобы остановить новые задачи, пока обработка завершений продолжается.

Должен ли 429 расходовать попытку повтора?

Подождите Retry-After плюс jitter и сохраните тот же ключ идемпотентности. Повторяющееся ограничение требует снизить частоту отправки. Ограничивайте повторы и по числу попыток, и по времени; если принятие всё ещё неопределённо, когда этот бюджет исчерпан, сверьте сохранённое намерение, прежде чем создавать замену.

Как избежать двойных списаний после таймаута?

Отправьте запрос повторно с тем же Idempotency-Key и тем же API-ключом в течение 24 часов и сохраняйте taskId, как только он у вас появился. После 24 часов ищите сохранённый taskId.

Достаточно ли очереди, чтобы предотвратить перегрузку?

Нет. Очередь хранит перегрузку; обратное давление её контролирует. По-прежнему нужны пороги допуска по возрасту очереди, лимит задач в полёте и ответ продукта на случай, когда обещанное ожидание не выдержать.

Какой параллелизм хорош для старта?

Завершений в секунду, умноженных на среднее время задачи, по каждому классу медиа, плюс около 25 % запаса: 40 для 5 изображений в секунду по 8 секунд каждое. Повышайте его, только пока возраст очереди, ошибки и расходы в норме.

Могут ли высокоприоритетные задачи обходить лимиты?

Нет. Приоритет выбирает среди допустимой работы и никогда не обходит глобальный лимит и бюджеты. Дайте интерактивной работе зарезервированную ёмкость и старите менее приоритетную, чтобы она не оказалась заморена.

Поручите вызов Agent

Используйте Agent, когда хотите, чтобы он подготовил входные данные и вернул результат, пока вы подтверждаете цену и творческие решения.

  • CLI: сохранив объект input одной строки как input.json (для Z-Image: prompt, aspect_ratio, enable_prompt_expansion, output_format, seed), используйте npx @spicyapi/cli tasks create --model alibaba/z-image-turbo/text-to-image --input-file input.json --wait. CLI подтверждает цену; сохраните возвращённый ID задачи для восстановления.
  • MCP: попросите ассистента использовать spicyapi_task_create для alibaba/z-image-turbo/text-to-image, затем spicyapi_task_wait. MCP-сервер запрашивает подтверждение цены перед созданием.
  • Skill: установите командой npx skills add spicyapi-ai/spicy-skill, затем попросите $spicyapi выполнить тот же workflow и остановиться для вашего подтверждения. Skill использует настроенное подключение CLI или MCP.

Другие языки English · 日本語 · 한국어 · Deutsch · Français · Español · Português (Brasil) · Italiano · Polski

Решения

API генерации видео: сравнить и подключитьСравните видеоэндпоинты по длительности, разрешению, звуку и цене, а затем отправьте тот же запрос со своего сервера.