본문으로 건너뛰기

큐 붕괴 없이 이미지와 영상을 일괄 생성하는 방법

대량 미디어 생성을 위한 동시성, 백프레셔, 멱등성, 공정한 스케줄링, 재시도, 비용 보호, 관측성까지 다루는 운영 환경 설계입니다.

작성자
SpicyAPI Engineering
발행
읽는 시간
약 23분
수정
일러스트로 그린 서버실에서 모니터 앞의 운영자가 이미지용과 영상용으로 나뉜 컨베이어 레인을 내려다보고 있다

일러스트: 이미지 큐와 영상 큐를 분리하면 각 워크로드가 자기 속도로 진행됩니다.

일괄 생성기는 대개 가장 평범한 순간에 무너집니다. 이미지 한 장을 생성할 때가 아니라, 고객이 20,000개 행을 가져오고 애플리케이션이 그 전부를 한꺼번에 실행 중인 요청으로 바꿀 때입니다.

API가 정상이고 모든 payload가 유효해도 시스템은 무너집니다. 데이터베이스 연결이 가득 차고, callback이 커밋 속도보다 빠르게 도착하며, 큰 테넌트 하나가 나머지 모두를 막고, 재시도가 비싼 영상을 중복 생성하고, 트래픽 급증이 곧 청구서가 됩니다.

대기 중인 작업은 내구성 있는 큐에 두고, 진행 중인 작업 수에 상한을 두며, 원격 작업이 이미 존재하는지 알 때만 재시도합니다. 더 큰 Promise.all은 이 가운데 어느 것도 해 주지 않습니다.

아래 모든 내용을 좌우하는 사실이 하나 있습니다. SpicyAPI에는 대량 또는 배치용 엔드포인트가 없습니다. 각 행은 고유한 작업, 홀드, 정산을 가진 createTask 호출 한 번이며, 접수된 작업은 취소할 수 없습니다. 따라서 큐, 동시성 상한, 중지 버튼은 모두 여러분의 애플리케이션 안에 있어야 합니다.

먼저 Playground에서 시험해 보기

z-image Playground를 엽니다. 대표적인 Prompt를 작성하고, Aspect ratio를 고르고, 배치에서 사용할 방식 그대로 Prompt expansion을 설정한 다음, Run model을 누르고 Output을 확인합니다. 그 한 건의 결과가 원하는 모습일 때만 규모를 키우세요. 스케줄러는 요청을 실수까지 포함해 그대로 곱절로 늘립니다.

짧은 답

  • 백로그는 자체 내구성 큐에 둡니다. HTTP 요청이나 메모리 속 promise에 두지 않습니다.
  • 진행 중인 작업 수의 상한은 직접 정합니다. 플랫폼의 요청 제한(읽기와 쓰기를 따로, 10초당 1,000회 요청)은 접수된 작업이 동시에 몇 개 실행되는지를 제한하지 않습니다. 그것을 제한하는 것은 잔액과 지출 상한입니다. 미디어 종류별로 숫자를 정하고, 작업이 종료 상태가 될 때까지 그 자리를 유지합니다.
  • 큐 길이가 아니라 큐 대기 시간으로 수용 여부를 판단하고, 첫 디스패치 전에 배치 전체의 비용을 확정합니다.
  • 논리적 출력 하나당 저장된 멱등성 키 하나를 쓰고, 항상 같은 API 키로 보내며, 24시간이 지나면 신뢰하지 않습니다.
  • 오류 코드에 따라 재시도합니다. 용량 및 전송 오류는 재시도하고, 잘못된 입력, 잔액 부족, 지출 상한은 재시도하지 않습니다.
  • 테넌트 간에는 라운드 로빈을 쓰고, 완료 처리는 계속 돌리면서 디스패치만 멈추는 킬 스위치를 둡니다.

세 가지 한도와 각각을 정하는 주체

제출, 디스패치, 완료를 별개의 단계로 다룹니다.

고객 배치

│

▼

수용 + 비용 확인 ──► 내구성 큐 ──► 제한된 워커 ──► createTask

│

폴링 / Webhook 완료

│

