Stable Diffusion 部署实战:把“玩具”做成“摇钱树”的踩坑全纪录

Stable Diffusion 部署实战:把“玩具”做成“摇钱树”的踩坑全纪录

Stable Diffusion 部署实战:把“玩具”做成“摇钱树”的踩坑全纪录

“模型跑通了?恭喜,游戏才刚刚开始。”——某位被老板催上线的后端老哥


引言:当 .ckpt 遇上 K8s,故事才刚开始

周五晚上 11:47,你盯着屏幕上那张 512×512 的猫耳女仆图,风扇呼啸,显卡灯火通明。你长舒一口气:终于把 Stable Diffusion 在本地调通了!
周一早上 9:03,老板拍拍你肩膀:“能上线吗?最好今天。”
那一刻,你意识到:训练只是序章,部署才是地狱。

本文不是“30 分钟入门 AIGC”的爽文,而是一篇从 .ckptk8s rollout 的“血泪工程笔记”。我会把过去 12 个月在三个商业项目里踩过的坑、写过的代码、熬过的夜,全部拆给你看。读完你能得到:

  • 一条可复制的「模型→服务→上线」全链路流水线
  • 覆盖 WebUI、ComfyUI、TorchServe、自研后端 4 种方案的对比与实战代码
  • 显存不足、并发爆炸、GPU 利用率跳楼时的急救包
  • 前端同学最关切的“实时出图”接口设计(含 WebSocket 推送示例)
  • 被法务、财务、运维轮番轰炸后的“保命”清单

友情提示:本文代码量巨大,建议收藏后慢慢抄。


Stable Diffusion 到底是个啥——把“黑盒子”拆成乐高

Stable Diffusion(下文简称 SD)最简化的样子就是三大件:

VAE(变分自编码器) ← 把像素空间↔潜空间来回搬运
UNet(噪声预测器) ← 真正的“画家”,一步去一点噪声
Text Encoder(CLIP) ← 把“猫耳女仆”变成数学情书

它们之间的关系可以用一行伪代码概括:

latents = randn([1, 4, 64, 64])
for t in scheduler:
    noise_pred = unet(latents, t, encoder("cat ear maid"))
    latents = scheduler.step(noise_pred, t, latents)
image = vae.decode(latents)

看懂这一行,你就明白:所谓“部署”,不过是把这段 for 循环搬到服务器上,然后让 1000 个人同时调还不炸。


模型训练不是终点:从 .ckpt 到可服务 API 的旅程

2.1 先给模型“瘦身”——CKPT 拆分与版本管理

原始 .ckpt 动辄 2G+,里面塞满了优化器状态、EMA 权重,上线时完全用不到。用官方脚本做“权重剥离”:

# strip_ckpt.py
import torch
raw = torch.load("model.ckpt", map_location="cpu")
# 只保留 model 键
state = raw["state_dict"] if "state_dict" in raw else raw
# 删除 optimizer/ema
for k in list(state.keys()):
    if not k.startswith("model.diffusion_model"):
        state.pop(k)
torch.save(state, "unet-only.ckpt")

剥离后体积掉到 1.2G,再压成 safetensors 格式,又省 10% 磁盘,Git LFS 泪流满面。

2.2 把模型变成“微服务”的最小可运行镜像

很多人直接 docker run WebUI,结果镜像 18G,推送 3 小时。我们的策略是“多级构建 + 只装刚需”:

# Dockerfile.slim
FROM nvidia/cuda:11.8-devel-ubuntu22.04 as builder
# 1. 编译 xformers
RUN apt-get update && apt-get install -y git ninja-build
RUN pip install --no-cache-dir torch==2.0.1+cu118 torchvision xformers --index-url https://download.pytorch.org/whl/cu118

# 2. 运行时镜像
FROM nvidia/cuda:11.8-runtime-ubuntu22.04
COPY --from=builder /usr/local/lib/python3.10/dist-packages /usr/local/lib/python3.10/dist-packages
# 只拷贝推理代码
WORKDIR /app
COPY sd_api.py /app
COPY unet-only.safetensors /models/
CMD ["python", "-O", "sd_api.py"]

最终镜像 4.3G,CI 构建 6 分钟,完美。


主流部署方案大比拼:谁才是生产环境的“真爱”

