WaveSpeed 批量生成:自信地每天運行 1,000+ 張圖像請求
WaveSpeed 批量圖像生成的生產指南。掌握隊列、重試、並發和監控,每天運行 1,000+ 個請求而不會失敗。

你好,各位!我是 Dora。這一切始於一個小煩惱:我需要為測試準備幾百個變體圖像,而我常用的單次請求迴圈就像推著一輛輪子卡住的購物車。我一直聽說 WaveSpeed 上的批次生成可以處理大量工作。我不需要花俏的功能,只是想讓工作輕鬆一點。
所以在十二月末和本週的幾次會議中,我在 WaveSpeed 上設置了一個簡單的批次管道,並要求它運行超過 1,000 個圖像請求。沒有英雄氣概,只是穩定的輸送量、清晰的狀態和乾淨的重試。以下是對我有效的方式、遇到的障礙,以及讓成本和錯誤不在我不注意時增加的小選擇。
批次架構概述
生成者/隊列/工作程序/存儲
我故意讓各部分保持簡單。一個小型生成者腳本收集提示詞和元數據,隊列保存作業,無狀態工作程序調用 WaveSpeed 圖像 API,存儲保存結果。每一部分都可以故障,而不會導致整個系統癱瘓。

- 生產者: 讀取包含提示詞和每張圖片設定(模型、尺寸、seed)的 CSV。每一行向佇列寫入一個作業,並附上穩定的作業 ID(輸入的雜湊值)。
- 隊列: 我曾經使用過 Redis Streams,也用過 RabbitMQ。兩者都有效。如果你還沒有運行其中任何一個,Redis 的起點更輕量。
- 工作程序: 容器化進程,拉取作業、調用 WaveSpeed、將結果寫入對象存儲(我使用了 S3)並更新狀態。它們是無狀態的,所以擴展是一個開關,而不是重建。
- 存儲: 一個用於圖像的桶,一個用於 JSON 元數據的桶。按日期和批次 ID 的簡單資料夾保持整潔。
讓我驚訝的是,當我從 100 張擴展到 1,200 張圖像時,代碼改動很少;大多數問題都是關於節奏和防止重複,而不是輸送量。
簡單架構圖
這是我心裡的圖:
生成者 → 隊列 → 工作程序 → WaveSpeed API → 存儲
↓ ↑
狀態數據庫 ←────────→ 指標/警報
- 狀態數據庫可以是相同的 Redis 或輕量級 Postgres 表。
- 指標在錯誤率或成本變得奇怪時觸發警報。
這不聰明。這就是重點。當 API 返回間歇性的 429/5xx 時,隊列會吸收它。當工作程序在運行中死亡時,另一個工作程序會在可見性超時後接管。
並發策略
安全的並行級別
在我的運行中,我從 5 個工作程序開始,每個都有 2 個進行中的請求。這給了我穩定的 8-10 張圖像/分鐘,而不會觸發限制。提升到 20 個並發請求短暫有效,但之後我看到重試激增。最好的設置不是最快的峰值,而是最平穩的平均值。
如果你想試試:找到使隊列不增長的最小工作程序數。然後逐漸增加。在改動任何東西之前,觀察 p95 延遲和錯誤率 10-15 分鐘。
速率限制意識
WaveSpeed 在文檔中發布速率限制指南,但限制因模型和帳戶而異。我添加了兩個防護措施:

