把 AI 生图接入网站

# 把 AI 生图接入网站

你已经能在 ComfyUI 或模型平台生成图片,下一步是让用户在自己的网站里使用它:输入描述、点击生成、等待片刻,然后下载图片。

这篇解决三个具体问题:生成期间页面显示什么,刷新后怎样找回结果,失败时怎样避免重复生成和扣费。 以下用商品海报功能举例,接口名称是业务设计示例,不是某家模型平台的现成 API。

# 点击生成后,前后端分别做什么

假设用户上传一张杯子照片,要求生成咖啡馆风格的海报。图片需要几十秒才能完成,用户不应该一直对着没有反馈的按钮。

顺序 用户看到什么 系统做什么
1. 提交 按钮显示正在提交,防止连续点击 后端检查图片、登录状态和使用额度
2. 接受任务 显示排队中 后端保存任务,返回编号,例如 img_001
3. 开始生成 显示生成中 后台调用模型 API,或执行 ComfyUI 工作流
4. 生成完成 展示海报和下载按钮 保存结果文件,将任务标记为成功
5. 刷新页面 仍能看到同一个任务 根据任务编号重新查询,不再重新生成

后台执行程序通常叫 Worker。它可以在自己的 GPU 服务器上运行,也可以只负责提交和查询第三方平台任务,使用云端模型 API 并不要求自己有 GPU。

短耗时、低并发的功能可以先使用普通请求返回结果。生成较慢,或者需要刷新恢复、排队和取消时,再拆成任务,不必一开始就搭复杂消息系统。通用实现见 异步任务笔记。

# 用两个接口完成提交和查进度

# 提交:先得到任务编号

用户已上传素材并拿到 asset_cup_01,前端提交:

POST /api/image-tasks
Content-Type: application/json
Idempotency-Key: poster-request-001

{
  "inputAssetId": "asset_cup_01",
  "prompt": "保留杯子外观,背景改为温暖的咖啡馆,右侧留出文案位置"
}
1
2
3
4
5
6
7
8

inputAssetId 指向已上传图片,后端检查它属于当前用户。Idempotency-Key 是本次提交的唯一标识:网络出错后重发同一次请求,仍使用同一标识;用户主动重新生成,才使用新标识。

后端接受任务后返回 HTTP 202 Accepted 和 JSON:

{ "taskId": "img_001", "status": "queued" }
1

后端在同一个用户范围内保证幂等键唯一,相同键但参数不同应拒绝,不能覆盖旧任务。前端禁用按钮只能减少误点,不能替代这个后端保证。

# 查询:根据状态更新页面

前端每隔几秒调用 GET /api/image-tasks/img_001。生成过程中返回 queued 或 running,成功时返回:

{
  "taskId": "img_001",
  "status": "succeeded",
  "imageUrl": "/api/image-assets/poster_001"
}
1
2
3
4
5

前端收到成功后停止查询,用 imageUrl 展示图片;失败、取消或离开页面时也停止查询。查询和下载接口都要检查当前用户是否有权限,不能只凭任务编号放行。

平台只报告排队或运行状态时,就显示对应文字,不伪造精确百分比。只有平台提供可信进度,才据此显示进度条。

# Worker 怎样真正调用 ComfyUI

业务接口不是直接把用户 JSON 转发给 ComfyUI。Worker 从服务端加载一份已经验收的工作流,只替换允许用户调整的字段,再向内网 ComfyUI 提交。这样用户不能自行指定未知节点、服务器路径或任意高成本参数。

ComfyUI 接口 在本例中做什么 返回值怎么使用
POST /prompt 提交 API 格式的工作流 保存返回的 prompt_id,它是执行任务标识,不是文字提示词
GET /history/{prompt_id} 查询已完成或失败任务的记录 检查状态,再读取输出节点中的图片描述
GET /view 按文件名、子目录和类型读取图片 获取图片字节,转存为业务资产
/ws 接收执行、进度和预览事件 有实时体验需求时使用;事件要按任务标识区分

