LoRA и тонкая настройка LLM: как обучить модель под свою задачу без миллиона долларов
Введение: Зачем обучать модель, если можно просто.promptить?
Вы уже запустили локальную LLM. Возможно, через Ollama, llama.cpp или LM Studio. Модель работает, отвечает, но не так, как нужно. Она не знает терминологию вашей компании. Не умеет форматировать ответы в нужном стиле. Не понимает специфику вашей отрасли.
Три пути:
- Промпт-инжиниринг — бесплатно, но ограничено контекстным окном
- Полное дообучение (full fine-tuning) — мощно, но требует 8×A100 и недель
- LoRA / QLoRA — золотая середина: одна GPU, несколько часов, результат близок к полному обучению
В этой статье — практическое руководство по тонкой настройке локальных LLM через LoRA: от теории к реальному обучению с открытыми инструментами.
Теория за 5 минут: что такое LoRA
LoRA (Low-Rank Adaptation) — метод дообучения, который не меняет оригинальные веса модели. Вместо этого он добавляет маленькие матрицы-адаптеры рядом с каждым слоем трансформера.
Оригинальная модель: W (например, 7B параметров = 14 ГБ в FP16)
LoRA-адаптеры: A × B (например, 0.01% от W = 1.4 МБ)
Итог при инференсе: W + (A × B)
Ключевая идея: вместо обновления всех 7 миллиардов параметров, мы обновляем только ~1 миллион маленьких.
Почему это работает?
Исследование показывает, что веса нейросети при дообучении меняются незначительно — достаточно низкой ранговой декомпозиции. Проще говоря: новая задача не требует перестройки всей модели, ей нужно лишь немного скорректировать направление.
LoRA vs QLoRA vs Full Fine-tuning
| Метод | GPU RAM | Время (7B) | Качество | Сложность |
|---|---|---|---|---|
| Full Fine-tuning | 80 ГБ+ (8×A100) | 3-7 дней | ★★★★★ | Высокая |
| LoRA | 12-24 ГБ (1×GPU) | 2-6 часов | ★★★★ | Низкая |
| QLoRA | 8-12 ГБ (1×GPU) | 3-8 часов | ★★★★ | Низкая |
QLoRA — это LoRA с квантованием модели в 4 бита. Дает почти тот же результат, что LoRA, но требует в 2 раза меньше памяти.
Подготовка данных
Структура данных для обучения
LoRA обучается на парах "ввод → вывод". Формат зависит от задачи:
Диалоговый формат (ChatML / Alpaca):
[
{
"messages": [
{"role": "system", "content": "Ты — юрист, специализирующийся на трудовом праве РФ."},
{"role": "user", "content": "Как уволить сотрудника за систематическое неисполнение обязанностей?"},
{"role": "assistant", "content": "Согласно ст. 81 ТК РФ, увольнение за систематическое неисполнение трудовых обязанностей возможно при наличии следующих условий: 1. Наличие дисциплинарного взыскания..."}
]
}
]
Формат Alpaca (проще):
[
{
"instruction": "Напиши функцию сортировки на TypeScript",
"input": "",
"output": "Вот функция быстрой сортировки на TypeScript:\n\n```typescript\nfunction quickSort(arr: number[]): number[] {\n if (arr.length <= 1) return arr;\n ...\n}\n```"
}
]
Где взять данные?
| Источник | Описание |
|---|---|
| Собственная документация | PDF, Markdown, HTML — конвертируйте в текст |
| GitHub репозитории | Исходный код + issues + PR descriptions |
| Stack Overflow | Вопросы и ответы по конкретной теме |
| Книги | O'Reilly, бесплатные книги с открытой лицензией |
| Synthetic data | Генерируйте данные через GPT-4 / Claude и фильтруйте |
Конвертация в нужный формат
# Пример: конвертация Markdown-документов в диалоговый формат
import json
from pathlib import Path
def markdown_to_conversations(md_files: list[str], system_prompt: str) -> list[dict]:
conversations = []
for md_file in md_files:
content = Path(md_file).read_text()
# Разбиваем на секции
sections = content.split("## ")
for section in sections:
if not section.strip():
continue
lines = section.strip().split("\n")
title = lines[0][:80]
body = "\n".join(lines[1:])[:2000]
conversations.append({
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Объясни: {title}"},
{"role": "assistant", "content": body}
]
})
return conversations
# Использование
data = markdown_to_conversations(
["docs/api.md", "docs/setup.md", "docs/config.md"],
"Ты — технический писатель. Отвечай на основе документации."
)
with open("training_data.json", "w") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"Сгенерировано {len(data)} примеров")
Разделение на train/val/test
from sklearn.model_selection import train_test_split
# 80% обучение, 10% валидация, 10% тест
train, temp = train_test_split(data, test_size=0.2, random_state=42)
val, test = train_test_split(temp, test_size=0.5, random_state=42)
print(f"Train: {len(train)}, Val: {len(val)}, Test: {len(test)}")
# Train: 1600, Val: 200, Test: 200
Инструменты для обучения
Hugging Face Transformers + TRL
pip install transformers trl peft accelerate bitsandbytes
TRL (Transformer Reinforcement Learning) — библиотека от Hugging Face, специально для дообучения LLM.
Axolotl
# Самый простой способ начать
git clone https://github.com/axolotl-ai-cloud/axolotl
cd axolotl
pip install -e .
Axolotl использует YAML-конфигурацию — никаких скриптов.
Unsloth (самый быстрый вариант)
pip install unsloth
Unsloth оптимизирует обучение в 2-5 раз за счет кастомных CUDA-ядер.
Практика: обучение LoRA на примере
Вариант 1: Axolotl (YAML-конфиг)
Создайте файл config.yml:
base_model: Qwen/Qwen2.5-7B-Instruct
model_type: auto
model_dtype: float16
model_attn_type: auto
model_flash_attn: true
# Загрузочная модель (с которой начинаем)
load_in_8bit: false
load_in_4bit: true
# Данные
datasets:
- path: ./training_data.json
type: sharegpt
conversation: chatml
# LoRA параметры
lora_model_name: null
lora_r: 16
lora_alpha: 32
lora_dropout: 0.05
lora_target_linear: true
lora_fan_in_fan_out: false
# Обучение
output_dir: ./qwen2.5-7b-lora
batch_size: 4
micro_batch_size: 1
num_epochs: 3
learning_rate: 0.0002
warmup_ratio: 0.05
weight_decay: 0.01
# Сэмплирование
bf16: true
gradient_accumulation_steps: 4
max_seq_length: 2048
# Логирование
logging_steps: 10
eval_strategy: steps
eval_steps: 100
eval_steps: 100
# Устройство
device_count: 1
Запуск:
axolotl train config.yml
Вариант 2: Unsloth (Python-скрипт)
from unsloth import FastLanguageModel
import torch
from trl import SFTTrainer
from transformers import TrainingArguments
from datasets import load_dataset
# 1. Загрузка модели
max_seq_length = 2048
dtype = None # авто
load_in_4bit = True # QLoRA
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="unsloth/Qwen2.5-7B-Instruct",
max_seq_length=max_seq_length,
load_in_4bit=load_in_4bit,
)
# 2. Добавляем LoRA
model = FastLanguageModel.get_peft_model(
model,
r=16,
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=42,
use_rslora=False, # см. ниже
loftq_config=None,
)
# 3. Подготовка данных
def format_dataset(example):
messages = example["messages"]
return {"text": tokenizer.apply_chat_template(messages, tokenize=False)}
dataset = load_dataset("json", data_files="training_data.json", split="train")
dataset = dataset.map(format_dataset, batched=True)
# 4. Обучение
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
dataset_text_field="text",
max_seq_length=max_seq_length,
args=TrainingArguments(
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
warmup_steps=5,
max_steps=100, # 1 epoch = ~1600 steps
learning_rate=2e-4,
fp16=not torch.cuda.is_bf16_supported(),
bf16=torch.cuda.is_bf16_supported(),
logging_steps=1,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=50,
output_dir="./qwen2.5-7b-lora",
optim="adamw_8bit",
weight_decay=0.01,
lr_scheduler_type="linear",
seed=42,
),
)
# 5. Запуск
trainer.train()
# 6. Сохранение LoRA-адаптеров
model.save_lora("./qwen2.5-7b-lora")
Вариант 3: Ollama + llama.cpp (через convert)
# 1. Обучите модель через Transformers / Unsloth
# 2. Конвертируйте в GGUF
python convert-lora-to-gguf.py \
--base-model qwen2.5-7b-instruct \
--lora-model ./qwen2.5-7b-lora \
--output ./qwen2.5-7b-lora.q4_k_m.gguf
# 3. Загрузите в Ollama
ollama create my-qwen-lora -f Modelfile
# Modelfile:
# FROM qwen2.5:7b
# ADAPTER ./qwen2.5-7b-lora.q4_k_m.gguf
# 4. Используйте
ollama run my-qwen-lora
Параметры обучения: что за что отвечает
LoRA-r (rank)
Определяет размерность адаптеров.
| r | Параметры (7B) | Качество | Скорость обучения |
|---|---|---|---|
| 8 | ~700 КБ | ★★★ | Быстро |
| 16 | ~1.4 МБ | ★★★★ | Средняя |
| 32 | ~2.8 МБ | ★★★★★ | Медленнее |
| 64 | ~5.6 МБ | ★★★★★ | Очень медленно |
Рекомендация: r=16 для начала, r=32 для сложных задач.
LoRA-alpha
Масштабирующий коэффициент для LoRA-весов. Эффективное learning rate = lr / alpha.
| alpha | Эффективный LR (при lr=2e-4) |
|---|---|
| 8 | 2.5e-5 |
| 16 | 1.25e-5 |
| 32 | 6.25e-6 |
| 64 | 3.125e-6 |
Рекомендация: alpha = 2 * r (стандарт из оригинальной статьи).
Learning Rate
lr = 1e-4 → медленное, стабильное обучение
lr = 2e-4 → стандарт (рекомендуется)
lr = 5e-5 → очень осторожно (для тонкой настройки)
lr = 1e-3 → слишком агрессивно (риск катастрофического забывания)
Epochs и batch size
# Для 1600 примеров:
epochs=1, batch_size=4, grad_accum=4 → 100 шагов
epochs=3, batch_size=4, grad_accum=4 → 300 шагов
epochs=5, batch_size=2, grad_accum=8 → 500 шагов
Правило: 1-3 эпохи для начала. Больше → риск переобучения.
Max sequence length
max_seq_length=1024 → быстрее, но короткие ответы
max_seq_length=2048 → стандарт
max_seq_length=4096 → длинные ответы, медленнее
max_seq_length=8192 → очень медленно, нужно много VRAM
Оценка качества
Метрики во время обучения
Loss: 2.5 → 1.8 → 1.2 → 0.9 → 0.85 → 0.87 → 0.92
↑ ↑
минимум переобучение!
Loss < 1.0 — хорошо для диалоговых моделей. Loss < 0.5 — отлично, но проверьте, не переобучились ли. Loss > 2.0 — плохо, модель не усваивает данные.
Валидация: ручное тестирование
# Загрузите обученную модель
python load_lora.py \
--base-model Qwen/Qwen2.5-7B-Instruct \
--lora-model ./qwen2.5-7b-lora
# Тестируйте
prompt = "Как составить трудовой договор с фрилансером?"
response = model.generate(prompt, max_new_tokens=500)
print(response)
Чек-лист оценки
- Модель использует правильную терминологию?
- Стиль ответов соответствует ожиданиям?
- Форматирование (списки, код, таблицы) корректно?
- Нет галлюцинаций по известным фактам?
- Ответы не слишком короткие/длинные?
- Модель не "забывает" общие знания (язык, грамматику)?
Применение обученной модели
Слияние с базовой моделью
from unsloth import FastLanguageModel
# Загрузка с LoRA
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="unsloth/Qwen2.5-7B-Instruct",
max_seq_length=2048,
load_in_4bit=True,
)
# Добавляем LoRA
FastLanguageModel.from_pretrained(
model_name="./qwen2.5-7b-lora", # ваш LoRA
max_seq_length=2048,
load_in_4bit=True,
)
# Слияние
model.save_pretrained_merged("./qwen2.5-7b-lora-merged",
tokenizer, save_method="merged_16bit")
Конвертация в GGUF для llama.cpp / Ollama
# Конвертация в GGUF
python convert-lora-to-gguf.py \
--model ./qwen2.5-7b-lora-merged \
--output ./qwen2.5-7b-lora.gguf \
--outtype Q4_K_M
Использование с Ollama
FROM ollama/ollama
# Копируем обученную модель
COPY qwen2.5-7b-lora.gguf /models/lora.gguf
# Modelfile
FROM qwen2.5:7b
ADAPTER /models/lora.gguf
SYSTEM "Ты — юрист, специализирующийся на трудовом праве РФ."
# Сборка и запуск
ollama build MyLegalAssistant.mod
ollama run MyLegalAssistant
Использование с llama.cpp
# Запуск с LoRA-адаптером
./main -m models/qwen2.5-7b-instruct.Q4_K_M.gguf \
-a qwen2.5-7b-lora.gguf \
-p "Как оформить отпуск по уходу за ребенком?" \
-n 512 \
--temp 0.7
Типичные проблемы и решения
Проблема 1: Модель "забывает" общие знания
Симптом: После обучения модель перестала понимать английский или не может ответить на общие вопросы.
Причина: Катастрофическое забывание (catastrophic forgetting).
Решение:
- Добавьте общие примеры в данные (50-70% данных — общие диалоги)
- Уменьшите learning rate (2e-4 → 1e-4)
- Уменьшите epochs (3 → 1-2)
- Используйте mix данных: 30% специализированные, 70% общие
# Пример микса данных
general_data = load_general_conversations() # 70%
specialized_data = load_legal_conversations() # 30%
mixed_data = general_data[:700] + specialized_data[:300]
Проблема 2: Loss не падает
Симптом: Loss стоит на месте или растет.
Решение:
- Проверьте формат данных (правильно ли размечены role/content?)
- Увеличьте learning rate (1e-4 → 2e-4)
- Увеличьте r (16 → 32)
- Проверьте, что данные не пустые
Проблема 3: Out of Memory
Симптом: Ошибка "CUDA out of memory" или "MPS out of memory".
Решение:
- Включите QLoRA (load_in_4bit=True)
- Уменьшите batch_size (4 → 1)
- Увеличьте gradient_accumulation_steps (4 → 8)
- Уменьшите max_seq_length (4096 → 2048)
- Используйте gradient_checkpointing
Проблема 4: Модель генерирует бред
Симптом: Ответы выглядят осмысленно, но содержат явные ошибки.
Решение:
- Проверьте качество данных — возможно, в них есть ошибки
- Увеличьте объем данных (минимум 500 примеров)
- Добавьте больше разнообразия в данные
- Уменьшите temperature при генерации (0.7 → 0.3)
Проблема 5: LoRA-файл слишком большой
Симптом: Адаптер весит >50 МБ.
Решение:
- Уменьшите r (32 → 16)
- Уменьшите количество target_modules
- Используйте QLoRA вместо LoRA
Чек-лист: обучение LoRA с нуля
- Собрать данные (минимум 500 примеров)
- Конвертировать в формат chatml / alpaca
- Разделить на train/val/test
- Выбрать базовую модель (Qwen 2.5 7B, Llama 3.2 8B)
- Установить инструменты (Unsloth / Axolotl / Transformers)
- Написать конфиг (YAML или Python)
- Запустить обучение
- Отслеживать loss (должен падать до ~1.0)
- Протестировать на валидационных примерах
- Слить LoRA с базовой моделью (или использовать отдельно)
- Конвертировать в GGUF для локального запуска
- Протестировать в Ollama / llama.cpp / LM Studio
Итоги
LoRA и QLoRA — это доступный способ адаптировать LLM под свою задачу:
- Данные: 500-5000 примеров "ввод → вывод"
- GPU: одна видеокарта с 8-12 ГБ VRAM (QLoRA)
- Время: 2-6 часов обучения
- Результат: модель, которая говорит в вашем стиле и знает вашу терминологию
С чего начать прямо сейчас:
# 1. Установите Unsloth
pip install unsloth
# 2. Подготовьте данные в формате JSON
# 3. Запустите обучение (см. пример выше)
# 4. Конвертируйте в GGUF и используйте локально
Локальное дообучение больше не требует дата-центров. Одна GPU, несколько часов и пачка данных — и ваша LLM говорит именно так, как нужно.