Ir al contenido principal

Generación por lotes de imágenes y video sin colapsar la cola

Diseño de producción para concurrencia, contrapresión, idempotencia, planificación justa, reintentos, control de costos y observabilidad en lotes grandes.

Autor
SpicyAPI Engineering
Publicado
Lectura
23 min de lectura
Actualizado
Un operador ante monitores supervisa cintas transportadoras separadas de imágenes y video en una sala de servidores ilustrada

Ilustración: colas separadas de imágenes y video permiten que cada carga avance a su propio ritmo.

Un generador por lotes suele fallar en el momento menos interesante: no al generar una imagen, sino cuando un cliente importa 20.000 filas y la aplicación las convierte todas a la vez en solicitudes activas.

La API puede estar sana y cada payload ser válido. Aun así el sistema colapsa: se llenan las conexiones a la base de datos, los callbacks llegan más rápido de lo que puedes confirmarlos, un tenant grande bloquea a todos los demás, un reintento duplica un video caro y un pico de tráfico se convierte en una factura.

Mantén el trabajo en espera en una cola duradera, limita las tareas en curso y reintenta solo cuando sepas si ya existe una tarea remota. Un Promise.all más grande no hace nada de eso.

Un hecho condiciona todo lo que sigue: SpicyAPI no tiene un endpoint masivo ni por lotes. Cada fila es una llamada createTask con su propia tarea, su propia retención y su propia liquidación, y una tarea aceptada no se puede cancelar. La cola, el límite de concurrencia y el botón de parada viven, por tanto, en tu aplicación.

Pruébalo primero en el Playground

Abre el Playground de z-image. Escribe un Prompt representativo, elige la Aspect ratio, configura Prompt expansion como planeas ejecutar el lote, pulsa Run model y revisa el Output. Escala solo cuando ese único resultado sea el que quieres; un planificador multiplica la solicitud, errores incluidos.

La respuesta corta

  • Guarda el trabajo pendiente en tu propia cola duradera, no en una solicitud HTTP ni en una promesa en memoria.
  • Limita tú las tareas en curso. El límite de tasa de la plataforma (1.000 solicitudes cada 10 segundos, por separado para lecturas y escrituras) no limita cuántas tareas aceptadas se ejecutan a la vez; lo hacen tu saldo y tus topes de gasto. Elige un número, por clase de medio, y mantenlo hasta que la tarea sea terminal.
  • Admite según la edad de la cola, no su longitud, y decide el costo de todo el lote antes del primer despacho.
  • Una clave de idempotencia guardada por salida lógica, siempre enviada con la misma API key y sin confiar en ella pasadas 24 horas.
  • Reintenta según el código de error: errores de capacidad y de transporte, sí; entrada inválida, saldo vacío y topes de gasto, no.
  • Round-robin entre tenants, y un interruptor de emergencia del despacho que deje funcionando el manejo de finalizaciones.

Tres límites y quién fija cada uno

Trata el envío, el despacho y la finalización como etapas separadas:

lote del cliente

│

▼

admisión + control de costo ──► cola duradera ──► workers acotados ──► createTask

│

finalización por polling / webhook

│

copiar salida ──► almacén de resultados + evento al cliente

LímiteQué acotaQuién lo fija en SpicyAPI
AdmisiónCuánto trabajo pendiente mantienes y prometes completarTú
Tasa de envíoLlamadas createTask por segundoLa plataforma: 1.000 escrituras cada 10 segundos por cuenta, un token bucket de unas 100 solicitudes por segundo, con X-RateLimit-Remaining en cada respuesta y Retry-After en un 429
Tareas en cursoTareas aceptadas y sin terminarSobre todo tú. La plataforma solo las acota mediante los fondos que retiene cada tarea, los topes de gasto de la clave, el equipo y la plataforma (40202), y la capacidad del modelo (50301)

El límite de tasa gobierna el envío, no la concurrencia: las tareas aceptadas no siguen consumiendo del bucket de solicitudes mientras generan.

