admin 管理员
积分160
帖子0
注册

从零训练一个 SDXL 角色 LoRA:kohya + Tesla P40 全流程实战(数据集 / 标注 / 判定 / 踩坑)

📌 置顶

从零训练一个 SDXL 角色 LoRA:kohya + Tesla P40 全流程实战


关键词:SDXL · LoRA · 角色 LoRA · kohya-ss/sd-scripts · Tesla P40 · 数据集标注 · 过拟合判定 · 权重体检 · ComfyUI


让 AI 稳定画出指定的那个角色(而不是"一个长得差不多的银发少女"),最靠谱的办法不是反复调提示词,而是自己训一个角色 LoRA。本文把整套流程完整记录一遍:从抽帧、筛图、标注,到训练参数、训练中自检、训练后判定,最后到出图验收。文中的所有数字都是实战实测值,不是理论值。


硬件用的是一张二手 Tesla P40(24G),底模 NoobAI-XL v1.1,训练框架 kohya-ss/sd-scripts。整个项目从"数据集是零"到"能稳定出图"大约两周,其中真正花在训练上的时间不到 20 小时——大部分功夫在数据上,而不是在参数上。



一、先算清楚账:硬件能给你什么


动手前先明确速度预期,否则很容易在半路放弃。P40 是 Pascal 架构,有两条硬约束:



  • fp16 非张量算力只有 fp32 的 1/64。 用 --mixed_precision fp16 跑 SDXL LoRA,实测 32.5 s/it;改成 --mixed_precision no(fp32)实测 8.13 s/it——快 4 倍。3000 步从 27 小时降到 6.8 小时,这一条是 P40 上最大的坑。

  • P40 不支持 bf16,也不支持 TF32。所以 fp32 是唯一选择。

  • fp32 + gradient_checkpointing + cache_latents_to_disk,在 1024 分辨率、batch=1 下,训练进程本身要吃 约 24 GB 显存。


实测工期参考(同分辨率 1024、batch 1、8.13 s/it):1410 步 ≈ 3.2 小时;1910 步 ≈ 4.3 小时;3530 步 ≈ 8.0 小时。出图另算:单阶段 txt2img 896×1152 / 36 步约 119 秒/张,两阶段 hires 约 280 秒/张。



二、环境搭建:用非破坏性 venv 复用 ComfyUI 的 torch


如果这台机器上已经跑着 ComfyUI,不要 pip install torch(2.5G,还容易把生产环境搞坏)。正确做法是建一个 venv,用 .pth 把 ComfyUI 的 site-packages 挂进来:


# 1) 用 ComfyUI 环境的 python 建 venv(必须是同一 3.x 小版本,保证 ABI 一致)
/data/comfyenv/bin/python -m venv /data/kohya-venv
SP=$(/data/kohya-venv/bin/python -c "import site;print(site.getsitepackages()[0])")
# 2) 把 ComfyUI 的 site-packages 挂进来
echo "/data/comfyenv/lib/python3.10/site-packages" > "$SP/_comfyenv.pth"
# 3) 给继承来的包造假 dist-info,否则 pip 判定"未安装"会重下 torch
for nv in torch==2.13.0+cu126 torchvision==0.28.0+cu126 numpy==2.2.6 Pillow==12.3.0; do
N=${nv%%==*}; V=${nv##*==}; D="$SP/$N-$V.dist-info"; mkdir -p "$D"
printf 'Metadata-Version: 2.1\nName: %s\nVersion: %s\n' "$N" "$V" > "$D/METADATA"
printf 'Wheel-Version: 1.0\nGenerator: manual\nRoot-Is-Purelib: false\nTag: py3-none-any\n' > "$D/WHEEL"
: > "$D/RECORD"
done
# 依赖版本必须抄 sd-scripts 的 requirements.txt,别自己钉(自己钉必然冲突)
git clone --depth 1 https://github.com/kohya-ss/sd-scripts.git /data/sd-scripts
cd /data/sd-scripts && pip install -e . --no-deps -q

两个必须设的环境变量(国内服务器):


export HF_ENDPOINT=https://hf-mirror.com   # 下模型
export HF_HUB_OFFLINE=1 # 关键!不设会卡死在 SYN-SENT

只设 HF_ENDPOINT 不够:transformers 会去访问 huggingface.co 的模板接口,墙内会卡成"进程活着、显存不涨、日志空"。另外 python 一定要加 -u,否则输出到文件是块缓冲,日志看起来是空的。



三、数据集:三个决定成败的细节


先说结论:角色 LoRA 训得好不好,八成取决于数据集,而不是参数。 下面三条是踩过坑才明白的。


3.1 抽帧 + 去重


素材一般是 PV / 过场动画。用 ffmpeg 抽帧(比如每秒 2 帧),然后用感知哈希(phash)去重,把连续相似帧压掉。几百帧去重后通常只剩几十到一百多张。


3.2 分辨率:要按"原生像素高"筛,不能按文件尺寸筛 ★


这是最阴的一个坑。抽帧脚本把小脸裁出来之后,通常会 Lanczos 放大到 1024 短边。于是文件名里写着"原始 129px"的图,实际尺寸是 1670×1024——看着很大,其实是 8 倍放大,细节早就没了。


如果把这种糊图喂进训练集,模型学到的是"糊脸"。所以筛图阈值必须按原生高度卡:实测把头部特写里原生高度 <350px 的 12 张剔掉后,只剩 14 张真正可用的。同一个判据用在背饰素材上也剔掉了 7 张。


3.3 标注:极简,身份全部交给触发词 ★★


这一步是"训废"与"训成"的分水岭。第一版用 WD14 自动打标,每条保留了 19 个 tag(发色、瞳色、服装……),结果训出来是个空壳:换 LoRA、换权重、换 epoch,出图几乎不变。原因很清楚——caption 里把角色外观写全了,模型"看标签就能画个大概",LoRA 没有梯度可学。


正确做法是白名单,不是黑名单:只留【构图 / 视角 / 表情 / 姿态 / 背景】类 tag,发色瞳色头饰服装全删,身份交给触发词(如 角色名_(作品名))去吸收。实测从每条 19 个 tag 砍到约 4 个,同一批数据立刻训出了有效权重。


另外两个细节:① 触发词按 danbooru 命名法写(xxx_(genshin_impact)),与模型认识的模式对齐;② 配件每个只写一次——官方只有两只角,如果你同时写 horns + red_horns + hair_ornament,模型会给每个 tag 各渲染一个部件,头上就会冒出 3~4 个红色装饰。



四、训练参数(完整可抄)


python -u sdxl_train_network.py \
--pretrained_model_name_or_path base.safetensors \
--train_data_dir /data/datasets/X/train_v5 \
--output_dir /data/models/lora/X --output_name X --logging_dir /data/models/lora/X/logs --log_with tensorboard \
--resolution 1024,1024 \
--enable_bucket --min_bucket_reso 512 --max_bucket_reso 1536 --bucket_reso_steps 64 \
--caption_extension .txt --keep_tokens 1 \
--train_batch_size 1 --max_train_epochs 10 --save_every_n_epochs 1 \
--network_module networks.lora --network_dim 32 --network_alpha 32 \
--learning_rate 1e-4 --text_encoder_lr 5e-5 \
--optimizer_type AdamW --max_grad_norm 1.0 \
--lr_scheduler cosine --lr_scheduler_num_cycles 1 --lr_warmup_steps 50 \
--min_snr_gamma 5 \
--mixed_precision no --save_precision fp16 --no_half_vae \
--gradient_checkpointing --cache_latents --cache_latents_to_disk --sdpa --clip_skip 1 \
--seed 42 --save_model_as safetensors --max_token_length 225 \
--sample_every_n_epochs 2 --sample_prompts samples.txt --sample_sampler euler_a

几个关键项为什么这么设:



  • --mixed_precision no:P40 上必须 fp32(见第一节)。

  • --text_encoder_lr 5e-5:这一项是最容易被忽略的致命项。早期版本用 1e-5,结果触发词的 embedding 几乎没学到(TE 层权重变化只有 0.082%),打触发词等于打乱码。提到 5e-5 之后变成 0.568%,同一个触发词立刻就"灵"了。

  • --network_alpha 32 = dim:让 scale = 1.0。早期用 alpha=16/dim=32,scale 只有 0.5,权重白打对折。

  • --keep_tokens 1:保证触发词排在最前面不被 shuffle 掉。

  • 目录名用 <重复次数>_<概念名>(如 5_ronova_detail)来控制每个子集的权重占比,不需要额外配置。



五、训练中自检:采样提示词格式(一行一条!)★


--sample_every_n_epochs 2 会让 kohya 每隔几个 epoch 用它当前的权重渲染一组图到 /sample/。这是零额外 GPU 成本的进度监控,非常有用。


但采样提示词文件的格式只有一种写法是对的:一条 prompt 独占一行,指令用空格+两个减号接在同一行末尾。


# ✅ 正确
<正向提示词> --n <负面> --w 896 --h 1152 --d 515001 --s 28 --l 7

# ❌ 错误:写成三行(提示词 / --n / --w)会被当成三条独立 prompt

因为它读取 .txt 的逻辑是"每个非空行 = 一条 prompt",根本不支持多行分块。写成三行的后果很隐蔽:



  • 第 1 行(真提示词)出图正常,但丢掉同块里的 --d/--s/--l/--w 设置(按默认参数出图);

  • 第 2 行 --n lowres, worst_quality... 的整串负面词被当成正向提示词 → 垃圾图;

  • 第 3 行 --w 1024 --h 1024 --d 515001 ... 的字面量被当成提示词 → 垃圾图,而且文件名里的种子后缀偏偏挂在这一条上。


结果是:每个采样事件里 2/3 是垃圾图,而"文件名带种子"的那些恰好全是垃圾。我一开始就是按种子后缀挑图,看到满屏"软件界面截图"和乱码色块,差点误判成"模型训崩了",白折腾一轮。正确做法是把 sample/ 目录里所有图拼成一张总图扫一遍,不要按种子后缀挑。


好消息是:采样时是现场读文件(不是启动时读一次),所以格式写错之后直接改文件就能救回后面 epoch 的样图,不必重训。验证方式也简单,直接 import kohya 自己的解析器断言一下就行。



六、训练后判定:三证合一,别只算一个数


训完第一件事不是出图,是算权重幅度——几秒钟、不占 GPU,能立刻判断"它到底学到东西没有"。方法是对每个 LoRA 模块计算 delta = up @ down × (alpha/dim),再和底模对应权重的绝对值均值比:


delta = (up.float() @ down.float()) * (alpha / dim)
ratio = delta.abs().mean() / base_weight.abs().mean()

判断标准必须拿第三方 LoRA 校准过,不能拍脑袋。 实测把两个公开可用的 LoRA 当标尺量了一遍,发现它们的 UNet 幅度也只有 0.885% 和 1.55%——如果按"要达到 1%"去卡,会把一堆正常的 LoRA 误判成空壳。真正可靠的判据是三证合一:







证据正常 / 异常说明
UNet delta/|W|0.6%~1.5% 都正常公开可用的 LoRA 也就 0.885%。低于 0.6% 只是"偏弱",不等于空壳
TE(触发词)组≈0 就是病灶TE 层几乎没变化,说明触发词 embedding 没学到——这才是最硬的空壳证据
像素消融同 seed 同 prompt 只换 LoRA,解码像素必须变必须比解码后的像素数组,不能比 PNG 的 md5(PNG 里嵌了 workflow,图一样时 md5 也不同)

⚠️ 做消融之前,先验证管线是确定性的:同 seed 同 prompt 连跑两遍,像素哈希必须一致。不一致就说明有别的随机源,此时"像素变了"不能证明 LoRA 生效,所有消融结论都不能采信。


本项目四代的实测数据(同一套判据下的横向对比):








版本UNetTE1数据规模与改动
v10.581%0.082%20 张;WD14 全量标注 + te_lr 1e-5 + alpha 16 → 典型空壳
v30.776%0.568%47 张;改极简标注 + te_lr 5e-5 + alpha=dim
v40.874%0.613%+10 张配饰特写 +专属触发词
v51.267%0.879%84 张;+14 张脸部特写 +13 张背饰(一套形态一个触发词)


七、出图:有 LoRA 之后就走 txt2img,别再图生图


这一步同样容易走错。没有 LoRA 时,身份靠参考图,用 img2img(denoise 0.45~0.60)是对的;但有了角色 LoRA 之后,参考图不但没用,还会跟 LoRA 抢构图——实测同一请求下,img2img(denoise 0.65)出的是"栏杆状"的结构垃圾,而 txt2img + LoRA 出的是干净的半身像。身份由 LoRA 扛,参考图退出。


另外记住两个数字:



  • 脸要有 200 像素以上才能在 SDXL 里活下来。 在 1344×768 里画全身,脸宽只有约 90~100px,VAE 下采样 8 倍后只剩十几个 latent 像素——这个尺寸下模型只能"编"五官,这不是运气问题,是算术问题。要么框得更紧(半身/特写),要么加 hires 第二段。

  • denoise 0.82 以上是个陷阱。 那一档会把画面重画 85%,在没有 LoRA 的情况下必然"五官扭曲 + 完全不像官方"。真要换装就用局部重绘(mask),别整帧洗。


出图参数(实测可用):dpmpp_2m + karras,36 步,cfg 6.5,denoise 1.0,896×1152;交付级再加一段 hires:放大 1.25 倍 → VAEEncode → KSampler denoise 0.30 / 24 步 / cfg 6.0。



八、进阶:一个部件有"多种形态"怎么办


如果角色的某个部件在官方素材里本来就是变动的(比如背后那团能量构造,随镜头生长/崩解),"固定它"和"忠于官方"是互相冲突的:把所有形态混在一个触发词下,LoRA 学到的恰恰是"这些都成立",出图照样随机。


解法是一种形态一个触发词,出图时点名。但有个前提:一种形态至少要有 ~5 张同形态的高清图,专属词才立得住。实测把候选素材聚类后发现官方有 7 种形态,可其中 4 种只有低分辨率素材(最糊的一张原生只有 118px),于是只能给达标的 4 种各建一个词,剩下 3 种合并进一个通用词。


同理,某个小配饰和原作不符时,先量它的像素上限。实测胸口那枚宝石在最高清素材里只有约 50px 宽——这么小的监督信号,LoRA 学不到精确形状,底模只能拿通用先验填坑。这时候的修法是"补数据":从官方立绘裁特写(放大到 1024 让它在梯度里有位置),单独放一个高重复次数的目录,配一个专属触发词,其余标注一个字都不加。



九、踩坑清单










现象原因 / 修法
开训就 torch.OutOfMemoryErrorComfyUI 常驻占约 12G 显存且空闲也不释放。必须 systemctl stop comfyui,并且轮询等显存真降到 1G 以下再开训(实测 stop 返回后仍占 5.6G)
多份训练抢显存启动脚本加单实例锁:exec 9>/var/lock/x.lock; flock -n 9 || exit 3
日志一片空白python 输出块缓冲 → 加 -u;或是在等 huggingface.co 超时 → 加 HF_HUB_OFFLINE=1
pkill -f 脚本名把自己 ssh 一起杀掉远端命令行里就含该字符串。用括号写法 pkill -f "sdxl_train_networ[k]" 或精确匹配 pkill -x -f "bash /data/run.sh"
新数据集的每个目录文件数是图片数的 3 倍--cache_latents_to_disk 会把 *.npz 潜在缓存写进图片目录,硬链接时一起带过来了。find <新目录> -name "*.npz" -delete(只删副本,别碰正在训练那份)
配饰自动定位全错按颜色阈值/连通域找小配饰不可靠(实测 22 个"命中"里只有 2 个是真的,其余是红飘带/红角/红背景)。候选图进训练集前必须逐张人工或视觉核实"有/没有"


十、小结


把整条链路浓缩成四句话:


① 数据 > 参数。 标注必须极简(身份交给触发词),筛图必须按原生分辨率,宁可少而精。

② 参数上有两个致命项: P40 必须 fp32(快 4 倍),文本编码器学习率别低于 5e-5(否则触发词等于没学)。

③ 判定要三证合一: UNet 幅度 + TE 组幅度 + 像素消融,单看一个数必然误诊。

④ 有 LoRA 之后就彻底走 txt2img。 参考图退出,让 LoRA 独扛身份。


整套东西跑通之后,你会得到一个小到几十 MB 的文件,把它丢进 ComfyUI 的 models/loras/,配上正确的触发词,就能稳定画出你要的那个人——而不是"一个长得差不多的银发少女"。


有同样想法的可以照着配,遇到问题欢迎在楼下讨论。

This is a test signature
暂无回复,快来抢沙发吧!
请 登录 后发表回复
hljs.highlightAll();