Продолжаем серию про файнтюнинг и создание DevOps-агента Oni. В прошлой части я встретился с реальностью — ни одна локальная модель не справилась с простой задачей «зайди на сервер, посмотри в контейнере backend — логи». Я решил попробовать дообучить модель на базе qwen3:14b. Раньше никогда не сталкивался с обучением моделей, и представление было такое: всё происходит по кругу — взял модель, обучил, протестировал, выявил ошибки, снова обучил, снова протестировал. И постоянно совершенствуешь одну модель, дообучая её. Как-то так. Дошёл до oni:v8 с 11/11 на Django scaffold — а дальше любая попытка добавить новый скилл ломала старое: 11/11 → 9/11 → 4/11 → 4/11 → 3/11 → 0/11. Эта статья про то, как я сам себя дообучил в дообучении моделей.

Делал агента из Qwen3:14B. Использовал два подхода, которым, естественно, в научном мире нашлось название — потому что я просто прошёл стандартный путь:

Способ #1 — incremental delta-merge: учу v1, тестирую, делаю delta-LoRA с новыми примерами поверх → получаю v2; повторяю до v8. Дошёл до Django scaffold с 11/11 на тесте L1.1. Попытался добавить SSH/docker — модель забыла Django. Пять попыток подряд (v1.01 → v8.s1.01) — пять регрессий. Anchor-примеры не помогли. Самый мягкий learning rate не помог. Стратегия оказалась неподходящей — может, дело в семействе Qwen, может, в файнтюнинге она вообще редко работает на таких задачах. Останется за кадром.

Способ #2 — fresh-from-base + dataset evolution: каждая новая base-N тренируется с нуля на чистом Qwen3:14B. Эволюционирует только датасет: нормализация, дедупликация, добавление distilled-сидов, hand-curated anchors. Дольше (1.5–3.5 часа vs 30–60 минут), зато воспроизводимо, и catastrophic forgetting исключён архитектурно. На этом подходе пришли к новому чемпиону oni:base-clean.v2 — на боевых тестах (realworld) 10/10, без галлюцинаций.


Способ #1: incremental delta-merge

Идея

Изначально казалось логично: зачем каждый раз учить с нуля, если можно дообучать поверх предыдущей версии? Тренируешь v1, смотришь где плохо, пишешь под слабые места десяток-другой трейсов, делаешь LoRA-адаптер с маленьким learning rate, мержишь в базу — получаешь v2. Цикл занимает 30–60 минут, диск ест мало (256 MB на адаптер), и можно держать сразу несколько моделей под разные задачи.

Схематично:

qwen3:14b
   ↓ train v1 (datasetv1) → bake → ollama deploy
oni:v1
   ↓ test → видим слабые места → доучиваем (datasetv2) → merge LoRA
oni:v2
   ↓ test → ... → доучиваем
oni:v3
   ...
oni:v8 ← Django scaffold, L1.1 = 11/11
   ↓ хочется добавить SSH/docker → delta-LoRA
oni:v1.01 ← регрессия
oni:v1.02 ← регрессия
oni:v1.03 ← регрессия
oni:v1.04 ← регрессия
oni:v8.s1.01 (max-soft, lr=1e-5) ← регрессия

Хронология роста: v1 → v8

Первые версии я выпускал каждые сутки, и каждая делала шаг вперёд:

Версия

Дата

Что добавил

Результат

v1–v4

начало апреля 2026

Qwen3:8B fresh fine-tune

Слабовато, перешёл на 14B

v5

2026-04-23

Qwen3:14B fresh, 2698 примеров, 3 epoch

Базовый агент, работает

v6

2026-04-24 утро

+501 трейс docker/scaffold (delta)

Научился копировать файлы в контейнер и поднимать проекты

v7

2026-04-24 вечер

+Django scaffold seeds (delta)

Ручное тестирование пройдено

v8

2026-04-24 ночь

+5 Django finishing seeds (delta)

11 из 11 идеальных прохождений ⭐ — лучший результат за всё время

К ночи 24 апреля казалось, что нашёл рабочий процесс. Маленький адаптер, точечные правки — через неделю-две модель будет иметь минимум для нормальной работы в OpenClaw.

Хронология провала: v9 → v8.s1.01

Утром 25 апреля добавил 7 свежих трейсов на docker и gitlab. Думал — модель уже хорошо знает Django, теперь докрутим инфраструктурные сценарии. Запустил тест.