- 客戶端令牌桶: 每個工作程序在調用 API 前獲取令牌。令牌按計劃的有效 RPS 補充。當我更改模型時,我調整了補充率。
- 退避紀律: 提交時的 429,以及輪詢時暫時性的 429/5xx 回應,都會觸發帶有抖動的指數退避,上限 30 秒(若有
Retry-After則遵循)。這避免了短暫故障後的請求湧入。
我還用模型 + 尺寸標記每個作業,以便在需要時為每個模型設置單獨的並發上限。這不是花哨的,只是一個小的 switch 語句,但它幫助避免了局部熱點。
重試和冪等性
避免重複圖像
我的第一批任務有一個隱蔽的 bug:某個 worker 在 API 呼叫返回之後、寫入儲存之前當機了。佇列重試了該作業,結果我為兩次相同的生成付了費。很不愉快。
為了避免這種情況,我把每個作業拆成兩步:提交任務,然後等待任務完成。提交用的 POST 會建立一個任務(並產生費用),所以 worker 會立即把回傳的任務 ID 儲存到作業記錄中。如果作業被再次領取且已經有任務 ID,worker 就跳過提交,只輪詢該任務。我還讓儲存路徑由作業 ID 確定性地產生,這樣重試時若發現圖片已經寫入,就直接短路並把作業標記為成功。成本很低的修正,卻讓人大大鬆了口氣。
冪等性鍵實現
我使用了正規化後的提示詞 + 模型 + seed + 尺寸 + guidance 的 SHA-256。這個雜湊值會成為:
- 佇列中的作業 ID
- 儲存檔名前綴
- 作業在資料庫中的主鍵
這個鍵是我自己的記帳方式,並不是 API 的功能。WaveSpeed 不會替你對提交請求去重:兩次提交會建立兩個任務,兩個都要付費。儲存在作業記錄中的任務 ID 和先檢查儲存的做法,才是避免重複付費的關鍵。
失敗作業恢復
並非每次失敗都值得重試,提交和輪詢遵循不同的規則:
- 提交: 只送出一次。僅當請求在任務建立之前就被明確拒絕時才重試,例如 429(退避後重新送出)。遇到 5xx、逾時或連線中斷時絕不重試:任務可能已經存在。應把作業標記為
needs_check,並在決定是否重新提交之前,先查看 WaveSpeed 控制台中的歷史記錄(History)。 - 輪詢: 用退避容忍暫時性的 429、500、502、503、504 和網路錯誤(遵循
Retry-After),然後持續輪詢,直到任務變為 completed、failed、cancelled、timeout 或 deleted。 - 不要重試: 因驗證錯誤、缺少參數或明顯錯誤的輸入導致的 4xx
429 之後的提交重試我限制為最多 5 次,並使用指數退避。之後作業連同錯誤內容進入死信佇列。我每天處理一次 DLQ 中的作業:有些修正輸入後重新入列,有些附上備註封存。needs_check 的作業由人工處理,每筆記錄的錯誤都會在有任務 ID 時帶上它。這使得 1,200 張圖片的一次執行中,整體失敗率維持在 1.5% 以下。
作業狀態管理
狀態:pending/running/success/failed
我嘗試過幾種狀態結構,最簡單的那種保留了下來:
- pending: 已入列,尚未被 worker 領取
- running: 已被 worker 領取,帶有租約到期時間
- success: 圖片和中繼資料已寫入,檢查通過
- failed: 終態,附帶錯誤碼、任務 ID 和最後一次嘗試的時間戳
- needs_check: 提交結果未知(5xx、逾時、無回應),因此任務可能已存在;等待人工檢查
我新增了一個 task_id 欄位,它讓恢復變得安全,另外還有兩個很有用的選用欄位:attempt_count 和 last_response_code。它們讓儀表板更易讀,也讓除錯少了很多猜測。
作業超時處理
有兩個逾時很重要:
- 租約逾時: 如果 worker 在執行中途掛掉,作業應在 N 秒後回到 pending。我用的是 120 秒。已儲存任務 ID 的作業被重新領取後會繼續輪詢;開始了提交但從未儲存任務 ID 的作業會進入
needs_check,而不是發起新的提交。 - 請求逾時: 如果 WaveSpeed 在 N 秒內沒有回應單一請求,就中止該請求。我用的是每次呼叫 60 秒。對輪詢來說,這只是又一個需要重試的暫時性錯誤;對提交來說,則意味著結果未知,所以作業進入
needs_check。任務本身沒有總時限:有些作業執行時間很長,所以我會一直輪詢到終態。
當 API 變慢時,這兩者可能互相衝突。為了避免重複工作,我只在租約過期且 worker 的心跳停止之後,才把 running 改回 pending。心跳只是每 10 秒更新一次 Redis 雜湊。如果心跳是新的,我就延長租約。
監控和警報

