Эксперимент - как мы обучили 14B-модель за 42 минуты и перестали платить за API

Типичный путь внедрения языковой модели в компании почти всегда начинается с иллюзии простоты.

Эксперимент - как мы обучили 14B-модель за 42 минуты и перестали платить за API

Команда берет флагманскую облачную модель через API, пишет системный промпт на четыре страницы с описанием корпоративных регламентов, прикручивает поиск по базе документов и считает задачу решенной. Первые десять запросов в интерфейсе работают отлично.

Реальность наступает через месяц эксплуатации в продакшене.

Контекст запроса раздувается до десяти тысяч токенов. Время генерации первого слова улетает за четыре секунды. Счет за вызовы API начинает пробивать триста-четыреста долларов в месяц на десяток активных сотрудников.

Но самое неприятное - нестабильность: на сложной задаче модель периодически путает внутренние форматы данных, выдумывает несуществующие функции и забывает обязательные поля. Попытка лечить это добавлением новых правил в промпт только увеличивает задержку и стоимость.

В этот момент возникает логичный вопрос о дообучении собственной компактной модели. Но большинство команд сразу отбрасывают эту идею, полагая, что тренировка современных нейросетей требует кластеров с видеокартами A100 за миллионы рублей и месяцев сбора терабайтных датасетов.

Это инженерное заблуждение.

В 2026 году связка из низкоранговой адаптации (LoRA), 4-битного квантования и кастомных ядер Unsloth позволяет дообучить открытую 14B-модель под узкую прикладную задачу на одной потребительской видеокарте за 40-50 минут. Затраты на электроэнергию или аренду облачной карты составляют буквально пару сотен рублей.

Ниже полный разбор этого эксперимента: от физики расхода видеопамяти и матричной факторизации до дневника отладки трех неудачных попыток и вывода готовой модели в рабочий контур.

Разбор инцидента - почему промпт проигрывает весам

Посмотрим на конкретный пример из нашей практики: генерация аналитических SQL-запросов к распределенной базе ClickHouse по сложным вопросам аналитиков.

Кстати, если вы хотите протестировать все самые свежие модели уровня Claude Sonnet 5, GPT-5.6 или Gemini 3 в одном удобном месте и сравнить их ответы без костылей с иностранными картами - на платформе SYNTX.AI это можно сделать за пару кликов.

По промокоду NEIROSKUF дадут 15% скидку на все тарифы.

Базовая модель Llama 3 70B через облачный API с подробным системным промптом на описание таблиц выдает следующий код:

SELECT user_id, count(*) as events FROM user_logs WHERE event_date >= '2026-05-01' GROUP BY user_id HAVING count(*) > 10;

Запрос выглядит синтаксически чистым, но при выполнении в СУБД падает с ошибкой. В реальной базе колонка с датой называется event_timestamp и имеет тип DateTime64(3), а таблицы user_logs не существует вовсе: аналитические события лежат в распределенной таблице events_v2 внутри кластера. Модель просто подставила типовые стандартные названия из своего открытого претрейна.

А вот результат работы компактной модели Qwen 2.5 14B, которую мы дообучили на шестистах реальных примерах наших аналитиков:

SELECT user_id, count() AS total_events FROM clusterAllReplicas('company_cluster', default.events_v2) WHERE event_timestamp >= toDateTime64('2026-05-01 00:00:00', 3) GROUP BY user_id HAVING total_events > 10;

Модель с первого раза использовала специфическую кластерную функцию clusterAllReplicas, указала правильное имя кластера, применила внутреннюю функцию парсинга дат ClickHouse и корректно отфильтровала партиции.

При этом запросу не нужен километровый системный промпт: контекст задачи сократился с восьми тысяч токенов до пятидесяти слов.

Физика видеопамяти - почему стандартный PyTorch требует суперкомпьютер

Чтобы понять, почему дообучение стало доступно на обычных видеокартах, нужно заглянуть в распределение видеопамяти при обратном распространении ошибки.

Для монолитной модели размером 14 миллиардов параметров в 16-битном формате (FP16/BF16) базовый вес составляет 28 гигабайт. При классическом полном дообучении (Full Fine-Tuning) видеопамять съедают четыре компонента:

  1. Веса модели: 28 ГБ.
  2. Градиенты: еще 28 ГБ для хранения производных по каждому параметру.
  3. Состояния оптимизатора AdamW: оптимизатор хранит два дополнительных 32-битных числа для каждого параметра (первый момент - среднее градиента, и второй момент - дисперсию). Это требует еще 56 гигабайт памяти.
  4. Матрица логитов при расчете функции потерь: самое коварное узкое место.