L1.1 (Django scaffold): 0 из 11. После одного цикла дообучения с маленькой LoRA на 7 примерах.

Подумал, что это случайность — может, lr был слишком агрессивный. Снизил, добавил 50 anchor-примеров старого Django-скилла, чтобы модель «не забывала». Тренировал заново.

Версия

Дата

Подход

Результат

v9

2026-04-25

+7 docker/gitlab seeds (delta)

L1.1 = 0/11 ❌ полная регрессия

v1.01

2026-04-25

conservative delta поверх v8 + 50 anchor

L1.1 = 0/11 (-11)

v1.02

2026-04-25

+ полный HF Magicoder mix

L1.1 = 9/11 (-2), L2.1 = 1/3 (+1)

v1.03

2026-04-25

+7 docker/gitlab seeds

L1.1 = 4/11 (-7)

v1.04

2026-04-25

+5 SSH/cwd seeds

L1.1 = 4/11 (-7)

v8.s1.01

2026-04-26

максимально мягкое: lr=1e-5, epochs=1, r=64, 117 примеров, без HF

L1.1 = 3/11 (-8), Stage 1 SSH = 1/7 (-1)

Последняя строка особенно показательна. lr=1e-5 — на грани «мы вообще что-то учим или нет». Один epoch. 117 примеров. Никакого HuggingFace-мусора. Самый щадящий режим, который я мог себе представить.

Результат: L1.1 = 3 из 11. И SSH-навык, ради которого всё затевалось, тоже просел — Stage 1 SSH = 1/7.

Закономерность одна:

Независимо от датасета, learning rate, размера LoRA, числа примеров — delta всегда размывает уже выученное. Anchor-примеры не сохраняют скилл. lr=1e-5 всё равно ломает.

Catastrophic forgetting на котиках

Академический термин — catastrophic forgetting. Каждый «скилл» в нейросети — это устойчивый узор весов. Все скиллы используют одни и те же веса в разных комбинациях. Когда обучение тянет веса под новый скилл — все остальные узоры искажаются одновременно.

В случае v8 узор Django-scaffold был остро настроен: 11/11. Любое касание весов сбивает чёткость:

v8         (baked + frozen)        L1.1 = 11/11   ⭐
v1.01      (delta + 50 anchor)     L1.1 = 0/11    полная потеря
v1.02      (delta + HF)            L1.1 = 9/11    -2 (повезло)
v1.04      (delta + SSH seeds)     L1.1 = 4/11    -7
v8.s1.01   (delta + lr=1e-5)       L1.1 = 3/11    -8

Почему 100 anchor-примеров не помогли

Anchor-примеры — стандартный приём против забывания. Берёшь старые задачи, перемешиваешь с новыми в пропорции 50/50 или 70/30 — теоретически модель помнит, как делать Django, пока учится SSH.

Не сработало.

Чтобы реально удержать узор Django scaffold в нашей конфигурации (14B + LoRA r=64 + датасеты 50–200 примеров), нужно минимум 200–300 anchor-примеров в каждой эпохе. Иначе градиент тянет в сторону SSH сильнее, чем якорь удерживает Django.

Но если давать 300 anchor + 30 ssh-примеров — модель проигнорирует SSH (его слишком мало), и ничего нового не выучит. Тупик: либо anchor доминирует и нет прогресса, либо новое доминирует и ломает старое.

Что ещё пробовал

Model soup (vsoup). Натренировать несколько узких моделей под разные задачи и усреднить их веса. Попробовал — 0 из 4 тестов.

Разные learning rates. От 1e-4 до 1e-5. Нижняя граница ничего не лечит, верхняя — ускоряет провал.

Разные размеры LoRA. r=16, r=32, r=64, r=128. Мельче не помогает (нечем учить), крупнее ломает быстрее.

Заморозка слоёв. Учить только последние N слоёв — даёт чуть меньше регрессии и чуть меньше прогресса. На общую динамику не влияет.

Плюсы способа #1 (если бы он работал)

  • Быстрее: delta = 30–60 минут на 117 примерах vs 2–3 часа на full retrain.

  • Требует мало места: каждый delta — 256 MB LoRA, можно держать N адаптеров под N задач.

  • Можно адаптировать под клиента инкрементально: написал 20 трейсов под его специфику — отдал.

