一次异步 Firefly 调用是三个 HTTP 请求,不是一个。先提交到带 -async 的端点,再拿返回的 statusUrl 轮询到终态,之后从 result.outputs 里读图片地址。
有三个坑直接来自官方页面:轮询时带错请求头、把状态主机写死、在状态响应的顶层找 outputs。
Firefly API 为什么改成异步
Adobe 用一句话解释了这次改动:Our original Firefly APIs operated in a synchronous fashion. 老接口会一直占着连接,直到素材生成完毕。出一张图无所谓,跑批处理就很难受。异步版本立刻返回一个任务句柄,worker 可以先把多个任务提交出去,再一起轮询。
官方在扩图示例里把这层好处说得很具体:Instead of doing one resize after another, we can kick off multiple jobs at once so we can resize an image much more efficiently. 与其一张一张改尺寸,不如一次把多个任务发出去。
五个异步操作与提交调用
Adobe 列了五个:Generate Image Async、Expand Image Async、Fill Image Async、Generate Object Composite Async、Generate Similar Images Async。五个的流程完全一样。凭证与同步调用一致,见我们的接入指南。
参数也是同一套。官方对异步调用的说法是 you have the same options that you do with the synchronous endpoint.,最少给一个文本提示词,其余都是可选项,用来控制内容类别、结构与风格。从同步迁移过去,除了端点后缀和响应处理,请求体基本不用动。
该 POST 到哪个端点
出图端点是 POST https://firefly-api.adobe.io/v3/images/generate-async。扩图是 POST https://firefly-api.adobe.io/v3/images/expand-async。图生图要先把素材上传到 POST https://firefly-api.adobe.io/v2/storage/image,上传的素材 valid for 7 days,七天有效。Image5 走的是另一条路径 /v4/images/generate-async,模型本身见 Image 5 上手页。自定义模型则在同一个端点上多一个字段,写法见自定义模型一篇。
# Submit a generation job and keep the statusUrl.
export FIREFLY_SERVICES_CLIENT_ID=<your-client-id>
export FIREFLY_SERVICES_CLIENT_SECRET=<your-client-secret>
TOKEN=$(curl -s -X POST 'https://ims-na1.adobelogin.com/ims/token/v3' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d "client_id=${FIREFLY_SERVICES_CLIENT_ID}" \
-d "client_secret=${FIREFLY_SERVICES_CLIENT_SECRET}" \
-d 'scope=openid,AdobeID,read_organizations,firefly_enterprise,firefly_api,ff_apis' \
| jq -r .access_token)
curl -s -X POST 'https://firefly-api.adobe.io/v3/images/generate-async' \
-H 'Content-Type: application/json' \
-H "x-api-key: ${FIREFLY_SERVICES_CLIENT_ID}" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{
"prompt": "a cat living their best life, sleeping in a sunbeam",
"contentClass": "art"
}' | jq -r .statusUrl > firefly-status-url.txt
提交响应只有三个字段
官方文档给出的提交成功响应很短。
{
"jobId": "urn:ff:jobs:eso851211:86ffe2ea-d765-4bd3-b2fd-568ca8fc36ac",
"statusUrl": "https://firefly-api.adobe.io/v3/status/urn:ff:jobs:eso851211:86ffe2ea-d765-4bd3-b2fd-568ca8fc36ac",
"cancelUrl": "https://firefly-api.adobe.io/v3/cancel/urn:ff:jobs:eso851211:86ffe2ea-d765-4bd3-b2fd-568ca8fc36ac"
}
该用哪个字段,官方写得很明确:you can use statusUrl and cancelUrl to get the latest status of your request or to cancel the request. 用 statusUrl 查最新状态,用 cancelUrl 取消任务,jobId 则用来打日志。
批处理里最好三个字段一起落库,而不是只存那串 status URL。取消要趁早:任务已经跑完再取消没有意义,而卡在 running 的任务会一直占着你的轮询预算,这时候 cancelUrl 是唯一的止损手段。
为什么状态主机不是固定的
两页官方文档给出的主机不同,而且都对。异步指南里是 firefly-api.adobe.io/v3/status/...,自定义模型教程里是 firefly-epo852211.adobe.io/v3/status/...。任务是挂在拥有它的租户上的。把收到的 statusUrl 原样请求回去,不要拿提交时的主机去拼。
轮询 statusUrl
官方给的循环很短:if (status !== 'succeeded' && status !== 'failed') await delay(1000); 每秒查一次。注意它带的请求头——只有 Authorization 和 x-api-key,没有 Content-Type,因为轮询是一个干净的 GET。
哪些状态是终态
官方样例里出现三种状态。succeeded 和 failed 会结束循环,running 不会。
{ "status": "running", "jobId": "86ffe2ea-d765-4bd3-b2fd-568ca8fc36ac" }
{
"status": "succeeded",
"jobId": "urn:ff:jobs:eso851211:86ffe2ea-d765-4bd3-b2fd-568ca8fc36ac",
"result": {
"size": { "width": 2048, "height": 2048 },
"outputs": [
{
"seed": 2142812600,
"image": {
"url": "https://pre-signed-firefly-prod.s3-accelerate.amazonaws.com/images/example"
}
}
],
"contentClass": "art",
"altText": "A futuristic city glowing at night with neon lights and flying cars streaking across a dark sky."
}
}

