Génération en lot sur WaveSpeed : Exécutez 1 000+ demandes d'images quotidiennes en toute confiance
Bonjour à tous ! Je m’appelle Dora. Tout a commencé par une petite contrariété : j’avais besoin de quelques centaines d’images variantes pour un test, et ma boucle habituelle de requêtes uniques me donnait l’impression de pousser un chariot avec une roue bloquée. J’entendais régulièrement dire que la Génération par lots sur WaveSpeed pouvait gérer des volumes importants. Je ne cherchais pas l’extraordinaire. Je voulais juste que le travail soit plus léger.
Alors, au fil de quelques sessions fin décembre et à nouveau cette semaine, j’ai mis en place un simple pipeline de lot sur WaveSpeed et je lui ai demandé de traiter plus de 1 000 requêtes d’images. Rien d’héroïque, juste un débit régulier, des états clairs et des tentatives propres. Voici la structure qui a fonctionné pour moi, les parties qui ont posé problème et les petits choix qui ont empêché les coûts et les erreurs de s’envoler pendant que mon attention était ailleurs.
Aperçu de l’architecture du lot
Producteur / File d’attente / Travailleur / Stockage
J’ai volontairement gardé les pièces simples. Un petit script producteur collecte les invites et les métadonnées, une file d’attente retient les tâches, des travailleurs sans état appellent l’API d’images WaveSpeed, et le stockage récupère les résultats. Chaque partie peut échouer sans faire tomber tout le système.

- Producteur : Lit un CSV d’invites et de paramètres par image (modèle, taille, seed). Il écrit une tâche par ligne dans la file d’attente avec une clé d’idempotence et une date limite souple.
- File d’attente : J’ai utilisé Redis Streams une fois et RabbitMQ une autre fois. Les deux ont fonctionné. Si vous n’en exécutez pas déjà, Redis est plus léger pour commencer.
- Travailleurs : Processus conteneurisés qui récupèrent les tâches, appellent WaveSpeed, écrivent les résultats dans le stockage d’objets (j’ai utilisé S3) et mettent à jour l’état. Ils sont sans état, donc la mise à l’échelle est un bouton, pas une reconstruction.
- Stockage : Un compartiment pour les images, un pour les métadonnées JSON. Des dossiers simples par date et ID de lot gardent tout organisé.
Ce qui m’a surprise, c’est combien peu de code a changé quand j’ai augmenté de 100 à 1 200 images ; la plupart des problèmes concernaient le rythme et la protection contre les doublons, pas le débit.
Diagramme d’architecture simple
Voici l’image que je gardais en tête :
Producteur → File d'attente → Travailleurs → API WaveSpeed → Stockage
↓ ↑
BD d'état ←────────→ Métriques/Alertes
- La BD d’état peut être le même Redis ou une table Postgres légère.
- Les Métriques alimentent les alertes quand les taux d’erreur ou les coûts deviennent bizarres.
Ce n’est pas compliqué. C’est le but. Quand l’API retourne des 429/5xx intermittents, la file d’attente les absorbe. Quand un travailleur meurt en cours d’exécution, un autre la reprend après l’expiration de la visibilité.
Stratégie de concurrence
Niveaux de parallélisme sûr
Dans mes exécutions, j’ai commencé avec 5 travailleurs, chacun faisant 2 requêtes en vol. Cela m’a donné un régime stable de 8–10 images/minute sans déclencher de limites. Passer à 20 requêtes simultanées a fonctionné brièvement, puis j’ai vu les tentatives monter en flèche. Le meilleur réglage n’était pas le pic le plus rapide, mais la moyenne la plus plate.
Si vous essayez ceci : trouvez le plus petit nombre de travailleurs qui empêche votre file d’attente de croître. Puis montez graduellement. Observez la latence p95 et les taux d’erreur pendant 10–15 minutes avant de toucher à quoi que ce soit.
Sensibilisation aux limites de débit
WaveSpeed publie des conseils sur les limites de débit dans la documentation, mais les limites varient encore selon le modèle et le compte. J’ai ajouté deux garde-fous :

