Блог
Публикации о процессе разработки, решённых задачах и изученных технологиях
Когда модель учит саму себя (и роняет цифры)
Работал над LLM Analisis — проектом, где модель решает math word problems на GSM8K датасете. Казалось, 80% accuracy — потолок? Но я хотел большего: что если модель сама будет создавать данные для собственного обучения? Начал с самоаугментации. Идея проста: возьми 80%-ную модель, пусть она переформулирует тысячу задач из обучающего набора, умножь на три варианта переписывания — получишь 3000 новых примеров. Модель обучится на собственных данных и поднимется выше. Правда? **Неправда.** За время выполнения 7000 операций (переформулировка + решение + верификация) я ждал результатов. И получил -3.5pp. Из 422 самогенерированных текстов модель научилась только хуже решать задачи. Причина: слабая модель-учитель порождает шумные формулировки, модель обучается на собственном шуме. Тогда попробовал voting на базовой модели вместо MetaMath — может быть, гибридный подход спасёт? Запустил эксперимент: **83.0%**, а базовый voting показывает 84.0%. Та же ошибка, что и на Phase 47 VF r16 — voting не спасает. Greedy при этом выдал рекорд: **80.0%** вместо 77.0%. Осознание пришло резко: **я усиливал не то**. Проблема не в модели — ей не нужны новые нейроны, она уже знает 95.5% ответов. Ей нужна другая *качество* данных, не количество. Переходу на уровень 3: модель не просто создаёт данные, а *учится искать*, что ей нужно. Включил SearXNG — модель определяет, какие задачи ей нужны ("multi-step arithmetic for grade 5", "word problems with percentages"), ищет в сети, парсит результаты, валидирует решения, тренируется. Впервые data pipeline включает не self-generated примеры, а реальные внешние данные. Это заняло 10 минут чистого Python без GPU. Потом 30-60 минут обучения. Конечно, web extraction получился наивным — регулярные выражения, шум в парсинге. Следующая итерация — LLM-based parsing, чтобы модель сама читала страницы и извлекала задачи. Но даже такой базовый пайплайн учит главное: модель должна *уметь учиться*, а не только решать. И знаете, разработчик на Stack Overflow уровня 😄
Молчаливый краш каждые восемь минут: как мы искали баг в конвейере тренд-анализа
Работаю над **Trend Analysis** — системой, которая вытаскивает из кластеров событий настоящие тренды. Идея простая: тренд — это не один факт, а паттерн, видимый сразу в нескольких независимых источниках. Например, "AI funding accelerating" подтверждается инвестициями OpenAI, Anthropic и Mistral одновременно. Добавили в систему извлечение `domain_tags` — метаданные, которые помогают понять, в каких сферах появляются тренды. Написал миграцию базы данных (092), обновил Pydantic-модель `ExtractionResult`, задеплоил в production. Всё выглядело хорошо. Потом начался ад. Pipeline рестартовался сам по себе каждые 8–10 минут. Не crashing с ошибкой, не падая с исключением — просто выходил нормально (exit code 0), будто завершил работу. PM2 считал это штатным поведением, счётчик restarts поднялся до 450. Логи не показывали nothing — ни ошибок, ни предупреждений, ни exception'ов. Я начал добавлять debug-маркеры на критических этапах. "PHASE_DEBUG" перед главной стадией extraction. Ждал цикла за циклом. Маркер никогда не появлялся. Потом заметил: логи говорят "Fact extraction done", потом сразу — крах. Между фазой extraction и следующей стадией что-то умирало молча. Проверил `_propagate_domain_tags` — новый код, который я добавил в event_linker. Он вызывается после commit. Обёрнут в try/except. Не должно быть проблем. Но потом я посмотрел на главный `asyncio.gather()` в функции `main()`. Там пять задач: `_crawl_start_with_flag`, `_retry_loop`, `_phase2_loop`, `_convergence_loop`, `_wal_checkpoint_loop`. И `gather()` **без флага `return_exceptions=True`**. Это значит, если ЛЮБАЯ из них упадёт — весь gather упадёт, и процесс завершится. Но логов нет... А потом вспомнил: я использую `asyncio.create_task()` для запуска `_extract_facts_pipeline` ВНУТРИ `crawl_once()`. Это отдельная задача, не добавленная в основной gather. Если она поднимает exception — в Python 3.13 это просто логируется где-то в недрах event loop, но не убивает процесс явно. Процесс выходит чисто, потому что задача закончилась (с ошибкой). Решение было банальным: либо добавить эту задачу в основной gather, либо завернуть её в try/except с явным логированием. Я выбрал второе — явное логирование всех ошибок внутри `_extract_facts_pipeline`. После fix pipeline работал стабильно. Uptime перевалил за 30 минут. Никаких рестартов. **Урок:** когда Python молчит, ищи asyncio. Необработанные исключения в create_task() — это коварный враг, потому что он не скалывается, он просто завершает процесс как ни в чём не бывало. 😄
Асинхронный краш, который молча убивал процесс
В проекте Bot Social Publisher я столкнулся с багом, который преподал мне урок о том, как Python молча убивает асинхронные задачи. Всё началось с того, что процесс падал каждые 8–10 минут. Exit code 0, как будто ничего не произошло. PM2 думал, что это нормальный цикл, и перезапускал всё заново. В логах структурного ничего не было — просто обрыв на середине слова. Я начал добавлять debug-маркеры в критические точки: перед extract, после linking, перед formation. Маркеры появлялись до определённого момента, а потом — тишина. Это означало только одно: краш происходит в асинхронном task'е, который создан через `asyncio.create_task()`, но не добавлен в основное `asyncio.gather()`. Нашёл проблему в `_extract_facts_pipeline` — это была задача, создаваемая внутри `crawl_once()`. Если в ней поднимался exception, он просто испарялся. Основной event loop об этом не знал, потому что task не был часть собранной группы. Python молча падает в таких случаях — exception в orphaned task'е не выводится в stderr. Решение было простым, но требовало переделки архитектуры: все критические задачи теперь либо ловят exception вручную и логируют его, либо зарегистрированы в основном `gather()`. Вместо: ```python asyncio.create_task(self._extract_facts_pipeline()) ``` Перешёл на явный контроль — task регистрируется и отслеживается. Дальше обнаружилась конкурентная проблема: `_extract_facts_pipeline` и translation loop одновременно пытались использовать один инстанс Ollama на одном порту. Dual-port routing, который я писал, не работал как ожидалось. Переделал маршрутизацию — теперь потребители разнесены по портам явно, через конфиг. После этого цикл прошёл 5+ минут без крахов. Рестартов всё ещё было 450 в логе, но это уже были контролируемые перезагрузки, а не молчаливые падения. Вывод прост: асинхронная архитектура требует такого же внимания к обработке ошибок, как синхронный код, но Python здесь хитрее. Orphaned task'и падают молча, и если ты не проверяешь логи на предмет неполных маркеров, потратишь дни на отладку фантомного бага. TypeScript в этом плане честнее — там ты не можешь просто так создать task и забыть про него, система будет ругаться. 😄
Замкнутый цикл: как модель сама себя обучает
Работал над **LLM Analisis** — проектом анализа математических рассуждений. Взял готовую модель на базе Claude и столкнулся с проблемой: обучающий датасет был исчерпан, а качество на тестах застыло на 78%. Нужно было что-то менять. Первый импульс — скачать MetaMathQA, больший датасет из нескольких источников. Но тут осознал: зачем искать внешние данные, если модель может их *создавать*? Идея простая, но изящная: взял существующий датасет GSM8K (7473 задачи на арифметику) и запустил самоаугментацию. Модель переформулирует каждую задачу тремя способами — получается 22 тысячи вариантов. Затем добавляю обратное рассуждение: если модель знает ответ, она может восстановить условие задачи с другими числами. Это даёт ещё 7000 новых примеров. Финальный трюк — FOBAR (Fixing Out-of-range Bad Answers): беру задачу, меняю числа так, чтобы сломать неправильные паттерны рассуждения. В итоге из 7473 исходных задач получилось примерно 36 тысяч разнообразных примеров. Замкнутый цикл: модель не скачивает, не ждёт аннотаторов — она *сама генерирует* себе обучающие данные. Запустил тренировку на полной MetaMathQA (395K примеров, не только GSM) с 10 тысячами шагов вместо 3 тысяч. Параллельно добавил voting: во время теста модель решает задачу восемь раз независимо, и берётся ответ, выбранный большинством. Это снижает влияние случайных ошибок. Результат: качество прыгнуло с 78% в режиме greedy decoding до ожидаемых 80-82% на одном проходе, а с voting обещает 88-91%. Для математических моделей это существенно. Самое интересное в этом подходе — масштабируемость. Когда SearXNG агент всё же поднимется, цикл усложнится: модель будет сама искать задачи на web, парсить их, валидировать и добавлять в тренировочный набор. Получится бесконечный конвейер: ошибка → диагностика → поиск примеров → переобучение → улучшение. Без человека в цикле. Знаешь, это напоминает Laravel: день 1 — восторг от элегантной архитектуры, день 30 — понимаешь, что elegance имеет цену 😄
Как HDBSCAN раскрыл истинное лицо трендов
Три месяца назад в проекте Trend Analisis возникла беда: система обозвала *трендом* любое, даже совершенно рандомное событие. Мы парили события в эмбеддингах, выуживали несколько похожих друг на друга и думали, что открыли закономерность. На самом деле собирали мусор. Первое время казалось, что проблема в нейросетях или в пороговых значениях для фильтрации. Раскидывали параметры кластеризации, ловили иголку в стоге сена. Потом дошло: проблема не в инструментах, а в самой логике. Мы искали тренд в одном событии вместо того, чтобы смотреть на *паттерны внутри кластера*. Развернули HDBSCAN и переписали всю pipeline с нуля. Теперь тренд — это не одно событие, а структурированный паттерн, извлечённый из группы связанных событий. В каждый кластер добавили шаг `_extract_trends_from_cluster()`, который просит LLM найти 0–3 реальных структурных закономерности с доказательствами: какие события их подтверждают, в какую сторону идёт изменение, кто задействован, какие метрики вообще говорят. Потом добавили **domain_tags** — 3–5 широких категорий для каждого события. Звучит небольшой деталью, но эта штука стала мостом между источниками данных. Теперь события из гита, Слака и журналов понимают друг друга через общие темы. И главное — это не требует дополнительных вызовов LLM: теги шли вместе с экстракцией паттернов. Пришлось перестраивать матчинг. Раньше пробовали простое совпадение по сущностям — полный провал. Теперь используем гибридный подход: 55% веса на эмбеддинг-похожесть, остальное на пересечение тегов и сущностей. Миграция базы добавила три новых таблицы для хранения связей события-тренд, и дедупликация трендов с порогом перекрытия 0.40. На 12GB сервере обработали 5 кластеров, вытащили 14 валидных трендов и повязали к ним 56 событий. Это не мировая цифра, но тренды стали реальными паттернами, а не сборищем разнополюсных событий. Одна смешная деталь — в пики нагрузки Ollama работает на два порта одновременно, всё синхронизируется мьютексом и гробит RAM нещадно. Поэтому пришлось временно отключить переклассификацию событий после дедупликации, иначе сервер шёл в отказ. TensorFlow, кстати, здесь не причём — но принцип тот же: решение проблемы, о которой ты не знал, способом, который никто до конца не понимает 😄
Как два портала Ollama спасли трендовый анализ от краша
Работаю над Trend Analysis — сервис, который ловит тренды из разных источников и анализирует их на лету. Недавно столкнулся с паттерном ошибок, который казался совершенно случайным: иногда pipeline падал с «Remote end closed connection», но воспроизвести его не удавалось. Выглядело так, будто кто-то рубит соединение с Ollama прямо во время запроса. Начал копать логи. Оказалось, что pipeline одновременно вызывал две разные модели — hermes3:8b и gemma4:e2b — через одно соединение к Ollama. Обе модели жрут VRAM как сумасшедшие, и когда они грузятся одновременно, память взрывается. Ollama просто закрывал соединение, и всё рушилось. Решение было дерзким и простым: развести модели на разные порты. Олдам запустил я на 11435 (для gemma4) и 11436 (для hermes3). Теперь каждая модель знает своё место в памяти, и они перестали давить друг на друга. Плюс добавил глобальный `_ollama_mutex` — теперь запросы идут в очередь, никаких гонок. Но это было только начало. Копался в конфигах и наткнулся на `keep_alive="-1"`. Выглядит невинно, но Ollama работает на Go, а там это не валидный duration. Сервер просто отклонял все запросы с такой настройкой. Заменил на `keep_alive="999h"` — модели теперь зависают в VRAM по 41 день, готовые к работе. Параллельно выяснилось, что при переводе chunk_size стоял в 50 символов. Это приводило к тому, что промпты раздували до 16K+ символов — контекстное окно переполнялось. Снизил до 5 — проблема решена. Ещё добавил retries (с 2 до 5), потому что FRP-туннель иногда глючит, и нужна возможность переподключиться. А busy_timeout для SQLite поднял до 60 секунд — иногда блокировка базы стоит дольше, чем ожидается. В watchdog cycle переделал логику: обогащение теперь работает *до* проверки кластеризации, а не параллельно. И если extraction активна, обогащение просто пропускает цикл, не ждёт. После фиксов pipeline стал стабильнее. Нет больше фантомных крахов, модели не воют в памяти, а timeouts предсказуемы. *По-поводу Scala и Stack Overflow:* оказывается, они правда считают себя специалистами. 😄