Продолжаем серию про файнтюнинг и создание 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 | 1× | 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