- Seau de jetons côté client : Chaque travailleur acquiert un jeton avant d’appeler l’API. Les jetons se reconstituent au RPS effectif du plan. Quand j’ai changé de modèle, j’ai ajusté la reconstitution.
- Discipline de backoff : Les 429 et 5xx déclenchent un backoff exponentiel avec jitter, plafonné à 30 secondes. Cela a empêché les ruées après les courtes pannes.
J’ai également marqué chaque tâche avec modèle + taille pour pouvoir définir des plafonds de concurrence distincts par modèle si nécessaire. Ce n’est pas sophistiqué, juste une petite déclaration switch, mais cela a aidé à éviter les points chauds localisés.
Tentatives et idempotence
Éviter les images dupliquées
Mon premier lot avait un bug silencieux : un travailleur s’est écrasé après que l’appel API ait retourné, avant qu’il n’écrive au stockage. La file d’attente a réessayé la tâche et j’ai fini par payer pour deux générations identiques. Pas agréable.
Pour arrêter cela, j’ai rendu le chemin de stockage déterministe à partir de la clé d’idempotence et du hash d’entrée. Si une tentative trouve l’image déjà écrite, elle court-circuite et marque simplement la tâche comme réussie. Correction bon marché, grand soulagement.
Implémentation de la clé d’idempotence
J’ai utilisé un SHA-256 de l’invite normalisée + modèle + seed + taille + guidance. Ce hash devient :
- la clé d’idempotence de l’API (envoyée dans un en-tête ou un champ de charge utile, selon le SDK)
- le préfixe du nom de fichier de stockage
- la clé primaire de la base de données pour la tâche
Si l’API de WaveSpeed respecte les clés d’idempotence (vérifiez la documentation pour votre point de terminaison), les appels répétés avec la même clé retournent le même résultat sans frais supplémentaires. Sinon, la vérification stockage-premier empêche toujours les doublons pour lesquels vous paieriez deux fois.
Récupération des tâches échouées
Chaque échec ne mérite pas une nouvelle tentative. Ma règle générale :
- Réessayer : 429, 5xx, délais d’expiration réseau, « modèle occupé », ou erreurs de stockage transitoires
- Ne pas réessayer : 4xx avec erreurs de validation, paramètres manquants, ou entrée clairement mauvaise
Je plafonne les tentatives à 5 avec un backoff exponentiel. Après cela, la tâche atterrit dans une file d’attente des lettres mortes avec la charge utile d’erreur. Une fois par jour, j’effectue un triage des tâches DLQ : certaines reçoivent une entrée corrigée et sont remises en file d’attente, d’autres sont archivées avec une note. Cela a maintenu mon taux d’échec global en dessous de 1,5 % pour une exécution de 1 200 images.
Gestion de l’état des tâches
Statut : pending/running/success/failed
J’ai essayé quelques formes d’état. La plus simple a collé :
- pending : en file d’attente, pas encore accordée par un travailleur
- running : accordée par un travailleur avec une expiration de bail
- success : image et métadonnées écrites, vérifications réussies
- failed : terminal, avec code d’erreur et timestamp de la dernière tentative
J’ai ajouté deux champs optionnels qui se sont avérés payants : attempt_count et last_response_code. Ils ont rendu les tableaux de bord plus lisibles et le débogage moins devinette.
Gestion du délai d’expiration de la tâche
Deux délais d’expiration importants :
- Délai d’expiration du bail : Si un travailleur meurt en cours d’exécution, la tâche doit retourner à pending après N secondes. J’ai utilisé 120s.
- Délai d’expiration de l’API : Si WaveSpeed ne répond pas dans N secondes, abandonnez et réessayez avec un backoff. J’ai utilisé 60s par appel.
Quand l’API est lente, ces deux peuvent être en conflit. Pour éviter les doublons, je ne marque running → pending qu’après l’expiration du bail et l’arrêt du battement de cœur d’un travailleur. Les battements de cœur n’étaient qu’une mise à jour de hash Redis toutes les 10 secondes. Si le battement de cœur est frais, j’étends le bail.
Surveillance et alertes

