4314 字
22 分钟
在 Vast.ai 上用 LoRA 微调 Qwen3.5-35B 写小说

背景#

我有一个中文网络小说数据集(~15K 本小说,2.2M 章节,77 亿字),经过 ETL 清洗后转换成了 ShareGPT 格式的 SFT 训练数据。目标是微调一个大语言模型来续写小说章节 — 给定一段情节,模型能续写出风格一致、情节连贯的长文本。

选择了 Qwen3.5-35B-A3B — 这是一个 MoE(Mixture of Experts)架构的模型,256 个 experts,虽然总参数量 35B,但每次推理只激活约 3B 参数,推理效率很高。

之前的训练跑出来的模型有一个严重的问题:过早输出 EOS token,生成几句话就停了,完全没法用。这次的目标是彻底解决这个问题。

不过在到达最终方案之前,我在前期 debug 上花了大量时间和钱。整个过程并不像这篇文章看起来那么顺利。

硬件与成本#

在 Vast.ai 上租了一台双 NVIDIA H200 的机器:

规格配置
GPU2× NVIDIA H200 140GB HBM3
内存2TB DDR5
vCPUs56
磁盘250GB
价格$4.71/小时

前期 Debug:从 OOM 到跑通#

在最终的训练配置定型之前,经历了一段痛苦的 debug 过程。

第一次尝试:device_map=“balanced” — 单卡瓶颈#

最初的 train.py 用的是 device_map="balanced"(pipeline parallelism),让 Hugging Face 自动把模型分到两张卡上。但这需要 hack accelerate 的 AcceleratorState,手动清理分布式环境变量(WORLD_SIZERANKLOCAL_RANK),否则 accelerate 会检测到分布式环境然后和 device_map 冲突:

# 旧代码(已删除)— 丑陋的 monkey-patch
from accelerate import AcceleratorState
AcceleratorState._shared_state = {} # 强制重置
for var in ["WORLD_SIZE", "RANK", "LOCAL_RANK", "MASTER_ADDR", "MASTER_PORT"]:
os.environ.pop(var, None)
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=args.model,
device_map="balanced", # pipeline parallelism
...
)

这个方案跑起来了,但训练时只有一张卡在算,另一张卡在等 — pipeline parallelism 的先天缺陷。GPU 利用率始终上不去。

第二次尝试:FSDP — 两张卡都 OOM#

改成 FSDP 后,第一次启动直接 OOM:

torch.OutOfMemoryError: CUDA out of memory.
Tried to allocate 16.00 MiB. GPU 0 has a total capacity of 139.83 GiB,
of which 6.00 MiB is free.
Process 5378: 70.27 GiB in use.
Process 5379: 69.50 GiB in use.

原因:FSDP 的 sharding 发生在模型加载之后。两个 rank 都先把完整的 70GB 模型加载到 GPU 0 上(因为默认 device_map 行为),然后才开始 shard。两个 70GB = 140GB,超过了单卡 139GB 的容量。

即使设置了 fsdp_cpu_ram_efficient_loading: true,Unsloth 的自定义加载路径也绕过了这个机制。

修复:per-rank device_map#

解决方案是让每个 rank 只加载到自己对应的 GPU 上:

local_rank = int(os.environ.get("LOCAL_RANK", 0))
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=args.model,
device_map={"": local_rank}, # rank 0 → GPU 0, rank 1 → GPU 1
...
)

这样两个进程各自加载到不同的 GPU,不再争抢同一块显存。FSDP 在此基础上再做 shard,把权重、梯度、优化器状态分片到两张卡上。

第三次尝试:FSDP wrapping 崩溃#

加载不 OOM 了,但在 LoRA patching + FSDP wrapping 阶段又崩了(rank 1 exit code 1)。这次是因为 FSDP 的 dtype 一致性要求:模型里混了 fp32 和 bf16 的参数,FSDP 不接受。

# 修复:强制所有 fp32 参数转 bf16
for name, param in model.named_parameters():
if param.dtype == torch.float32:
log.info("Casting %s from fp32 → bf16", name)
param.data = param.data.to(torch.bfloat16)

终于跑起来了#

经过这三轮 debug,FSDP 终于跑通了。前期 debug 花费约 $26

完整训练代码#

FSDP 配置#

用 Hugging Face Accelerate 的 FSDP 做分布式训练。关键配置:

