批量跑 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 之间的对应关系。请保留这份记录、继续跑已有的任务,并把下载重试与生成分开处理。当一个小规模的集合表现符合预期后,再在一个你能监控的预算内扩大工作量——预算方法见价格指南。