Suivi du taux d’erreur
J’ai observé trois chiffres pendant les exécutions :
error_rate_5m: proportion roulante sur 5 minutes des tentatives échouéesp95_latency: par modèle, par tailleretry_depth: combien de tâches sont à tentative ≥ 2
Si error_rate_5m > 5% pendant 10 minutes, j’ai automatiquement réduit de moitié la concurrence et me suis envoyé une note. La plupart des pics se sont résorbés en cinq minutes sans bidouille manuelle.
Alertes de pics de coûts
Les coûts peuvent s’insinuer. J’ai enregistré :
- cost_per_image : signalé par WaveSpeed si disponible, sinon estimé à partir du plan
- duplicate_prevented : nombre de court-circuits de stockage
- total_estimated_cost : cumulatif
Quand cost_per_image a grimpé de plus de 30% par rapport à la moyenne de la dernière heure, j’ai suspendu la prise de nouvelles tâches et laissé la file d’attente se vider. Deux fois, cela a attrapé des changements de paramètres involontaires (tailles plus grandes, modèle différent) avant que la facture ne dérive. Des garde-fous silencieux comme celui-ci valent bien leurs quelques lignes de code.
Implémentation de référence
Pseudo-code Python Voici la forme que j’ai utilisée. Ce n’est pas du code complet, juste le squelette :
# producer.py
for row in csv_rows:
key = hash_inputs(row)
job = { "id": key, "inputs": row, "deadline": now+6*3600 }
queue.push(job)
# worker.py
while True:
job = queue.lease(timeout=120)
if not job:
sleep(1)
continue
try:
record_heartbeat(job.id)
resp = wavespeed.generate_image(inputs=job.inputs, idempotency_key=job.id, timeout=60)
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 Retryable as e:
mark_retry(job.id, e)
backoff_sleep(job.attempt)
except Fatal as e:
mark_failed(job.id, e)
finally:
queue.release(job)
Pseudo-code Node.js
// producer.mjs
for (const row of rows) {
const key = hashInputs(row);
queue.push({ id: key, inputs: row, deadline: Date.now() + 6 * 3600e3 }); // 6 hours
}
// worker.mjs
while (true) {
const job = await queue.lease(120); // lease timeout in seconds
if (!job) { // 原来的 ".job" 改为 "!job"
await delay(1000);
continue;
}
try {
await heartbeat(job.id);
const resp = await wavespeed.generateImage(
{ ...job.inputs, idempotencyKey: job.id },
{ timeout: 60000 } // 60 seconds
);
const path = makePath(job.id, job.inputs);
if (!(await storage.exists(path))) { // 原来的 ".(await ...)" 修正
await storage.write(path, resp.image);
await storage.write(path + '.json', resp.metadata);
}
await markSuccess(job.id);
} catch (e) {
if (isRetryable(e)) {
await markRetry(job.id, e);
} else {
await markFailed(job.id, e);
}
} finally {
await queue.release(job);
}
}
Recommandations de configuration
- Commencez petit : 5–10 requêtes simultanées, puis montez graduellement. Observez
p95eterror_rate_5m, pas juste le débit. - Configurations séparées par modèle : la concurrence, le délai d’expiration et les attentes de coût changent avec le modèle et la taille.
- Idempotence partout : clé dans la requête, chemin de stockage déterministe, et une table de tâches clé par la même valeur.
- Battements de cœur et baux : ils sonnent pointilleux, mais ils vous sauvent des doublons fantômes.
- Tableaux de bord simples : 6–8 panneaux suffisent — longueur de la file d’attente, succès/min, erreurs/min, p95, profondeur de tentative et coûts.
Si vous exécutez déjà des tâches par lots ailleurs, cela vous semblera familier. WaveSpeed n’a pas nécessité une refonte, juste quelques garde-fous soignés. C’est ce que je voulais.

Une dernière note de mes exécutions : les lots les plus fluides étaient ceux que je regardais à peine. Non pas parce que c’était « réglé et oublié », mais parce que le système me disait quand il avait besoin d’attention et restait silencieux quand ce n’était pas le cas. Cela semble être la bonne sorte de vitesse.
Et vous ? Avez-vous traité des images par lots avec WaveSpeed récemment ? Quel est votre point optimal pour la concurrence (je suis régulièrement autour de 8–10 en ce moment) ? Ou avez-vous rencontré des bugs sournois (comme des frais dupliqués) ? N’hésitez pas à partager votre configuration, vos pièges ou vos conseils dans les commentaires !