Минусы (которые убили подход)

  • Catastrophic forgetting не лечится anchor-примерами в нашей конфигурации (14B, LoRA r=64, малые датасеты).

  • Нет способа выяснить заранее, какая комбинация скиллов «совместима» при delta-merge — узнаёшь только после тренировки и теста (~1 час потерян на каждую попытку).

  • Нельзя комбинировать адаптеры — model soup не работает.

  • Накапливается hidden bias. После 5 delta-merge'ов веса в неизвестном состоянии. Невозможно сказать, на что именно модель сейчас «настроена», без полного прогона тестов.

  • eval_loss не предсказывает регрессию. У v1.01–v1.04 eval_loss падал (то есть формально «модель учится»), а реальный тест показывал противоположное. Метрика, на которую обычно смотрят во время тренировки, врёт.

Когда delta-merge всё-таки работает

Если у тебя 100B+ модель, миллионы adapter-примеров и стабильная anchor loss — возможно, всё иначе. Но такие масштабы — это уже не файнтюнинг агента дома, это работа большой ML-команды с инфраструктурой и бюджетом. В нашем кейсе (14B + ~1000 трейсов на тему) catastrophic forgetting побеждает любой режим.

К 26 апреля 2026 стало понятно, что delta-merge оказался неподходящим. Пошёл придумывать что-то другое.


Размышление

Главный вопрос после провала: что вообще должно эволюционировать в этом процессе — модель или датасет?

В способе #1 эволюционирует модель. Каждая новая версия — это очередной слой знаний, наложенный поверх всех предыдущих. К пятой итерации никто не знает что внутри. Воспроизвести нельзя. Откатиться нельзя. Регрессию диагностировать нельзя — слишком много переменных.

А что если наоборот — зафиксировать процесс тренировки полностью: всегда стартуем с чистого Qwen3:14B, всегда одни и те же гиперпараметры, одна и та же архитектура. И пусть эволюционирует только датасет. Тогда любая регрессия объясняется ровно одной вещью: что я добавил или убрал из train.jsonl.

Это и есть способ #2.


Способ #2: fresh-from-base + dataset evolution

Идея

                                     ┌──────────────────────────────┐
                                     │  Эволюционирует ТОЛЬКО       │
qwen3:14b ←─── всегда от чистой ─────│  ДАТАСЕТ (train.jsonl):      │
   ↓                                 │  • normalize распределения   │
   │                                 │  • +distilled-сиды           │
   ↓ train base-N (datasetN)         │  • -избыточные дубли         │
oni:base-N                           │  • +hand-curated anchors     │
                                     └──────────────────────────────┘

    base-N+1 НЕ доучивается от base-N. Свежая тренировка
    от Qwen3:14B с эволюционированным датасетом.

Принцип жёсткий: каждая base-N+1 тренируется заново, от Qwen3:14B, ничего не зная о существовании base-N. Единственное, что переходит между версиями — train.jsonl.

Что значит «эволюционировать датасет»

Не «писать новые примеры», а пять конкретных операций над уже собранным train.jsonl:

1. L1 dedup (exact). Удалить байт-в-байт дубли. Звучит просто, но в base-3 было 62 точных дубликата — они появлялись из-за того, что один и тот же seed попадал в разные стейджи через скрипт-генератор.

2. L2 cap per-seed. Один seed × paraphrase=13 даёт 13 трейсов. Если этот seed случайно попал в три стейджа — становится 39 трейсов. Я ставлю cap=13 жёстко: каждый seed = 13 трейсов в датасете, никаких исключений. Это даёт ровное распределение по темам, без скрытого перекоса.

3. L3 cross-stage routing. Один seed относится только к одной канонической категории. Не плодим копии в трёх разных стейджах под разными именами.

4. L4 semantic dedup (опционально). Через embeddings отбрасываем трейсы с cosine similarity ≥ 0.95. Помогает на больших датасетах, на малых — только время теряешь.

5. Distilled merge. Добавить новые трейсы через teacher LLM (про дистилляцию — отдельная статья, она следующая в серии).

Хронология base-N

Build

Trace count

Стратегия

Результат

base-1

1747

17 stages × paraphrase=13, 100% own seeds

Stage 1 = 2/7, L1.1 = 5/11, train_loss = 1.29 (не сошлась)

base-2

~2247

base-1 + 300 Magicoder Django filter, 2 epoch

L1.1 не подняло — HF format mismatch разбавил scaffold

base-3.v1–v3

4985

+ Magicoder mix expanded

Регрессия (4–5K шумных хуже чем 2K чистых)

base-4

