如何自动生成图像:模板、job 与 webhook
这个问题的大多数答案,是一张文本框的截图。敲一条 prompt,按一下按钮,等,下载,再来一遍。那不是自动化。那是您本人在手工干活,只不过更快。
自动意味着环里没有人。一行数据进来,一张图出去,没有人盯着它发生。这需要四个部件:一个带洞的 prompt 模板、用来填洞的数据、一个接活儿但不阻塞的队列,以及一个能长久放结果的地方。我们跑的是这一切中间的那个 API,所以我们见得到人们搭起来的流水线,也见得到那些在上线三周后悄悄断掉的。

没人会先告诉您的那件事:现在它很便宜
先从数字说起,因为它会改变您愿意去搭什么。
一千张图走 Nano Banana 2 Lite 是 $23.80。那是 1,000 × $0.0238,按我们 2026 年 8 月 26 日的价格算。十万张产品图——一份真实的目录,每一个 SKU、每一种配色——是 $2,380。
同样的量在您会去挑的那几档上:
| 图像数 | Nano Banana 2 Lite | Nano Banana 2 | Nano Banana Pro |
|---|---|---|---|
| 100 | $2.38 | $4.00 | $7.50 |
| 1,000 | $23.80 | $40.00 | $75.00 |
| 10,000 | $238.00 | $400.00 | $750.00 |
| 100,000 | $2,380.00 | $4,000.00 | $7,500.00 |
价格与产品条款可能随时间变化;在做出采购决定前,请核对各家提供商的当前价格。Google 的 Batch 或 Flex 价格在调度、可用容量和处理条件上均有所不同,因此与标准的按需 API request 并不直接等价,已从本对比中排除。这是一次限定范围的对比,并非声称 E2X 在任何配置下都是全球最便宜的选择。
大多数来问怎么自动化图像生成的人,心里早就把它标价成了「贵到不值得试」。这是一个 $23.80 的问题。把东西搭起来。
第一步:别再写 prompt,开始写模板
您手敲一次的 prompt 是一个句子。一台机器要跑一万次的 prompt 是一个带槽位的模板,而这个差别就是全部工作。
规则是:一切会变的进槽位,一切必须完全一致的留在固定文本里。这条边界划错,整组就会漂——一半图落在白底上,一半落在灰底上,因为「背景」漏进了可变的那一半。
TEMPLATE = (
"一件 {product_name} 的棚拍产品照,颜色为 {colour},"
"居中置于纯净的暖灰色背景上,柔和的方向性主光来自左上方,"
"浅景深,杂志目录式的修饰风格,"
"无文字,无 logo,无人物"
)
两个槽位。打光、背景、取景和排除项都被冻住了。正是被冻住的那一半,让 400 张图看起来像同一次拍摄,而不是 400 次意外。
有四样东西要挡在可变的那一半之外:媒介、光的方向、背景,以及否定指令。那些是您的视觉身份。如果它们跟着每一行变,您就没有视觉身份。

