批次執行 GPT Image 2 請求而不遺失任務
提交一組 GPT Image 2 提示詞,儲存任務 ID,中斷後可繼續輪詢,並在不重複提交的前提下下載已完成的影像。
以 Markdown 閱讀在 SeedRouter 上做 GPT Image 2 批次生成,指的是提交一組普通的影像請求,並追蹤每一個回傳的任務 ID。本文用的是一個很小的用戶端腳本,而不是某個獨立的 Batch API 或批次折扣產品。每條提示詞都是一個獨立任務;腳本會儲存進度,這樣在中斷之後你還能再去查詢這些任務。
這裡其實是兩件互相獨立的事:生成影像,和下載影像。下載失敗並不需要重新生成一次;輪詢到了截止時間,也不代表影像任務失敗了。把這些區分寫進腳本裡,既省事又不容易搞混。
開始之前需要準備什麼?
需要 Node.js 22 或更新版本、一個位於伺服器端的 SEEDROUTER_API_KEY 環境變數,以及夠跑完這組測試的帳號餘額。先從兩條提示詞開始。在增加出圖數量之前,請先查看目前價格。
把下面的程式碼儲存為私有工作目錄下的 images.mjs。它會在該目錄建立 image-tasks.json,並把 PNG 檔案下載到 images/ 裡。請保留這個狀態檔:正是它把你本機的標籤和已受理的任務連在一起。不要把金鑰或涉及客戶敏感資訊的提示詞提交到公開儲存庫。
範例中每條提示詞只請求一張圖。如果你想讓同一條提示詞產出多張結果,請使用 n 參數,不要把它和「提交多條不同的提示詞」混為一談。SeedRouter 在模型參考文件裡說明了取值範圍和其他限制。
先提交一次,然後儲存任務 ID
腳本會在送出請求之前先寫入一筆 unconfirmed 記錄。這樣即便行程在儲存回應之前就中止了,下一次執行也不會悄悄把這一項再提交一遍。在決定如何處理一筆 unconfirmed 記錄之前,請先到帳號裡查看該任務的實際狀態。
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
const mode = process.argv[2];
if (!['submit', 'poll'].includes(mode)) {
throw new Error('Use: node images.mjs submit | poll');
}
const key = process.env.SEEDROUTER_API_KEY;
if (!key) throw new Error('Set SEEDROUTER_API_KEY on your server.');
const base = 'https://api.seedrouter.ai/v1';
const headers = { Authorization: `Bearer ${key}` };
const file = 'image-tasks.json';
let state;
try {
state = JSON.parse(await readFile(file, 'utf8'));
} catch (error) {
if (error.code !== 'ENOENT') throw error;
state = {};
}
const save = async () => {
await writeFile(`${file}.tmp`, JSON.stringify(state, null, 2), { mode: 0o600 });
await rename(`${file}.tmp`, file);
};
const pause = () => new Promise((resolve) => setTimeout(resolve, 3000));
const jobs = [
['blue-mug', 'A cobalt-blue ceramic mug on a pale gray tabletop.'],
['green-bowl', 'A green ceramic bowl on a pale gray tabletop.'],
];
if (mode === 'submit') {
for (const [name, prompt] of jobs) {
if (state[name]) continue;
state[name] = { status: 'unconfirmed' };
await save();
try {
const response = await fetch(`${base}/images/generations`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-image-2', prompt,
size: '1024x1024', quality: 'low', output_format: 'png', n: 1,
}),
signal: AbortSignal.timeout(60000),
});
const task = await response.json();
if (typeof task.id === 'string' && task.id) {
state[name] = { id: task.id, status: 'processing' };
await save();
}
if (!response.ok || !state[name].id) {
throw new Error('Submission needs review.');
}
} catch {
console.error(`${name}: stopped; inspect the saved state before continuing.`);
process.exitCode = 1;
break;
}
}
} else {
await mkdir('images', { recursive: true });
const deadline = Date.now() + 600000;
do {
for (const [name, entry] of Object.entries(state)) {
if (!entry.id || entry.status === 'failed' || entry.downloaded) continue;
try {
const response = await fetch(`${base}/tasks/${encodeURIComponent(entry.id)}`, {
headers, signal: AbortSignal.timeout(30000),
});
if (!response.ok) throw new Error('Task check failed.');
const task = await response.json();
if (!['processing', 'completed', 'failed'].includes(task.status)) {
throw new Error('Unexpected task status.');
}
entry.status = task.status;
await save();
if (task.status !== 'completed') continue;
const images = task.output?.data;
if (!Array.isArray(images) || !images.length) {
throw new Error('Completed task has no image URLs.');
}
for (const [index, image] of images.entries()) {
const url = new URL(image.url);
if (url.protocol !== 'https:') throw new Error('Expected an HTTPS image URL.');
// The download is a separate request: never attach the API key.
const result = await fetch(url, { signal: AbortSignal.timeout(60000) });
if (!result.ok || !result.headers.get('content-type')?.startsWith('image/png')) {
throw new Error('PNG download failed.');
}
await writeFile(`images/${name}-${index}.png`, Buffer.from(await result.arrayBuffer()));
}
entry.downloaded = true;
await save();
} catch {
console.error(`${name}: check or download incomplete; task ID retained.`);
}
}
if (!Object.values(state).some((entry) => entry.id && entry.status !== 'failed' && !entry.downloaded)) break;
await pause();
} while (Date.now() < deadline);
}
console.log(JSON.stringify(state, null, 2));先執行 node images.mjs submit,再執行 node images.mjs poll。同一時間只跑一份腳本,也不要去改狀態檔裡的標籤名。這只是一個很小的本機範例,不是多行程的儲存方案。如果你的服務有多個應用程式實例,請改用持久化儲存,並讓每一次提交都有明確的歸屬,而不是共用這個 JSON 檔。
繼續輪詢,而不是重新發起生成
本機逾時或網路中斷之後,再執行一次 node images.mjs poll。它會讀取已儲存的任務 ID,用 GET 請求去查詢它們,並不會再送一次生成請求。對於已完成但下載沒做完的任務,它會重新檢查,並把 PNG 檔案下載到同樣確定的檔名上。
那個十分鐘的截止時間只屬於這個範例腳本,它既不是承諾的完成時間,也不是伺服器端的取消操作。某個正在進行中的網路請求,完全可能在迴圈截止之後才結束。真正的狀態定義見任務生命週期參考:processing、completed 和 failed。
unconfirmed 的項目需要人工確認,因為腳本手上沒有儲存下來的任務 ID。不要只是為了「看看第二次會不會成功」就刪掉這筆記錄重新提交。請先查看帳號裡的任務歷史:如果找回了它的 ID,就把 ID 填進這筆記錄然後走輪詢;否則請先把這次不確定的提交弄清楚,再決定要不要重新生成。
並行度該開到多大?
像這個範例一樣,先用循序提交和一個小規模的測試組開始。已受理的任務會在腳本提交下一條時繼續處理,但請求不會毫無節制地一次全部送出。這段程式碼並不意味著存在某個放諸四海皆準的安全並行數。
在更大的應用程式裡,請把「未完成任務的上限」做成一個明確的設定。達到上限時就停止接納新工作,同時繼續查詢已受理的任務。遇到帳號層面的限制,請依文件中的錯誤處理指引來因應,而不是盲目地重複送 POST 請求。
對那些需要重新創作的結果,請單獨記錄。一張你不滿意的成品圖,並不是一個失敗的 API 任務——那是一次新的創作判斷;如果你為此再提交一次請求,那就是一次需要計入預算的新生成。
宣布完成之前先核對這一組
把你輸入的標籤與狀態檔、已下載的檔案逐一對照。每一個標籤都應該有一個明確的去向:已下載、仍在處理、已失敗,或是等待人工確認提交結果。只數檔案個數,可能會掩蓋一個沒跑完的任務,或者一次中途停掉的下載。
另外,請把圖片真正打開看一看。腳本會檢查 HTTP 狀態碼和 PNG 內容類型,但它判斷不了馬克杯是不是你要的顏色、標籤上的字是否清晰可讀。視覺需求要和「傳輸成功」分開來驗收。
如果你改了 output_format,請把期望的內容類型和副檔名一起改。把下載到的 JPEG 重新命名為 .png 並不會真的轉換格式。範例刻意固定使用 PNG,就是為了不讓格式處理這個額外問題,遮住任務復原這條主線。
常見問題
這是 OpenAI 的 Batch API 嗎?
不是。它只是用腳本提交普通的 SeedRouter 影像任務,既沒有使用 Batch API 端點,也不意味著存在特殊的批次價格。
可以只恢復下載嗎?
可以。帶著已儲存的狀態執行輪詢指令即可。它會重新取回已完成任務的輸出,把缺少的結果補下來,而不會新建影像任務。
一個任務失敗會影響其他任務嗎?
不會。輪詢迴圈會把那個任務記為失敗,然後繼續查詢其餘任務。失敗的那一項請單獨複查。失敗的任務不計費;而僅僅是本機逾時,並不能認定為失敗。
把任務記錄和影像放在一起
可靠的 GPT Image 2 批次生成,仰賴於保住每條提示詞與其任務 ID 之間的對應關係。請保留這份記錄、繼續跑既有的任務,並把下載重試與生成分開處理。當一個小規模的集合表現符合預期後,再在一個你能監控的預算內擴大工作量——預算方法見價格指南。



