Chapter 10

案例:4090 上训一个 Llama-3-8B 中文对话模型

📌 commit 1cf145c070ea 端到端 4 阶段 · 一台机器 1-2 天

把前面 9 章的能力串起来,跑一个完整实战:在一张 4090(24G)上,用 Unsloth 把 unsloth/llama-3-8b-bnb-4bit 训成一个对中文友好的 chat 模型。本章给完整命令、训练曲线、和部署到 Ollama 的步骤。

10.1目标

指标目标
资源 1 × 4090 24G + 32 GB RAM
训练时间 ~ 8-16 小时
模型规模 Llama-3-8B QLoRA
训练数据 5-10 万条中文指令
最终产物 GGUF 量化模型,可用 Ollama 跑
预期效果 中文对话流畅、不串英文、不犯低级错误

10.2总流程

flowchart LR A([Llama-3-8B base]) --> B[阶段 1
准备中文 SFT 数据
~ 1 小时] B --> C[阶段 2
SFT 训练
~ 8-16 小时] C --> D[阶段 3
测试 + 合并 LoRA
~ 30 分钟] D --> E[阶段 4
导出 GGUF + Ollama
~ 30 分钟] E --> F([部署完成])

10.3阶段 1:准备中文数据

有几个公开中文 SFT 数据集可选:

数据集规模风格
silk-road/alpaca-data-gpt4-chinese 52K alpaca 格式 + GPT-4 增强
BelleGroup/train_2M_CN 200 万 多任务中文指令
YeungNLP/firefly-train-1.1M 110 万 23 类任务
shareAI/CodeChat-Chinese 50K 代码 + 中文对话
Skywork/Skywork-Reward-Preference-80K-v0.2 80K 偏好 给 DPO 用

本案例用 silk-road/alpaca-data-gpt4-chinese(5 万条质量好的)+ 自己写的 1000 条"身份认同"数据。

数据预处理脚本

from datasets import load_dataset, Dataset
import json

# 加载公开数据
zh_alpaca = load_dataset("silk-road/alpaca-data-gpt4-chinese", split="train")

# 加载自己的身份数据(让模型知道自己叫"小助手")
with open("identity.json") as f:
    identity_data = json.load(f)
identity_ds = Dataset.from_list(identity_data)

# 合并
from datasets import concatenate_datasets
combined = concatenate_datasets([zh_alpaca, identity_ds])
combined = combined.shuffle(seed=3407)

# 转 chat 格式
def to_chat_messages(example):
    return {
        "messages": [
            {"role": "user",      "content": example["instruction"] + ("\n" + example["input"] if example.get("input") else "")},
            {"role": "assistant", "content": example["output"]},
        ]
    }
combined = combined.map(to_chat_messages, remove_columns=combined.column_names)
combined.save_to_disk("./zh_sft_data")

10.4阶段 2:SFT 训练

完整训练脚本 train.py

from unsloth import FastLanguageModel, is_bfloat16_supported
from unsloth.chat_templates import get_chat_template, train_on_responses_only
import torch
from datasets import load_from_disk
from trl import SFTTrainer
from transformers import TrainingArguments

# === 1. 模型 + LoRA ===
max_seq_length = 2048
model, tokenizer = FastLanguageModel.from_pretrained(
    "unsloth/llama-3-8b-bnb-4bit",
    max_seq_length = max_seq_length,
    dtype = None,
    load_in_4bit = True,
)
model = FastLanguageModel.get_peft_model(
    model,
    r = 32,                          # 中文这种"大调整"用大点的 r
    target_modules = ["q_proj","k_proj","v_proj","o_proj",
                      "gate_proj","up_proj","down_proj"],
    lora_alpha = 32,
    lora_dropout = 0,
    bias = "none",
    use_gradient_checkpointing = "unsloth",
    random_state = 3407,
)

# === 2. tokenizer + chat template ===
tokenizer = get_chat_template(tokenizer, chat_template="llama-3")

def format_messages(examples):
    texts = [
        tokenizer.apply_chat_template(m, tokenize=False, add_generation_prompt=False)
        for m in examples["messages"]
    ]
    return {"text": texts}

# === 3. 数据 ===
dataset = load_from_disk("./zh_sft_data")
dataset = dataset.map(format_messages, batched=True, num_proc=4)
print(f"训练样本数: {len(dataset)}")
print(f"样例: {dataset[0]['text'][:500]}")

# === 4. trainer ===
trainer = SFTTrainer(
    model = model,
    tokenizer = tokenizer,
    train_dataset = dataset,
    dataset_text_field = "text",
    max_seq_length = max_seq_length,
    dataset_num_proc = 4,
    packing = True,                  # ★ 短数据训长上下文必开
    args = TrainingArguments(
        per_device_train_batch_size = 2,
        gradient_accumulation_steps = 4,         # 等效 batch=8
        warmup_steps = 100,
        num_train_epochs = 3,                     # 3 轮够了
        learning_rate = 2e-4,
        bf16 = is_bfloat16_supported(),
        fp16 = not is_bfloat16_supported(),
        logging_steps = 10,
        optim = "adamw_8bit",
        weight_decay = 0.01,
        lr_scheduler_type = "cosine",
        seed = 3407,
        output_dir = "outputs",
        save_strategy = "steps",
        save_steps = 500,
        save_total_limit = 3,
        report_to = "wandb",
    ),
)

