一次异步 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 AsyncExpand Image AsyncFill Image AsyncGenerate Object Composite AsyncGenerate 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); 每秒查一次。注意它带的请求头——只有 Authorizationx-api-key,没有 Content-Type,因为轮询是一个干净的 GET

哪些状态是终态

官方样例里出现三种状态。succeededfailed 会结束循环,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."
  }
}
Firefly 异步任务状态机示意图:提交后返回 statusUrl,running 期间反复轮询,任务最终停在 succeeded 或 failed

失败分支官方只给了 status,没有别的字段,所以官方样例里的轮询函数要么直接抛错,要么返回原始字符串。遇到不认识的状态,按 running 继续轮询,但一定要给循环设上限,否则卡住的任务会一直空转。

结果字段读哪一层

这里有个嵌套陷阱。jobIdstatusUrlcancelUrl 三个字段平铺在提交响应的顶层,但成功之后素材藏在 result 底下:result.outputs[].image.url,同层还有 result.sizeresult.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 端点列得更宽,还包括 401409503

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

哪些状态会结束轮询? succeededfailedrunning 表示继续查。

图片地址在哪一层? 成功响应里的 result.outputs[].image.url,不在响应顶层。

可以比每秒一次查得更快吗? 官方只给了「每秒查一次」这个例子,没有任何间隔规定。选一个能塞进 4 RPM 预算的间隔,而且要算上所有 worker。

遇到 429 怎么办? 响应里有 retry-after 就照着等,没有就指数退避,同时减少同时在途的提交数。

轮询会占用限流额度吗? 官方没有把状态查询排除在限流之外的说明,所以按同一个 4 RPM 额度来算更稳妥。轮询间隔越大,留给提交的余量越多。