ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DALL·E与Gemini批量生图实战:API调用、并发限速与质量管控

DALL·E与Gemini批量生图实战:API调用、并发限速与质量管控

1. 先想清楚再动手:DALL·E 与 Gemini 的生图定位差异

1.1 两者的接口形态与生成风格差异

做批量生图之前,最容易被忽略的一件事就是:DALL·E 和 Gemini 根本不是同一类东西。虽然它们都叫“文生图模型”,但接口形态、返回方式、适用场景差别很大。

DALL·E 走的是 OpenAI 的 Images API,核心就是images.generate这一组接口。你传入提示词、指定尺寸和数量,它同步返回一张或一组图片的 URL 或者 base64 数据。生成质量稳定、语义理解细腻,尤其擅长把长句子里的多个约束条件拆解到画面里。比如“一只穿红色雨衣的柴犬站在东京街头,身后是霓虹灯招牌,雨滴清晰可见”这种多层描述,DALL·E 基本都能还原到位。它的短板也很明显:单次请求最多生成 10 张,SDK 频率限制严格,量一大就得考虑排队和退避。

Gemini 这边则更像是多模态大模型的“附带技能”。你调用的是 Gemini API 里的generateContent接口,把提示词放进contents里,同时指定responseModalities: ["Image"],模型会返回生成图像的内联 base64 数据。它的优势在于:如果你本来就在用 Gemini 做文本生成、图片理解、甚至视频理解,再顺手接入生图能力,整个架构可以共用一套鉴权和调用链路,不用为生图单独维护一套 SDK。Gemini 的图在写实风格和光影质感上有自己的特色,某些场景下甚至比 DALL·E 更细腻,但在复杂指令的还原度上没有 DALL·E 那么“较真”。

我自己的感受是:如果项目核心就是“把一段描述变成高质量图片”,选 DALL·E 更省心;如果项目本身是多模态应用,比如图文问答、内容审核、配图生成一体化的内部工具,那用 Gemini 可以把系统复杂度压得更低。

1.2 选型矩阵:什么时候用 DALL·E,什么时候用 Gemini

先给一个可以直接抄作业的判断表:

场景推荐选择理由
电商商品图、营销海报,对画面还原度要求极高DALL·E复杂的商品描述还原更准,出图稳定
大量不同风格插画、虚拟形象Gemini光影质感好,批次之间风格一致性比较容易控制
多模态应用中的配图模块Gemini与文本生成共用一套 API,链路简单
高并发、大批量(日均千张以上)DALL·E + 排队机制官方限速虽有,但退避机制成熟,文档齐全
成本敏感的小团队Gemini 免费额度 + 低档位免费额度初期够用,试错成本低
需要严格按固定比例出图(如 1:1 头像、9:16 封面)两者均可,需实测不同尺寸支持能力不同,实测为准

选择的关键不是“哪个模型更强”,而是“你的工程链路到底愿不愿意为某个模型做专门适配”。DALL·E 的优势是生态成熟,Python SDK 里就能完成几乎所有事;Gemini 的优势是统一,如果你已有的项目里 Google 系服务占了很大比重,硬塞一套 OpenAI SDK 反而显得臃肿。

另外一个很实际的考量:账号和网络的可得性。这一点我不想展开说太多,但提醒大家在实际部署前先确认好当前网络环境下哪个服务能够稳定访问、鉴权畅通。这直接决定了后续调试的效率。我见过不少团队在架构上选了很理想的方案,结果每次跑批处理都要先折腾十分钟环境,最终被迫换方案。

2. 环境准备与第一张图:把步骤落实到能跑通

2.1 API 密钥管理与环境变量

无论选哪个模型,第一步都是密钥管理。别把密钥写死在代码里,哪怕项目不公开也尽量不要,因为你永远不知道代码什么时候会被分享出去。正确做法是放到环境变量,或者项目根目录的.env文件里,并且保证.env不进 git。

# .env OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx GOOGLE_API_KEY=AIzaxxxxxxxxxxxxxxxx