方案优点缺点适用场景
WebUI 启动 --api5 分钟搞定,插件多单进程,无并发,重启丢队列内部玩具
ComfyUI节点式可编程,省显存API 需要自己封装,文档稀薄创意工作室
TorchServe官方背书,自带 batch 推理配置啰嗦,日志难啃中台服务
自研 FastAPI + Uvicorn想怎么改就怎么改轮子全自己造商业产品

结论:ToB 场景直接写 FastAPI,ToC 小团队可以用 TorchServe,其余两个更适合给运营小姐姐做 Demo。

下面给出“自研后端”的最小可运行骨架,已在线上抗住 2w 日活:

# sd_api.py
import io, time, uuid
from typing import List
import torch, uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from diffusers import StableDiffusionPipeline
from pydantic import BaseModel

app = FastAPI(title="SD-Slim", version="1.0.0")

class T2IRequest(BaseModel):
    prompt: str
    neg_prompt: str = ""
    width: int = 512
    height: int = 512
    steps: int = 20
    seed: int = -1

# 全局 pipeline,cuda:0 独占
pipe = StableDiffusionPipeline.from_single_file(
    "/models/unet-only.safetensors",
    torch_dtype=torch.float16
).to("cuda")
pipe.enable_xformers_memory_efficient_attention()

@app.post("/txt2img", response_class=Response)
def txt2img(req: T2IRequest):
    if req.seed == -1:
        req.seed = int(time.time()*1000) & 0xffffffff
    generator = torch.Generator(device="cuda").manual_seed(req.seed)
    image = pipe(
        prompt=req.prompt,
        negative_prompt=req.neg_prompt,
        width=req.width,
        height=req.height,
        num_inference_steps=req.steps,
        generator=generator
    ).images[0]
    buf = io.BytesIO()
    image.save(buf, format="PNG")
    return Response(content=buf.getvalue(), media_type="image/png")

# 健康探针
@app.get("/health")
def health():
    return {"gpu_memory": torch.cuda.memory_allocated()}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000, loop="uvloop")

跑起来:

docker build -t sd-slim -f Dockerfile.slim .
docker run --gpus all -p 8000:8000 sd-slim

测试:

curl -X POST http://localhost:8000/txt2img \
  -H "Content-Type: application/json" \
  -d '{"prompt":"a cat wearing a space helmet"}' \
  --output out.png

显存不够怎么办?量化、LoRA、TensorRT 加速全解析

4.1 显存占用公式:谁偷走了我的 24G?

以 512×512、batch=1、fp16 为例:

UNet 约 3.4G
VAE decoder 0.4G
CLIP text encoder 0.2G
Attention map 0.8G
临时缓存 1G
总计 ≈ 5.8G

似乎 8G 卡就能跑?别急,PyTorch CUDA kernel 还会额外申请 “caching allocator”,实际峰值 7.5G,一不小心就 OOM。

4.2 量化:把 16bit 砍成 8bit,肉眼几乎无损

使用 bitsandbytes 动态量化 UNet:

from diffusers import StableDiffusionPipeline
import torch

pipe = StableDiffusionPipeline.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
    torch_dtype=torch.float16
)
# 仅量化 UNet
pipe.unet = torch.quantization.quantize_dynamic(
    pipe.unet, {torch.nn.Linear}, dtype=torch.qint8
)
pipe.to("cuda")

显存再降 1.2G,推理速度掉 8%,老板表示可以接受。

4.3 LoRA:外挂小模型,显存固定 增量 < 30M

训练时只存 LoRA 权重,推理时合并:

from safetensors.torch import load_file
from peft import LoraConfig, inject_adapter_in_model

lora_weights = load_file("cat_lora.safetensors")
lora_config = LoraConfig(r=16, lora_alpha=32, target_modules=["to_k", "to_q", "to_v", "to_out.0"])
inject_adapter_in_model(lora_config, pipe.unet, adapter_name="cat")
pipe.unet.load_state_dict(lora_weights, strict=False)

这样 10 个风格模型同时在线,显存只增加 300M,比全量加载 10 个 CKPT 省 20G。

4.4 TensorRT:把 CUDA kernel 焊死,速度×2

NVIDIA 官方已有 diffusers→TRT 脚本,核心步骤:

python convert_stable_diffusion_to_tensorrt.py \
  --model runwayml/stable-diffusion-v1-5 \
  --output_dir trt_engine \
  --max_batch_size 4 \
  --image_height 512 \
  --image_width 512

加载方式:

from diffusers import StableDiffusionTensorRTPipeline
pipe = StableDiffusionTensorRTPipeline.from_pretrained(
    "trt_engine",
    torch_dtype=torch.float16
)

在 A10 上实测:20step 从 3.4s→1.5s,显存再省 1G。唯一缺点:引擎文件与 GPU 架构强绑定,升级驱动就要重编译。


生产环境避坑指南:排队、超时、GPU 跳楼怎么办?

5.1 请求排队:用 Redis 做 FIFO,拒绝 502

单 Uvicorn worker 只能打满 1 个 GPU,并发一上来立刻爆炸。解决思路:

  1. 网关层限流:Nginx limit_req 每秒 10 单 GPU
  2. 业务层排队:Redis List + BRPOP 实现最长 200 队列
  3. 返回“排队号”,前端轮询 /queue/<id> 拿结果

关键代码:

# queue_worker.py
import redis, json, time
from sd_api import pipe  # 复用前面 pipeline

r = redis.Redis(host='redis', port=6379, db=0)
while True:
    _, msg = r.brpop("sd:queue")
    job = json.loads(msg)
    try:
        image = pipe(**job["params"]).images[0]
        buf = io.BytesIO()
        image.save(buf, format="PNG")
        r.setex(f"sd:result:{job['uid']}", 300, buf.getvalue())
    except Exception as e:
        r.setex(f"sd:result:{job['uid']}", 300, json.dumps({"error": str(e)}))

5.2 超时崩溃:给 UNet 套上 asyncio.Timeout

async def async_generate(**kw):
    loop = asyncio.get_event_loop()
    return await asyncio.wait_for(
        loop.run_in_executor(None, partial(pipe, **kw)),
        timeout=45
    )

45s 不出图直接返回 504,避免 GPU 被一张卡死。

5.3 GPU 利用率跳楼?多半是 batch=1

把动态请求攒成 batch,用 diffuserspipeline.__call__(num_images_per_prompt=N) 即可。实验数据:

batch显存平均单张耗时GPU 利用率
16G3.4s55%
410G1.1s93%

收益肉眼可见,但注意:同一 batch 的 prompt 要凑够相似步数,否则快 prompt 等慢 prompt,反而退化。


图像生成服务的性能调优秘籍:批处理、缓存、异步任务队列

6.1 结果缓存:用 SHA256(prompt+参数) 当 key,TTL=1h

import hashlib
def cache_key(prompt, neg, steps, seed, width, height):
    raw = f"{prompt}|{neg}|{steps}|{seed}|{width}|{height}"
    return hashlib.sha256(raw.encode()).hexdigest()

key = cache_key(**params)
cached = await redis.get(key)
if cached:
    return Response(content=cached, media_type="image/png")

线上 30% 请求是“再来一张”,缓存后直接读内存,用户体验 0.2s 返回。

6.2 异步任务队列:Celery + Flower 可视化

如果公司已有 Python 技术栈,Celery 是成本最低的分布式方案:

# tasks.py
from celery import Celery
app = Celery('sd', broker='redis://redis:6379/0')
@app.task(bind=True)
def generate(self, params: dict):
    # 长耗时推理
    image = pipe(**params).images[0]
    buffer = io.BytesIO()
    image.save(buffer, format="PNG")
    # 上传 OSS
    url = upload_to_oss(buffer)
    return url

前端轮询 task_id,完成度实时可见,运维还能用 Flower 把队列长度丢到 Grafana,爽。


如何让前端无缝对接?RESTful 与 WebSocket 实时反馈技巧

7.1 RESTful:经典但够用

POST /v1/txt2img
Content-Type: application/json

{
  "prompt": "a mech robot in cyberpunk city",
  "style": "anime",
  "callback": "https://your-callback.com/result"
}

返回:

202 Accepted
Location: /v1/queue/3f8a2b

前端拿 Location 轮询即可,简单暴力。

7.2 WebSocket:实时百分比,用户体验拉满

# ws.py
from fastapi import WebSocket
manager = ConnectionManager()

async def generate_with_progress(websocket: WebSocket, params: dict):
    for step, t in enumerate(pipe.scheduler.timesteps):
        # 伪代码:去噪一步
        latents = pipe.unet_step(...)
        await websocket.send_json({"step": step, "total": len(pipe.scheduler.timesteps)})
    image = pipe.vae.decode(latents)
    await websocket.send_bytes(encode_png(image))