prompt 的手艺本身是另一篇文章的事。对自动化来说要紧的是形状:固定的框、命名好的洞、没有意外。
第二步:数据才是真正的产品
模板是琐碎的。工作量在数据里,而它通常是您手上已经有的东西。
- 一张产品目录表。每个 SKU 一行,列里有名称、颜色、材质、类目。
- 一份博客文章的 CMS 导出,标题和主题就成了每篇文章配图的槽位。
- 您应用数据库里的行——用户发布的条目、活动页、菜谱。
- 一张广告变体的表格:六条标题乘四种背景就是二十四张图,没人会手工去写这份 brief。
不管来源是什么,在它碰到 API 之前先规整成一个字典列表,并且给每一行留一个稳定的 ID。您需要它把结果对回记录上,而省掉它的痛,恰好会在一个 webhook 打过来的那一刻发作。
第三步:提交,别等
我们的 API 是刻意做成异步的。您 POST 一个 job,立刻拿回一个 job ID,生成在我们这边发生。
curl -X POST https://api.e2x.ai/v1/jobs/submit \
-H "Authorization: Bearer $E2X_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "google/nano-banana-2-lite/text-to-image",
"input": {
"prompt": "一盏胡桃木台灯的棚拍产品照,颜色为哑光黑……",
"aspect_ratio": "1:1"
},
"webhookUrl": "https://your-app.example.com/hooks/e2x"
}'
把一张表变成一批 job 的那个遍历器,大约三十行。它读一个 CSV,按行套模板,提交,并把 job ID 对着行 ID 记下来,这样就没有东西会变成孤儿。
import csv
import os
import time
import requests
API = "https://api.e2x.ai/v1"
HEADERS = {
"Authorization": f"Bearer {os.environ['E2X_API_KEY']}",
"Content-Type": "application/json",
}
TEMPLATE = (
"一件 {product_name} 的棚拍产品照,颜色为 {colour},"
"居中置于纯净的暖灰色背景上,柔和的方向性主光来自左上方,"
"浅景深,杂志目录式的修饰风格,"
"无文字,无 logo,无人物"
)
MAX_IMAGES = 500 # 硬上限,见成本那一节
UNIT_COST = 0.0238 # Nano Banana 2 Lite,核查日期 2026 年 8 月 26 日
def submit(row):
body = {
"model": "google/nano-banana-2-lite/text-to-image",
"input": {
"prompt": TEMPLATE.format(**row),
"aspect_ratio": "1:1",
},
"webhookUrl": "https://your-app.example.com/hooks/e2x",
}
for attempt in range(4):
r = requests.post(f"{API}/jobs/submit", json=body, headers=HEADERS, timeout=30)
if r.status_code < 300:
return r.json()["data"]["jobId"]
if r.status_code == 429 or r.status_code >= 500:
time.sleep(2 ** attempt)
continue
raise RuntimeError(f"row {row['id']} rejected: {r.status_code} {r.text}")
raise RuntimeError(f"row {row['id']} still failing after 4 attempts")
with open("catalog.csv") as fh:
rows = list(csv.DictReader(fh))[:MAX_IMAGES]
print(f"submitting {len(rows)} jobs, budget ${len(rows) * UNIT_COST:.2f}")
submitted = {}
for row in rows:
try:
submitted[row["id"]] = submit(row)
except RuntimeError as err:
print(f"skipped: {err}")
print(f"{len(submitted)}/{len(rows)} accepted")
请注意那个循环不做什么:某一行失败时停下来。一批 500 行、其中第 212 行的颜色是空值,应该给您 499 张图和一条记录在案的跳过,而不是凌晨三点的一段 stack trace 加零张图。
slug 比它看起来更要紧。google/nano-banana-2-lite/text-to-image 带着能力后缀;而 Nano Banana 2 的文生图 slug 是光秃秃的 google/nano-banana-2,一个后缀都没有。猜错,会让一条本来看着已经完工的流水线拿到 404。每个模型都在一份机器可读的规格文件里公布自己的参数清单。
第四步:webhook 优于 polling,理由在这里
您可以做 polling。GET /v1/jobs/{JOB_ID} 会返回 pending、processing、completed、failed 或 cancelled,对一个一次性脚本来说这没问题。
对一条流水线来说这是错的选择,而理由不是优雅与否。polling 把您进程的生命周期和这批里最慢的那个 job 绑死了。一次 Lite 生成在我们这边大约跑 60 秒。提交 500 个,那个轮询器就必须一直活着、一直持有状态,直到尾巴跑完——熬过一次部署、一次容器重启、您所在的那个 serverless 平台的 15 分钟超时。等它死掉的时候,它是攥着「哪些 job 还在飞」的唯一记录死掉的。
webhook 把这件事反过来。您提交,然后退出。稍后,一个 POST 带着一个完成的 job 到达,您就无状态地只处理那一个 job。
// Express handler。幂等:同一个 jobId 可能到达两次。
app.post("/hooks/e2x", express.json(), async (req, res) => {
res.status(200).end(); // 先确认,再干活
const job = req.body.data;
if (job.status !== "completed") {
await markFailed(job.jobId, job.error?.message ?? "unknown");
return;
}
const already = await findAsset(job.jobId);
if (already) return;
await storeImage(job.jobId, job.outputs[0].url);
});
当您在手工跑一个脚本、并且盯着它的时候,用 polling。当没有人在盯着的时候,用 webhook——而没有人在盯着,正是自动化的定义。
第五步:输出 URL 是临时的,流水线就是这么断的
就是这一条。如果别的您都只是扫一眼,这一节请读两遍。
data.outputs[0].url 是一个交付 URL,不是存储。它现在能用。它不会永远能用。任何把它当成永久链接的做法——一个 product_image_url 列、一个 CMS 字段、一条邮件发给客户的链接——在预发环境里渲染得很好,两周后显示成一堆碎图标。
这个故障讨厌的地方在于它是延迟的。写入的时候什么都不报错。测试通过。这批任务报告 500 次成功。然后图像开始从最老的记录起一条条消失,等有人注意到的时候,您已经没法在不重跑整批任务的情况下重新生成它们了,因为那些 prompt 也从来没被存下来。
把字节下载下来。放进您自己的 bucket。存您自己的 URL。