fsdp_config.yaml
compute_environment: LOCAL_MACHINE
distributed_type: FSDP
fsdp_config:
fsdp_sharding_strategy: FULL_SHARD
fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP
fsdp_transformer_layer_cls_to_wrap: Qwen3_5MoeDecoderLayer
fsdp_state_dict_type: FULL_STATE_DICT
fsdp_offload_params: false
fsdp_backward_prefetch: BACKWARD_PRE
fsdp_forward_prefetch: true
fsdp_use_orig_params: true
fsdp_sync_module_states: false
fsdp_cpu_ram_efficient_loading: false
mixed_precision: bf16
num_processes: 2

几个值得注意的点:

  • FULL_SHARD:权重、梯度、优化器状态全部分片到两张卡上,最大化显存利用
  • fsdp_transformer_layer_cls_to_wrap: Qwen3_5MoeDecoderLayer:按 decoder layer 粒度做 wrap,这对 MoE 模型很重要
  • fsdp_forward_prefetch: true:在前向传播时预取下一层的参数,减少通信等待
  • fsdp_use_orig_params: true:LoRA 需要这个选项,否则 FSDP 会破坏 PEFT 的参数结构

启动脚本#

run_fsdp.sh
#!/usr/bin/env bash
set -euo pipefail
cd /workspace/training
accelerate launch \
--config_file fsdp_config.yaml \
train.py \
--max-seq-length 32768 \
--lora-r 32 \
--epochs 1 \
--lr 2e-4 \
--batch-size 2 \
--grad-accum 4 \
--max-samples 1000 \
"$@" \
2>&1 | tee train_fsdp.log

训练脚本#

完整的 train.py,分四个部分讲解。

1. 模型加载与 LoRA 配置#

def load_model(args):
local_rank = int(os.environ.get("LOCAL_RANK", 0))
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=args.model, # "unsloth/Qwen3.5-35B-A3B"
max_seq_length=args.max_seq_length, # 32768
load_in_4bit=False, # MoE + BnB 4bit 不兼容,必须用 bf16
dtype=torch.bfloat16,
device_map={"": local_rank}, # FSDP 需要每个 rank 只加载到自己的 GPU
)
# FSDP 要求所有参数 dtype 一致,把 fp32 的参数强制转 bf16
for name, param in model.named_parameters():
if param.dtype == torch.float32:
param.data = param.data.to(torch.bfloat16)
# Qwen3.5 是 VLM,tokenizer 可能是 Processor 包装的
tokenizer = get_chat_template(tokenizer, chat_template="qwen-2.5")
text_tokenizer = getattr(tokenizer, "tokenizer", tokenizer)
model = FastLanguageModel.get_peft_model(
model,
r=32,
lora_alpha=32,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
lora_dropout=0,
bias="none",
use_gradient_checkpointing="unsloth",
random_state=3407,
)
return model, tokenizer, text_tokenizer
TIP

Unsloth 会自动检测到 MoE 模型,并额外给 mlp.experts.gate_up_projmlp.experts.down_proj 加上 LoRA。最终可训练参数 931M / 18.5B(5.04%)。

2. Cut Cross Entropy — 省 30GB 显存的关键#

35B 模型的 vocab projection(lm_head)会生成一个巨大的 logits tensor:(batch_size × seq_length × vocab_size)。在 batch=2、seq=32768、vocab=152064 的情况下,这个 tensor 占约 30GB 显存。

解决方案:用 cut_cross_entropy 库,直接在 hidden states 和 lm_head weight 上计算 loss,跳过 logits 的显式分配。

