LoRA-файнтюн qwen3-1.7B за вечер: 78,9% → 94,4%

Задача на вечер: научить маленькую локальную модель превращать русскую голосовую команду («кароче, сделай на кухне потемнее») в строгий JSON tool-call для умного дома. Промпты честно попробовал первыми — и упёрся в их потолок. Рассказываю, как за один вечер закрыл вопрос дообучением и что пошло не так с квантованием.
Контекст
Это часть большого плана «голос → MCP-команда» (MCP — Model Context Protocol, протокол подключения инструментов к LLM): локальный голосовой ассистент, который на дешёвой модели понимает команды дома и зовёт инструменты. Требования к NLU-звену скромные: одна короткая фраза на входе, один JSON на выходе, латентность доли секунды, работа без облака.
Железо: MacBook на M3 Max как тренажёр, стенд с GTX 1660 для замеров, домашний сервер — как будущий 24/7 хост. Модель-кандидат — qwen3-1.7b: достаточно маленькая, чтобы жить на слабом железе.
Почему не промпты
Базовая модель с большим системным промптом давала 22–25 правильных из 30 тестовых команд. Начался классический whack-a-mole: добавляешь правило «“потемнее” = яркость 30» — и число «70» начинает утекать в чужие кейсы, а «объяви ужин готов» вдруг включает свет на кухне.
Потолок промптов оказался ~26–27 из 30, и главное — он хрупкий: любая правка правил ломала соседние случаи. Диагноз: дело не в тексте инструкции, а в весах. Значит — дообучение.
Датасет дистилляцией за 18,43 ₽
Размечать тысячи примеров руками не хочется. Схема такая:
- 86 реальных команд дома размечены вручную (инструмент → аргументы) — это единственный источник истины;
- облачная модель (deepseek-v4.1-flash) сгенерировала по восемь перефразировок каждой команды при temperature 0.9 — до фильтра judge дошло 672 из них;
- judge-фильтр round-trip: сгенерированная фраза отправлялась обратно модели («что имелось в виду?») и несовпадающие отбрасывались — прошло 562;
- программные вариации (таймеры, яркость, температуры) плюс шум-пертурбации: выброс и перестановка слов, слова-паразиты («Кароче», «Эм»), обрывы фразы.
Итог: 1236 обучающих и 180 контрольных примеров в chat-формате. Стоимость дистилляции — 18,43 ₽ при внутреннем лимите 100–200 ₽ на эксперимент.
Принцип, который стоит проговорить: учитель не размечает. Метки идут из ручной разметки, учитель порождает только разнообразие поверхностных форм. Поэтому «взять модель покрупнее как учителя» — наименьший рычаг: узкое место — покрытие данных, а не IQ генератора.
Обучение на MacBook
LoRA (Low-Rank Adaptation) на 4-битной базе через mlx-lm — фреймворк Apple MLX:
- 800 итераций, batch 4, learning rate 1e-5, адаптер на 16 слоях;
- 14 минут на M3 Max;
- loss: 3,64 → 0,033.
Адаптер фьюзится в базу — на выходе обычная модель в HF-формате, без внешних зависимостей от LoRA-рантайма.
Конверсия в GGUF: две неочевидности
GGUF — формат весов для llama.cpp и ollama. Дальше модель ждал ollama на стенде, и тут две грабли подряд:
- mlx-lm не умеет экспорт qwen3 в GGUF («Model type qwen3 not supported») —
спасает конвертер из llama.cpp (
convert_hf_to_gguf.py) на сфьюзенной bf16-модели; - свежая ollama не делает create-time квантование GGUF (только импорт
safetensors) — квантовать нужно утилитой
llama-quantizeдо импорта.
Результаты — и главный сюрприз
Бенчмарк на 180 контрольных примерах (стенд, GTX 1660):
| Вариант | Точность | Латентность | Память |
|---|---|---|---|
| база q4 | 142/180 = 78,9% | 0,28 с | 1,9 ГБ |
| fine-tune q4_K_M | 154/180 = 85,6% | 0,28 с | ~1,5 ГБ |
| fine-tune q8_0 | 170/180 = 94,4% | 0,43 с | 2,5 ГБ |
| fine-tune f16 | 169/180 = 93,9% | 0,55 с | 4,1 ГБ |
Ключевое открытие: q4_K_M стирает мелкие LoRA-правки — минус 8,8 процентных пункта против q8_0. Адаптер учит тонкие различия (resume против play, «потише» против «погромче»), и 4-битное сжатие их съедает. q8_0 даёт качество f16 при половинном размере — выбор очевиден, даже ценой +0,15 с латентности.
Что реально исправилось по сравнению с базой:
- выдуманные аргументы (
room: "везде", яркость из ниоткуда); - инверсии действий («выключи» → включение);
- ложные срабатывания на незнакомых устройствах;
- ломкость к словам-паразитам: «Кароче, свет в выключи» теперь разбирается корректно.
Честный остаток
10 промахов на 180 осталось (170/180 у выбранного q8_0): «продолжи музыку» → play вместо resume, «ворота» → калитка, «покажи на кухне» → свет вместо запроса температуры. Это не потолок подхода, а пробелы покрытия датасета — и лечится раундом-2:
- реальные STT-транскрипты в обучении (STT — распознавание речи; корпус из 1425 клипов уже записан) — пусть модель компенсирует ошибки распознавания, промптом это не получить в принципе;
- контрастные пары: «продолжи/включи/следующую/паузу», балансировка «ворота vs калитка»;
- schema-randomized training: учить на нескольких вариантах схем инструментов, чтобы модель читала схему из промпта, а не заучивала одну.
Отдельная оговорка про метрику: контрольный набор синтетический (та же генерация, что и обучение), реальная цифра будет ниже. Следующий шаг — сквозной прогон «аудио → распознавание → JSON» на живом корпусе.
Выводы
За вечер и 18 рублей дистилляции маленькая модель научилась тому, что промпты не могли дать в принципе: стабильному формату без хрупких правил. Два урока на вынос — учитель не размечает (метки только ручные), и мелкие адаптеры требуют бережного квантования: q8 вместо q4 — это не перестраховка, а +8 п.п.