import requests
import boto3
s3 = boto3.client("s3")
def store_image(job_id, row_id, source_url):
img = requests.get(source_url, timeout=60)
img.raise_for_status()
key = f"products/{row_id}.jpg"
s3.put_object(
Bucket="my-assets",
Key=key,
Body=img.content,
ContentType=img.headers.get("content-type", "image/jpeg"),
)
return f"https://cdn.example.com/{key}"
既然都动到这儿了,顺手把 prompt 和图像一起存下来。一个文本列,而它就是「重生成那个变体」和「从头再来」之间的差别。
还有两个从第一天起就值得养成的习惯。检查那次下载返回的是图像字节而不是一个错误页,因为一个 body 里装着 HTML 的 200 会心安理得地写进您的 bucket。以及,把存储的键定在您的行 ID 上,而不是我们的 job ID 上,这样一次重生成会覆盖掉正确的那个对象,而不是把它变成孤儿。
第六步:上了量之后真正会出的问题
小批量把一切都藏起来了。下面是您过了几百张之后会冒出来的东西。
部分失败是常态。 任何大批量运行都会有一小部分失败——一条触发了内容过滤的 prompt、一行格式不对的数据、一次上游的瞬时错误。请把 500 中的 497 当作成功情形,并给自己留一条只针对那三行的一键重试。
重试需要一个花钱的天花板。 一个没有上限的重试循环是一个会开账单的 bug。一次 $0.0238 听着人畜无害,直到一个卡住的循环跑了它四万次。请在代码里放一个硬计数——上面那行 MAX_IMAGES 就是这里最便宜的保险。
幂等不是可选项。 webhook 可能被投递不止一次。一个在同一个 job ID 上跑两次不安全的处理器,会给您重复的素材和重复的记录行,而您会在最忙的那一周发现它。
成本需要一个实时计数器,不是一张月度账单。 我们 API 里的金额以微分(micro-cent)返回——1,000,000 等于 $1。请按批次把它们求和,并在批次收尾时把总数打进日志。一条每晚报告「412 张图,$9.81」的流水线,是一条没人需要去审计的流水线。
aspect ratio 的默认值会给您惊喜。 Lite 默认 1:1,而 Nano Banana 2 和 Pro 默认 9:16。请在模板里把它钉死,否则您半个目录会悄悄变成竖版。
watermark 跟着文件走。 Google 会把 SynthID 烙进它的图像模型产出的一切东西里。它是不可见的,它不是一个 flag,而且无论您的流水线之后对那些字节做什么,它都活得下来。在您这边负责品牌或法务的人,应该在这条流水线存在之前就为 AI 生成素材签字,而不是在它已经往 bucket 里写了两万个对象之后。