def _patch_model_with_cce(model):
"""替换 lm_head 为 identity,用 CCE 直接算 loss"""
import types
from transformers.modeling_outputs import CausalLMOutputWithPast
# 层层剥开 PEFT 包装,找到真正的 CausalLM 模型
lm_model = model
while hasattr(lm_model, "model"):
lm_model = lm_model.model
if not hasattr(lm_model, "lm_head"):
lm_model = model.base_model.model
if hasattr(lm_model, "language_model"):
lm_model = lm_model.language_model
# 保存 lm_head 权重,替换为 identity
lm_head_weight = lm_model.lm_head.weight # (vocab_size, hidden_dim)
class IdentityLMHead(torch.nn.Module):
def __init__(self, weight):
super().__init__()
self.weight = weight
def forward(self, hidden_states):
return hidden_states # 直接返回 hidden states,不做 vocab projection
lm_model.lm_head = IdentityLMHead(lm_head_weight)
# Monkey-patch forward
orig_forward = lm_model.forward.__func__
def cce_forward(self, *args, **kwargs):
labels = kwargs.pop("labels", None)
kwargs["labels"] = None # 不让原始 forward 计算 loss
outputs = orig_forward(self, *args, **kwargs)
if labels is not None:
hidden_states = outputs.logits # 其实是 identity 返回的 hidden states
if hidden_states.dtype == torch.float32:
hidden_states = hidden_states.to(torch.bfloat16)
lm_weight = self.lm_head.weight
if lm_weight.dtype == torch.float32:
lm_weight = lm_weight.to(torch.bfloat16)
# 直接从 hidden × weight 计算 CE loss,不分配 logits
loss = linear_cross_entropy(
hidden_states, lm_weight, labels,
ignore_index=-100, shift=True, reduction="mean",
)
return CausalLMOutputWithPast(
loss=loss,
logits=None, # 没有 logits — 省了 ~30GB
past_key_values=outputs.past_key_values,
hidden_states=outputs.hidden_states,
attentions=outputs.attentions,
)
return outputs
lm_model.forward = types.MethodType(cce_forward, lm_model)

原理图:

标准流程:
hidden_states → lm_head(Linear) → logits [30GB tensor] → CrossEntropy → loss
CCE 流程:
hidden_states → identity (pass through) → linear_cross_entropy(hidden, weight, labels) → loss
↑ 无需分配 logits,直接在 hidden 上算

3. 数据集处理#

数据集是 ShareGPT 格式(messages 字段,包含 user/assistant 对话)。处理流程:

def prepare_dataset(args, tokenizer, text_tokenizer):
dataset = load_dataset(args.dataset, split="train")
# 过滤:必须有 user 和 assistant 角色
def has_user_role(example):
msgs = json.loads(example["messages"]) if isinstance(example["messages"], str) else example["messages"]
roles = {m["role"] for m in msgs}
return "user" in roles and "assistant" in roles
dataset = dataset.filter(has_user_role, num_proc=4)
# 子采样
if args.max_samples > 0 and len(dataset) > args.max_samples:
dataset = dataset.shuffle(seed=42).select(range(args.max_samples))
# 用 chat template 格式化
def format_sharegpt(example):
messages = json.loads(example["messages"]) if isinstance(example["messages"], str) else example["messages"]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
return {"text": text}
dataset = dataset.map(format_sharegpt, num_proc=4, remove_columns=dataset.column_names)
# 手动 tokenize — 绕过 SFTTrainer 的 eos_token 验证 bug
def tokenize(examples):
all_ids, all_masks = [], []
for text in examples["text"]:
enc = text_tokenizer(text, truncation=True, max_length=args.max_seq_length)
all_ids.append(enc["input_ids"])
all_masks.append(enc["attention_mask"])
return {"input_ids": all_ids, "attention_mask": all_masks, "labels": all_ids}
dataset = dataset.map(tokenize, batched=True, remove_columns=["text"])
return dataset
WARNING

这里手动 tokenize 而不是让 SFTTrainer 做,是因为 Unsloth 的 Qwen3.5 tokenizer 有一个 eos_token 验证的 bug,SFTTrainer 会因此报错。手动 tokenize 后把 input_ids 同时赋给 labels,绕过了这个问题。

4. 训练配置与 DataCollator#

def train(args, model, tokenizer, dataset):
training_args = SFTConfig(
output_dir=args.output_dir,
per_device_train_batch_size=args.batch_size, # 2
gradient_accumulation_steps=args.grad_accum, # 4
warmup_ratio=0.03,
num_train_epochs=args.epochs, # 1
learning_rate=args.lr, # 2e-4
lr_scheduler_type="cosine",
bf16=True,
logging_steps=1,
save_steps=200,
save_total_limit=3,
optim="adamw_torch",
weight_decay=0.01,
max_seq_length=args.max_seq_length, # 32768
packing=False,
report_to="wandb",
dataloader_num_workers=2,
)
# 关键:batch_size > 1 时必须有 DataCollator 来 padding 变长序列
text_tokenizer = getattr(tokenizer, "tokenizer", tokenizer)
data_collator = DataCollatorForSeq2Seq(
tokenizer=text_tokenizer,
padding=True,
pad_to_multiple_of=8,
)
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
data_collator=data_collator,
args=training_args,
)
trainer.train()