Con 5 finalizaciones por segundo creas unas 5 tareas por segundo, una pequeña fracción del bucket de 100 por segundo. Para la mayoría de los lotes, el límite de envío es un fusible de seguridad, no tu freno; antes te detienen el dinero retenido por las tareas en curso y tus propios topes. El polling usa el bucket de lectura aparte, así que un bucle de polling descontrolado no puede bloquear createTask, pero sí puede empezar a devolver 429 en recordInfo y dejarte a ciegas. Lee el encabezado en lugar de codificar los valores por defecto.

Otras APIs pueden imponer límites separados de concurrencia y de cola pendiente; la guía de concurrencia de Leonardo los distingue. SpicyAPI no documenta un tope numérico de tareas en curso, así que configura el tuyo en lugar de apoyarte en su límite de tasa de solicitudes.

Dimensiona el tope en curso con tiempo y dinero

Para una clase de carga estable, la ley de Little relaciona el número medio de tareas en el sistema con la tasa de finalización y el tiempo medio que cada tarea pasa en él:

tareas en curso medias ≈ finalizaciones por segundo × segundos medios desde la aceptación hasta el estado terminal

Si las tareas de imagen duran de media 8 segundos y necesitas 5 por segundo:

5 trabajos/s × 8 s = 40 tareas de imagen concurrentes

Considera 40 una ocupación media, no una garantía. Un tope inicial de 50 añade un 25 % de margen; súbelo solo mientras la edad de la cola, los errores y el gasto se mantengan sanos. Usa p95 y p99 para las colas largas y las alarmas de edad de la cola. Mide el tiempo de tarea con createdAt y completedAt de recordInfo, por modelo y configuración.

El video merece su propio pool. Si los clips de Seedance 2.0 duran de media 70 segundos en tu carga y quieres 0,2 por segundo:

0,2 trabajos/s × 70 s = 14 tareas de video concurrentes

Los promedios de 8 y 70 segundos son supuestos para dimensionar, no tiempos de generación prometidos.

Con un único pool compartido de 50, una ráfaga de videos largos puede ocupar todas las plazas mientras las imágenes rápidas esperan. Con 50 plazas de imagen y 14 de video, ambas clases avanzan. Z-Image y Seedream 5.0 Pro también pueden tener pools propios si sus duraciones o prioridades difieren.

Ahora contrasta el tope con el dinero. Cada tarea aceptada retiene su estimatedCost hasta que se liquida, y esa retención cuenta para el tope de cada clave y equipo. Con precios públicos del catálogo:

En cursoRetenido a la vez
50 imágenes de Seedream 5.0 Pro en 1K ($0.036 cada una)$1.80
14 clips de texto a video de Seedance 2.0, 5 s en 720p ($0.152 por segundo, $0.76 cada uno)$10.64

Un saldo que no cubra esas retenciones hace que las tareas nuevas fallen con 40201, por bueno que sea tu planificador. Importa más el gasto total: 20.000 imágenes de Seedream 5.0 Pro en 1K suman $720 y 20.000 imágenes de Z-Image a $0.01 con Prompt expansion desactivado, $200. Los precios cambian, así que léelos en la página del modelo en vivo o en una cotización. El tope que detiene un mal lote es el que fijas antes de empezar: define un tope de gasto diario en la clave de producción antes del despacho, dimensionado según lo máximo que puedas permitirte perder en un mal día. Un tope en blanco significa sin límite; los límites del equipo también pueden detener tareas nuevas con 40202.

La estimación del tiempo de vaciado es la otra mitad:

tiempo de vaciado ≈ trabajos en cola ÷ finalizaciones sostenibles por segundo

18.000 imágenes en cola a 5 por segundo son una hora de trabajo. Si prometes empezar en 15 minutos, la admisión tiene que frenar mucho antes de las 18.000. Una cola que crece no está en régimen estable, así que confía más en la edad de cola observada que en las ecuaciones.

La contrapresión empieza antes del despacho

Contrapresión significa que el sistema aguas abajo puede hacer que el productor vaya más despacio. Sin ella la cola absorbe trabajo sin fin y el cliente se entera de la sobrecarga horas después.

Usa dos umbrales de admisión sobre la edad de la cola, la edad del elemento listo más antiguo. La longitud engaña cuando los elementos difieren en costo; la edad dice directamente si se cumple la espera prometida.

  • Umbral blando: acepta el lote, devuelve una estimación con retraso, frena al productor.
  • Umbral duro: aplaza o rechaza el trabajo nuevo con una respuesta de producto reintentable antes de que exista cualquier tarea remota.