失败分支官方只给了 status,没有别的字段,所以官方样例里的轮询函数要么直接抛错,要么返回原始字符串。遇到不认识的状态,按 running 继续轮询,但一定要给循环设上限,否则卡住的任务会一直空转。
结果字段读哪一层
这里有个嵌套陷阱。jobId、statusUrl、cancelUrl 三个字段平铺在提交响应的顶层,但成功之后素材藏在 result 底下:result.outputs[].image.url,同层还有 result.size 和 result.altText。
Quickstart 页面的示例直接读 job_response['outputs'][0]['image']['url']。异步调用里顶层没有这个键,异步指南和它自己的轮询示例都是先经过 result 的。嵌套关系以异步指南为准。
import os
import time
from pathlib import Path
import requests
CLIENT_ID = os.environ['FIREFLY_SERVICES_CLIENT_ID']
TOKEN = os.environ['FIREFLY_SERVICES_ACCESS_TOKEN']
HEADERS = {'x-api-key': CLIENT_ID, 'Authorization': f'Bearer {TOKEN}'}
def submit(prompt, content_class='photo'):
response = requests.post(
'https://firefly-api.adobe.io/v3/images/generate-async',
headers={**HEADERS, 'Content-Type': 'application/json'},
json={'prompt': prompt, 'contentClass': content_class},
timeout=60,
)
response.raise_for_status()
return response.json()['statusUrl']
def poll(status_url, interval=5, max_attempts=60):
"""Poll until the job reaches a terminal state, then return the payload."""
for _ in range(max_attempts):
response = requests.get(status_url, headers=HEADERS, timeout=30)
response.raise_for_status()
payload = response.json()
status = payload.get('status')
if status == 'succeeded':
return payload
if status == 'failed':
raise RuntimeError(f'job failed: {payload}')
time.sleep(interval)
raise TimeoutError(f'job still running after {max_attempts} polls')
def download(payload, directory='./out'):
Path(directory).mkdir(exist_ok=True)
for output in payload['result']['outputs']:
image = requests.get(output['image']['url'], timeout=60)
image.raise_for_status()
path = Path(directory) / f"{output['seed']}.jpg"
path.write_bytes(image.content)
return directory
print(download(poll(submit('a cat sleeping in a sunbeam'))))
每个输出里的 seed 值得存下来。它是复现结果的一部分,也是 Adobe 返回的唯一逐图标识。响应里的 altText 是官方生成的描述文本,可以直接拿去做图片的可访问性说明,省掉一次额外的模型调用。
429、retry-after 与并发铺开
限流按组织计算:每分钟 4 个请求,每天 9,000 个请求。超过任意一条都会返回 HTTP 429 Too Many Requests。我们的 API 计费拆解里讲了这些额度是怎么计的。Adobe 给出的第一条处置意见是 Review your usage and reduce unnecessary requests.,先检查用量、砍掉多余请求。
退避不要靠猜
官方点名了机制,但没给数字:Implement retry logic via a retry-after HTTP header or an exponential backoff strategy. 所以响应里带 retry-after 就照着它等,没带就用自己的指数退避。API reference 里每个生成端点都把 429 列进了状态码,Image5 端点列得更宽,还包括 401、409 和 503。
import time
import requests
def post_with_backoff(url, headers, payload, attempts=5, cap=60.0):
"""Honour retry-after when the header is present, then back off."""
delay = 1.0
for _ in range(attempts):
response = requests.post(url, headers=headers, json=payload, timeout=60)
if response.status_code == 429:
wait = float(response.headers.get('retry-after', delay))
time.sleep(min(wait, cap))
delay = min(delay * 2, cap)
continue
response.raise_for_status()
return response.json()
raise RuntimeError('still rate limited after retries')
铺开并发是限流策略的另一半。官方扩图示例用 Promise.all 一次发起三个尺寸,再逐个轮询 statusUrl。每分钟四个请求就是预算上限,所以三十个任务排期是调度问题,不是并发问题。并发上限、结果 URL 的有效期、以及超限之后的排队行为,Adobe 都没有公布,这三项一律按官方未公布处理。
常见问题
异步任务从哪个端点发起? 出图用 POST https://firefly-api.adobe.io/v3/images/generate-async,Image5 用 /v4/images/generate-async,扩图用 POST /v3/images/expand-async。
哪些状态会结束轮询? succeeded 和 failed。running 表示继续查。
图片地址在哪一层? 成功响应里的 result.outputs[].image.url,不在响应顶层。
可以比每秒一次查得更快吗? 官方只给了「每秒查一次」这个例子,没有任何间隔规定。选一个能塞进 4 RPM 预算的间隔,而且要算上所有 worker。
遇到 429 怎么办? 响应里有 retry-after 就照着等,没有就指数退避,同时减少同时在途的提交数。
轮询会占用限流额度吗? 官方没有把状态查询排除在限流之外的说明,所以按同一个 4 RPM 额度来算更稳妥。轮询间隔越大,留给提交的余量越多。