← Инженерные заметки

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

Ночная иллюстрация: кот в худи за неоновым ноутбуком с панелью обучения
Qwen3 1.7B LoRA, стопка книг по файнтюнингу и квантованию, робот с чек-листом
и спящий кот у серверной стойки

Задача на вечер: научить маленькую локальную модель превращать русскую голосовую команду («кароче, сделай на кухне потемнее») в строгий 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 ₽

Размечать тысячи примеров руками не хочется. Схема такая:

  1. 86 реальных команд дома размечены вручную (инструмент → аргументы) — это единственный источник истины;
  2. облачная модель (deepseek-v4.1-flash) сгенерировала по восемь перефразировок каждой команды при temperature 0.9 — до фильтра judge дошло 672 из них;
  3. judge-фильтр round-trip: сгенерированная фраза отправлялась обратно модели («что имелось в виду?») и несовпадающие отбрасывались — прошло 562;
  4. программные вариации (таймеры, яркость, температуры) плюс шум-пертурбации: выброс и перестановка слов, слова-паразиты («Кароче», «Эм»), обрывы фразы.

Итог: 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):

ВариантТочностьЛатентностьПамять
база q4142/180 = 78,9%0,28 с1,9 ГБ
fine-tune q4_K_M154/180 = 85,6%0,28 с~1,5 ГБ
fine-tune q8_0170/180 = 94,4%0,43 с2,5 ГБ
fine-tune f16169/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 п.п.

Источники

Обсудить в Telegram

Расскажите о задаче

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