출력 복사 ──► 결과 저장소 + 고객 이벤트

한도제한 대상SpicyAPI에서 정하는 주체
수용보유하고 완료를 약속하는 백로그의 양여러분
제출 속도초당 createTask 호출 수플랫폼: 계정당 10초에 쓰기 1,000회, 초당 약 100회 요청의 토큰 버킷이며, 모든 응답에 X-RateLimit-Remaining, Retry-After가 429에 붙습니다
진행 중 작업접수되었으나 끝나지 않은 작업대부분 여러분. 플랫폼은 각 작업이 잡아 두는 자금, 키·팀·플랫폼 지출 상한(40202), 모델 용량(50301)을 통해서만 제한합니다

요청 제한은 제출을 다스리는 것이지 동시성을 다스리는 것이 아닙니다. 접수된 작업은 생성되는 동안 요청 버킷을 더 소모하지 않습니다.

초당 5건이 완료된다면 초당 약 5개의 작업을 만들게 되며, 이는 초당 100개 버킷의 극히 일부입니다. 대부분의 배치에서 제출 한도는 속도를 조절하는 장치가 아니라 안전 퓨즈입니다. 먼저 여러분을 멈추는 것은 진행 중인 작업이 잡아 둔 돈과 직접 정한 상한입니다. 폴링은 별도의 읽기 버킷을 쓰므로 폭주한 폴링 루프가 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를 홀드하며, 그 홀드는 모든 키와 팀 상한에 포함됩니다. 공개된 카탈로그 가격을 쓰면 다음과 같습니다.

진행 중한꺼번에 홀드되는 금액
1K Seedream 5.0 Pro 이미지 50장(장당 $0.036)$1.80
Seedance 2.0 텍스트→영상 클립 14개, 720p 5초(초당 $0.152, 클립당 $0.76)$10.64

이 홀드를 감당하지 못하는 잔액이라면, 스케줄러가 아무리 훌륭해도 새 작업은 40201로 실패합니다. 총 지출은 더 중요합니다. 1K Seedream 5.0 Pro 이미지 20,000장은 $720이고, Prompt expansion을 끈 장당 $0.01의 Z-Image 이미지 20,000장은 $200입니다. 가격은 바뀌므로 최신 모델 페이지나 견적에서 확인하세요. 잘못된 배치를 멈춰 주는 상한은 시작하기 전에 정해 둔 상한입니다. 디스패치 전에 운영 키에 일일 지출 상한을 설정하고, 그 금액은 최악의 하루에 잃어도 감당할 수 있는 최대치에 맞춥니다. 상한이 비어 있으면 제한이 없다는 뜻이며, 팀 한도도 새 작업을 40202로 막을 수 있습니다.

처리 완료까지 걸리는 시간 추정은 나머지 절반입니다.

소진 시간 ≈ 큐에 있는 작업 수 ÷ 지속 가능한 초당 완료 수

큐에 쌓인 이미지 18,000장을 초당 5장으로 처리하면 한 시간치 작업입니다. 15분 안에 시작하겠다고 약속했다면, 18,000건에 이르기 훨씬 전에 수용 속도를 늦춰야 합니다. 늘어나는 백로그는 정상 상태가 아니므로, 방정식보다 관측한 큐 대기 시간을 더 믿으세요.

백프레셔는 디스패치 전에 시작됩니다

백프레셔란 하위 시스템이 생산자를 늦추게 할 수 있다는 뜻입니다. 이것이 없으면 큐는 끝없이 작업을 흡수하고, 고객은 과부하를 몇 시간 뒤에야 알게 됩니다.

큐 대기 시간, 즉 가장 오래된 실행 가능 항목의 나이에 두 개의 수용 임계값을 둡니다. 항목마다 비용이 다르면 길이는 거짓말을 하지만, 대기 시간은 약속한 대기 시간이 지켜지고 있는지 곧바로 보여 줍니다.

  • 소프트 임계값: 배치는 받아들이고, 지연된 예상 시간을 반환하며, 생산자를 늦춥니다.
  • 하드 임계값: 원격 작업이 생기기 전에, 재시도할 수 있는 제품 응답으로 새 작업을 보류하거나 거절합니다.