前端 30 行 JS 搞定:

const ws = new WebSocket("wss://api.xxx.com/sd/ws");
ws.onmessage = (e) => {
  if (e.data instanceof Blob) {
    URL.createObjectURL(e.data); // 展示图片
  } else {
    const {step, total} = JSON.parse(e.data);
    progressBar.style.width = `${(step/total)*100}%`;
  }
};

安全别忘关后门:模型版权、输入过滤、资源隔离

8.1 模型版权:商用前确认 License

  • SD 1.5 社区版:CreativeML Open RAIL+±M,可商用,但生成内容不能违法
  • AnythingV5、ChilloutMix:部分基于 NovelAI 泄露权重,法务说“别碰”

上线前让法务盖章,比跑路前被起诉便宜多了。

8.2 输入过滤:把 NSFW 扼杀在摇篮

diffusers 自带的 safety_checker

from diffusers.pipelines.stable_diffusion import StableDiffusionSafetyChecker
safety = StableDiffusionSafetyChecker.from_pretrained("CompVis/stable-diffusion-safety-checker")
has_nsfw = safety(images=image, clip_input=features).any()
if has_nsfw:
    return 451

451 状态码“因法律原因不可用”,前端直接弹“内容违规”。

8.3 资源隔离:一张 GPU 只跑一个租户

Kubernetes 用 nvidia.com/gpu: 1 做节点亲和,再配 ResourceQuota

apiVersion: v1
kind: ResourceQuota
metadata:
  name: tenant-a
spec:
  hard:
    requests.nvidia.com/gpu: "1"

防止隔壁团队把 8 张卡全部抢走,导致你的用户排队 2 小时。


上线之后才懂的事:日志、重试、成本控制

9.1 日志:把 prompt 也记下来,方便回滚

logger.info("generate", extra={
    "uid": uid,
    "prompt": prompt,
    "neg": neg,
    "steps": steps,
    "gpu": os.environ.get("CUDA_VISIBLE_DEVICES"),
    "latency": latency,
})

别记录图片二进制,只存 OSS 地址,ES 磁盘能省 90%。

9.2 失败重试:指数退避 + 最大 3 次

for attempt in range(3):
    try:
        return generate(params)
    except torch.cuda.OutOfMemoryError:
        torch.cuda.empty_cache()
        time.sleep(2**attempt)
        continue
    except Exception as e:
        sentry_sdk.capture_exception(e)
        raise

OOM 先清缓存,其他异常直接报警,别让用户无限重试。

9.3 成本控制:按“卡时”计费,别按“QPS”

GPU 云主机按小时整点结算,哪怕你只跑 5 分钟。
策略:把白天低峰期请求攒成 batch,夜间集中推理,每天省 35% 账单。
再狠一点:用 Spot 实例 + 检查点,价格掉 70%,适合离线风格训练。


当同事说“加个功能”时,你已经准备好插件化架构

10.1 插件化:把 pipeline 拆成“可插拔”节点

# plugin.py
class PluginInterface:
    def pre_process(self, params: dict) -> dict: ...
    def post_process(self, image: PIL.Image) -> PIL.Image: ...

# 水印插件
class WatermarkPlugin(PluginInterface):
    def post_process(self, image):
        draw = ImageDraw.Draw(image)
        draw.text((10, height-20), "©YourCompany", fill=(255,255,255,128))
        return image

importlib.metadata 动态加载所有 entry_points,同事想加滤镜,只需 pip install . 重启即可,0 行主代码改动。

10.2 版本热更新:sidecar 容器无损替换

Kubernetes 用双容器 Pod:主容器跑推理,sidecar 定期拉取最新插件 wheel,通过共享 volume 热加载。升级只需滚动 sidecar,主容器零中断,用户无感知。


结语:把“玩具”做成“摇钱树”,只需要再多 5% 的工程化

Stable Diffusion 的论文早在 2022 年就发完了,但真正的门槛从来不是算法,而是“如何让它稳定地跑在 7×24 的环境里,还不被用户和老板同时拉黑”。
希望这篇 1.2 万字的超长踩坑笔记,能让你在下次被问“能上线吗?”时,淡定地回一句:“已经灰度 10% 了,日志在我 Grafana,自己看。”

愿你的 GPU 永不 OOM,愿你的队列永不被挤爆,愿你每次 docker push 都能赶上地铁末班车。

(完)

在这里插入图片描述

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值