/history 尚无记录,不代表任务失败,也可能正在排队或执行。业务任务持久化不能依赖 ComfyUI 内存历史永久存在。

# 最小脚本:提交、等待、下载

先在本机 127.0.0.1:8188 跑通 基础 SD 工作流,保留 Save Image,通过 File → Export (API) 导出为 workflow-api.json。下面对应官方基础示例中的节点 6(正向文字)与 3(KSampler);如果自己的节点编号不同,需要按导出文件调整,不能直接猜。

保存为同目录下的 generate.mjs,用 Node.js 18+ 执行 node generate.mjs。这是单任务调用示例,不包含网站鉴权、数据库和计费模块。

import { readFile, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';

const base = 'http://127.0.0.1:8188'; // 仅连接受控的本机 ComfyUI。
const clientId = randomUUID();

async function request(path, options = {}) {
  const response = await fetch(base + path, {
    ...options,
    signal: AbortSignal.timeout(30_000), // 单次网络请求超时,不是生成时限。
  });
  if (!response.ok) {
    throw new Error(`ComfyUI HTTP ${response.status}: ${await response.text()}`);
  }
  return response;
}

// 已有编号时只查询原任务。生产环境应从数据库读取,不依赖终端输出。
let promptId = process.env.RESUME_PROMPT_ID;
if (!promptId) {
  const workflow = JSON.parse(await readFile('workflow-api.json', 'utf8'));
  if (workflow['6']?.class_type !== 'CLIPTextEncode' ||
      workflow['3']?.class_type !== 'KSampler') {
    throw new Error('工作流节点编号与本例不同,请先核对导出文件');
  }
  // 修改可信模板的明确字段,不接收用户上传的任意执行图。
  workflow['6'].inputs.text = 'white ceramic cup, cafe, product photography';
  workflow['3'].inputs.seed = 42;

  const submitted = await (await request('/prompt', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt: workflow, client_id: clientId }),
  })).json();
  if (!submitted.prompt_id) throw new Error('未返回任务编号,不能视为提交成功');
  promptId = submitted.prompt_id;
  console.log('保存此 prompt_id,之后可恢复查询:', promptId);
}

// 示例最多等待 10 分钟;超时只停止本脚本等待,不自动取消或重复提交。
const deadline = Date.now() + 10 * 60_000;
let completed = false;
while (Date.now() < deadline) {
  const history = await (await request(`/history/${encodeURIComponent(promptId)}`)).json();
  const record = history[promptId];
  if (record?.status?.status_str === 'error') {
    throw new Error(`生成失败:${JSON.stringify(record.status.messages)}`);
  }
  if (record?.status?.completed) {
    // Save Image 的图片描述包含 filename、subfolder 和 type。
    const images = Object.values(record.outputs ?? {}).flatMap(node => node.images ?? []);
    if (!images.length) throw new Error('任务完成但没有图片,请检查 Save Image 节点');
    const output = images[0]; // 本例只下载第一张,批量任务应遍历并分别保存。
    const query = new URLSearchParams({
      filename: output.filename,
      subfolder: output.subfolder ?? '',
      type: output.type ?? 'output',
    });
    const response = await request(`/view?${query}`);
    // 基础 Save Image 模板输出 PNG;固定本地文件名,避免直接信任远端路径。
    await writeFile('result.png', Buffer.from(await response.arrayBuffer()));
    console.log('已保存 result.png');
    completed = true;
    break;
  }
  await delay(2000);
}
if (!completed) throw new Error(`等待超时,保留 ${promptId},查询原任务而非重新提交`);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

中断后可以将已记录的编号放入 RESUME_PROMPT_ID 再运行,脚本会跳过提交。该文件路径和恢复方式只用于本地学习;业务 Worker 应持久化 taskId → prompt_id、输出资产位置和最终状态。