금액은 이진 부동소수점이 아니라 정확한 십진 단위로 비교합니다. 큐 어댑터에서 예약은 배치 수용과 함께 원자적으로 커밋하여, 동시 제출이 같은 남은 예산을 소모하지 못하게 합니다.

내부 과부하를 모두 고객에게 보내는 API 429로 바꾸지 마세요. 작업이 내구적으로 저장되어 있다면 지연 상태로 접수된 배치 ID를 반환합니다. 책임 있게 보관할 수 없는 작업만 거절하고, 거절할 때는 재시도 시각을 알려 주며 배치 제출 자체를 멱등하게 만듭니다.

이 API의 두 가지 세부 사항이 항목이 큐에서 안전하게 기다릴 수 있는 시간을 결정합니다.

  • 참조 파일은 하루 뒤에 만료됩니다. 업로드로 받은 spicy:// URI는 커밋 응답의 expiresAt(기본 하루)까지 유효하고, 재시도는 원래 입력을 재사용하므로 그 이후의 재시도는 400을 반환합니다. 큐에는 공개 HTTPS URL이나 자체 소스 ID를 두고, 업로드는 디스패치 시점에 하세요.
  • 견적은 5분간 유효합니다. 가격을 고정하고 싶다면 디스패치 시점에 견적을 받고, quoteId와 expectedCost를 createTask에 보냅니다. 그러면 가격이 바뀐 경우 새 금액을 청구하는 대신 40901로 실패합니다. 견적은 무료이지만 읽기 버킷을 소모하므로, 수용 단계에서 20,000개 행의 견적을 받는 것은 잘못된 위치입니다.

멱등성은 두 겹입니다

막아야 할 중복은 두 가지입니다. 고객이 같은 배치를 두 번 제출하는 경우와, 디스패처가 생성의 성공 여부를 알지 못하는 경우입니다. 배치에는 안정적인 식별자를 부여하고, 새 논리 항목마다 UUID를 생성해 저장합니다. 복구 때는 그 항목에 저장된 키를 재사용합니다.

그 키에는 데이터베이스에서 고유 제약을 둡니다. API를 호출하기 전에 항목을 "intent"로 기록하고, 키를 Idempotency-Key로 보내며, 반환된 taskId를 같은 행에 저장합니다. 크래시가 난 뒤 접수 여부가 아직 불명확하다면, 다음 워커가 저장된 키를 재사용합니다. 전송한 뒤에야 intent를 기록하면, 재시작 때문에 같은 출력을 두 번 구매하는 틈이 남습니다.

멱등성 문서가 추가로 정하며 큐가 지켜야 하는 내용은 다음과 같습니다.

  • 키는 처음 접수된 요청으로부터 24시간 유효하며, 리플레이로 연장되지 않습니다. 그 이후에는 같은 키로 과금되는 새 작업이 만들어집니다. 큐나 데드 레터에 남은 오래된 항목을 다시 실행하기 전에, 키를 믿지 말고 저장된 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)))

배포는 운영 환경 연동 문서에서 다룹니다. 이 예제는 잘못된 행을 분리하고, 잔액 문제나 불확실한 오류가 나면 새 수용을 일시 중지하며, 일시적인 재시도에 상한을 둡니다. 수용을 중지한 뒤에도 이미 접수된 작업은 계속 수거됩니다. 유료 자동 재시도는 의도적으로 하지 않습니다. 실패한 행은 정산 이후 명시적으로 다시 시도할 수 있도록 기록된 채 남습니다. 이런 프로세스를 미디어 종류별로 하나씩 실행하고, 파일 앞단에 테넌트 라운드 로빈을 두세요.

공정한 스케줄링: 작은 테넌트 보호하기

선입선출은 작업에는 공정하지만 고객에게는 공정하지 않습니다. 테넌트 B가 10건을 큐에 넣기 전에 테넌트 A가 1분에 100,000건을 넣으면, B는 A의 모든 항목 뒤에서 기다리게 됩니다. 테넌트별로 큐를 두고, 한 바퀴마다 테넌트당 한 건씩 꺼냅니다.