Python 端用python-dotenv加载:

import os from dotenv import load_dotenv load_dotenv() openai_key = os.getenv("OPENAI_API_KEY") google_key = os.getenv("GOOGLE_API_KEY") if not openai_key and not google_key: raise RuntimeError("至少配置一个 API Key")

这里有个经验之谈:两个密钥都配上,代码里做多后端支持。原因不是“两个模型都要用”,而是开发阶段某个服务万一出现临时不可用,你可以一键切到另一个,不至于整个进度卡死。后面要讲的完整流水线本身就应该设计成“生成后端可配置”,而不是硬编码死一个模型。

2.2 单张生成的最小可运行代码

DALL·E 的最小可运行代码是这么写的:

import os from openai import OpenAI client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) resp = client.images.generate( model="dall-e-3", prompt="一只穿红色雨衣的柴犬站在东京街头,霓虹灯倒映在湿润的路面上,写实摄影风格", size="1024x1024", quality="hd", n=1, ) image_url = resp.data[0].url print(image_url)

Gemini 这边略有不同:

import os import google.generativeai as genai genai.configure(api_key=os.getenv("GOOGLE_API_KEY")) model = genai.GenerativeModel("gemini-2.0-flash-exp") resp = model.generate_content( "一只穿红色雨衣的柴犬站在东京街头,霓虹灯倒映在湿润的路面上,写实摄影风格", generation_config={"response_modalities": ["Image"]}, ) # 返回的是内联 base64 image_data = resp.candidates[0].content.parts[0].inline_data.data with open("sample.png", "wb") as f: f.write(image_data)

注意 Gemini 的输出是 base64,需要自己写文件;DALL·E 默认返回 URL,你也可以设置response_format="b64_json"让它直接返回 base64,避免 URL 过期问题。两条链路拿到手之后,建议先把这两份代码跑通,再考虑批量化。基础不牢的批处理脚本,后面出bug你都分不清是模型问题还是代码问题。

2.3 参数详解:size、quality、n 与 response_format

批量场景下,参数选择决定了你的单位成本和质量,值得花点时间理解。

size参数直接影响计费。DALL·E 3 的 1024x1024、1024x1792、1792x1024 三个档位价格不同,宽幅图相对贵一些。Gemini 这边也有尺寸限制,不同模型对输出分辨率有上限。我的建议是:按最终用途选尺寸,不要一律选最大。如果只是做内容配图(web端展示),1024x1024 足够了;如果是做印刷物料或视频封面,再看是否需要上高分版本。

quality参数是 DALL·E 特有的。标准档生成速度快、成本低,高清档细节更好但也更慢。实战里我发现:如果是批量生成后还要人为筛选,建议先用标准档跑全量,筛出值得精修的图再用高清档重生成。这样能用最低成本覆盖最大样本量。当然这个策略不适用于“必须一步到位”的生产场景。

n参数在 DALL·E 3 里最多10张,Gemini 没有 n 参数,但你可以通过并发多个请求来达到类似效果。说实话,我一般不用n>1的批量模式,因为它在单次请求里一次性消耗多个配额,一旦某张图出问题整个请求就要重来。相比之下,用并发控制一个个生成,单张失败只需要重试那一张,错误隔离性更好。

response_format建议直接设成b64_json。URL 看起来方便,但拿去做流水线必须下载,等于多一次 HTTP 往返,还可能出现 URL 过期。base64 直接入库或写盘,一步到位。当然要注意 base64 比二进制膨胀约 33%,传参和写文件时稍微注意一下内存占用就行。

两个模型都跑通、参数都理清楚之后,你的“单张”能力就绪了。这时候再回头看批量需求,思路会清晰很多:批量不是把单张代码包个 for 循环那么简单,它还涉及限速、错误处理、断点续跑这些工程问题。

3. 批量化路上真正难啃的骨头:限速、并发与重试

3.1 速率限制的真实形态与应对策略