В стандартной реализации PyTorch при расчете ошибки Cross-Entropy модель берет скрытое состояние последнего слоя и умножает его на матрицу эмбеддингов, материализуя в видеопамяти полный массив вероятностей для каждого токена словаря. При словаре в 128 000 токенов, длине контекста 2048 и батче 2 этот временный тензор логитов съедает более двадцати гигабайт VRAM за доли секунды.

Суммарно для запуска стандартного обучения 14B-модели требуется более 140 гигабайт видеопамяти. Это заставляет арендовать кластеры из двух или четырех карт A100/H100.

Компиляторный прорыв: как ядра Triton в Unsloth сжимают вычисления

Фреймворк Unsloth решил проблему расхода памяти не за счет компромиссов по качеству, а через переписывание низкоуровневых вычислительных ядер на языке OpenAI Triton.

Главная оптимизация затронула расчет функции потерь. Вместо материализации гигантского массива логитов на 128 000 элементов в глобальной видеопамяти (HBM), кастомное ядро Unsloth вычисляет градиенты по частям прямо в сверхбыстрой разделяемой памяти процессора (SRAM). Память под массив логитов не выделяется вовсе.

Второй рубеж экономии - 4-битное квантование базовой модели в формате NormalFloat4 (NF4). Базовые 14 миллиардов параметров замораживаются и сжимаются до 8.5 гигабайт.

В итоге для всего процесса обучения 14B-модели требуется около 11-12 гигабайт видеопамяти. Процесс спокойно помещается в потребительскую видеокарту уровня RTX 3060 12GB, RTX 4070 или бесплатный инстанс Google Colab.

Принцип низкоранговой адаптации (LoRA)

Вместо того чтобы обновлять все 14 миллиардов весов, мы применяем метод Low-Rank Adaptation.

Математический фундамент метода прост: при адаптации предобученной нейросети под конкретную задачу реальное пространство необходимых изменений имеет крайне низкую размерность.

Пусть W0 - замороженная матрица исходного слоя трансформера. Мы представляем изменение веса как произведение двух компактных матриц:

dW = B * A

где матрица A инициализируется случайными числами, а матрица B - нулями. Ранг r (внутренняя размерность) выбирается во много раз меньше размера слоя (например, r = 16).

В момент старта обучения произведение B * A строго равно нулю, поэтому начальное поведение модели не нарушается.

При прямом проходе сигнал вычисляется по формуле:

h = W0 * x + (alpha / r) * (B * A) * x

Параметр alpha задает коэффициент масштабирования адаптера. Мы используем проверенное правило: alpha = 2 * r. При ранге r = 16 коэффициент масштабирования равен 32.

Для слоя внимания размером 4096 на 4096 количество параметров уменьшается с 16.7 миллионов до 131 тысячи. Оптимизатор AdamW хранит состояния только для этих микроскопических матриц, что снижает расход памяти на оптимизатор с 56 гигабайт до пары сотен мегабайт.

Критически важный нюанс: адаптеры обязаны подключаться ко всем семи линейным проекциям трансформера (q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj). Обучение одних лишь слоев внимания позволяет модели усвоить форму ответа, но оставляет пробелы в запоминании новых фактических связей, которые хранятся в блоках многослойного перцептрона (MLP).

Наука о данных: почему 500 примеров бьют 50 000

Главная ошибка начинающих команд - попытка собрать парсерами десятки тысяч грязных текстов с форумов или документации и залить их в обучение. Модель мгновенно теряет способность к рассуждениям и начинает воспроизводить мусорный синтаксис.

В исследовании Meta под названием LIMA (Less Is More for Alignment) исследователи доказали фундаментальный принцип: качество финальной модели определяется не объемом данных, а их чистотой и разнообразием. Одна тысяча выверенных вручную примеров дает устойчивость генерации выше, чем 50 000 сырых нефильтрованных строк.

Для прикладной задачи достаточно подготовить от 400 до 800 качественных примеров.

Датасет оформляется в виде стандартного JSON-файла с диалогами:

[ { "messages": [ { "role": "user", "content": "Посчитай количество уникальных пользователей, совершивших покупку на сумму более 5000 рублей в марте 2026 года." }, { "role": "assistant", "content": "SELECT uniqExact(user_id) AS paying_users FROM clusterAllReplicas('company_cluster', default.orders) WHERE status = 'completed' AND total_amount > 5000 AND order_date >= '2026-03-01' AND order_date < '2026-04-01';" } ] } ]

Каждый пример обязан быть проверен живым инженером на синтаксическую корректность и отсутствие разночтений в именовании полей.

Дневник отладки: три сбоя перед идеальным результатом

Мы собрали 600 эталонных пар "вопрос аналитика - SQL-запрос" и запустили процесс дообучения.

Вот через какие практические грабли пришлось пройти:

Сбой 1. Взрыв градиентов при завышенном Learning Rate