1라운드: A1, B1, C1

2라운드: A2, B2, C2

가중치로 구매한 용량을 반영할 수 있지만, 활성 상태인 모든 테넌트에게 최소 몫을 보장하세요. 테넌트별 진행 중 작업 수도 제한합니다. 전체 상한은 계정을 보호하고, 테넌트 상한은 시끄러운 이웃을 가둡니다. 한 테넌트 안에서는 우선순위 레인(상호작용형 미리보기, 일반 배치, 백필)은 미리보기 지연이 실제 불만이 된 뒤에야 추가할 가치가 있습니다. 추가한다면 우선순위가 큐 대기 시간에 따라 높아지게(에이징) 하세요. 그렇지 않으면 미리보기가 끊임없이 들어와 백필이 영원히 굶게 됩니다. 한 계정의 모든 작업은 같은 두 개의 요청 버킷과 같은 잔액을 공유하므로, 테넌트 간 공정성은 API가 아니라 여러분의 몫입니다.

재시도 전에 실패를 분류하세요

"세 번 재시도"는 정책이 아닙니다. 올바른 조치는 무엇을 알고 있는지, 그리고 작업이 이미 존재할 수 있는지에 달려 있습니다. 이 표는 문서화된 코드를 사용합니다. code 값은 응답 본문에 있고, 작업 실패는 state: "failed"와 errorCode로, 성공한 recordInfo 호출(code: 200) 안에서 도착합니다.

위치신호재시도?조치
생성타임아웃, 끊어진 연결, 읽을 수 없는 본문예결과를 알 수 없습니다. 같은 키, 같은 API 키, 지터를 넣은 백오프
생성429(Retry-After 있음)예지시된 초만큼 기다리고, 지터를 더하고, 디스패치 속도를 낮춥니다. 너무 빨랐다는 증거로 해석하지 마세요
업로드429(Retry-After 없음, 대기 중인 업로드)즉시 재시도 안 함대기 중인 업로드를 끝내거나 해당 티켓이 만료되기를 기다린 뒤에 더 요청하세요
생성500, 503, 50301(모델 사용 불가)예, 제한적으로기한을 둔 백오프. 50301은 활성 가격이 없다는 뜻일 수도 있으니 카탈로그를 새로 고치세요
생성409 같은 키 경합한 번몇백 ms 뒤에 같은 키로. 다른 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새 리비전참조 파일이 사라졌습니다. 다시 업로드하고, 입력을 갱신하고, 새 작업의 가격을 산정하세요
로컬콜백은 받았지만 내 트랜잭션이 실패예생성이 아니라 내 핸들러를 재시도합니다. 전송 ID 또는 taskId로 중복을 제거하세요

워커가 일제히 재시도하지 않도록 무작위 백오프를 쓰세요.

확정된 실패와 거절된 입력은 분류와 안전한 리플레이 조치를 함께 달아 데드 레터 큐로 옮깁니다. 생성 여부가 모호한 건이나 해결되지 않은 접수 작업은 저장된 키와 작업 ID를 붙여 조정 큐에 둡니다. 로컬의 재시도 기한이 지났다고 해서 실패했다는 증거가 되지는 않습니다. 실패한 작업은 보통 홀드를 전액 해제하지만, 과금되는 일부 content_rejected 결과는 견적 금액으로 정산됩니다. 환불을 가정하지 말고 settled와 cost를 읽으세요.

비용 보호는 스케줄러의 몫입니다

동시성 제한은 기계를 지킬 뿐 돈을 지키지는 않습니다. 완벽하게 안정적인 워커도 감당할 수 없는 배치를 디스패치할 수 있습니다. 디스패치 전에 예상 비용을 모든 한도와 대조하세요.

허용 = 테넌트의 일일 잔여

∩ 테넌트의 월간 잔여

∩ 프로젝트 배치의 잔여

∩ 전체 시간당 잔여

실제 정산과 대조해 조정합니다. createTask는 홀드된 estimatedCost를 반환하고, 성공한 작업은 그 금액 이하로 정산되며, settled: true와 cost가 최종 청구액입니다. API가 거절한 항목은 자체 예약을 해제하고, 접수된 항목은 settled가 바뀔 때까지 예약을 유지합니다. HTTP 타임아웃만 보고 추측하지 마세요.