很多人第一次跑批量脚本都栽在同一个地方:前 20 张生成得好好的,第 21 张突然报 429。原因很简单,你触发了服务端的速率限制。这里的限制通常有两个维度:每分钟请求数(RPM)和每分钟图片数(IPM)。DALL·E 的 RPM 限制在不同账号等级下差异很大,免费档和付费档差距可以到数倍。

你可以在服务端文档里查到具体数值,但更好的做法是:代码里不写死限速参数,而是做成可配置项。这样账号升级或者换服务商时不用改代码逻辑。

# config.py RATE_LIMIT = { "rpm": 60, # 每分钟最多请求数 "ipm": 120, # 每分钟最多生成图片数 "max_retries": 6, "base_delay": 2.0, }

有一个非常容易踩的坑:RPM 限制看着够用,但 IPM 先爆了。比如你每分钟发 60 个请求,每个请求生成 3 张图,图片数就是 180,直接超了 IPM 限制。所以批量脚本里要同时跟踪两个维度的消耗。

我自己比较推荐的是令牌桶思路:维护一个计数器,每次请求前根据已用请求数和已用图片数做双重判断,超了任意一个就等待当前周期结束。实现不复杂,但能把 429 的出现概率大幅降低。

3.2 线程池与信号量:实现可调控的并发

Python 里做并发最高效的方式是用concurrent.futures.ThreadPoolExecutor,然后通过信号量控制最大并发数。

import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed semaphore = threading.Semaphore(5) # 同一时刻最多 5 个请求在飞 def generate_one(task): with semaphore: # 调用 DALL·E 或 Gemini 的单张生成函数 result = generate_image(task["prompt"], task["output_path"]) return task, result with ThreadPoolExecutor(max_workers=10) as executor: futures = [executor.submit(generate_one, task) for task in tasks] for future in as_completed(futures): task, result = future.result() print(f"完成: {task['output_path']}")

这里有两个细节值得注意。第一,max_workers和信号量的大小是两个概念:max_workers决定线程池里最多多少线程,信号量在函数内部再做一层闸门。你可以开 20 个线程,同时只放行 5 个真实请求,其余的排队等待。这样即使任务里混入了一些耗时操作(比如图片后处理),也不会挤占 API 请求的配额。

第二,不要只靠线程池控制并发,因为线程池只限制线程数,不感知 API 配额。两个脚本实例同时跑的时候,线程池的限流形同虚设。把限速参数做成全局配置,必要时用 Redis 或文件锁做跨进程限流。单机小批量可以忽略,但如果你计划定时任务+多进程跑,迟早会遇到这个问题。

3.3 退避重试与可观测性设计

429 和 5xx 的错误处理是批量脚本的核心。不处理重试的批量脚本,本质上是一次性赌运气。

推荐指数退避策略:第一次重试等 2 秒,第二次等 4 秒,第三次等 8 秒,加上随机抖动防止所有任务同时重试导致又撞在一起。

import random import time def generate_with_retry(task, max_retries=6, base_delay=2.0): for attempt in range(max_retries): try: return generate_image(task) except RateLimitError as e: delay = base_delay * (2 ** attempt) + random.uniform(0, 1) print(f"请求被限流,{delay:.1f}s 后重试 ({attempt+1}/{max_retries})") time.sleep(delay) except APIStatusError as e: if e.status_code >= 500: delay = base_delay * (2 ** attempt) + random.uniform(0, 1) print(f"服务端错误 {e.status_code},{delay:.1f}s 后重试") time.sleep(delay) else: raise # 4xx 错误一般不重试,直接抛出去人工处理 raise RuntimeError(f"任务重试 {max_retries} 次仍然失败: {task['prompt'][:50]}")

除了重试,可观测性同样重要。批量任务跑起来后,你需要知道:跑了多少?成功多少?失败多少?失败的原因分布是什么?当前处于什么阶段?

我习惯在每个任务的处理函数里记录状态到 SQLite 或 JSONL 文件,格式大概是:

{"task_id": "t001", "prompt": "xxx", "status": "success", "cost": 0.04, "duration": 3.2, "error": null}