Compara el dinero en unidades decimales exactas y no en coma flotante binaria. Confirma la reserva de forma atómica junto con la admisión del lote en tu adaptador de cola, para que los envíos concurrentes no consuman el mismo presupuesto restante.

No conviertas toda sobrecarga interna en un 429 de API para tus propios clientes. Si el trabajo está guardado de forma duradera, devuelve un ID de lote aceptado en estado diferido. Rechaza solo el trabajo que no puedas conservar con responsabilidad y, cuando lo hagas, indica cuándo reintentar y haz idempotente el propio envío del lote.

Dos detalles de esta API deciden cuánto puede esperar con seguridad un elemento en tu cola:

  • Los archivos de referencia caducan en un día. Un URI spicy:// de una subida es válido hasta el expiresAt de su respuesta de confirmación (un día por defecto), y un reintento reutiliza la entrada original, así que un reintento posterior devuelve 400. Guarda en la cola una URL HTTPS pública o tu propio ID de origen y sube el archivo en el momento del despacho.
  • Las cotizaciones duran cinco minutos. Si quieres fijar el precio, cotiza en el despacho y envía quoteId con expectedCost a createTask; un cambio de precio falla entonces con 40901 en lugar de cobrar un importe nuevo. Las cotizaciones son gratuitas pero cuentan contra el bucket de lectura, así que cotizar 20.000 filas en la admisión es el lugar equivocado.

La idempotencia tiene dos capas

Hay que evitar dos duplicados distintos: que el cliente envíe el mismo lote dos veces y que tu despachador no sepa si una creación tuvo éxito. Da al lote una identidad estable y genera y guarda un UUID por cada elemento lógico nuevo. La recuperación reutiliza la clave guardada de ese elemento.

Mantén una restricción de unicidad sobre esa clave en tu base de datos. Escribe el elemento como «intención» antes de llamar a la API, envía la clave como Idempotency-Key y guarda el taskId devuelto en la misma fila. Tras una caída, el siguiente worker reutiliza la clave guardada si la aceptación aún es desconocida. Escribir la intención solo después de enviar deja una ventana en la que un reinicio puede comprar la misma salida dos veces.

Lo que añade la documentación de idempotencia y la cola debe respetar:

  • Una clave es válida durante 24 horas desde la primera solicitud aceptada; las repeticiones no la prolongan. Después, la misma clave crea una tarea nueva y facturada. Antes de reproducir un elemento antiguo en cola o en la dead-letter queue, consulta su taskId guardado en lugar de fiarte de la clave.
  • La clave pertenece a la misma cuenta y la misma API key, y al modelo, input y callBackUrl exactos. Cambiar cualquiera devuelve 409. Una clave hermana de tu cuenta también recibe 409 y no puede leer la tarea: solo la clave que creó una tarea puede leerla o reintentarla, así que usa una sola clave para crear, consultar y reintentar en una flota de workers.
  • Una repetición devuelve la tarea original en su estado actual, sin ninguna marca de que fue una repetición.
  • Una carrera poco frecuente con la misma clave devuelve 409 «Idempotency key conflict, please retry shortly». Reintenta una vez tras unos cientos de milisegundos; los otros dos mensajes 409 nunca tendrán éxito.

Un despachador que mantiene el tope hasta que termina la tarea

Guarda un objeto por línea en batch.jsonl, por ejemplo {"model": "alibaba/z-image-turbo/text-to-image", "input": {"prompt": "<one row's prompt>"}}, usando las entradas que acepta el modelo elegido. Define SPICY_API_KEY, BATCH_BUDGET_USD y, opcionalmente, BATCH_WORKERS (2 por defecto). Ejecuta este ejemplo en Python y aprueba el tope del lote. Mantiene ocupado a cada worker hasta que su tarea es terminal, está liquidada y ya no prepara la salida.

Conserva la carpeta batch-run para la recuperación y no reordenes sus filas de origen. Esa misma carpeta conserva el gasto liquidado y las reservas entre reinicios. Ejecuta un solo coordinador por carpeta.

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