该把流水线指向哪个模型
默认走 Nano Banana 2 Lite,只有当某件具体的事逼着您换,才离开它。
这是算术,不是打太极。$0.0238 是我们卖的能出图的东西里最便宜的,比它取代的初代 Nano Banana 整整新了一代,而在流水线的量级上,这个价差会累积成财务唯一会看的那个数字。它还带着别处都没有的四种 aspect ratio——1:4、4:1、1:8 和 8:1——如果输出是横幅和竖长条,这正是您要的。有一个怪癖:它根本没有 resolution 参数,发一个过去是错误,而不是被悄悄忽略。
只在下面这四种情况下把流量路由出去,其余不必:
- 1K 以上的输出。 Lite 只有 1K。Nano Banana 2 能到 4K,基准价 $0.04,2K 是 ×1.5,4K 是 ×2。
- 图像里要有可读的文字。 带价格的产品卡、本地化的信息图。那是 Nano Banana Pro 的活儿,$0.075,而且它的 1K 和 2K 同价——拿 2K,那是白送的。
- 透明背景。 抠像和贴纸表需要
background: transparent,而 GPT Image 2 是我们目录里唯一能一次调用做到的模型。1K 请按 $0.0525 做预算,而不是目录里的原始数字——quality在每一档都乘 7。 - 在很多帧里保持同一张脸或同一个瓶子。 那是参考图的问题,不是量的问题,我们为一致性单写了一篇。
对混合负载,把大头跑在 Lite 上,再按数据里的某一列,把那 2% 需要文字或高保真的分支到 Pro。一条流水线,两个模型,Pro 的价格只花在它挣得回来的地方。
想要 submit 与 polling 契约的逐参数走查,看 Nano Banana Pro API 教程;想要逐档拆解,看模型对比。这两篇都假设您已经选好了模型。如果还没有,text-to-image 模型是带着价格列出来的,而目录还加上了视频那一侧。
整件事,按顺序
带槽位的模板。带稳定 ID 的数据。在一个硬上限之下提交成 job。接 webhook。把字节下载进您自己的存储。把失败和花销记进日志。六步,没有一步难,而被跳过的那一步永远是第五步。
常见问题
怎么自动生成图像?
把您的 prompt 写成一个带命名槽位的模板,从产品目录或 CSV 这样的数据源填这些槽位,再把每一条填好的 prompt 作为一个异步 job 提交给图像 API。用 webhook 而不是 polling 来接结果,然后把图像字节下载进您自己的存储。这个循环里没有一环需要人,这正是它算「自动」而不是「更快的手工活」的原因。
自动生成 1,000 张图要多少钱?
按 E2X 在 2026 年 8 月 26 日的价格,1,000 张图走 Nano Banana 2 Lite 是 $23.80,因为每次 request 是 $0.0238。同样的量在 Nano Banana 2 上是 $40.00,在 Nano Banana Pro 上是 $75.00。更高的分辨率会往上乘,但对标准的 1K 输出来说,每张图那个数字就是全部计算。
图像生成 job 该用 webhook 还是 polling?
任何无人值守运行的东西都用 webhook。polling 把您进程的生命周期和这批里最慢的 job 绑在一起,所以一次部署或一次 serverless 超时就会杀掉那个轮询器,而它当时攥着「哪些 job 还在跑」的唯一记录。webhook 让您提交完就退出,然后无状态地处理每一个完成的 job。对一个您正盯着跑的脚本来说,polling 没问题。
为什么我生成的图像过一阵就加载不出来了?
因为您存的是 API 的输出 URL,而不是图像本身。那个 URL 是临时交付,不是存储,所以一个存着它的列一开始渲染正常,之后就坏掉。job 一完成就把字节下载下来,写进您自己的 bucket,存您自己的 URL。这是图像流水线带着缺陷上线最常见的方式。
一条自动化图像流水线该默认用哪个模型?
Nano Banana 2 Lite,每张 $0.0238。它是我们目录里最便宜的图像模型,也比初代 Nano Banana 新了一代,而在批量的量级上,价格压过其他所有考量。只为 1K 以上的输出换到 Nano Banana 2,只为可读的图内文字换到 Nano Banana Pro,只为透明背景换到 GPT Image 2。
一批任务里有些图失败了会怎样?
总会有一些失败,而流水线应该把这当作常态而不是致命错误。某一行被拒之后继续提交,把失败的 ID 连同它们的错误一起记进日志,再搭一条只跑失败项的重试路径。请给重试次数设上限,因为每一次都是一笔真实的费用。