Stable Diffusion 部署实战:把“玩具”做成“摇钱树”的踩坑全纪录
- Stable Diffusion 部署实战:把“玩具”做成“摇钱树”的踩坑全纪录
- 引言:当 `.ckpt` 遇上 K8s,故事才刚开始
- Stable Diffusion 到底是个啥——把“黑盒子”拆成乐高
- 模型训练不是终点:从 `.ckpt` 到可服务 API 的旅程
- 主流部署方案大比拼:谁才是生产环境的“真爱”
- 显存不够怎么办?量化、LoRA、TensorRT 加速全解析
- 生产环境避坑指南:排队、超时、GPU 跳楼怎么办?
- 图像生成服务的性能调优秘籍:批处理、缓存、异步任务队列
- 如何让前端无缝对接?RESTful 与 WebSocket 实时反馈技巧
- 安全别忘关后门:模型版权、输入过滤、资源隔离
- 上线之后才懂的事:日志、重试、成本控制
- 当同事说“加个功能”时,你已经准备好插件化架构
- 结语:把“玩具”做成“摇钱树”,只需要再多 5% 的工程化
Stable Diffusion 部署实战:把“玩具”做成“摇钱树”的踩坑全纪录
“模型跑通了?恭喜,游戏才刚刚开始。”——某位被老板催上线的后端老哥
引言:当 .ckpt 遇上 K8s,故事才刚开始
周五晚上 11:47,你盯着屏幕上那张 512×512 的猫耳女仆图,风扇呼啸,显卡灯火通明。你长舒一口气:终于把 Stable Diffusion 在本地调通了!
周一早上 9:03,老板拍拍你肩膀:“能上线吗?最好今天。”
那一刻,你意识到:训练只是序章,部署才是地狱。
本文不是“30 分钟入门 AIGC”的爽文,而是一篇从 .ckpt 到 k8s 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 启动 --api | 5 分钟搞定,插件多 | 单进程,无并发,重启丢队列 | 内部玩具 |
| 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,并发一上来立刻爆炸。解决思路:
- 网关层限流:Nginx
limit_req每秒 10 单 GPU - 业务层排队:Redis List + BRPOP 实现最长 200 队列
- 返回“排队号”,前端轮询
/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,用 diffusers 的 pipeline.__call__(num_images_per_prompt=N) 即可。实验数据:
| batch | 显存 | 平均单张耗时 | GPU 利用率 |
|---|---|---|---|
| 1 | 6G | 3.4s | 55% |
| 4 | 10G | 1.1s | 93% |
收益肉眼可见,但注意:同一 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都能赶上地铁末班车。
(完)


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



