Claude Opus 5.5 已在 SeedRouter 上线

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

相关指南