# === 5. 只对 assistant 部分计 loss ===
trainer = train_on_responses_only(
    trainer,
    instruction_part = "<|start_header_id|>user<|end_header_id|>\n\n",
    response_part    = "<|start_header_id|>assistant<|end_header_id|>\n\n",
)

# === 6. 开训 ===
trainer.train()

# === 7. 保存 LoRA adapter ===
model.save_pretrained("lora_model")
tokenizer.save_pretrained("lora_model")

启动 + 监控

python train.py 2>&1 | tee train.log

训练过程中你应该看到(4090 / Llama-3-8B / r=32 / packing):

10.5阶段 3:测试 + 合并 LoRA

跑几个测试 prompt

from unsloth import FastLanguageModel
from unsloth.chat_templates import get_chat_template

model, tokenizer = FastLanguageModel.from_pretrained(
    "unsloth/llama-3-8b-bnb-4bit",
    max_seq_length=2048, load_in_4bit=True,
)
# 加载训好的 LoRA
model.load_adapter("lora_model")
tokenizer = get_chat_template(tokenizer, chat_template="llama-3")

# 推理模式
FastLanguageModel.for_inference(model)

prompts = [
    "你是谁?你叫什么名字?",
    "用三句话解释什么是相对论",
    "写一首关于秋天的五言绝句",
    "我家猫不爱吃饭,怎么办?",
]

for q in prompts:
    messages = [{"role": "user", "content": q}]
    inputs = tokenizer.apply_chat_template(messages, tokenize=True, add_generation_prompt=True, return_tensors="pt").to("cuda")
    outputs = model.generate(input_ids=inputs, max_new_tokens=512, temperature=0.8, top_p=0.9, do_sample=True)
    print("Q:", q)
    print("A:", tokenizer.decode(outputs[0][inputs.shape[-1]:], skip_special_tokens=True))
    print("-" * 60)

预期:

合并 LoRA 到完整模型

# 在脚本里
model.save_pretrained_merged(
    "merged_16bit",
    tokenizer,
    save_method="merged_16bit",   # 完整 fp16;也可 "merged_4bit" 保持 4bit
)

得到 ./merged_16bit/ 是标准 HF 模型目录(约 16 GB),可以:

10.6阶段 4:导出 GGUF 给 Ollama 部署

Unsloth 自带 GGUF 导出,一行命令完成:

model.save_pretrained_gguf(
    "llama3_zh_chat_q4",
    tokenizer,
    quantization_method = "q4_k_m",   # 最常用的 4bit 量化
)

会生成 ./llama3_zh_chat_q4/unsloth.Q4_K_M.gguf(约 5 GB),是 llama.cpp / Ollama 直接能跑的格式。

Ollama 部署

写 Modelfile:

# Modelfile
FROM ./llama3_zh_chat_q4/unsloth.Q4_K_M.gguf

TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|>

{{ .System }}<|eot_id|>{{ end }}<|start_header_id|>user<|end_header_id|>

{{ .Prompt }}<|eot_id|><|start_header_id|>assistant<|end_header_id|>

"""

SYSTEM """你是小助手,一个友好的中文 AI 助手。"""

PARAMETER stop "<|eot_id|>"
PARAMETER stop "<|end_of_text|>"
PARAMETER temperature 0.8
PARAMETER top_p 0.9

注册 + 跑:

ollama create llama3-zh -f Modelfile
ollama run llama3-zh

>>> 你好,介绍一下你自己
我是小助手,一个友好的中文 AI 助手……

整个模型在本地跑,不联网、不发数据,是 Ollama + GGUF 流的精髓。

10.7验证 / 评测

用一份小型评测集(自己准备 100 道题,每道题对照 base 模型和训完模型的回答)做 side-by-side:

维度base Llama-3-8B训完 8B
中文流畅度 有时夹英文 纯中文
身份认知 说自己是 Meta 的 Llama说自己是小助手
古诗写作 能写但不押韵 押韵 / 平仄基本对
生活闲聊 偏冷淡 有温度
代码 / 数学 没退化 没退化

常见量化评测:用 opencompass / lm-eval-harness 跑 CMMLU / C-Eval / MMLU。

10.8这个 case 的成本

金额
电费(4090 跑 12 小时 × 0.45 kWh × 0.7 元/度) ~ 4 元
OpenAI API(如果用 GPT-4 蒸馏数据) ~ 100 元
本地 SSD 存储 ~ 30 GB
人力 1 天
总计 ~ 100 元 + 1 天

对比:"用 LLaMA-Factory + DeepSpeed 在 8 卡 A100 上训"的云成本 = ~ $200/小时 × 6 小时 = $1200,差两个数量级。 这就是 Unsloth 对个人 / 小团队的革命性意义。

10.9把这个 case 发散

诉求改造
训行业模型(法律 / 医疗) 换数据集;data 准备时加领域语料
训代码模型 codealpaca 数据;max_seq_length 升 4-8K
训长上下文 第 7 章配置;用长文档数据
训完做 DPO 偏好 第 8.3 章 + 偏好数据集
训 GRPO 推理模型 第 8.4 章 + 数学 / 代码可验证 reward
多模态 VLM 用 FastVisionModel;数据集要带 image

10.10这章带走的