유용한 보호 장치는 다음과 같습니다.

  • 큰 배치가 시작되기 전에 사람에게 보여 주는, 배치당 최대 예상 비용
  • 테넌트별 시간당 디스패치 예산과 전체 지출 속도 알람
  • 모델별 또는 테넌트별 서킷 브레이커
  • 계정 잔액보다 낮게 설정한 API 키 상한과 잔액 부족 알림
  • 완료 처리는 계속 돌리면서 새 디스패치를 멈추는 킬 스위치

접수된 작업은 취소할 수 없으므로, 생성을 멈추는 것이 유일한 브레이크입니다. 비용 사고에서 피해를 제한하는 것은 취소 버튼이 아니라 진행 중 작업 수의 상한입니다. Webhook 수신, 오래된 작업의 폴링, 예약 정산, 출력 복사는 계속하세요.

배치 규모의 완료 처리

작업별 callBackUrl 전송은 15초 뒤에 타임아웃됩니다. 등록된 계정 엔드포인트도 자체 전송을 받을 수 있으므로, 수신 측은 원시 본문에 대해 서명을 검증하고, 이벤트를 inbox 테이블에 기록하고, 2xx를 반환한 뒤 나머지는 워커에 맡겨야 합니다. v2 전송의 request_id로 중복을 제거하고, 도착 순서가 아니라 data.state로 판단하세요. 놓친 이벤트에 대비한 예비로 느린 폴링도 유지합니다(Webhook 문서 참고).

작업이 끝나는 대로 출력을 자체 저장소로 복사하세요. 결과 링크는 약 20분간 유효하고 생성된 미디어는 길어야 약 14일간 보관됩니다(보존 기간). 초당 5건이 완료되는 경우 복사 단계도 처리량의 일부이며, 느린 업로더는 "완료된" 작업 속에 숨어 있는 병목이 됩니다. 보존 기간을 줄이면 이미 실행한 작업의 결과가 지워질 수도 있습니다.

오류만이 아니라 흐름을 관측하세요

쓸모 있는 대시보드는 네 가지 질문에 답합니다. 작업을 받고 있는가, 시작되고 있는가, 끝나고 있는가, 비용은 예상대로 정산되고 있는가. 모델, 미디어 종류, 테넌트 등급, 결과별로 추적하세요.

신호중요한 이유
가장 오래된 실행 가능 항목의 나이고객 대기와 백프레셔 실패를 직접 보여 주는 지표
큐, 진행 중, 종료 상태의 건수작업이 어디에 쌓이는지 보여 줍니다
초당 접수된 작업 vs. 시도한 생성실제 처리량과 재시도를 구분합니다
작업 소요 시간 p50/p95/p99(completedAt - createdAt)동시성 산정과 정체된 작업 감지의 근거가 됩니다
종료 상태 결과와 errorCode 구성모델 또는 입력에 특화된 성능 저하
X-RateLimit-Remaining429가 나기 전에 경고합니다
홀드된 잔액 vs. 사용 가능한 잔액(GET /api/v1/chat/credit)배치가 멈추기 전에 40201을 예측합니다
예약된 비용 vs. 정산된 비용, 테넌트별 시간당 지출해제되지 않은 예약과 폭주한 배치
Webhook 지연과 폴링 복구완료 경로의 건강 상태

상관관계 체인(batchId, 항목 ID, 멱등성 키, taskId, request_id) 하나를 로그와 이벤트 전체에 걸쳐 유지하고, 프롬프트, API 키, 비공개 에셋 URL은 지표 레이블과 일반 로그에 넣지 마세요. 알람은 증상에 거세요. "큐에 50,000건"은 정상일 수 있지만, "가장 오래된 상호작용형 항목이 12분 경과"는 바로 조치할 수 있는 신호입니다.

제한된 배치로 시작하고, 그다음 용량을 늘리세요

가장 오래된 항목의 나이, 작업 실패, 지출이 임계값 안에 있는 동안만 용량을 늘리세요. 워커를 더 늘려도 과부하가 걸린 완료 경로는 고칠 수 없습니다.