La documentación de integración en producción cubre el despliegue. Este ejemplo aísla las filas inválidas, pausa la admisión nueva por saldo o por errores inciertos y acota los reintentos transitorios. Las tareas ya aceptadas se siguen recogiendo después de pausar la admisión. A propósito no hace reintentos automáticos facturados; una fila fallida queda registrada para un nuevo intento explícito tras la liquidación. Ejecuta un proceso así por clase de medio y pon el round-robin por tenant delante del archivo.

Planificación justa: protege a los tenants pequeños

El primero en entrar, primero en salir es justo con los trabajos, no con los clientes. Si el tenant A encola 100.000 elementos por minuto antes de que el tenant B encole diez, B espera detrás de todos los de A. Mantén una cola por tenant y toma un elemento por tenant en cada ronda:

ronda 1: A1, B1, C1

ronda 2: A2, B2, C2

Los pesos pueden reflejar la capacidad contratada, pero da a cada tenant activo una cuota mínima. Limita también el número de tareas en curso de cada tenant: el tope global protege tu cuenta y el del tenant contiene a los vecinos ruidosos. Dentro de un tenant, los carriles de prioridad (previsualizaciones interactivas, lotes normales, rellenos) solo valen la pena cuando la latencia de las previsualizaciones sea una queja real. Si los añades, haz que la prioridad envejezca con el tiempo en cola; de lo contrario, un flujo constante de previsualizaciones deja sin servicio a un relleno para siempre. Todo lo de una cuenta comparte los mismos dos buckets de solicitudes y el mismo saldo, así que la equidad entre tenants es cosa tuya, no de la API.

Clasifica los fallos antes de reintentar

«Reintentar tres veces» no es una política. La acción correcta depende de lo que sepas y de si ya puede existir una tarea. Esta tabla usa los códigos documentados; los valores de code están en el cuerpo de la respuesta, y los fallos de tarea llegan como state: "failed" con un errorCode en una llamada recordInfo correcta (code: 200).

DóndeSeñal¿Reintentar?Qué hacer
CrearTimeout, conexión cortada, cuerpo ilegibleSíResultado desconocido. Misma clave, misma API key, backoff con jitter
Crear429 con Retry-AfterSíEspera los segundos indicados, añade jitter, baja la tasa de despacho. No lo tomes como prueba de que fuiste demasiado rápido
Subir429 sin Retry-After (subidas pendientes)Sin reintento inmediatoTermina las subidas pendientes o espera a que caduquen sus tickets antes de pedir más
Crear500, 503, 50301 (modelo no disponible)Sí, acotadoBackoff con plazo límite; 50301 también puede significar que no hay precio activo, así que actualiza el catálogo
Crear409 carrera con la misma claveUna vezUnos cientos de ms, misma clave. Otros 409 significan payload cambiado u otra clave: detente
Crear400, 40003, 40004NoCampo inválido, combinación no admitida o desajuste de tipo o tamaño de archivo; corrige el elemento y muestra el mensaje
Crear40201 saldo, 40202 tope de gastoNoPausa el despacho y avisa. Reintentar no cambiará un saldo ni un tope
Crear401, 403, 40301NoPermiso de clave o de modelo: pausa la integración y avisa a un operador
Tarearate_limited, upstream_unavailable, upstream_failedSíSolo tras settled: true: POST /api/v1/jobs/retry con una clave ligada a la tarea de origen; crea una tarea nueva facturada. Un reintento anterior devuelve 409
Tareatimeout, generation_failedQuizáA menudo falla igual. Prueba una duración menor o una resolución más baja, o abandona
Tareainvalid_request, unsupported_combination, content_rejectedNoLa misma entrada vuelve a fallar. Cambia la entrada o elige otro modelo
Tareainvalid_assetRevisión nuevaEl archivo de referencia ya no existe: súbelo de nuevo, actualiza la entrada y cotiza una tarea nueva
LocalCallback recibido, tu transacción fallóSíReintenta tu handler, no la creación; deduplica por ID de entrega o taskId

Usa backoff aleatorio para que los workers no reintenten al unísono.

Mueve los fallos confirmados y las entradas rechazadas a una dead-letter queue con su clasificación y la acción segura de reproducción. Mantén una creación ambigua o una tarea aceptada sin resolver en la cola de conciliación con su clave guardada y su ID de tarea; un plazo de reintento local no prueba que haya fallado. Una tarea fallida normalmente libera su retención por completo, pero algunos resultados content_rejected con cargo se liquidan a la cotización, así que lee settled y cost en lugar de dar por hecho un reembolso.

