direct-shablonyблог · практика
← Блог
Дообучил LLM локально на 4060 Ti и трижды наступил на грабли
2026-08-01

Дообучил LLM локально на 4060 Ti и трижды наступил на грабли

LLMfine-tuneLoRAлокально

Расскажу историю про то, как я убил вечер на отладку модели, которая после «обучения» отвечала на любой вопрос одинаково: !!!!!. Пять восклицательных знаков. Больше ничего.

Спойлер: дело было не в данных и не в гиперпараметрах. Дело было в одной строчке, которую я забыл. Но чтобы до неё добраться, я успел перепробовать вообще всё остальное. Если собираетесь дообучать что-то локально — эта статья, возможно, сэкономит вам тот самый вечер.

Зачем я вообще в это полез

Задача была такая: есть открытая модель, есть свои данные — пары «вопрос-ответ» по нашей теме (контекстная реклама). Хотелось, чтобы модель отвечала как наш человек, а не как вежливый чат-бот из коробки. И чтобы всё крутилось локально: данные в чужой облачный API отдавать нельзя, политика такая.

Железо — одна RTX 4060 Ti на 16 гигов. Не сервер, обычная десктопная карта. Модель — Qwen2.5-7B. В голове сразу зашевелилось «семь миллиардов параметров на бытовой карте, ага, щас». Но нет — в 4 битах с LoRA заходит нормально, дальше покажу.

LoRA в двух словах, без пафоса

Никто не учит модель с нуля — язык она уже знает. Нам надо только чуть подвинуть её в сторону своих данных. Поэтому вешаем LoRA: маленькие адаптеры поверх замороженных весов, учим только их.

Конфиг квантизации — и вот тут первая мина, о которой чуть ниже:

import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig

bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,   # bf16, не fp16 — запомните
    bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B", quantization_config=bnb, device_map="auto",
)

Дальше LoRA поверх:

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

model = prepare_model_for_kbit_training(model)   # <-- вот эта строчка. Запомните и её тоже
lora = LoraConfig(
    r=8, lora_alpha=16, lora_dropout=0.05,
    target_modules=["q_proj", "v_proj"], task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora)

Восемь строк — и модель готова учиться. Красиво? Красиво. А теперь про то, как это всё молча ломается.

Грабля №1 — та самая строчка

Помните prepare_model_for_kbit_training? Так вот, в первый раз я её не написал. Просто не знал, что она нужна.

Результат: градиенты ушли в NaN, лосс с первого шага 0.0, а модель после «обучения» выдавала !!!!! на что угодно. И — вот что бесит — никакой ошибки. Скрипт отработал, чекпоинт сохранился, всё «успешно».

Как понять, что ты попал именно сюда: смотри на первый шаг. Если loss=0 и grad_norm=nan — всё, дальше можно не ждать, оно уже мёртвое.

{'loss': 0.0, 'grad_norm': nan, 'learning_rate': 2e-05, 'epoch': 0.01}

Одна строка кода. Убитый вечер. Записал себе в блокнот большими буквами.

Грабля №2 — проверяй базовую модель ДО обучения

Окей, prepare_model... добавил. Запускаю на другой модели (взял 3B, хотел побыстрее потестить) — снова NaN. Приехали.

Оказалось — сама базовая модель в 4 битах уже выдавала NaN-логиты. Битые веса или несовместимая версия, я так до конца и не понял какие именно. Но с тех пор перед каждым обучением делаю тупую проверку:

from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B")
with torch.no_grad():
    ids = tok("проверка", return_tensors="pt").to(model.device)
    logits = model(**ids).logits
assert not torch.isnan(logits).any(), "базовая модель уже даёт NaN — стоп, дальше нет смысла"

Один forward-pass. Три секунды. Экономит целый прогон, который иначе сгорит впустую. На той 3B я потерял полчаса на ровном месте — а Qwen-7B и Mistral-7B, кстати, проверку прошли чисто.

Грабля №3 — учи только ответ, а не вопрос

Это уже не про NaN, это про качество. Если считать лосс по всему тексту — и по вопросу, и по ответу — модель начинает прилежно учиться... задавать вопросы. Логично же: она видит вопрос в обучающих метках и решает, что его тоже надо воспроизводить.

Лечится маскировкой. Токены вопроса помечаем -100, тогда они не идут в лосс:

labels = input_ids.clone()
labels[:prompt_len] = -100   # всё, что до ответа ассистента — не учим

Две строки, а разница в поведении — огромная.

Что в итоге вышло

Собрал 300 пар «вопрос-ответ», запустил на Qwen2.5-7B. Прогон — 13 минут на одной карте. Лосс уехал с 2.14 до 0.47 (график прикладываю: классическая картина — резкое падение в первые шаги, потом выход на полку).

Один и тот же вопрос до и после:

До: «Настройка минус-слов помогает исключить нежелательные показы. Вот пошаговая инструкция: Шаг 1…» — вода, как у любого ассистента.

После: «Через Директ Коммандер. Раздел „Минус-фразы“, вводишь без глаголов, минусуешь на группу…» — коротко, по делу, как отвечал бы практик.

Разница видна невооружённым глазом. Модель перестала быть вежливым всезнайкой и заговорила как человек, который этим реально занимается.

Куда её потом

Адаптер сливаешь с базой, гонишь в GGUF, поднимаешь через Ollama — и получаешь локальный API на localhost:11434. Дальше твои сервисы ходят к нему как к обычному эндпоинту. Наружу не уходит ничего, за инференс никто не просит денег, 7B в 4 битах отвечает в реальном времени.

Честно про границы

Чтобы никого не обманывать: fine-tune двигает СТИЛЬ и предметную область. Он не запихивает в модель свежие факты. Нужны актуальные цены или вчерашние новости — это к RAG, не сюда. Дообучение отвечает за то, КАК модель говорит, а не за то, что она помнит поимённо.

И да — почти всё время съело не обучение (оно быстрое), а возня с данными и вот эти три грабли. Так что если решитесь — закладывайте время на подготовку, а не на «нажать train».

Если кто-то тоже воевал с NaN при 4-битном обучении и нашёл решение элегантнее моих костылей — напишите в комментах, правда интересно.

А prepare_model_for_kbit_training — не забывайте. Я серьёзно.

Нужен такой же результат под вашу задачу?

Дообучу LLM на ваших данных, соберу AI-ассистента или проверю модель на безопасность. Локально и приватно.