첫 유료 배치 전에, 목(mock)을 써서 제출 직전과 직후에 워커를 중단하고 429, 실패한 작업, 지출 상한 오류를 반환하게 하세요. 복구는 저장된 intent를 재사용하고, 알 수 없는 작업은 해결하지 않은 채 두며, 완료 처리는 멈추지 않고 새 디스패치만 멈춰야 합니다. 그런 다음 큰 가져오기에 수용을 열기 전에 승인된 작은 배치를 실행하세요.

완료 처리는 "Polling vs webhooks for async generation jobs"를, 배치 예산은 Per-unit pricing vs subscriptions를 참고하세요. 운영 체크리스트는 키 설정과 출시 전 테스트를 다룹니다.

FAQ

SpicyAPI에 배치 엔드포인트나 동시성 제한이 있나요?

배치 엔드포인트는 없으며 각 행이 createTask 한 번입니다. 진행 중 작업 수의 상한도 문서화되어 있지 않습니다(한도는 위의 표에 있고, 현재 속도는 X-RateLimit-Limit에 있습니다). 피크 때 요청 제한이 너무 낮다면, 초당 피크 요청 수와 배치 크기를 설명해 콘솔 지원팀에 더 높은 한도를 요청하세요.

실수로 시작한 배치를 취소할 수 있나요?

아니요. 완료 처리는 계속 돌리면서 새 작업만 멈추려면 디스패치 킬 스위치를 쓰세요.

429가 재시도 횟수를 소모해야 하나요?

Retry-After에 지터를 더해 기다리고 같은 멱등성 키를 유지하세요. 제한이 반복되면 디스패치 속도를 낮춰야 합니다. 재시도는 횟수와 시간 모두로 제한하세요. 그 예산을 다 썼는데도 접수 여부가 불확실하다면, 대체 작업을 만들기 전에 저장된 intent를 조정하세요.

타임아웃 뒤 이중 청구를 막으려면 어떻게 하나요?

24시간 이내에 같은 Idempotency-Key와 같은 API 키로 다시 보내고, taskId는 받는 즉시 저장하세요. 24시간이 지났다면 대신 저장된 taskId를 조회하세요.

큐만으로 과부하를 막을 수 있나요?

아니요. 큐는 과부하를 저장할 뿐이고, 이를 통제하는 것은 백프레셔입니다. 큐 대기 시간에 대한 수용 임계값, 진행 중 작업 수 상한, 약속한 대기 시간을 지킬 수 없을 때의 제품 차원의 답변이 여전히 필요합니다.

좋은 시작 동시성은 얼마인가요?

미디어 종류별로 초당 완료 수에 평균 작업 시간을 곱하고 약 25 %의 여유를 더합니다. 초당 이미지 5장을 장당 8초로 처리한다면 40입니다. 큐 대기 시간, 오류, 지출이 정상 범위에 있는 동안만 올리세요.

우선순위가 높은 작업은 제한을 우회할 수 있나요?

아니요. 우선순위는 수용 가능한 작업 가운데에서 고르는 것일 뿐, 전체 상한이나 예산을 우회하지 않습니다. 상호작용형 작업에는 예약된 용량을 주고, 우선순위가 낮은 작업은 에이징시켜 굶지 않게 하세요.

Agent에게 호출 맡기기

입력 준비와 결과 반환은 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에게 같은 워크플로를 실행하고 승인을 위해 멈추라고 요청합니다. Skill은 설정된 CLI 또는 MCP 연결을 사용합니다.

다른 언어 English · 日本語 · Deutsch · Français · Español · Português (Brasil) · Русский · Italiano · Polski

솔루션

영상 생성 API: 비교하고, 시험하고, 연동하기길이·해상도·오디오·가격으로 영상 엔드포인트를 비교한 뒤, 같은 요청을 서버에서 보내고 작업 ID로 추적하세요.

직접 해 보기

실행한 만큼만 결제하세요. 요금제도, 구독도 없습니다.

사용 모델: MiniMax H3 · ByteDance Seedance 2.0 Fast