~3200

foundation-first reorder

L1.1 = 0/11 — protocol последним overwrite scaffold

base-5

4985

foundation-first исправлено

Stage 1 = 0/8 partial, HF mix всё равно вреден

base-6.v2

1747

БЕЗ HF, 2 epoch, TEMPLATE override Qwen3

Stage 1 = 14/22 ⭐ первый «без HF» прорыв

base-7.v2

1867

base-6 + 19 own lib_items

Stage 1 = 15/22 ⭐ legacy champion

base-8.v2

1862

+ 5–10 точечных трейсов под honest_failure

Stage 1 = 14/22 ↓, галлюцинации на realworld

base-9.v2

1756

normalize_own(base-7), без distilled

Stage 1 = 10/22, не дал прироста — ровный шум

base-console.v2

2229

base-norm + 225 Magicoder distilled SSH

галлюцинации — 87% off-topic в distilled_ssh

base-clean.v2

2107

base-norm + 351 distilled из правильного источника

Realworld = 10/10 ⭐ новый overall champion

base-final.v2

4999

base-norm + 3243 distilled из 16 тематических тегов

в работе сейчас

Ключевые повороты:

  • base-3..5: HF Magicoder mix размывает узкие скиллы → откат.

  • base-6/7: «100% own seeds, без HF» → первый стабильный чемпион.

  • base-8: точечные правки в датасете → галлюцинации и регрессия. Урок: даже в способе #2 нельзя просто «дописать 10 трейсов» — нужно нормализовать всё распределение.

  • base-console: Magicoder filter попробовали ещё раз → 87% off-topic, снова галлюцинации. Magicoder для DevOps-агента не подходит в принципе — про это в следующей статье.

  • base-clean: правильный источник → strict-filter → 351 distilled traces → 10/10 realworld.

Что получилось на финальной модели

Новая модель прошла те же тесты, что и старая — для честного сравнения. Результаты:

Фаза

base-clean.v2

base-7.v2 (предыдущий)

Stage 1 SSH (3 fresh runs avg)

11.33 / 22

10 / 22

Realworld stepped (post-checks)

10 / 10

7 / 10

Realworld step 5 nginx

✅ all checks PASS

❌ compose/curl/nginx FAIL

Hallucination (reported vs actual)

none

none

Wall time realworld 5 шагов

85 s

110 s

То есть мало того что новый чемпион даёт чистые 10/10 на realworld против 7/10 у предшественника — он ещё и на ~30% быстрее проходит pipeline. Никаких галлюцинаций «отчитался, но не сделал». Все шаги (compose / curl / nginx), которые base-7.v2 валил — у base-clean.v2 проходят.

Качество против количества

Один из самых неожиданных результатов: 1.7K чистых трейсов лучше, чем 5K шумных.

base-3..5 содержали 4985 трейсов с миксом Magicoder. Stage 1 скор: ~0–8 partial из 22. base-6/7 содержали 1747–1867 трейсов, 100% свои hand-crafted. Stage 1 скор: 14–15 из 22.

В академическом мире это называется LIMA-эффект (Less Is More for Alignment). Для агента с узкой задачей оптимум — 1500–5000 чистых трейсов. Больше — пользы не даёт, мусор по теме — модель деградирует.

Что фундаментально работает в способе #2

1. Полная воспроизводимость. Дай мне recipe.yaml + train.jsonl + config.json — пересоберу identical модель за 2 часа. Можно вернуться к любой версии за 6 недель назад и сравнить.

2. Никаких hidden bias. Каждый train стартует с чистого Qwen3:14B, который не знает ничего о моих экспериментах. Состояние весов известно ровно — то, что в train.jsonl.

3. eval_loss перестал быть бесполезным. При обучении с нуля он коррелирует с реальными тестами заметно лучше, чем на delta-merge. Не идеально, но уже можно использовать как ранний сигнал.

4. Можно делать ablation. Что произойдёт, если убрать seeds_honest_failure_verification? Просто пересобрать train.jsonl без него и переобучить. На delta-merge такое в принципе невозможно.

5. Catastrophic forgetting исключён архитектурой. Нет «накопления». Каждая модель знает ровно то, что в её train.jsonl. Хочешь добавить SSH — добавляешь сиды в датасет, тренируешь от нуля, проверяешь. Если регрессия — точно знаешь, что навредили именно эти сиды.