踩坑记录#

第一坑:batch-size=1,GPU 只跑了一半#

第一次跑用的 batch-size=1 + 100 个样本测试,WandB 的 System 监控显示:

  • GPU Utilization:40-80% 之间波动
  • GPU Memory Allocated:~60%
  • GPU Power Usage:200-600W 之间震荡(满载应该稳定在 700W 附近)

原因很简单:batch 太小,每个 micro-step 算得快但 gradient accumulation 之间有空闲。而且 LoRA 只更新 5% 的参数,计算密度本身就低于全量训练。

改成 batch-size=2 后立即改善:

指标batch-size=1batch-size=2
GPU Utilization40-80%85-100%
GPU Memory~60%~84%
GPU Power200-600W400-700W

第二坑:batch-size > 1 导致 DataLoader 崩溃#

改成 batch-size=2 后,训练在第一个 step 直接报错:

ValueError: Unable to create tensor, you should probably activate truncation
and/or padding with 'padding=True' 'truncation=True' to have batched tensors
with the same length. Perhaps your features (`labels` in this case) have
excessive nesting (inputs type `list` where type `int` is expected).

完整 traceback 指向 transformers/data/data_collator.py

File "transformers/tokenization_utils_base.py", line 731, in convert_to_tensors
tensor = as_tensor(value)
ValueError: expected sequence of length 7495 at dim 1 (got 29105)

原因:batch-size=1 时每个 batch 只有一个样本,不需要 padding。但 batch-size=2 时,两个样本的 token 长度不同(比如 7495 和 29105),default collator 试图直接把它们拼成 tensor,当然失败了。

修复:加一个 DataCollatorForSeq2Seq,它会自动把同一 batch 内的序列 padding 到最长的那个:

from transformers import DataCollatorForSeq2Seq
data_collator = DataCollatorForSeq2Seq(
tokenizer=text_tokenizer,
padding=True,
pad_to_multiple_of=8, # 对齐到 8 的倍数,有利于 GPU 计算效率
)

第三坑:250GB 磁盘空间不够#

训练完成后要做量化,流程是:LoRA adapter → merge 成完整模型 → 转 GGUF → 量化。merge 那一步需要把完整的 35B 模型写到磁盘上(~70GB bf16 safetensors),但磁盘已经满了:

$ df -h /workspace
Filesystem Size Used Avail Use% Mounted on
overlay 250G 250G 56K 100% /
$ du -sh /workspace/training/*/
35G qwen35-35b-novel-lora/ # LoRA adapter + checkpoints
43G qwen35-35b-novel-merged/ # 不完整的 merge(写到一半空间满了)
$ du -sh /root/.cache/huggingface/
163G /root/.cache/huggingface/ # 基础模型缓存

清理 checkpoints 只腾出 64GB,还是不够 merge 的 70GB。

解决方案:用 /dev/shm(容器自带的 tmpfs 内存盘)。这台机器有 2TB 内存,/dev/shm 有 251GB 可用:

$ df -h /dev/shm
Filesystem Size Used Avail Use% Mounted on
shm 251G 0 251G 0% /dev/shm
WARNING

最初试过 mount -t tmpfs 挂载自定义 ramdisk,但容器内没有 mount 权限:

mount: /mnt/ramdisk: permission denied.

/dev/shm 是默认可用的,不需要额外权限。

第四坑:Unsloth API 参数陷阱#

做 inference 时加载模型:

# ❌ 报错:Can only load in 4bit or 8bit or 16bit, not a combination!
FastModel.from_pretrained(model_name=path, load_in_16bit=True)
# ✅ 必须同时显式禁用 4bit(Unsloth 默认 load_in_4bit=True)
FastModel.from_pretrained(model_name=path, load_in_4bit=False, load_in_16bit=True)

查看 Unsloth 的函数签名才发现,load_in_4bit 默认是 True

def from_pretrained(
model_name='...', max_seq_length=2048, dtype=None,
load_in_4bit=True, # ← 默认开启 4bit
load_in_8bit=False,
load_in_16bit=False, # ← 想用 16bit 必须同时关 4bit
...
)

另一个坑:apply_chat_template 在 Qwen3.5 的 tokenizer(实际上是 Processor)上返回的是 BatchEncoding 而不是 raw tensor,直接调用 .shape 会报 AttributeError

# ❌
inputs = tokenizer.apply_chat_template(messages, return_tensors="pt").to(device)
log.info("Input tokens: %d", inputs.shape[-1]) # AttributeError
# ✅
encoded = tokenizer.apply_chat_template(messages, return_tensors="pt")
if hasattr(encoded, "input_ids"):
input_ids = encoded["input_ids"].to(model.device)
else:
input_ids = encoded.to(model.device)

训练日志#

训练指标总览#

Training Metrics

四张图分别是:

  • Training Loss:从 22.8 快速下降到 ~20,最后一个 step 降到 10.2(最后一个 batch 可能样本数不足)
  • Learning Rate:cosine schedule,warmup 后从 2e-4 逐步衰减到 0
  • GPU Utilization:两张卡大部分时间在 80-100%,偶尔掉到 0% 是 gradient accumulation 之间的同步间隙
  • GPU Memory Allocated:稳定在 ~85%,说明 batch-size=2 + 32K seq length 是 H200 的甜点

详细训练 log#

完整的训练 log(1000 samples, 63 steps, 1 epoch):

\\ /| Num examples = 1,000 | Num Epochs = 1 | Total steps = 63
O^O/ \_/ \ Batch size per device = 2 | Gradient accumulation steps = 4
\ / Data Parallel GPUs = 2 | Total batch size (2 x 4 x 2) = 16
"-____-" Trainable parameters = 931,102,720 of 18,484,693,688 (5.04% trained)

Loss 曲线:

{'loss': '22.81', 'grad_norm': '5.311', 'learning_rate': '0', 'epoch': '0.016'}
{'loss': '23.58', 'grad_norm': '5.302', 'learning_rate': '0.0001', 'epoch': '0.032'}
{'loss': '21.62', 'grad_norm': '2.429', 'learning_rate': '0.0002', 'epoch': '0.048'}
{'loss': '20.47', 'grad_norm': '1.292', 'learning_rate': '0.0002', 'epoch': '0.064'}
{'loss': '20.62', 'grad_norm': '0.474', 'learning_rate': '0.0002', 'epoch': '0.08'}
{'loss': '20.14', 'grad_norm': '0.318', 'learning_rate': '0.0002', 'epoch': '0.096'}
{'loss': '21.06', 'grad_norm': '0.226', 'learning_rate': '0.0002', 'epoch': '0.112'}
{'loss': '19.44', 'grad_norm': '0.233', 'learning_rate': '0.0002', 'epoch': '0.128'}
...
{'loss': '19.14', 'grad_norm': '0.145', 'learning_rate': '0.0001', 'epoch': '0.528'}
...
{'loss': '19.26', 'grad_norm': '0.141', 'learning_rate': '2.8e-05', 'epoch': '0.784'}
...
{'loss': '20.94', 'grad_norm': '4.931', 'learning_rate': '6.4e-06', 'epoch': '0.912'}
{'loss': '20.06', 'grad_norm': '0.151', 'learning_rate': '4.7e-06', 'epoch': '0.928'}
{'loss': '19.82', 'grad_norm': '0.162', 'learning_rate': '3.3e-06', 'epoch': '0.944'}

几个观察:

  • Loss 从 22.81 快速下降到 ~20,然后在 19-21 之间波动稳定
  • grad_norm 在 step 1 之后迅速从 5.3 降到 0.1-0.2,说明模型很快适应了数据分布
  • step 59 有一个 grad_norm 异常飙升到 4.93(可能碰到了一个特别长或特别怪的样本),但 loss 没有跳,说明模型足够稳定
  • warmup 阶段(前 2 steps,lr 从 0 升到 2e-4)loss 先升后降,正常现象
  • 每 step 约 14 分钟(823-923 秒),总耗时约 14.5 小时

Inference 验证#

训练完成后,用两个 prompt 测试模型是否还会过早 EOS。

Prompt 1:都市修仙#

System: 你是一位优秀的网络小说作家,擅长根据已有情节续写出精彩的故事章节。请保持人物性格一致、情节连贯,文风自然流畅。 User: 写一个关于都市修仙的故事开头,要求细节丰富,人物对话自然。

模型输出(节选):

林默没有回头,他知道自己现在的速度根本跑不过对方。他在巷子里狂奔,脑子里飞速运转。

刚才那个电话,那个纸条,还有那个神秘的男人……这一切都不是巧合。

他摸了摸口袋,那里除了手机,还有一样东西。是他从老纺织厂宿舍捡到的,一张皱巴巴的符纸。刚才为了扔外卖,他把符纸随手塞进了兜里。此刻,那符纸正隐隐发烫。

“看来,今晚是没法睡了。”

林默嘴角勾起一抹苦笑,脚下发力,朝着巷子深处的围墙跃去。

雨越下越大,冲刷着这座城市的污垢,却洗不掉即将降临的阴霾。

而属于林默的都市修仙之路,就在这雨夜,悄然拉开了帷幕。

Prompt 2:宗门大比#

User: 写一个玄幻世界的宗门大比场景,重点描写主角以弱胜强的战斗过程。

模型输出(节选):

“不管是谁,想从我身上拿走什么,都得付出代价。”

他站起身,整理了一下有些破损的衣袍,迈步走向自己的住处。阳光洒在他的身上,拉出一道长长的影子,看起来依旧瘦削,但在那影子深处,却仿佛有一柄利剑,正悄然出鞘。

此时,演武场的高台之上。

赵长老望着林辰消失的方向,手指轻轻敲击着扶手,喃喃自语:“无尘步配合残锋劲……这可不是普通弟子能想到的。看来,我们苍梧山,又出了一条’潜龙’啊。”

他身旁的另一位长老忽然开口:“长老,要不要派人去查查他的来历?”

赵长老摇了摇头,眼中闪过一丝精光:“不必。真正的猎手,从不轻易出手。既然他选择了隐藏,我们就陪他玩玩。不过……若是他真有什么不该有的心思,这苍梧山的规矩,也不是摆设。”

两个输出都没有过早 EOS,生成了完整的长篇内容,人物、对话、情节都很到位。问题解决。

量化与分发#

训练完成后,模型自动上传 LoRA adapter 到 HuggingFace。接下来做 GGUF 量化,方便在本地用 llama.cpp 或 Ollama 跑。

编译 llama.cpp#

Vast.ai 容器没有预装 llama.cpp,需要从源码编译(带 CUDA 加速):

Terminal window
cd /tmp
git clone --depth 1 https://github.com/ggml-org/llama.cpp.git
cd llama.cpp
cmake -B build -DGGML_CUDA=ON
cmake --build build --config Release -j8

量化脚本#

完整的 quantize.sh,利用 /dev/shm 内存盘做中间存储:

quantize.sh
#!/usr/bin/env bash
set -euo pipefail
cd /workspace/training
ADAPTER_DIR="./qwen35-35b-novel-lora"
RAMDISK="/dev/shm"
MERGED_DIR="${RAMDISK}/qwen35-35b-novel-merged"
GGUF_DIR="./qwen35-35b-novel-gguf"
LLAMA_CPP="/tmp/llama.cpp"
HF_REPO="Steven10429/qwen35-35b-novel-gguf"
# Step 1: Merge LoRA → 完整模型(写到内存盘)
python3 -c "
import os; os.environ['UNSLOTH_COMPILE_DISABLE'] = '1'
from unsloth import FastLanguageModel
import torch
model, tokenizer = FastLanguageModel.from_pretrained(
model_name='${ADAPTER_DIR}', max_seq_length=32768,
load_in_4bit=False, dtype=torch.bfloat16,
)
model.save_pretrained_merged('${MERGED_DIR}', tokenizer, save_method='merged_16bit')
"
# Step 2: 转 GGUF bf16(仍在内存盘)
python3 "${LLAMA_CPP}/convert_hf_to_gguf.py" \
"${MERGED_DIR}" \
--outfile "${RAMDISK}/qwen35-35b-novel-bf16.gguf" \
--outtype bf16
# 清理 merged safetensors,释放内存盘空间
rm -rf "${MERGED_DIR}"
# Step 3 & 4: 量化(输出到磁盘)
mkdir -p "${GGUF_DIR}"
"${LLAMA_CPP}/build/bin/llama-quantize" \
"${RAMDISK}/qwen35-35b-novel-bf16.gguf" \
"${GGUF_DIR}/qwen35-35b-novel-Q8_0.gguf" Q8_0
"${LLAMA_CPP}/build/bin/llama-quantize" \
"${RAMDISK}/qwen35-35b-novel-bf16.gguf" \
"${GGUF_DIR}/qwen35-35b-novel-Q4_K_M.gguf" Q4_K_M
# 清理 bf16 GGUF
rm -f "${RAMDISK}/qwen35-35b-novel-bf16.gguf"
# Step 5: 上传 HuggingFace
python3 -c "
from huggingface_hub import HfApi
api = HfApi()
import os
api.create_repo(repo_id='${HF_REPO}', repo_type='model', private=True, exist_ok=True)
for f in sorted(os.listdir('${GGUF_DIR}')):
if f.endswith('.gguf'):
path = os.path.join('${GGUF_DIR}', f)
size_gb = os.path.getsize(path) / 1e9
print(f'Uploading {f} ({size_gb:.1f} GB)...')
api.upload_file(path_or_fileobj=path, path_in_repo=f,
repo_id='${HF_REPO}', repo_type='model')
"

数据流:

LoRA adapter (7.5GB, 磁盘)
→ merge → 完整模型 safetensors (~70GB, /dev/shm 内存盘)
→ convert_hf_to_gguf → bf16.gguf (~70GB, /dev/shm 内存盘)
→ llama-quantize → Q8_0.gguf (35GB, 磁盘)
→ llama-quantize → Q4_K_M.gguf (20GB, 磁盘)
→ upload → HuggingFace

量化结果#

$ ls -lh ./qwen35-35b-novel-gguf/*.gguf
-rw-r--r-- 1 root root 20G qwen35-35b-novel-Q4_K_M.gguf
-rw-r--r-- 1 root root 35G qwen35-35b-novel-Q8_0.gguf

量化 log 示例(Q8_0):

[ 74/ 733] blk.3.ffn_up_exps.weight - [2048, 512, 256, 1], type = bf16,
converting to q8_0 .. size = 512.00 MiB -> 272.00 MiB

MoE 的 expert 权重占了大头,每层 256 个 expert 的 gate/up/down projection 各 512MB(bf16),量化到 Q8_0 后变 272MB。

最终产出#

产物大小HuggingFace
LoRA adapter7.5GBSteven10429/qwen35-35b-novel-lora
GGUF Q8_035GBSteven10429/qwen35-35b-novel-gguf
GGUF Q4_K_M20GBSteven10429/qwen35-35b-novel-gguf

经验总结#

  1. 先看 GPU 监控再调参数。batch-size=1 时看起来在跑,但 GPU 只用了一半。WandB 的 System 面板(GPU Utilization、Memory Allocated、Power Usage)是最直观的判断依据。

  2. MoE 模型的 LoRA 微调,batch-size=2 是 H200 上的甜点。再大可能 OOM(尤其是遇到两个都接近 32K 的样本时),再小浪费算力。

  3. 长文本生成任务必须保持大 max_seq_length。如果训练时序列长度太短,模型学不到”继续写下去”的行为模式,会过早 EOS。32768 是这次成功的关键。

  4. Vast.ai 的磁盘永远不够用。250GB 听起来很多,但基础模型缓存 + adapter + checkpoints 就占满了。/dev/shm 是救命稻草 — H200 机器通常有 2TB 内存,/dev/shm 默认可用且不需要 mount 权限。

  5. Cut Cross Entropy 是大模型 SFT 的必备技巧。对于 vocab_size > 100K 的模型,logits tensor 的显存占用是灾难性的。CCE 可以把这部分开销从 30GB 降到接近 0。

  6. 总花费约 $120。明细如下:

阶段花费说明
前期 debug~$26H100 OOM → 换 H200、pipeline parallelism → FSDP、FSDP OOM → per-rank loading
训练~$6814.5h × $4.71/hr,1000 samples,63 steps
数据转换 + 量化导出~$14merge LoRA → GGUF bf16 → Q8_0 + Q4_K_M
其他(小规模测试等)~$12100 samples 测试跑、inference 验证

Debug 的钱几乎和训练本身一样多 — 这在大模型微调中很常见,做好心理准备。

在 Vast.ai 上用 LoRA 微调 Qwen3.5-35B 写小说
https://blog.lishuyu.top/posts/qwen35-35b-lora-sft-novel/
作者
猫猫魔女
发布于
2026-03-24
许可协议
CC BY-NC-SA 4.0