La protección de costos pertenece al planificador

Los límites de concurrencia protegen máquinas, no dinero. Un worker perfectamente estable puede despachar un lote inasumible. Comprueba el costo estimado contra cada envolvente antes del despacho:

permitido = restante diario del tenant

∩ restante mensual del tenant

∩ restante del lote del proyecto

∩ restante global por hora

Concilia con la liquidación real: createTask devuelve el estimatedCost retenido, una tarea correcta se liquida en ese importe o menos, y settled: true con cost es el cargo final. Libera tu propia reserva de los elementos que la API rechazó y conserva la reserva de los aceptados hasta que settled cambie. No adivines a partir de un timeout HTTP.

Salvaguardas útiles:

  • un costo estimado máximo por lote, mostrado a una persona antes de que empiece uno grande;
  • un presupuesto de despacho por hora por tenant y una alarma de velocidad de gasto global;
  • un circuit breaker por modelo o tenant;
  • topes de API key por debajo del saldo de la cuenta y una alerta de saldo bajo;
  • un interruptor de emergencia que detenga el despacho nuevo y deje funcionando el procesamiento de finalizaciones.

Como las tareas aceptadas no se pueden cancelar, detener la creación es el único freno: el tope en curso, y no un botón de cancelar, limita el daño en un incidente de costos. Sigue recibiendo webhooks, consultando las tareas antiguas, liquidando reservas y copiando salidas.

Finalización a escala de lote

La entrega por callBackUrl de cada tarea agota el tiempo a los 15 segundos; un endpoint de cuenta registrado también puede recibir su propia entrega, así que el receptor debería verificar la firma contra el cuerpo sin procesar, escribir el evento en una tabla de bandeja de entrada, devolver 2xx y dejar que un worker haga el resto. Deduplica por el request_id de entrega v2 y decide según data.state, no según el orden de llegada. Mantén un polling lento como respaldo ante eventos perdidos (consulta la documentación de webhooks).

Copia las salidas a tu propio almacenamiento a medida que terminan las tareas. Los enlaces de resultados duran unos 20 minutos y los medios generados se conservan unos 14 días como máximo (retención); a 5 finalizaciones por segundo el paso de copia forma parte de tu rendimiento, y un uploader lento se convierte en el cuello de botella que se esconde en las tareas «completadas». Acortar la retención también puede eliminar resultados de tareas que ya ejecutaste.

Observa el flujo, no solo los errores

Un buen panel responde cuatro preguntas: ¿estamos aceptando trabajo?, ¿está empezando?, ¿está terminando?, ¿se liquida el costo como esperamos? Haz el seguimiento por modelo, clase de medio, nivel de tenant y resultado:

SeñalPor qué importa
Edad del elemento listo más antiguoMedida directa de la espera del cliente y del fallo de contrapresión
Recuentos en cola, en curso y terminalesMuestra dónde se acumula el trabajo
Tareas aceptadas por segundo vs. creaciones intentadasSepara el rendimiento real de los reintentos
Duración de tarea p50/p95/p99 (completedAt - createdAt)Guía el dimensionamiento de la concurrencia y la detección de tareas atascadas
Resultados terminales y mezcla de errorCodeDegradación específica de un modelo o de una entrada
X-RateLimit-RemainingAvisa antes de un 429
Saldo retenido vs. disponible (GET /api/v1/chat/credit)Predice 40201 antes de que detenga el lote
Costo reservado vs. liquidado, gasto por hora y tenantReservas filtradas, lotes descontrolados
Retraso de webhooks y recuperaciones por pollingSalud de la ruta de finalización

Lleva una única cadena de correlación (batchId, ID de elemento, clave de idempotencia, taskId, request_id) por los logs y eventos, y mantén prompts, API keys y URL de assets privados fuera de las etiquetas de métricas y de los logs rutinarios. Alerta sobre síntomas: «50.000 elementos en cola» puede ser normal; «el elemento interactivo más antiguo tiene 12 minutos» es accionable.

Empieza con un lote acotado y luego aumenta la capacidad

