Файн-тюнинг Llama 3 8B: Полный гайд (Unsloth + PyTorch)

Дообучаем Llama 3 8B на своих данных через QLoRA и Unsloth: актуальная установка, рабочий код на SFTConfig и сохранение адаптеров в GGUF. Хватает одной карты на 16 ГБ.

YouGPU Team Обновлено: 3 мин

TL;DR — Кратко:

  • Unsloth заявляет обучение в 2 раза быстрее и на 70% меньше VRAM без потери точности.
  • QLoRA грузит модель в 4 битах: 8B занимает около 6 ГБ вместо 16.
  • Параметры обучения задаются через SFTConfig, а модель и токенизатор передаются в SFTTrainer отдельно.

Базовая Llama 3 8B знает обо всём понемногу. Чтобы она отвечала в стиле вашей поддержки, писала код под ваш внутренний фреймворк или разбирала документы вашей отрасли, её нужно дообучить.

Раньше это требовало кластера. Сегодня связка QLoRA и Unsloth укладывает дообучение 8B в одну карту потребительского класса. Ниже рабочий код от установки до сохранения в GGUF.

Почему Unsloth, а не чистый Trainer

Unsloth переписал ядра PyTorch для обратного прохода вручную. По заявлению разработчиков, это даёт обучение в 2 раза быстрее при расходе VRAM на 70% меньше и без потери точности.

Последний пункт важнее первых двух: математика остаётся прежней, ускорение идёт за счёт эффективности вычислений, а не аппроксимаций. Качество адаптера не страдает.

Установка

Понадобится Linux и карта NVIDIA. Минимум 12 ГБ VRAM, комфортно от 16 ГБ.

Актуальный способ установки из документации Unsloth использует uv:

curl -LsSf https://astral.sh/uv/install.sh | sh

uv venv unsloth_env --python 3.13
source unsloth_env/bin/activate
uv pip install unsloth --torch-backend=auto

Шаг 1. Загрузка модели в 4 битах

Модель грузится квантованной, поэтому 8B занимает около 6 ГБ вместо 16 в fp16. Откуда берутся эти цифры и что ещё расходует память при обучении, разобрано в статье про VRAM.

import torch
from unsloth import FastLanguageModel

max_seq_length = 2048  # можно поднять до 8192
dtype = None           # автоопределение: float16 для Tesla T4, bfloat16 для Ampere и новее
load_in_4bit = True

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/llama-3-8b-bnb-4bit",
    max_seq_length=max_seq_length,
    dtype=dtype,
    load_in_4bit=load_in_4bit,
)

Готовые 4-битные сборки лежат в репозитории Unsloth на Hugging Face. Для инструкт-версии берите unsloth/llama-3-8b-Instruct-bnb-4bit.

Шаг 2. Настройка LoRA-адаптеров

Все 8 миллиардов весов переучивать не нужно. LoRA замораживает базовую модель и обучает небольшие матрицы поверх неё, поэтому градиенты и состояния оптимизатора считаются только для адаптера. Метод описан в статье авторов QLoRA.

model = FastLanguageModel.get_peft_model(
    model,
    r=16,  # ранг адаптера: 16, 32, 64 — рабочие значения
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_alpha=16,
    lora_dropout=0,
    bias="none",
    use_gradient_checkpointing="unsloth",
    random_state=3407,
    use_rslora=False,
    loftq_config=None,
)

Чем выше ранг r, тем больше ёмкость адаптера и тем больше памяти он занимает. Начинать имеет смысл с 16.

Шаг 3. Подготовка данных

Для примера берём датасет Alpaca, но структура подойдёт для любого своего JSON с полями инструкции, контекста и ответа.

from datasets import load_dataset

alpaca_prompt = """Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.

### Instruction:
{}

### Input:
{}

### Response:
{}"""

EOS_TOKEN = tokenizer.eos_token


def formatting_prompts_func(examples):
    texts = []
    for instruction, inp, output in zip(
        examples["instruction"], examples["input"], examples["output"]
    ):
        texts.append(alpaca_prompt.format(instruction, inp, output) + EOS_TOKEN)
    return {"text": texts}


dataset = load_dataset("yahma/alpaca-cleaned", split="train")
dataset = dataset.map(formatting_prompts_func, batched=True)

EOS_TOKEN в конце каждого примера обязателен. Без него модель не научится останавливаться и будет генерировать текст до упора в лимит токенов.

Шаг 4. Запуск обучения

Параметры датасета задаются через SFTConfig, а не напрямую в SFTTrainer: там живут dataset_text_field, max_length, packing и dataset_num_proc. Токенизатор передаётся в processing_class.

from trl import SFTConfig, SFTTrainer

trainer = SFTTrainer(
    model=model,
    processing_class=tokenizer,
    train_dataset=dataset,
    args=SFTConfig(
        dataset_text_field="text",
        max_length=max_seq_length,
        dataset_num_proc=2,
        packing=False,
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,
        warmup_steps=5,
        max_steps=60,  # для реального прогона замените на num_train_epochs=1
        learning_rate=2e-4,
        fp16=not torch.cuda.is_bf16_supported(),
        bf16=torch.cuda.is_bf16_supported(),
        logging_steps=1,
        optim="adamw_8bit",
        weight_decay=0.01,
        lr_scheduler_type="linear",
        seed=3407,
        output_dir="outputs",
    ),
)

trainer_stats = trainer.train()

При ошибке OOM снижайте per_device_train_batch_size с 2 до 1 и компенсируйте через gradient_accumulation_steps. Следующий рычаг — уменьшить max_length: расход памяти на активации растёт вместе с длиной последовательности.

Значение optim="adamw_8bit" выбрано не случайно: состояния Adam обычно занимают 8 байт на параметр, а восьмибитная версия сжимает их до 2.

Шаг 5. Проверка результата

FastLanguageModel.for_inference(model)

inputs = tokenizer(
    [alpaca_prompt.format(
        "Напиши функцию на Python для вычисления чисел Фибоначчи.",
        "",
        "",  # ответ оставляем пустым, его дополнит модель
    )],
    return_tensors="pt",
).to("cuda")

outputs = model.generate(**inputs, max_new_tokens=64, use_cache=True)
print(tokenizer.batch_decode(outputs))

Шаг 6. Сохранение

Адаптеры весят порядка сотни мегабайт, потому что содержат только обученные матрицы, а не всю модель:

model.save_pretrained("lora_model")
# model.push_to_hub("your_name/lora_model")

Для запуска через Ollama или LM Studio нужен GGUF с вшитыми весами:

model.save_pretrained_gguf("model", tokenizer, quantization_method="q4_k_m")

Как поднять получившийся GGUF в Ollama с веб-интерфейсом, разобрано в гайде по Llama 3 и Docker.

Что дальше

Схема неизменна для любой своей задачи: соберите датасет в формате «инструкция — контекст — ответ», подставьте его вместо Alpaca, поднимите max_steps до полного прохода по данным.

Если 8B перестанет хватать, следующий шаг упирается в память: 70B через QLoRA требует уже 48 ГБ и выше. Что даёт переход на карты такого класса, разобрано в сравнении A100 и RTX 4090.

Нужна карта под дообучение?

RTX A6000 на 48 ГБ от 80 ₽/час или A100 80GB для моделей покрупнее. Драйверы, CUDA и Jupyter уже настроены.

Выбрать карту