錯誤率追蹤
我在運行期間觀察了三個數字:
error_rate_5m:失敗嘗試的 5 分鐘滾動比例p95_latency:每個模型、每個尺寸retry_depth:有多少作業處於嘗試 ≥ 2 的狀態
如果 error_rate_5m > 5% 持續 10 分鐘,我自動將並發減半並向自己發送一條消息。大多數尖峰在 5 分鐘內穩定下來,無需手動調整。
成本尖峰警報
成本可能會增加。我記錄了:
- cost_per_image: WaveSpeed 報告的(如果可用),否則從計劃估計
- duplicate_prevented: 透過已儲存的任務 ID 恢復或被儲存檢查短路的作業數量
- total_estimated_cost: 累計
當 cost_per_image 相比上一小時的平均值跳升超過 30% 時,我暫停新作業進入並讓隊列清空。兩次,這在賬單漂移之前捕捉到了無意中的參數更改(更大的尺寸、不同的模型)。像這樣的安靜防護欄值得花代碼。
參考實現
Python 偽代碼
以下是我使用的形式。這不是完整的代碼,只是骨架:
# producer.py
for row in csv_rows:
key = hash_inputs(row) # stable job ID
queue.push({"id": key, "inputs": row})
# worker.py
while True:
job = queue.lease(timeout=120)
if not job:
sleep(1)
continue
try:
record_heartbeat(job.id)
# Step 1: submit once. A saved task ID means the submit already happened.
if not job.task_id:
job.task_id = submit_once(job)
save_task_id(job.id, job.task_id)
# Step 2: poll until a terminal status.
resp = poll_until_terminal(job.task_id)
path = storage_path(job.id, job.inputs)
if not storage.exists(path):
storage.write(path, resp.image)
storage.write(path + ".json", resp.metadata)
mark_success(job.id)
except SubmitUnconfirmed as e: # 5xx, timeout, dropped connection: a task may exist
mark_needs_check(job.id, e) # do NOT resubmit
except Fatal as e: # rejected input, failed/cancelled task, 429s exhausted
mark_failed(job.id, e, task_id=job.task_id)
finally:
queue.release(job)
def submit_once(job):
for attempt in range(5):
try:
return wavespeed.submit(job.model, job.inputs, timeout=60).task_id # one POST
except RateLimited as e: # 429: rejected, no task was created
backoff_sleep(attempt, e.retry_after)
# 5xx / timeout / no response raises SubmitUnconfirmed and is never retried
raise Fatal("rate limited on submit")
def poll_until_terminal(task_id):
attempt = 0
while True:
try:
r = wavespeed.get_result(task_id, timeout=60)
except TransientError as e: # 429/500/502/503/504 or network error
attempt += 1
backoff_sleep(attempt, e.retry_after)
continue
if r.status == "completed":
return r
if r.status in ("failed", "cancelled", "timeout", "deleted"):
raise Fatal(f"task {task_id} {r.status}: {r.error}")
sleep(3)
Node.js 偽代碼
// producer.mjs
for (const row of rows) {
const key = hashInputs(row); // stable job ID
queue.push({ id: key, inputs: row });
}
// worker.mjs
async function submitOnce(job) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
const resp = await wavespeed.submit(job.model, job.inputs, { timeout: 60000 }); // one POST
return resp.taskId;
} catch (e) {
// 5xx / timeout / no response is a SubmitUnconfirmed error: never retried
if (!(e instanceof RateLimited)) throw e;
await backoffSleep(attempt, e.retryAfter); // 429: rejected, no task was created
}
}
throw new Fatal("rate limited on submit");
}
async function pollUntilTerminal(taskId) {
let attempt = 0;
while (true) {
let r;
try {
r = await wavespeed.getResult(taskId, { timeout: 60000 });
} catch (e) {
if (!(e instanceof TransientError)) throw e; // 429/500/502/503/504 or network error
await backoffSleep(++attempt, e.retryAfter);
continue;
}
if (r.status === "completed") return r;
if (["failed", "cancelled", "timeout", "deleted"].includes(r.status)) {
throw new Fatal(`task ${taskId} ${r.status}: ${r.error}`);
}
await delay(3000);
}
}
while (true) {
const job = await queue.lease(120); // lease timeout in seconds
if (!job) {
await delay(1000);
continue;
}
try {
await heartbeat(job.id);
// A saved task ID means the submit already happened.
if (!job.taskId) {
job.taskId = await submitOnce(job);
await saveTaskId(job.id, job.taskId);
}
const resp = await pollUntilTerminal(job.taskId);
const path = makePath(job.id, job.inputs);
if (!(await storage.exists(path))) {
await storage.write(path, resp.image);
await storage.write(path + '.json', resp.metadata);
}
await markSuccess(job.id);
} catch (e) {
if (e instanceof SubmitUnconfirmed) {
await markNeedsCheck(job.id, e); // a task may exist: do NOT resubmit
} else {
await markFailed(job.id, e, job.taskId);
}
} finally {
await queue.release(job);
}
}
配置建議
- 小規模開始: 5-10 個並發請求,然後逐漸增加。觀察
p95和error_rate_5m,而不只是輸送量。 - 每個模型單獨配置: 並發、超時和成本預期隨著模型和尺寸而改變。
- 處處防重複: 穩定的作業 ID、提交後立即儲存的任務 ID、確定性的儲存路徑,以及以同一個值為鍵的作業表。
- 心跳和租用: 它們聽起來很麻煩,但它們可以保護你免受虛幻重複。
- 簡單儀表板: 6-8 個面板就夠了 — 隊列長度、成功/分鐘、錯誤/分鐘、p95、重試深度和成本。
如果你已經在其他地方運行批次作業,這將感到熟悉。WaveSpeed 不需要重新思考,只需要幾個仔細的防護措施。那就是我想要的。

我的運行中的最後一個說明:最順利的批次是我幾乎沒有看管的。不是因為它是「設置後忘記」,而是因為系統告訴我什麼時候需要注意,什麼時候保持安靜。那感覺像是正確的速度。
你呢?你最近在用 WaveSpeed 批量處理圖像嗎?你的並發甜蜜點是多少(我現在一直在 8-10 左右)?或者你遇到過任何隱蔽的漏洞(例如重複收費)?請在評論中分享你的設置、陷阱或提示!
/filters:quality(82)/media/images/1790879706333726948_TmjHVhrF.webp)
/filters:quality(82)/media/images/1790903936921698253_6vhqAJT2.webp)
/filters:quality(82)/media/images/1790881558547248482_yDNV6fox.webp)
/filters:quality(82)/media/images/1773962750383987480_n3hqzHRZ.webp)