Плюсы способа #2

  • Воспроизводимость на уровне «дай мне recipe и я пересоберу».

  • Чёткий сигнал от реальных тестов после обучения.

  • Эволюционирует ровно одно — train.jsonl. Всё остальное (config, scripts, train code) зафиксировано.

  • Distillation становится first-class citizen — добавляешь distilled subset, нормализуешь, мерджишь, тренируешь.

  • Анализ регрессии прозрачен: «base-N добавил X traces типа Y → score упал» — видно, что именно навредило.

Минусы способа #2

  • Долго: full retrain = 1.5–3.5 часа на RTX 3090 vs 30–60 минут для delta.

  • Весит много: каждая base = 9 GB Q4_K_M GGUF + 1 GB LoRA. За 6 недель накопил ~120 GB истории.

  • Не подходит для per-customer кастомизации — нельзя клиенту в реальном времени добавить 30 примеров и отдать модель за час. Каждый кастом = новый full retrain.

  • Медленно «дать модели 30 примеров за обед». Каждый эксперимент — это 3–4 часовой цикл.

Когда делать exception (точечные добавления)

Иногда замечаешь конкретную проблему в работе модели — например, агент начал галлюцинировать на каком-то типе задач. В таких случаях я не дообучаю поверх, а пишу 5–10 трейсов под эту конкретную проблему, добавляю их в общую seeds-библиотеку и запускаю обучение заново с чистого Qwen3:14B. Это всё равно полная переработка — просто датасет эволюционирует.

Реальный пример: 45 hand-crafted трейсов под honest_failure написал 10 мая (см. seeds_handcrafted_honest_failure_2026/) для следующей базовой версии. Не доучивал поверх — добавил в seeds, нормализовал, запустил обучение заново.


Сравнительная таблица

Параметр

Способ #1 (delta)

Способ #2 (fresh)

Время одной итерации

30–60 мин

1.5–3.5 ч

Воспроизводимость

низкая (зависит от предыдущей версии)

полная

Catastrophic forgetting

не лечится

исключён архитектурно

Анализ регрессии

непрозрачный

прозрачный

Пик на одной задаче

высокий (v8 = 11/11)

средний (base-clean.v2 = 10/10)

Робастность через много задач

разваливается

стабилен

Можно добавить новую тему

теоретически да, реально нет

да, через эволюцию датасета

Подходит для production

нет

да

Цена compute

3–5×

Цена disk

1× (256 MB delta)

1× (1 GB adapter) + 9 GB GGUF на версию

LIMA-оптимизация

не применима

применима


Главный вывод

やらなきゃ、わからないだろ

Если не попробуешь — не узнаешь.

— Гай Цуцугами, аниме «Корона грешника» (Guilty Crown)

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

Да, по дороге я спотыкался и открывал велосипеды, которые до меня уже давно изобрели. Где-то прошёл по граблям, где-то потерял время. А ещё в начале сильно отклонился от своих же целей — захотел впихнуть в маленькую модель сразу всё: чтобы и Django умела разворачивать, и сервер сопровождала. Тут важно помнить конечную цель: если у вас агент для поддержки серверов — это одно, если для создания приложений — это совсем другой уровень модели. И я это успешно забыл. Но именно в этом и смысл этой статьи: не нужно ждать — нужно пробовать. На моём примере любой может просто начать. Все шишки, которые я набил, — я показал. Всё остальное, что встретилось по дороге, — это и есть сам путь. Лёгким он не бывает.

И ещё один момент. 14B — это не компромисс «потому что не хватает памяти на видеокарте на 70B», а правильный размер для такой задачи. Большие модели на простых командных задачах начинают галлюцинировать и творить отсебятину — додумывают сценарии, лезут принимать «инициативные» решения, могут запросто грохнуть базу данных или ещё что-нибудь интересное сделать «для пользы дела». От такой инициативы потом загораются сердца. А маленькая 14B на узком hand-crafted датасете работает как солдат: получил приказ — выполнил. Без фантазий и лишних мыслей.

Что дальше

В этой статье я показал, как я подошёл к обучению своего ИИ-агента и каким путём пришёл к рабочей модели. Дальше будем заниматься созданием датасетов и дистилляцией — будет интересно.


Тизер следующей статьи:

Локальная дистилляция через teacher-модель. Бенчмарк 4 кандидатов, выбор модели, неделя ночных прогонов, провал с Magicoder и финальный прорыв.

Если интересно следить — @oni_devops_lab.

— makarsuperstar, 2026