本例使用本地模型节点;如果工作流包含调用外部付费服务的 API 节点,还涉及该节点自己的鉴权、计费和数据上传,不能因为由本机 ComfyUI 发起就认为是离线免费执行。

# 生产接入最重要的三处差异

  1. 模板受控。 后端固定节点图,限制尺寸、批次、模型与输入文件;用户提供的是业务参数,不是任意执行代码。
  2. 先存编号,再跟踪。 保存业务任务与执行编号;查询失败不立即重复提交。业务幂等键不自动让下游 /prompt 也具有相同幂等语义。
  3. 结果归业务系统。 下载成功后写入受控资产存储,保存模型与参数,再更新业务状态;不能把 ComfyUI 的 /view 直接作为所有用户共享的公开下载口。

# 刷新、超时和取消怎么处理

# 刷新:找回原任务

把任务编号放在 URL 或用户任务历史中,例如 /posters/img_001。刷新只查询,不再次提交。任务记录在数据库中,浏览器关闭不会删除它。

# 超时:先确认有没有开始生成

超时只说明没有及时收到响应。平台可能已经开始生成,直接再提交一次可能生成两张并收两次费。

已知情况 处理方式
已拿到平台任务编号 查询原任务,不重新生成
平台支持幂等键 按它的约定使用同一键恢复原请求
平台明确返回参数错误 修改参数,不自动重试同一请求
没有编号,也无法确认是否受理 保留待确认状态,不无限重试

业务任务编号和模型平台编号不是一回事,后端需要保存对应关系,才能从网站里的 img_001 追到真实生成任务。排障时也要分开记录排队时间和模型执行时间,避免把排队太久误判成模型变慢。

# 取消:停止等待不等于停止计费

排队中的任务可标记取消,Worker 开始前再次检查。已提交到外部平台的任务,需要调用其取消接口并确认结果;不支持取消时,只能停止页面等待,不能承诺模型已停止或费用已退回。

# 怎样让这个功能真的好用

  1. 先预览,再生成大图。 用户确认背景和构图后再放大,减少高成本试错;同时限制单用户并发、每日预算和重试次数。
  2. 准确文字用程序排版。 商品价格、日期和二维码在背景生成后叠加,不把正确性完全交给模型。
  3. 费用按可用结果算。 假设每次 0.2 元,10 次只选中 2 张,每张可用图的模型费用就是 1 元,还未包含放大与修图。这是计算示例,不是实际报价。
  4. 结果按需要转存。 第三方下载链接可能过期,按服务条款保存到自己的资产系统,并提供有权限控制的下载入口。

保存结果时记录模型、工作流、参数、Seed 和输入素材版本,便于复现。缓存也要区分这些条件和用户权限,不能因 Prompt 相同就把甲的私有图片返回给乙。上传限制格式和大小,临时素材设置清理期限;模型许可与图片使用权分别确认。

# 面试问答

1. 怎么把 AI 生图做成网站功能?参考答案

我会把长耗时生成拆成提交任务和查询结果两部分。用户点击后先拿到任务编号,页面显示排队或生成中,后台调用模型并保存结果;刷新只查询原任务,不重新生成。后端用幂等键防重复提交,并保存业务任务编号与平台编号的对应关系,这样超时后才能继续追踪,避免重复计费。

2. 生成请求失败,直接重试不就可以了吗?参考答案

不能一律重试,因为超时不代表模型没执行,可能只是响应没回来。我会先用平台任务编号查状态,或者按平台的幂等机制恢复原请求。完全无法确认是否受理时,保留待确认状态,不盲目重复提交,否则可能生成两份、扣两次费用。

3. 为什么前端禁用按钮还不够?参考答案

禁用按钮只能防止当前页面连续点击,刷新、多标签页和网络重发仍可能重复提交。后端需要在用户范围内用唯一幂等键识别同一次请求,并返回原任务;相同键却带了不同参数时应拒绝。这样才能从业务上避免重复创建任务。

# 参考资料