在线 AI 绘图平台:Flux 模型集成、Serverless 弹性部署与 ComfyUI 工作流的 React + Spring AI 全栈实战
本文将构建一个功能完整的在线 AI 绘图平台,支持文生图、图生图、图片编辑等功能。前端使用 React + Ant Design,后端使用 Spring Boot + Spring AI 集成 DashScope 通义万相文生图 API,同时深入讲解 Flux 模型的企业级 Java 集成方案、阿里云函数计算(FC)Serverless 弹性部署架构以及 ComfyUI 工作流的异步任务编排。
博客:
https://blog.csdn.net/badao_liumang_qizhi
一、项目架构设计
1.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ 前端 (React + Vite) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 绘图面板 │ │ 画廊展示 │ │ 图片编辑 │ │ 用户中心 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
│ HTTP / WebSocket
┌─────────────────────────────────────────────────────────────┐
│ 后端 (Spring Boot 3.x) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 绘图服务 │ │ 图片存储 │ │ 任务队列 │ │ 用户服务 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Spring AI Alibaba (ImageModel / DashScope) │ │
│ │ 通义万相 wanx2.1-t2i-turbo | Flux 推理服务 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 数据层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ MySQL │ │ Redis │ │ MinIO │ │ RabbitMQ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
1.2 技术栈选型
| 层级 | 技术选型 | 版本 | 选型理由 |
|---|---|---|---|
| 前端框架 | React + TypeScript | 18.2+ | 组件化开发,类型安全 |
| UI 组件库 | Ant Design | 5.12+ | 企业级中后台标准 |
| 构建工具 | Vite | 5.0+ | 极速 HMR,开箱即用 |
| 状态管理 | Zustand | 4.4+ | 轻量、无样板代码 |
| 后端框架 | Spring Boot | 3.2+ | 成熟的企业级生态 |
| AI 框架 | Spring AI Alibaba | 1.1.2+ | 统一 ImageModel 抽象,自动配置 |
| 文生图模型 | DashScope 通义万相 | wanx2.1-t2i-turbo | 中文理解好、速度快 |
| 备用模型 | Flux.1-dev-fp8-dit | - | 写实画质高,fp8 量化后 A10 可跑 |
| 对象存储 | MinIO | - | 兼容 S3,私有化部署 |
| 消息队列 | RabbitMQ | 3.12+ | 异步任务解耦 |
架构选型说明 :后端选择 Spring AI Alibaba 而非直接调用 DashScope SDK,核心原因是其提供了统一的 ImageModel 接口,底层无论对接通义万相还是 Flux 推理服务,业务代码都保持一致。若未来需要切换到其他图像生成模型,只需更换 ImageModel 的 Bean 实现,Controller 和 Service 层无需改动。
1.3 核心功能模块与数据流
| 功能模块 | 核心能力 | 关键技术 |
|---|---|---|
| 文生图 | 输入文字描述生成图片 | ImageModel + DashScopeImageOptions |
| 图生图 | 上传参考图生成变体 | 图生图 API + Denoising Strength |
| ComfyUI 工作流 | 复杂多步骤图像编辑 | ComfyUI HTTP API + 工作流 JSON |
| 任务队列 | 异步处理绘图请求 | RabbitMQ + 信号量并发控制 |
| 实时进度推送 | WebSocket 推送任务状态 | STOMP + SimpMessagingTemplate |
| 图片存储 | 生成结果持久化 | MinIO 对象存储 + 预签名 URL |
| 安全过滤 | NSFW 内容拦截 | 内容安全 API |
核心数据流:用户输入提示词 → 提交绘图任务 → RabbitMQ 异步队列 → ImageModel 调用模型 → 轮询或 WebSocket 获取结果 → 图片存储到 MinIO → 返回访问 URL → 前端展示。
二、后端实现
2.1 项目依赖配置
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>ai-draw-backend</artifactId>
<version>1.0.0</version>
<name>AI Draw Backend</name>
<properties>
<java.version>17</java.version>
<spring-ai-alibaba.version>1.1.2.0</spring-ai-alibaba.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Spring AI Alibaba --- 统一 ImageModel 抽象 -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>${spring-ai-alibaba.version}</version>
</dependency>
<!-- MinIO -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- OkHttp for Flux inference service calls -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
</dependencies>
</project>
关键依赖说明 :核心依赖只有 spring-ai-alibaba-starter-dashscope,它已经替你管理好了和阿里云通信的全部逻辑,引入后 Spring Boot 会自动配置一个 DashScopeImageModel 实例。
2.2 配置文件
yaml
# application.yml
server:
port: 8080
spring:
application:
name: ai-draw-backend
datasource:
url: jdbc:mysql://localhost:3306/ai_draw?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
data:
redis:
host: localhost
port: 6379
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
# Spring AI Alibaba DashScope 配置
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY}
image:
options:
model: wanx2.1-t2i-turbo
size: 1024*1024
# MinIO 配置
minio:
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin
bucket-name: ai-draw-images
# Flux 推理服务配置(可选)
flux:
enabled: ${FLUX_ENABLED:false}
endpoint: ${FLUX_ENDPOINT:http://flux-inference-service:8000}
timeout-seconds: 45
max-retries: 2
# ComfyUI 服务配置(可选)
comfyui:
enabled: ${COMFYUI_ENABLED:false}
endpoint: ${COMFYUI_ENDPOINT:http://comfy-server:8188}
# 任务配置
draw:
task:
timeout: 120000
max-concurrent: 5
retry-count: 2
配置说明 :spring.ai.dashscope 前缀下的配置是 Spring AI Alibaba 自动配置的入口。只需填写 API Key 和模型名称,框架就会自动创建 ImageModel Bean。Flux 和 ComfyUI 的配置默认关闭,需要时通过环境变量启用。
2.3 实体类定义
java
package com.example.aidraw.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "draw_task")
public class DrawTask {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 32)
private String taskId;
@Column(nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private DrawType type; // TEXT_TO_IMAGE, IMAGE_TO_IMAGE, COMFY_WORKFLOW
@Column(columnDefinition = "TEXT")
private String prompt;
@Column(columnDefinition = "TEXT")
private String negativePrompt;
private String imageUrl; // 输入图片URL(图生图)
private String resultImageUrl; // 生成结果URL
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TaskStatus status = TaskStatus.PENDING;
private Integer width = 1024;
private Integer height = 1024;
private Integer steps = 20;
private Double cfgScale = 7.0;
private String style;
private Long seed;
@Column(length = 50)
private String model; // 使用的模型名称
private String errorMessage;
private Integer retryCount = 0;
@CreationTimestamp
private LocalDateTime createdAt;
private LocalDateTime completedAt;
public enum DrawType {
TEXT_TO_IMAGE,
IMAGE_TO_IMAGE,
COMFY_WORKFLOW
}
public enum TaskStatus {
PENDING, // 等待中
PROCESSING, // 处理中
COMPLETED, // 完成
FAILED // 失败
}
}
java
package com.example.aidraw.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "gallery_image")
public class GalleryImage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private String imageUrl;
@Column(length = 500)
private String thumbnailUrl;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String prompt;
@Column(columnDefinition = "TEXT")
private String negativePrompt;
@Column(length = 50)
private String model;
private Integer width;
private Integer height;
@Column(nullable = false)
private Boolean isPublic = false;
@Column(nullable = false)
private Integer likeCount = 0;
@Column(nullable = false)
private Integer viewCount = 0;
@CreationTimestamp
private LocalDateTime createdAt;
}
2.4 基于 ImageModel 的文生图服务
Spring AI 设计的精髓在于"换个模型只是换个实现"。对于图像生成,它定义了 ImageModel 接口作为核心入口,ImagePrompt 包含提示词和选项,ImageResponse 返回生成结果。你只需要注入一个 ImageModel,传入 ImagePrompt,就能得到结果。底层到底是通义万相、OpenAI DALL·E 还是 Flux,对你来说都变成同一套代码。
java
package com.example.aidraw.service;
import com.alibaba.cloud.ai.dashscope.image.DashScopeImageOptions;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.image.*;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiDrawService {
private final ImageModel imageModel;
/**
* 文生图 --- 基于 Spring AI ImageModel 统一抽象
*/
public String textToImage(String prompt, String negativePrompt,
int width, int height, String model) {
// 1. 构建生成选项
DashScopeImageOptions options = DashScopeImageOptions.builder()
.withModel(model != null ? model : "wanx2.1-t2i-turbo")
.withWidth(width)
.withHeight(height)
.withN(1)
.build();
// 2. 创建 ImagePrompt
ImagePrompt imagePrompt = new ImagePrompt(prompt, options);
// 3. 调用模型生成图片
ImageResponse response = imageModel.call(imagePrompt);
// 4. 提取图片 URL(阿里云百炼的临时访问链接)
String imageUrl = response.getResult().getOutput().getUrl();
log.info("文生图完成: prompt={}, url={}", prompt, imageUrl);
return imageUrl;
}
/**
* 批量生成多张图片
*/
public List<String> batchGenerate(String prompt, int count,
int width, int height) {
DashScopeImageOptions options = DashScopeImageOptions.builder()
.withModel("wanx2.1-t2i-turbo")
.withWidth(width)
.withHeight(height)
.withN(count)
.build();
ImageResponse response = imageModel.call(
new ImagePrompt(prompt, options));
return response.getResults().stream()
.map(r -> r.getOutput().getUrl())
.collect(Collectors.toList());
}
}
代码解析 :textToImage 方法通过 DashScopeImageOptions 构建生成选项,包括模型名称、尺寸和生成数量。然后创建 ImagePrompt 并调用 imageModel.call() 获取结果。getResult().getOutput().getUrl() 是标准的 URL 提取路径,返回的是阿里云百炼的临时图片 URL。
生活类比:ImageModel 就像"万能遥控器"。 无论你家里是格力空调还是美的空调(底层模型不同),遥控器上的"开机"按钮(call 方法)都能让空调启动。你不需要因为换了空调品牌而重新学习使用遥控器。
2.5 Flux 模型企业级集成
对于需要更高写实画质的企业场景,Flux.1-dev-fp8-dit 是理想选择。它用 fp8 精度和 dit 架构做了轻量化设计,在 A10 或 A100 级别显卡上就能跑出印刷级画质,对中文提示词的理解很扎实,不需要反复调试英文关键词。
集成思路:把 Flux 部署成独立的推理服务(比如用星图 GPU 平台的一键镜像),然后在 SpringBoot 里用标准 HTTP 方式调用。这样做的好处很明显------模型更新不用动 Java 代码,显卡故障不影响业务主流程,甚至可以把生成服务部署在单独的 GPU 集群上,和业务系统物理隔离。
java
package com.example.aidraw.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class FluxImageService {
@Value("${flux.endpoint}")
private String fluxEndpoint;
@Value("${flux.timeout-seconds:45}")
private int timeoutSeconds;
private final ObjectMapper objectMapper = new ObjectMapper();
private final OkHttpClient httpClient;
public FluxImageService(@Value("${flux.timeout-seconds:45}") int timeout) {
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(timeout, TimeUnit.SECONDS)
.build();
}
/**
* 调用 Flux 推理服务生成图片
* 超时设为45秒,因为实测95%的请求都在30秒内完成,
* 留15秒缓冲刚好覆盖复杂提示词的生成波动
*/
public String generateImage(String prompt, String negativePrompt,
int width, int height) throws IOException {
Map<String, Object> params = new HashMap<>();
params.put("prompt", prompt);
if (negativePrompt != null) {
params.put("negative_prompt", negativePrompt);
}
params.put("width", width);
params.put("height", height);
params.put("num_inference_steps", 20);
params.put("guidance_scale", 3.5);
String jsonBody = objectMapper.writeValueAsString(params);
Request request = new Request.Builder()
.url(fluxEndpoint + "/generate")
.post(RequestBody.create(jsonBody,
MediaType.parse("application/json")))
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
// 只对网络超时重试,对模型返回"生成失败"的响应绝不重试
throw new IOException("Flux 推理失败: " + response.code());
}
String body = response.body().string();
JsonNode node = objectMapper.readTree(body);
return node.path("image_url").asText();
}
}
}
重试策略:只对网络超时重试两次,但对模型返回"生成失败"的响应绝不重试------这说明是提示词或内容问题,重试只会浪费资源。
2.6 ComfyUI 工作流集成
对于复杂多步骤图像编辑需求(如换脸、风格迁移、Inpainting),ComfyUI 的节点化工作流是首选。ComfyUI 提供两种 API:同步的 /prompt 接口直接触发工作流执行;异步方式结合 /prompt 和 /history 接口获取结果。
java
package com.example.aidraw.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class ComfyWorkflowService {
@Value("${comfyui.endpoint}")
private String comfyEndpoint;
private final ObjectMapper objectMapper = new ObjectMapper();
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build();
/**
* 提交 ComfyUI 工作流任务
* 需要先在 ComfyUI 界面手动配置好工作流,
* 通过"保存工作流为JSON"功能获取模板
*/
public String submitWorkflow(String workflowJson,
String imageUrl, String stylePrompt)
throws IOException {
// 1. 动态构建工作流JSON --- 替换节点中的参数
JsonNode workflow = objectMapper.readTree(workflowJson);
// 替换输入图片URL(节点ID需要根据实际工作流配置)
((ObjectNode) workflow.path("nodes").path("10").path("inputs"))
.put("image", imageUrl);
// 替换风格提示词
((ObjectNode) workflow.path("nodes").path("15").path("inputs"))
.put("text", stylePrompt);
// 2. 包装为 ComfyUI API 请求格式
ObjectNode request = objectMapper.createObjectNode();
request.set("prompt", workflow);
request.put("client_id", UUID.randomUUID().toString());
Request httpRequest = new Request.Builder()
.url(comfyEndpoint + "/prompt")
.post(RequestBody.create(
objectMapper.writeValueAsString(request),
MediaType.parse("application/json")))
.build();
try (Response response = httpClient.newCall(httpRequest).execute()) {
String body = response.body().string();
JsonNode result = objectMapper.readTree(body);
return result.path("prompt_id").asText();
}
}
/**
* 查询 ComfyUI 任务结果
*/
public String getWorkflowResult(String promptId) throws IOException {
Request request = new Request.Builder()
.url(comfyEndpoint + "/history/" + promptId)
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
String body = response.body().string();
JsonNode history = objectMapper.readTree(body);
// 从历史记录中提取生成的图片
JsonNode outputs = history.path(promptId).path("outputs");
var fields = outputs.fields();
while (fields.hasNext()) {
var entry = fields.next();
JsonNode images = entry.getValue().path("images");
if (images.isArray() && images.size() > 0) {
return images.get(0).path("filename").asText();
}
}
return null; // 尚未完成
}
}
}
关键设计:一是用 Spring 的异步任务处理来解耦请求和耗时操作,提升接口响应速度;二是精心设计 HTTP 客户端与 ComfyUI 服务的通信协议,特别是动态构建工作流 JSON 那部分,需要耐心调试。
2.7 任务队列服务
java
package com.example.aidraw.service;
import com.example.aidraw.entity.DrawTask;
import com.example.aidraw.repository.DrawTaskRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class DrawTaskService {
private final DrawTaskRepository taskRepository;
private final AiDrawService aiDrawService;
private final FluxImageService fluxImageService;
private final RabbitTemplate rabbitTemplate;
private final SimpMessagingTemplate messagingTemplate;
private final RedisTemplate<String, Object> redisTemplate;
private final ImageStorageService imageStorageService;
@Value("${draw.task.max-concurrent}")
private int maxConcurrent;
private final Semaphore semaphore = new Semaphore(5);
private static final String TASK_QUEUE = "draw.task.queue";
private static final String TASK_STATUS_CHANNEL = "/topic/task/";
/**
* 提交绘图任务
*/
public DrawTask submitTask(DrawTask task) {
task.setTaskId(generateTaskId());
task.setStatus(DrawTask.TaskStatus.PENDING);
task = taskRepository.save(task);
// 发送到消息队列
rabbitTemplate.convertAndSend(TASK_QUEUE, task.getTaskId());
return task;
}
/**
* 消费并处理任务
*/
@RabbitListener(queues = TASK_QUEUE, concurrency = "3-5")
public void processTask(String taskId) {
DrawTask task = taskRepository.findByTaskId(taskId)
.orElseThrow(() -> new RuntimeException("任务不存在: " + taskId));
try {
semaphore.acquire();
task.setStatus(DrawTask.TaskStatus.PROCESSING);
taskRepository.save(task);
// 通知前端状态更新
notifyStatusChange(task);
// 根据任务类型选择模型
String resultUrl;
if ("flux".equals(task.getModel()) && fluxEnabled) {
resultUrl = fluxImageService.generateImage(
task.getPrompt(), task.getNegativePrompt(),
task.getWidth(), task.getHeight());
} else {
resultUrl = aiDrawService.textToImage(
task.getPrompt(), task.getNegativePrompt(),
task.getWidth(), task.getHeight(), task.getModel());
}
// 下载图片并存储到 MinIO
String storedUrl = imageStorageService
.downloadAndStore(resultUrl, taskId);
task.setStatus(DrawTask.TaskStatus.COMPLETED);
task.setResultImageUrl(storedUrl);
task.setCompletedAt(LocalDateTime.now());
taskRepository.save(task);
notifyStatusChange(task);
} catch (Exception e) {
log.error("任务处理失败: {}", taskId, e);
task.setStatus(DrawTask.TaskStatus.FAILED);
task.setErrorMessage(e.getMessage());
taskRepository.save(task);
notifyStatusChange(task);
} finally {
semaphore.release();
}
}
/**
* 通过 WebSocket 通知前端
*/
private void notifyStatusChange(DrawTask task) {
messagingTemplate.convertAndSend(
TASK_STATUS_CHANNEL + task.getTaskId(), task);
}
/**
* 获取用户任务列表
*/
public List<DrawTask> getUserTasks(Long userId, int page, int size) {
return taskRepository.findByUserIdOrderByCreatedAtDesc(
userId, PageRequest.of(page, size)).getContent();
}
/**
* 获取任务详情
*/
public DrawTask getTask(String taskId) {
return taskRepository.findByTaskId(taskId)
.orElseThrow(() -> new RuntimeException("任务不存在"));
}
private String generateTaskId() {
return java.util.UUID.randomUUID().toString()
.replace("-", "").substring(0, 16);
}
}
代码解析 :processTask 是异步任务处理的核心。通过 semaphore.acquire() 控制并发数(最大 5 个同时执行),避免 GPU 显存溢出。notifyStatusChange 通过 WebSocket 的 STOMP 协议将任务状态推送给前端,实现实时进度展示。根据 task.getModel() 决定使用通义万相还是 Flux 推理服务,实现了多模型路由。
2.8 图片存储服务
java
package com.example.aidraw.service;
import io.minio.*;
import io.minio.http.Method;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URL;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class ImageStorageService {
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.access-key}")
private String accessKey;
@Value("${minio.secret-key}")
private String secretKey;
@Value("${minio.bucket-name}")
private String bucketName;
private MinioClient minioClient;
@PostConstruct
public void init() {
minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
createBucketIfNotExists();
}
private void createBucketIfNotExists() {
try {
boolean exists = minioClient.bucketExists(
BucketExistsArgs.builder().bucket(bucketName).build());
if (!exists) {
minioClient.makeBucket(
MakeBucketArgs.builder().bucket(bucketName).build());
log.info("创建 Bucket: {}", bucketName);
}
} catch (Exception e) {
log.error("初始化 MinIO 失败", e);
}
}
/**
* 下载临时 URL 的图片并转存到 MinIO
* 阿里云百炼返回的是临时访问链接,如果业务需要长期保存,
* 应该由后端下载后再转存到对象存储
*/
public String downloadAndStore(String imageUrl, String taskId)
throws Exception {
URL url = new URL(imageUrl);
byte[] imageBytes;
try (InputStream inputStream = url.openStream()) {
imageBytes = inputStream.readAllBytes();
}
String fileName = "images/" + taskId + ".png";
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(fileName)
.stream(new ByteArrayInputStream(imageBytes),
imageBytes.length, -1)
.contentType("image/png")
.build());
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucketName)
.object(fileName)
.expiry(7, TimeUnit.DAYS)
.build());
}
/**
* 上传用户图片(图生图场景)
*/
public String uploadImage(byte[] imageData, String fileName,
String contentType) throws Exception {
String objectName = "uploads/" + System.currentTimeMillis()
+ "_" + fileName;
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(new ByteArrayInputStream(imageData),
imageData.length, -1)
.contentType(contentType)
.build());
return endpoint + "/" + bucketName + "/" + objectName;
}
}
2.9 REST 控制器
java
package com.example.aidraw.controller;
import com.example.aidraw.entity.DrawTask;
import com.example.aidraw.entity.GalleryImage;
import com.example.aidraw.service.*;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/draw")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class DrawController {
private final DrawTaskService taskService;
private final AiDrawService aiDrawService;
private final GalleryService galleryService;
private final ImageStorageService storageService;
private final ComfyWorkflowService comfyWorkflowService;
/**
* 提交文生图任务
*/
@PostMapping("/text-to-image")
public ResponseEntity<DrawTask> textToImage(
@RequestBody TextToImageRequest request) {
DrawTask task = new DrawTask();
task.setUserId(request.getUserId());
task.setType(DrawTask.DrawType.TEXT_TO_IMAGE);
task.setPrompt(request.getPrompt());
task.setNegativePrompt(request.getNegativePrompt());
task.setWidth(request.getWidth());
task.setHeight(request.getHeight());
task.setStyle(request.getStyle());
task.setSeed(request.getSeed());
task.setModel(request.getModel());
return ResponseEntity.ok(taskService.submitTask(task));
}
/**
* 提交图生图任务
*/
@PostMapping("/image-to-image")
public ResponseEntity<DrawTask> imageToImage(
@RequestBody ImageToImageRequest request) {
DrawTask task = new DrawTask();
task.setUserId(request.getUserId());
task.setType(DrawTask.DrawType.IMAGE_TO_IMAGE);
task.setPrompt(request.getPrompt());
task.setNegativePrompt(request.getNegativePrompt());
task.setImageUrl(request.getImageUrl());
task.setWidth(request.getWidth());
task.setHeight(request.getHeight());
return ResponseEntity.ok(taskService.submitTask(task));
}
/**
* 提交 ComfyUI 工作流任务
*/
@PostMapping("/comfy-workflow")
public ResponseEntity<Map<String, String>> comfyWorkflow(
@RequestBody ComfyWorkflowRequest request) throws Exception {
String promptId = comfyWorkflowService.submitWorkflow(
request.getWorkflowJson(),
request.getImageUrl(),
request.getStylePrompt());
return ResponseEntity.ok(Map.of("promptId", promptId));
}
/**
* 查询任务状态
*/
@GetMapping("/task/{taskId}")
public ResponseEntity<DrawTask> getTaskStatus(
@PathVariable String taskId) {
return ResponseEntity.ok(taskService.getTask(taskId));
}
/**
* 上传图片
*/
@PostMapping("/upload")
public ResponseEntity<Map<String, String>> uploadImage(
@RequestParam("file") MultipartFile file) throws Exception {
String url = storageService.uploadImage(
file.getBytes(), file.getOriginalFilename(),
file.getContentType());
return ResponseEntity.ok(Map.of("url", url));
}
}
// DTO 类
@Data
class TextToImageRequest {
private Long userId;
private String prompt;
private String negativePrompt;
private Integer width = 1024;
private Integer height = 1024;
private String style;
private Long seed;
private String model; // "wanx2.1-t2i-turbo" | "flux"
}
@Data
class ImageToImageRequest {
private Long userId;
private String prompt;
private String negativePrompt;
private String imageUrl;
private Integer width = 1024;
private Integer height = 1024;
}
@Data
class ComfyWorkflowRequest {
private String workflowJson;
private String imageUrl;
private String stylePrompt;
}
2.10 WebSocket 配置
java
package com.example.aidraw.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
三、前端实现
3.1 项目初始化
bash
npm create vite@latest ai-draw-frontend -- --template react-ts
cd ai-draw-frontend
npm install
npm install antd @ant-design/icons axios zustand
npm install @stomp/stompjs sockjs-client
3.2 状态管理
typescript
// src/store/drawStore.ts
import { create } from 'zustand'
import { DrawTask, GalleryImage } from '../types'
interface DrawState {
currentTask: DrawTask | null
tasks: DrawTask[]
galleryImages: GalleryImage[]
isLoading: boolean
prompt: string
negativePrompt: string
width: number
height: number
style: string
seed: number | null
model: string
setCurrentTask: (task: DrawTask | null) => void
setTasks: (tasks: DrawTask[]) => void
updateTask: (taskId: string, updates: Partial<DrawTask>) => void
setGalleryImages: (images: GalleryImage[]) => void
setLoading: (loading: boolean) => void
setPrompt: (prompt: string) => void
setNegativePrompt: (prompt: string) => void
setWidth: (width: number) => void
setHeight: (height: number) => void
setStyle: (style: string) => void
setSeed: (seed: number | null) => void
setModel: (model: string) => void
}
export const useDrawStore = create<DrawState>((set) => ({
currentTask: null,
tasks: [],
galleryImages: [],
isLoading: false,
prompt: '',
negativePrompt: '',
width: 1024,
height: 1024,
style: 'default',
seed: null,
model: 'wanx2.1-t2i-turbo',
setCurrentTask: (task) => set({ currentTask: task }),
setTasks: (tasks) => set({ tasks }),
updateTask: (taskId, updates) => set((state) => ({
tasks: state.tasks.map(t =>
t.taskId === taskId ? { ...t, ...updates } : t
),
currentTask: state.currentTask?.taskId === taskId
? { ...state.currentTask, ...updates }
: state.currentTask
})),
setGalleryImages: (images) => set({ galleryImages: images }),
setLoading: (loading) => set({ isLoading: loading }),
setPrompt: (prompt) => set({ prompt }),
setNegativePrompt: (prompt) => set({ negativePrompt: prompt }),
setWidth: (width) => set({ width }),
setHeight: (height) => set({ height }),
setStyle: (style) => set({ style }),
setSeed: (seed) => set({ seed }),
setModel: (model) => set({ model })
}))
3.3 API 封装
typescript
// src/api/draw.ts
import axios from 'axios'
import { DrawTask, GalleryImage, PageResponse } from '../types'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
timeout: 30000
})
export const drawApi = {
textToImage: (data: {
userId: number
prompt: string
negativePrompt?: string
width?: number
height?: number
style?: string
seed?: number
model?: string
}) => api.post<DrawTask>('/draw/text-to-image', data),
imageToImage: (data: {
userId: number
prompt: string
negativePrompt?: string
imageUrl: string
width?: number
height?: number
}) => api.post<DrawTask>('/draw/image-to-image', data),
comfyWorkflow: (data: {
workflowJson: string
imageUrl: string
stylePrompt: string
}) => api.post<{ promptId: string }>('/draw/comfy-workflow', data),
getTaskStatus: (taskId: string) =>
api.get<DrawTask>(`/draw/task/${taskId}`),
getUserTasks: (userId: number, page = 0, size = 20) =>
api.get<PageResponse<DrawTask>>('/draw/tasks', {
params: { userId, page, size }
}),
enhancePrompt: (prompt: string) =>
api.post<{ prompt: string }>('/draw/enhance-prompt', { prompt }),
uploadImage: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return api.post<{ url: string }>('/draw/upload', formData)
}
}
3.4 类型定义
typescript
// src/types/index.ts
export interface DrawTask {
id: number
taskId: string
userId: number
type: 'TEXT_TO_IMAGE' | 'IMAGE_TO_IMAGE' | 'COMFY_WORKFLOW'
prompt: string
negativePrompt?: string
imageUrl?: string
resultImageUrl?: string
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'
width: number
height: number
steps?: number
cfgScale?: number
style?: string
seed?: number
model?: string
errorMessage?: string
createdAt: string
completedAt?: string
}
export interface GalleryImage {
id: number
userId: number
imageUrl: string
thumbnailUrl?: string
title: string
prompt: string
negativePrompt?: string
model?: string
width?: number
height?: number
isPublic: boolean
likeCount: number
viewCount: number
createdAt: string
}
export interface PageResponse<T> {
content: T[]
totalElements: number
totalPages: number
size: number
number: number
}
3.5 绘图面板组件
tsx
// src/components/DrawPanel.tsx
import React, { useCallback, useRef } from 'react'
import {
Card, Input, Button, Select, Space, Spin, message,
Row, Col, Tooltip, Tag
} from 'antd'
import {
PictureOutlined, SettingOutlined, ThunderboltOutlined,
ReloadOutlined, CopyOutlined, DownloadOutlined
} from '@ant-design/icons'
import { useDrawStore } from '../store/drawStore'
import { drawApi } from '../api/draw'
import { useWebSocket } from '../hooks/useWebSocket'
const { TextArea } = Input
const { Option } = Select
const STYLES = [
{ value: 'default', label: '默认' },
{ value: 'anime', label: '动漫' },
{ value: 'oil', label: '油画' },
{ value: 'watercolor', label: '水彩' },
{ value: 'sketch', label: '素描' },
{ value: '3d', label: '3D渲染' },
{ value: 'cyberpunk', label: '赛博朋克' }
]
const MODELS = [
{ value: 'wanx2.1-t2i-turbo', label: '通义万相 Turbo(快速)' },
{ value: 'flux', label: 'Flux.1-dev(高质量)' }
]
const SIZES = [
{ value: '512x512', label: '512×512', width: 512, height: 512 },
{ value: '768x768', label: '768×768', width: 768, height: 768 },
{ value: '1024x1024', label: '1024×1024', width: 1024, height: 1024 },
{ value: '1024x768', label: '1024×768 (横版)', width: 1024, height: 768 },
{ value: '768x1024', label: '768×1024 (竖版)', width: 768, height: 1024 }
]
export const DrawPanel: React.FC = () => {
const {
currentTask, prompt, negativePrompt, width, height,
style, seed, model, setPrompt, setNegativePrompt,
setWidth, setHeight, setStyle, setSeed, setModel,
setCurrentTask, updateTask, isLoading, setLoading
} = useDrawStore()
// WebSocket 监听任务状态
useWebSocket(currentTask?.taskId || null, (task) => {
updateTask(task.taskId, task)
if (task.status === 'COMPLETED' || task.status === 'FAILED') {
setLoading(false)
}
})
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) {
message.warning('请输入提示词')
return
}
setLoading(true)
try {
const { data } = await drawApi.textToImage({
userId: 1,
prompt,
negativePrompt,
width,
height,
style,
seed: seed || undefined,
model
})
setCurrentTask(data)
message.success('任务已提交')
} catch (error) {
message.error('提交失败')
setLoading(false)
}
}, [prompt, negativePrompt, width, height, style, seed, model])
const handleEnhancePrompt = async () => {
if (!prompt.trim()) return
try {
const { data } = await drawApi.enhancePrompt(prompt)
setPrompt(data.prompt)
message.success('提示词已优化')
} catch (error) {
message.error('优化失败')
}
}
const handleRandomSeed = () => {
setSeed(Math.floor(Math.random() * 2147483647))
}
const handleDownload = () => {
if (currentTask?.resultImageUrl) {
const link = document.createElement('a')
link.href = currentTask.resultImageUrl
link.download = `ai-draw-${currentTask.taskId}.png`
link.click()
}
}
return (
<div className="draw-panel">
<Row gutter={24}>
<Col xs={24} lg={12}>
<Card title="AI 绘图" extra={
<Tooltip title="高级设置">
<Button icon={<SettingOutlined />} />
</Tooltip>
}>
<div className="form-item">
<label>模型</label>
<Select value={model} onChange={setModel} style={{ width: '100%' }}>
{MODELS.map(m => (
<Option key={m.value} value={m.value}>{m.label}</Option>
))}
</Select>
</div>
<div className="form-item">
<label>提示词 (Prompt)</label>
<TextArea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="描述你想生成的图片..."
rows={4}
maxLength={1000}
showCount
/>
<Button
type="link"
icon={<ThunderboltOutlined />}
onClick={handleEnhancePrompt}
>
优化提示词
</Button>
</div>
<div className="form-item">
<label>负面提示词 (可选)</label>
<TextArea
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
placeholder="不想出现的元素..."
rows={2}
/>
</div>
<div className="form-item">
<label>风格</label>
<Select value={style} onChange={setStyle} style={{ width: '100%' }}>
{STYLES.map(s => (
<Option key={s.value} value={s.value}>{s.label}</Option>
))}
</Select>
</div>
<div className="form-item">
<label>尺寸</label>
<Select
value={`${width}x${height}`}
onChange={(val) => {
const size = SIZES.find(s => s.value === val)
if (size) { setWidth(size.width); setHeight(size.height) }
}}
style={{ width: '100%' }}
>
{SIZES.map(s => (
<Option key={s.value} value={s.value}>{s.label}</Option>
))}
</Select>
</div>
<div className="form-item">
<label>种子 (Seed)</label>
<Space>
<Input
value={seed || ''}
onChange={(e) => setSeed(
e.target.value ? Number(e.target.value) : null)}
placeholder="随机"
style={{ width: 150 }}
/>
<Tooltip title="随机种子">
<Button icon={<ReloadOutlined />} onClick={handleRandomSeed} />
</Tooltip>
</Space>
</div>
<Button
type="primary"
size="large"
block
icon={<PictureOutlined />}
onClick={handleGenerate}
loading={isLoading}
style={{ marginTop: 24 }}
>
{isLoading ? '生成中...' : '开始生成'}
</Button>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title="预览"
extra={
currentTask?.resultImageUrl && (
<Space>
<Button
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(currentTask.resultImageUrl!)
message.success('已复制链接')
}}
>复制链接</Button>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleDownload}
>下载</Button>
</Space>
)
}
>
<div className="preview-area">
{isLoading ? (
<div className="loading-preview">
<Spin size="large" tip="AI 正在创作中..." />
<div className="task-status">
{currentTask?.status === 'PENDING' &&
<Tag color="default">等待中</Tag>}
{currentTask?.status === 'PROCESSING' &&
<Tag color="processing">生成中</Tag>}
</div>
</div>
) : currentTask?.resultImageUrl ? (
<img
src={currentTask.resultImageUrl}
alt="生成结果"
className="result-image"
/>
) : (
<div className="empty-preview">
<PictureOutlined style={{ fontSize: 64, color: '#ccc' }} />
<p>输入提示词并点击生成</p>
</div>
)}
</div>
</Card>
</Col>
</Row>
</div>
)
}
3.6 WebSocket Hook
typescript
// src/hooks/useWebSocket.ts
import { useEffect, useRef } from 'react'
import { Client, IMessage } from '@stomp/stompjs'
import SockJS from 'sockjs-client'
import { DrawTask } from '../types'
const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8080/ws'
export const useWebSocket = (
taskId: string | null,
onMessage: (task: DrawTask) => void
) => {
const clientRef = useRef<Client | null>(null)
useEffect(() => {
if (!taskId) return
const client = new Client({
webSocketFactory: () => new SockJS(WS_URL),
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
onConnect: () => {
client.subscribe(`/topic/task/${taskId}`, (message: IMessage) => {
const task = JSON.parse(message.body) as DrawTask
onMessage(task)
})
},
onStompError: (frame) => {
console.error('WebSocket error:', frame)
}
})
client.activate()
clientRef.current = client
return () => { client.deactivate() }
}, [taskId, onMessage])
}
3.7 画廊组件
tsx
// src/components/Gallery.tsx
import React, { useEffect, useState } from 'react'
import { Card, Row, Col, Modal, Tag, Space, Pagination, Spin } from 'antd'
import { HeartOutlined, EyeOutlined, DownloadOutlined } from '@ant-design/icons'
import { drawApi } from '../api/draw'
import { GalleryImage } from '../types'
const { Meta } = Card
export const Gallery: React.FC = () => {
const [images, setImages] = useState<GalleryImage[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null)
const loadImages = async (pageNum = 0) => {
setLoading(true)
try {
const { data } = await drawApi.getGallery(pageNum, 12)
setImages(data.content)
setTotalPages(data.totalPages)
} catch (error) {
console.error('加载画廊失败', error)
} finally {
setLoading(false)
}
}
useEffect(() => { loadImages() }, [])
return (
<div className="gallery-container">
<Spin spinning={loading}>
<Row gutter={[16, 16]}>
{images.map((img) => (
<Col xs={24} sm={12} md={8} lg={6} key={img.id}>
<Card
hoverable
cover={
<div className="gallery-image-wrapper">
<img
alt={img.title}
src={img.thumbnailUrl || img.imageUrl}
onClick={() => setSelectedImage(img)}
/>
</div>
}
actions={[
<Space><HeartOutlined /> {img.likeCount}</Space>,
<Space><EyeOutlined /> {img.viewCount}</Space>,
<DownloadOutlined onClick={() => {
const link = document.createElement('a')
link.href = img.imageUrl
link.download = `${img.title}.png`
link.click()
}} />
]}
>
<Meta
title={img.title}
description={
<Space direction="vertical" size={0}>
<Tag color="blue">{img.model || 'AI'}</Tag>
<span className="prompt-text">
{img.prompt.slice(0, 50)}...
</span>
</Space>
}
/>
</Card>
</Col>
))}
</Row>
</Spin>
<Pagination
current={page + 1}
total={totalPages * 12}
pageSize={12}
onChange={(p) => { setPage(p - 1); loadImages(p - 1) }}
style={{ marginTop: 24, textAlign: 'center' }}
/>
<Modal
open={!!selectedImage}
footer={null}
onCancel={() => setSelectedImage(null)}
width="80%"
style={{ top: 20 }}
>
{selectedImage && (
<div className="image-detail">
<img
src={selectedImage.imageUrl}
alt={selectedImage.title}
style={{ width: '100%' }}
/>
<div className="image-info" style={{ marginTop: 16 }}>
<h3>{selectedImage.title}</h3>
<p><strong>提示词:</strong>{selectedImage.prompt}</p>
{selectedImage.negativePrompt && (
<p><strong>负面提示词:</strong>
{selectedImage.negativePrompt}</p>
)}
<p><strong>模型:</strong>{selectedImage.model}</p>
</div>
</div>
)}
</Modal>
</div>
)
}
3.8 主应用组件
tsx
// src/App.tsx
import React from 'react'
import { Layout, Menu, ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import {
PictureOutlined, AppstoreOutlined, HistoryOutlined, UserOutlined
} from '@ant-design/icons'
import { DrawPanel } from './components/DrawPanel'
import { Gallery } from './components/Gallery'
import { TaskHistory } from './components/TaskHistory'
const { Header, Content } = Layout
type TabKey = 'draw' | 'gallery' | 'history' | 'profile'
const App: React.FC = () => {
const [activeTab, setActiveTab] = React.useState<TabKey>('draw')
const menuItems = [
{ key: 'draw', icon: <PictureOutlined />, label: 'AI 绘图' },
{ key: 'gallery', icon: <AppstoreOutlined />, label: '画廊' },
{ key: 'history', icon: <HistoryOutlined />, label: '历史记录' },
{ key: 'profile', icon: <UserOutlined />, label: '个人中心' }
]
const renderContent = () => {
switch (activeTab) {
case 'draw': return <DrawPanel />
case 'gallery': return <Gallery />
case 'history': return <TaskHistory />
case 'profile': return <div>个人中心</div>
default: return null
}
}
return (
<ConfigProvider locale={zhCN}>
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ display: 'flex', alignItems: 'center',
background: '#001529' }}>
<div className="logo" style={{ color: '#fff', fontSize: 20,
marginRight: 48 }}>
AI 绘图平台
</div>
<Menu
theme="dark"
mode="horizontal"
items={menuItems}
onClick={({ key }) => setActiveTab(key as TabKey)}
selectedKeys={[activeTab]}
style={{ flex: 1 }}
/>
</Header>
<Content style={{ padding: 24 }}>
{renderContent()}
</Content>
</Layout>
</ConfigProvider>
)
}
export default App
四、Serverless 弹性部署方案
4.1 为什么选择 Serverless 部署
当前企业落地 AI 大模型时,通常面临 GPU 服务器采购成本高昂、环境配置复杂、资源利用率低、弹性能力不足等挑战。Serverless 架构通过将 GPU 计算能力函数化,可以实现 AI 推理能力的按需调用与按量计费。
类比:Serverless 部署就像"共享充电宝"模式。 传统 GPU 服务器是你自己买一个充电宝------不管用不用,充电宝都是你的资产。Serverless 是共享充电宝------用的时候扫码付费,不用的时候不需要为闲置付费。对于 AI 绘图这种"事件驱动型"负载------有用户请求才需要 GPU,没有请求时 GPU 闲置浪费------Serverless 方案的经济性优势非常明显。
4.2 基于阿里云函数计算(FC)的架构
yaml
# 函数计算 FC 部署配置(s.yaml)
# 基于阿里云函数计算部署 ComfyUI + Flux 文生图服务
service:
name: ai-draw-serverless
description: AI 绘图 Serverless 服务
internetAccess: true
provider:
name: alibaba
region: cn-hangzhou
runtime: custom-container
memorySize: 32768 # 32GB 内存
timeout: 300 # 5分钟超时
instanceConcurrency: 1 # 单实例并发
functions:
comfyui-flux:
handler: not-used
caPort: 8188
customContainerConfig:
image: registry.cn-hangzhou.aliyuncs.com/your-ns/comfyui-flux:latest
command: ["python", "main.py", "--listen", "0.0.0.0"]
instanceType: gpu # GPU 实例
gpuMemorySize: 16384 # 16GB GPU 显存
nasConfig:
mountPoints:
- serverAddr: "your-nas.cn-hangzhou.nas.aliyuncs.com:/models"
mountDir: /root/models
triggers:
- type: http
config:
authType: function
methods: [POST]
关键配置说明:
- 实例规格:32GB 内存 + 16GB GPU + 8 核 vCPU,这是运行 ComfyUI + Flux 的基础配置
- NAS 挂载:用于持久化大模型文件(Checkpoints、LoRA 等),避免实例销毁后重复下载
- 冷启动初始化:约 60 秒(含模型加载),热调用推理 512×512 图像约 5 秒
- 预估成本:0.02 ~ 0.2 元/张
4.3 成本对比与优化策略
| 部署方案 | 月度成本(日均1000张) | 适用场景 | 启动延迟 |
|---|---|---|---|
| 自建GPU服务器 | 5000-15000元 | 高并发稳定负载 | 无 |
| 函数计算FC | 600-6000元 | 波动性负载 | 冷启动60秒 |
| 容器实例(ECI) | 2000-8000元 | 中等负载 | 冷启动15-30秒 |
成本优化建议:函数计算按调用时长与资源规格计费,闲置不产生费用。对于日调用量低于 5000 张的场景,Serverless 方案的月成本通常仅为自建 GPU 服务器的 10%-40%。新用户可享受免费额度,进一步降低试错成本。