В первом запуске мы выставили скорость обучения learning_rate = 0.001 (1e-3), посчитав, что для быстрой сходимости на маленьком датасете нужен высокий темп.

На 14-м шаге обучения функция потерь совершила резкий скачок с 2.1 до 14.8, после чего значение упало в NaN. Матрицы адаптера переполнились, градиенты взорвались, и модель на любой вход стала генерировать бесконечный цикл из символов !!!.

Решение: Скорость обучения для LoRA обязана лежать в диапазоне от 0.0001 (1e-4) до 0.00025 (2.5e-4). Мы зафиксировали learning_rate = 0.0002 (2e-4), включили косинусное расписание спада (cosine decay) и добавили 10 шагов плавного прогрева (warmup steps). График ошибки стабилизировался.

Сбой 2. Обучение только матриц внимания

Во втором прогоне мы ради экономии времени подключили адаптеры только к матрицам q_proj и v_proj, как рекомендовали старые статьи 2022 года.

Модель сошлась быстро, а значение ошибки упало до 0.38. Однако при тестировании на контрольных вопросах выяснилось, что модель отлично строит общую структуру запроса (SELECT ... GROUP BY), но путает специфические функции ClickHouse (например, пишет стандартный COUNT(DISTINCT user_id) вместо быстрого uniqExact(user_id)).

Решение: Добавление адаптеров на все слои MLP (gate_proj, up_proj, down_proj). Размер файла адаптера вырос с 60 до 150 мегабайт, но модель полностью зафиксировала внутренний диалект базы.

Сбой 3. Рассинхронизация шаблона токенизатора

После успешного третьего прогона мы экспортировали модель, запустили сервер и обнаружили, что на любой запрос в чате модель молчит либо повторяет вопрос пользователя.

Причина оказалась в токенизации: при подготовке датасета функция форматирования использовала стандартные текстовые разделители User: и Assistant:, в то время как базовая модель Qwen 2.5 использует специальный системный шаблон ChatML с управляющими токенами <|im_start|> и <|im_end|>. Модель во время обучения видела один формат разметки, а в продакшене получала другой.

Решение: Использование строго нативного метода tokenizer.apply_chat_template() при предобработке датасета. Модель сразу начала отвечать идеально.

Запускаемый скрипт обучения на Python

Вот полный и проверенный рабочий код, который выполняет весь цикл тренировки:

import torch from unsloth import FastLanguageModel from datasets import load_dataset from trl import SFTTrainer from transformers import TrainingArguments # 1. Загрузка базовой модели в 4-битном формате max_seq_length = 2048 model, tokenizer = FastLanguageModel.from_pretrained( model_name="Qwen/Qwen2.5-Coder-14B-Instruct", max_seq_length=max_seq_length, load_in_4bit=True, ) # 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", ) # 3. Подготовка датасета с применением нативного шаблона ChatML dataset = load_dataset("json", data_files="dataset.json", split="train") def format_conversations(batch): formatted_texts = [ tokenizer.apply_chat_template(dialogue, tokenize=False, add_generation_prompt=False) for dialogue in batch["messages"] ] return {"text": formatted_texts} dataset = dataset.map(format_conversations, 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=10, max_steps=150, # Около 2.5 эпох на 600 примерах learning_rate=2e-4, fp16=not torch.cuda.is_bf16_supported(), bf16=torch.cuda.is_bf16_supported(), logging_steps=10, optim="adamw_8bit", weight_decay=0.01, lr_scheduler_type="cosine", output_dir="training_output", seed=42, ), ) # 5. Запуск обучения trainer.train() # 6. Экспорт в квантованный формат GGUF для продакшена model.save_pretrained_gguf("qwen_clickhouse_14b_q4", tokenizer, quantization_method="q4_k_m")

Результаты в цифрах и эксплуатация

Обучение на видеокарте RTX 4070 Ti заняло ровно 42 минуты.

Значение функции потерь снизилось с начальных 2.38 до 0.27 на финальном шаге. На тестовой выборке из 100 контрольных задач, которые модель не видела во время обучения, точность генерации составила 98%.

Итоговый файл qwen_clickhouse_14b_q4.gguf весит 8.6 гигабайт. Мы развернули его на сервере с видеокартой RTX 3060 12GB под управлением vLLM.

Эксплуатационные показатели:

  • Скорость генерации около 46-50 токенов в секунду.
  • Время формирования сложного аналитического SQL - менее одной секунды.
  • Зависимость от внешних облачных API - ноль.
  • Ежемесячные затраты на токены - ноль рублей.

Эра слепой переплаты за облачные токены пока еще не подошла к концу, но что-то под себя сделать уже вполне возможно.

Эксперимент - как мы обучили 14B-модель за 42 минуты и перестали платить за API
1111