Aumenta la capacidad solo mientras la edad del elemento más antiguo, los fallos de tareas y el gasto se mantengan dentro de tus umbrales; más workers no arreglan una ruta de finalización sobrecargada.

Antes del primer lote de pago, usa un mock para interrumpir un worker justo antes y justo después del envío y para devolver 429, tareas fallidas y errores de tope de gasto. La recuperación debería reutilizar la intención guardada, dejar sin resolver las tareas desconocidas y detener el despacho nuevo sin detener la finalización. Después ejecuta un lote pequeño aprobado antes de abrir la admisión a importaciones grandes.

Para el manejo de finalizaciones, consulta «Polling vs webhooks for async generation jobs»; para el presupuesto de un lote, consulta Per-unit pricing vs subscriptions. La lista de comprobación de producción cubre la configuración de claves y las pruebas de lanzamiento.

FAQ

¿SpicyAPI tiene un endpoint por lotes o un límite de concurrencia?

No hay endpoint por lotes: cada fila es un createTask, y no hay un tope documentado de tareas en curso (los límites están en la tabla de arriba; la tasa actual, en X-RateLimit-Limit). Si el límite de tasa es demasiado bajo para tu pico, pide uno mayor al soporte de la consola, describiendo las solicitudes por segundo en el pico y el tamaño del lote.

¿Puedo cancelar un lote iniciado por error?

No. Usa el interruptor de emergencia del despacho para detener las tareas nuevas mientras el manejo de finalizaciones sigue funcionando.

¿Debe un 429 consumir un intento de reintento?

Espera Retry-After más jitter y conserva la misma clave de idempotencia. Si el límite se repite, baja la tasa de despacho. Acota los reintentos por intentos y por tiempo; si la aceptación sigue siendo incierta cuando se agota ese presupuesto, concilia la intención guardada antes de crear un reemplazo.

¿Cómo evito cobros duplicados tras un timeout?

Vuelve a enviar con la misma Idempotency-Key y la misma API key dentro de las 24 horas, y guarda el taskId en cuanto lo tengas. Pasadas 24 horas, consulta en su lugar el taskId guardado.

¿Basta una cola para evitar la sobrecarga?

No. Una cola almacena la sobrecarga; la contrapresión la controla. Sigues necesitando umbrales de admisión sobre la edad de la cola, un tope de tareas en curso y una respuesta de producto para cuando no se pueda cumplir la espera prometida.

¿Cuál es una buena concurrencia inicial?

Finalizaciones por segundo por tiempo medio de tarea, por clase de medio, más un 25 % de margen: 40 para 5 imágenes por segundo a 8 segundos cada una. Súbela solo mientras la edad de la cola, los errores y el gasto se mantengan sanos.

¿Pueden los trabajos de alta prioridad saltarse los límites?

No. La prioridad elige entre el trabajo admisible y nunca se salta el tope global ni los presupuestos. Da al trabajo interactivo una capacidad reservada y haz que el de menor prioridad envejezca para que no se quede sin servicio.

Deja que un Agent gestione la llamada

Usa un Agent cuando quieras que prepare las entradas y devuelva el resultado mientras tú apruebas el precio y las decisiones creativas.

  • CLI: con el objeto input de una fila guardado como input.json (para Z-Image: prompt, aspect_ratio, enable_prompt_expansion, output_format, seed), usa npx @spicyapi/cli tasks create --model alibaba/z-image-turbo/text-to-image --input-file input.json --wait. La CLI confirma el precio; conserva el ID de tarea devuelto para la recuperación.
  • MCP: pide a tu asistente que use spicyapi_task_create para alibaba/z-image-turbo/text-to-image y después spicyapi_task_wait. El servidor MCP solicita confirmación del precio antes de crear la tarea.
  • Skill: instálala con npx skills add spicyapi-ai/spicy-skill y pide a $spicyapi que ejecute el mismo flujo y se detenga para tu aprobación. La Skill usa una conexión configurada de CLI o MCP.

También en English · 日本語 · 한국어 · Deutsch · Français · Português (Brasil) · Русский · Italiano · Polski

Soluciones

API de vídeo generativo: comparar e integrarCompara endpoints de vídeo por duración, resolución, audio y precio, y envía después la misma petición desde tu servidor.