跑完一批之后直接按状态聚合,成功率一目了然。这样做的成本很低,但能帮你省下大量的排障时间。批量任务最怕的不是失败,而是失败了不知道、不知道失败了多少、不知道失败的原因。

4. 提示词工程:让批量生成的风格保持稳定

4.1 模板化提示词与变量注入

批量生成和单张生成最大的区别在于:你需要让几十上百张图片保持统一的风格基线,而不是每张图自由发挥。做法就是模板化提示词。

假设你要做一套 30 张的“城市夜景插画”系列,提示词模板大概是这样的:

PROMPT_TEMPLATE = ( "一幅城市夜景插画,主题是{city}的{landmark}," "画面以深蓝色和金色为主色调,笔触细腻," "氛围宁静且带有一点科幻感,适合作为竖版手机壁纸,尺寸 9:16。" ) cities = [ {"city": "上海", "landmark": "外滩建筑群"}, {"city": "东京", "landmark": "涩谷十字路口"}, {"city": "巴黎", "landmark": "埃菲尔铁塔"}, # ... 更多 ]

每个城市只替换{city}和{landmark}两个变量,其余所有描述词固定不变。这样一来,不同图片之间天然具备了风格一致性,你后续筛选也只需要关注主题差异,不用费劲比对风格漂移。

模板化还有一个隐藏的好处:方便批量修改。如果客户看完前 5 张说“色调太深了,要明亮一点”,你只需要改模板里关于色调的描述,然后重新生成全量,耗时和成本都可控。这才是批量的工程化价值。

4.2 负面提示词与风格锚点

DALL·E 没有专门的负面提示词参数,但 Gemini 的部分接口支持system_instruction,那里可以做风格约束。更通用的做法是把“不要什么”直接写进提示词里。

我自己常用的表述方式:

  • “不要文字、不要水印、不要边框”
  • “不要歪斜的建筑透视”
  • “不要过度锐化”
  • “保持自然光影,不要 HDR 效果”

写提示词时,建议把“风格锚点”放在开头,把“负面约束”放在中段,把“技术参数”(尺寸、构图、光线)放在末尾。顺序对生成结果有影响,模型通常更关注靠前的描述。

说到风格锚点,我强烈推荐给批量任务建一个“风格词汇表”:

风格方向推荐锚点词汇
写实摄影85mm 镜头、f/1.8 光圈、自然肤色、浅景深
赛博朋克霓虹灯、雨夜、全息投影、高对比、冷色调青色与洋红
国风插画水墨质感、留白、淡彩、轮廓线柔和
卡通 3D皮克斯风格渲染、柔软材质、圆润造型、柔和全局光照
极简海报大量留白、单一主色、几何构图、无衬线字体的感觉

一旦确定风格,所有提示词统一挂上锚点词汇,不要每次凭感觉增删。

4.3 批量场景下的提示词版本管理

这个细节很多人会忽略,但实际上是批量项目中最大的隐性成本之一:提示词改动了,批量结果的一致性立刻被打破。你昨天用模板 A 跑出 100 张,今天觉得某个形容词不够好,随手改成模板 B 又跑了 100 张,这两批图混在一起就会出现明显的风格断层。

解决思路其实很简单,给提示词模板加上版本号:

PROMPT_TEMPLATES = { "v1": ( "一幅城市夜景插画,主题是{city}的{landmark}," "画面以深蓝色和金色为主色调..." ), "v2": ( "一幅城市夜景插画,主题是{city}的{landmark}," "画面以深蓝色和金色为主色调,增加了月光反射效果..." ), }

然后在任务清单里记录用的模板版本:

{"task_id": "t001", "prompt_version": "v2", "prompt": "..."}

以后你看到任何一张图,都能追溯到它是由哪个版本的提示词生成的。如果某个版本的结果整批被弃用,你也知道应该重建哪些任务。这本质上就是数据管理思维,图像生成项目跑到后期,瓶颈往往不在“模型能力”,而在“管理和追溯能力”。

5. 从脚本到流水线:任务队列、断点续跑与产物管理

5.1 任务清单与状态管理

批量脚本跑一次容易,但真实生产里你会遇到无数需要断点续跑的场景:跑到一半 API 挂了、磁盘满了、提示词需要调整、某几张图超时。如果脚本设计成“从头跑”,前面的产出全废,时间和成本双重浪费。

正确的做法是引入任务状态机。每个任务有四个状态:待处理、处理中、成功、失败。状态记录在 JSONL 或 SQLite 里,脚本每次启动时读取状态文件,跳过已成功的任务,只处理待处理和失败的任务。

import json from pathlib import Path STATE_FILE = Path("tasks_state.json") def load_state(): if STATE_FILE.exists(): with open(STATE_FILE, "r", encoding="utf-8") as f: return {t["task_id"]: t for t in map(json.loads, f)} return {} def save_result(task, status, error=None): with open(STATE_FILE, "a", encoding="utf-8") as f: f.write(json.dumps({**task, "status": status, "error": error}, ensure_ascii=False) + "\n")

实际可以做得更轻:不需要每次成功都写文件,可以攒 5~10 条批量 flush 一次,减少磁盘 IO。但状态文件一定要有,否则重跑全量任务的时间损失不是线性增长,而是结合限速的指数级增长。

5.2 结果落盘与文件命名规范

产物管理是另一个容易被忽视的点。批量生成的图片如果没有规范的命名,几天后你打开文件夹,看到的是一堆 hash 文件名,完全对不上任务。

我建议的文件命名规则:

{series_name}/{task_id}_{version}_{style_tag}_{timestamp}.png

例如:

city_night_series/t001_v2_shanghai_bund_20250114.png

这个命名包含了系列名、任务 ID、提示词版本、主题标识和时间戳。配合状态文件里的完整提示词,你可以在任何时候从图片反推生成信息。

另外,图片本身建议同时保留原始 base64 和 final PNG 两个版本。base64 可以存到状态文件里做备份溯源,PNG 用于实际展示和使用。两个都留一份不大,但恢复成本完全不同。

5.3 失败任务的恢复与重新生成

失败任务怎么处理?我的策略是:先尝试自动重试,重试耗尽后把任务标记为失败,跑完一批后统一人工检查失败原因。

常见的失败原因:

失败错误常见原因处理方式
429 限流并发过大或配额不足增加退避时间或降低并发,重试
400 无效请求提示词包含敏感词或非法字符修改提示词后人工重跑
执行超时网络波动或服务端异常单独挑出重试
图片内容被拒触发内容审核修改提示词,重新生成
密钥配额耗尽本月用量超限换密钥或等额度恢复

这里要特别提醒:内容审核拦截是批量项目里最容易被低估的失败因素。某些提示词单看没问题,但批量跑几天后就会撞上审核系统的高敏感模式。这时候别硬怼,换个措辞就能解决。比如“穿着性感”改成“穿着时尚”,“身材曲线”改成“姿态优雅”,表述安全也能让生成更稳定。

恢复操作建议写成独立函数:

def recover_failed_tasks(state_file): failed_tasks = [] for record in load_state(): if record["status"] == "failed": failed_tasks.append(record) # 重新跑失败的 run_batch(failed_tasks)

这样你随时可以在不触碰成功任务的前提下,只补跑失败的那几条。对于批量流水线,这种“精确惩戒粒度”很重要。整批重跑是最后的选项,而不是默认选项。

6. 实测数据与踩坑总结

6.1 一次批量生产的实测数据

拿一个实际模拟项目举例:生成 120 张不同城市夜景插画,DALL·E 3,1024x1024 标准质量,并发 5,单价约 0.04 美元一张。

实测数据大概是这样的:

指标数值
总耗时约 34 分钟
成功116 张
失败4 张
失败原因3 张触发内容审核,1 张超时
总费用约 4.8 美元
平均单张耗时约 17 秒
429 触发次数2 次,均已自动恢复

失败率 3.3%,在可接受范围内。但如果失败率超过 10%,说明提示词模板或参数配置有问题,不建议直接加并发硬跑,先停下来分析失败模式。盲目增加重试次数和并发数,只会让成本更快消耗。

另外我注意到一个规律:内容审核失败往往集中在某些特定主题上。比如涉及“夜景人像”的任务失败率远高于“纯建筑夜景”。如果项目允许,尽量在模板里避开高危主题组合。

6.2 我踩过的几个坑和解决方案

第一个坑:直接使用默认 network 超时。OpenAI SDK 的默认超时配置在很多网络环境下不够用,批量任务里单张请求耗时超过 30 秒并不罕见。建议手动设置 timeout,否则会出现客户端超时但服务端还在处理的状况,任务被标记失败但实际已经生成,重试会造成重复扣费。

from openai import OpenAI client = OpenAI( api_key=os.getenv("OPENAI_API_KEY"), timeout=60.0, # 给足时间,不要用默认值 )

第二个坑:把 URL 下载和生成混在同一个事务里。用 URL 模式时,生成成功并不代表下载成功。稳妥的做法是生成时直接用b64_json,把下载环节彻底省掉。

第三个坑:任务清单里的提示词没有做长度校验。某些模型对提示词长度有限制,超长提示词会在中间被截断,导致生成结果偏离预期。批量脚本里统一加一个截断或预检逻辑,长度超限直接报错,避免稀里糊涂跑几百张废图。

第四个坑也是最容易忽略的:本地磁盘空间。1000 张 1024x1024 的 PNG 大约占用 1.5~2GB,如果还保留 JSONL 状态文件,内存和磁盘都要提前规划。跑之前检查一次磁盘剩余空间,不费什么事但能避免跑到一半磁盘写满的尴尬。

6.3 成本优化与质量抽检建议

最后说说成本和质量管理。批量场景下成本控制和质量保障是两条线:

成本控制方面,最有效的策略是“先小批量验证,再全量铺开”。先拿 5~10 条典型任务跑一遍,人工看结果质量,确认提示词没问题后再放全量。这个策略下,哪怕是全量失败,浪费的也只有 10 张图的钱。另外可以监控每次运行的总费用,设置一个月度预算阈值,脚本跑之前先判断预算是否足够。

质量抽检方面,我建议每一批任务产出后,至少随机抽 10% 的图片做人工检查。检查维度包括:是否与提示词匹配、风格是否一致、有没有明显畸变或瑕疵。抽检结果如果合格率低于 80%,不要继续跑下一批,先回头调提示词。

关于成本,还有一个容易被忽略的点:模型版本升级后的价格差异。同一家服务商的不同模型版本可能有不同价格,代码里硬编码的版本号会在不知情的情况下悄悄增加成本。建议在配置中心统一管理模型版本,每次升级先跑小批量对比效果和成本。

至于 DALL·E 和 Gemini 的日常分工,我的实测体会是:DALL·E 更适合“描述复杂、需要精确还原”的任务,Gemini 更适合“风格感强、需要氛围渲染”的插画类任务。如果一个项目两者都涉及,建议分成两条流水线分支,各跑各的,而不是在同一个循环里无缝切换。理由很简单:两者的限速、错误类型、出图风格差异都不同,混在一个循环里会让问题定位变得非常困难。

最后想补充一点私人体会:批量生图这类项目,技术层面其实没有什么神秘的,API 调用、并发、重试都是常规工程手段。真正拉开差距的,是对任务的管理方式——你有没有能力在 500 张图里快速定位到那一张异常图?能不能在三天后准确复现某批图的生成条件?这些数据管理和工程化的细节,往往比模型本身的选型更决定项目成败。

我自己在跑过几个批量项目后的经验是:把前面说的任务状态机、提示词版本、产物命名规范都搭好之后,生成图片本身的代码几乎不用改,项目推进的速度主要取决于你筛图、改词、复跑的反馈循环够不够快。这套基础设施值得在你第一次跑批量前就搭好,否则越到后面越难补。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进