前言
文本模型通常可以在一次 HTTP 请求中返回结果,但图片、音频和视频生成任务可能需要几十秒甚至几分钟。
如果服务端一直保持 HTTP 连接,会带来一些问题:
- 容易触发网关超时
- 占用服务器连接
- 客户端断开后难以恢复
- 不方便展示排队和处理状态
- 难以处理耗时较长的生成任务
因此,生成类 API 经常采用异步任务模式:
客户端提交任务
↓
服务端返回任务 ID
↓
客户端定时查询任务状态
↓
任务完成后获取结果地址
本文将实现一套可以本地运行的异步图片任务接口。
一、异步任务接口应该包含什么?
一套最小可用的异步生成接口通常包含两个端点。
提交任务:
POST /v1/images/generations
查询任务:
GET /v1/tasks/{task_id}
任务状态可以设计为:
queued 等待处理
processing 正在生成
succeeded 生成成功
failed 生成失败
提交任务后,服务端不立即返回图片,而是返回任务信息:
{
"id": "task_a1b2c3d4",
"status": "queued",
"created_at": 1723000000
}
任务完成后,查询接口返回结果:
{
"id": "task_a1b2c3d4",
"status": "succeeded",
"progress": 100,
"result": {
"url": "https://example.com/generated/image.png"
}
}
二、创建项目
安装依赖:
pip install fastapi uvicorn requests pydantic
创建以下文件:
async-image-api/
├── server.py
└── client.py
其中:
server.py:模拟异步图片生成服务client.py:提交任务并轮询结果
三、定义任务数据结构
先在 server.py 中定义请求和响应模型:
import time
import uuid
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
class TaskStatus(str, Enum):
QUEUED = "queued"
PROCESSING = "processing"
SUCCEEDED = "succeeded"
FAILED = "failed"
class ImageGenerationRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=2000)
width: int = Field(default=1024, ge=256, le=2048)
height: int = Field(default=1024, ge=256, le=2048)
class TaskResponse(BaseModel):
id: str
status: TaskStatus
progress: int = 0
created_at: int
updated_at: int
result: dict[str, Any] | None = None
error: str | None = None
这里使用枚举限制任务状态,避免接口返回不一致的字符串。
progress 表示任务进度,范围为 0~100。
result 只在任务成功后出现,error 则只在任务失败时出现。
四、实现任务存储
为了让示例容易运行,我们先使用内存字典保存任务:
tasks: dict[str, TaskResponse] = {}
真实项目不建议这样做。
因为服务重启后,内存中的任务会全部丢失;如果部署多个服务实例,不同实例也无法共享任务状态。
生产环境通常使用:
- Redis
- PostgreSQL
- MySQL
- 专用任务队列
- 云数据库
本文先关注异步接口流程,因此暂时使用内存存储。
五、实现任务提交接口
在 server.py 中创建 FastAPI 应用:
from fastapi import FastAPI, HTTPException
app = FastAPI(
title="Async Image Generation API",
version="1.0.0"
)
实现任务提交接口:
@app.post(
"/v1/images/generations",
response_model=TaskResponse
)
def create_image_task(
request: ImageGenerationRequest
) -> TaskResponse:
now = int(time.time())
task_id = f"task_{uuid.uuid4().hex[:16]}"
task = TaskResponse(
id=task_id,
status=TaskStatus.QUEUED,
progress=0,
created_at=now,
updated_at=now
)
tasks[task_id] = task
return task
此时接口只创建任务,并没有真正执行图片生成。
客户端会立即得到一个任务 ID:
{
"id": "task_37b080a9c0bc47d9",
"status": "queued",
"progress": 0,
"created_at": 1723000000,
"updated_at": 1723000000,
"result": null,
"error": null
}
六、在后台执行任务
FastAPI 提供了 BackgroundTasks,可以在响应返回后执行简单后台任务。
修改接口参数:
from fastapi import BackgroundTasks
创建模拟生成函数:
def run_image_generation(
task_id: str,
request: ImageGenerationRequest
) -> None:
try:
task = tasks[task_id]
task.status = TaskStatus.PROCESSING
task.progress = 10
task.updated_at = int(time.time())
# 模拟耗时的图片生成过程
for progress in (30, 50, 70, 90):
time.sleep(1)
task.progress = progress
task.updated_at = int(time.time())
time.sleep(1)
task.status = TaskStatus.SUCCEEDED
task.progress = 100
task.updated_at = int(time.time())
task.result = {
"url": (
"https://placehold.co/"
f"{request.width}x{request.height}/png"
),
"prompt": request.prompt,
"width": request.width,
"height": request.height
}
except Exception as error:
task = tasks[task_id]
task.status = TaskStatus.FAILED
task.updated_at = int(time.time())
task.error = str(error)
然后在任务提交接口中启动后台任务:
@app.post(
"/v1/images/generations",
response_model=TaskResponse
)
def create_image_task(
request: ImageGenerationRequest,
background_tasks: BackgroundTasks
) -> TaskResponse:
now = int(time.time())
task_id = f"task_{uuid.uuid4().hex[:16]}"
task = TaskResponse(
id=task_id,
status=TaskStatus.QUEUED,
progress=0,
created_at=now,
updated_at=now
)
tasks[task_id] = task
background_tasks.add_task(
run_image_generation,
task_id,
request
)
return task
接口返回后,后台函数会继续更新任务状态。
需要注意,BackgroundTasks 适合演示或较轻的后台任务,不适合作为大型生成系统的完整队列方案。
生产环境更适合使用:
- Celery
- RQ
- Dramatiq
- RabbitMQ
- Redis Streams
- 云任务队列
七、实现任务查询接口
客户端需要根据任务 ID 查询状态:
@app.get(
"/v1/tasks/{task_id}",
response_model=TaskResponse
)
def get_task(task_id: str) -> TaskResponse:
task = tasks.get(task_id)
if task is None:
raise HTTPException(
status_code=404,
detail="Task not found"
)
return task
现在完整的服务端代码如下:
import time
import uuid
from enum import Enum
from typing import Any
from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel, Field
class TaskStatus(str, Enum):
QUEUED = "queued"
PROCESSING = "processing"
SUCCEEDED = "succeeded"
FAILED = "failed"
class ImageGenerationRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=2000)
width: int = Field(default=1024, ge=256, le=2048)
height: int = Field(default=1024, ge=256, le=2048)
class TaskResponse(BaseModel):
id: str
status: TaskStatus
progress: int = 0
created_at: int
updated_at: int
result: dict[str, Any] | None = None
error: str | None = None
app = FastAPI(
title="Async Image Generation API",
version="1.0.0"
)
tasks: dict[str, TaskResponse] = {}
def run_image_generation(
task_id: str,
request: ImageGenerationRequest
) -> None:
try:
task = tasks[task_id]
task.status = TaskStatus.PROCESSING
task.progress = 10
task.updated_at = int(time.time())
for progress in (30, 50, 70, 90):
time.sleep(1)
task.progress = progress
task.updated_at = int(time.time())
time.sleep(1)
task.status = TaskStatus.SUCCEEDED
task.progress = 100
task.updated_at = int(time.time())
task.result = {
"url": (
"https://placehold.co/"
f"{request.width}x{request.height}/png"
),
"prompt": request.prompt,
"width": request.width,
"height": request.height
}
except Exception as error:
task = tasks[task_id]
task.status = TaskStatus.FAILED
task.updated_at = int(time.time())
task.error = str(error)
@app.post(
"/v1/images/generations",
response_model=TaskResponse
)
def create_image_task(
request: ImageGenerationRequest,
background_tasks: BackgroundTasks
) -> TaskResponse:
now = int(time.time())
task_id = f"task_{uuid.uuid4().hex[:16]}"
task = TaskResponse(
id=task_id,
status=TaskStatus.QUEUED,
progress=0,
created_at=now,
updated_at=now
)
tasks[task_id] = task
background_tasks.add_task(
run_image_generation,
task_id,
request
)
return task
@app.get(
"/v1/tasks/{task_id}",
response_model=TaskResponse
)
def get_task(task_id: str) -> TaskResponse:
task = tasks.get(task_id)
if task is None:
raise HTTPException(
status_code=404,
detail="Task not found"
)
return task
八、启动服务
在项目目录中运行:
uvicorn server:app --reload
启动成功后访问:
http://127.0.0.1:8000/docs
FastAPI 会自动生成接口调试页面。
可以提交以下参数:
{
"prompt": "一只坐在电脑前写 Python 的橘猫",
"width": 1024,
"height": 1024
}
九、编写轮询客户端
接下来实现 client.py。
先定义异常类型:
class ImageGenerationError(Exception):
pass
class ImageGenerationTimeout(ImageGenerationError):
pass
创建客户端:
import time
import requests
class AsyncImageClient:
def __init__(
self,
base_url: str,
request_timeout: float = 30.0
):
self.base_url = base_url.rstrip("/")
self.request_timeout = request_timeout
提交任务:
def create_task(
self,
prompt: str,
width: int = 1024,
height: int = 1024
) -> str:
response = requests.post(
f"{self.base_url}/v1/images/generations",
json={
"prompt": prompt,
"width": width,
"height": height
},
timeout=self.request_timeout
)
response.raise_for_status()
data = response.json()
return data["id"]
查询任务:
def get_task(self, task_id: str) -> dict:
response = requests.get(
f"{self.base_url}/v1/tasks/{task_id}",
timeout=self.request_timeout
)
response.raise_for_status()
return response.json()
实现轮询:
def wait_for_result(
self,
task_id: str,
poll_interval: float = 2.0,
max_wait_seconds: float = 120.0
) -> dict:
started_at = time.monotonic()
while True:
task = self.get_task(task_id)
status = task["status"]
print(
f"任务状态:{status},"
f"进度:{task['progress']}%"
)
if status == "succeeded":
return task["result"]
if status == "failed":
error = task.get("error") or "Unknown error"
raise ImageGenerationError(error)
elapsed = time.monotonic() - started_at
if elapsed >= max_wait_seconds:
raise ImageGenerationTimeout(
f"等待任务超时:{task_id}"
)
time.sleep(poll_interval)
这里使用 time.monotonic() 计算时间,而不是 time.time()。
因为系统时间可能被人为修改或自动校准,而单调时钟只会向前移动,更适合计算超时。
十、完整客户端代码
import time
import requests
class ImageGenerationError(Exception):
pass
class ImageGenerationTimeout(ImageGenerationError):
pass
class AsyncImageClient:
def __init__(
self,
base_url: str,
request_timeout: float = 30.0
):
self.base_url = base_url.rstrip("/")
self.request_timeout = request_timeout
def create_task(
self,
prompt: str,
width: int = 1024,
height: int = 1024
) -> str:
response = requests.post(
f"{self.base_url}/v1/images/generations",
json={
"prompt": prompt,
"width": width,
"height": height
},
timeout=self.request_timeout
)
response.raise_for_status()
data = response.json()
return data["id"]
def get_task(self, task_id: str) -> dict:
response = requests.get(
f"{self.base_url}/v1/tasks/{task_id}",
timeout=self.request_timeout
)
response.raise_for_status()
return response.json()
def wait_for_result(
self,
task_id: str,
poll_interval: float = 2.0,
max_wait_seconds: float = 120.0
) -> dict:
started_at = time.monotonic()
while True:
task = self.get_task(task_id)
status = task["status"]
print(
f"任务状态:{status},"
f"进度:{task['progress']}%"
)
if status == "succeeded":
return task["result"]
if status == "failed":
error = task.get("error") or "Unknown error"
raise ImageGenerationError(error)
elapsed = time.monotonic() - started_at
if elapsed >= max_wait_seconds:
raise ImageGenerationTimeout(
f"等待任务超时:{task_id}"
)
time.sleep(poll_interval)
if __name__ == "__main__":
client = AsyncImageClient(
base_url="http://127.0.0.1:8000"
)
task_id = client.create_task(
prompt="一只坐在电脑前写 Python 的橘猫",
width=1024,
height=1024
)
print(f"任务已创建:{task_id}")
result = client.wait_for_result(
task_id=task_id,
poll_interval=1.0,
max_wait_seconds=60.0
)
print("生成完成:", result)
运行:
python client.py
预期输出:
任务已创建:task_37b080a9c0bc47d9
任务状态:processing,进度:10%
任务状态:processing,进度:30%
任务状态:processing,进度:50%
任务状态:processing,进度:70%
任务状态:processing,进度:90%
任务状态:succeeded,进度:100%
生成完成:{...}
十一、下载生成结果
得到图片地址后,可以使用流式下载,避免一次性把大文件全部载入内存:
from pathlib import Path
import requests
def download_file(
url: str,
output_path: str
) -> Path:
target = Path(output_path)
with requests.get(
url,
stream=True,
timeout=120
) as response:
response.raise_for_status()
with target.open("wb") as file:
for chunk in response.iter_content(
chunk_size=64 * 1024
):
if chunk:
file.write(chunk)
return target
调用方式:
result = client.wait_for_result(task_id)
image_path = download_file(
result["url"],
"generated-image.png"
)
print(f"图片已保存:{image_path.resolve()}")
十二、轮询间隔应该设置多长?
轮询太频繁会增加服务端压力,轮询太慢又会影响用户体验。
可以根据任务类型设置:
文本异步任务:1~2 秒
图片生成:2~5 秒
音频生成:3~10 秒
视频生成:5~15 秒
还可以采用逐步增加的轮询间隔:
poll_interval = min(
2 * (1.5 ** attempt),
10
)
例如:
第 1 次:2 秒
第 2 次:3 秒
第 3 次:4.5 秒
第 4 次:6.75 秒
之后最多:10 秒
这样可以减少长时间任务产生的查询请求。
十三、生产环境还需要解决哪些问题?
本文实现的是最小异步任务流程。生产环境还需要考虑以下问题。
- 持久化任务
不能只使用内存字典,否则服务重启后任务状态会丢失。
- 独立任务队列
耗时生成任务不应该长期占用 Web 服务进程,应交给独立 Worker 执行。
- 幂等控制
客户端可能因为网络问题重复提交同一个任务,可以通过幂等键避免重复创建。
- 任务过期
已经完成很久的任务需要定期清理,避免数据库持续增长。
- 取消任务
长时间任务可以提供取消接口:
POST /v1/tasks/{task_id}/cancel
- 回调通知
如果任务需要几十分钟,与其一直轮询,不如让客户端提供 Webhook 地址,任务完成后由服务端主动通知。
- 访问控制
查询任务时应验证任务是否属于当前用户,不能只知道任务 ID 就读取结果。
- 下载安全
不要盲目信任接口返回的下载地址。下载外部文件时,需要考虑域名白名单、大小限制和内容类型检查。
总结
本文完成了一套基本的异步图片生成 API:
- 提交图片生成任务
- 返回唯一任务 ID
- 在后台更新任务状态
- 通过查询接口获取进度
- 在客户端轮询任务结果
- 处理任务失败和等待超时
- 使用流式方式下载图片
异步任务模式不仅适用于图片生成,也适用于:
- 视频生成
- 音频合成
- 大文件解析
- 批量数据处理
- 长时间模型推理
- 报表导出
它的核心思想是把“接收请求”和“完成任务”拆开,使耗时操作不再受单次 HTTP 连接生命周期限制。
466



被折叠的 条评论
为什么被折叠?



