10 дней спустя: как мой бот дважды умирал незаметно, а метрика релевантности мне врала
TL;DR — продолжение прошлого поста про @futur_e_news_bot (двуязычная лента новостей на sqlite-vec за ~$5/мес). За 10 дней в проде: два тихих многодневных простоя (бот поллил Telegram и казался живым, но не создавал ни одной новости), метрика релевантности, которую отравил один человек 106 дизлайками, и сигнал от реакций 78 юзеров, который заставил выкинуть половину источников. Плюс — я открыл код (MIT). Пост про надёжность, наблюдаемость и data-driven решения на маленьком проекте, где нет ни SRE, ни аналитика — только ты и логи.
В прошлый раз я собрал Telegram-бота с персональной лентой новостей: дедуп через sqlite-vec, локальные эмбеддинги, бесплатные LLM, «хорошие новости по умолчанию», одна машина на Fly.io. Пост на Habr привёл первых живых пользователей — и тут началось самое интересное. Не код. Эксплуатация.
Глава 1. Бот, который поллит, но мёртв
Самый коварный класс багов в боте-воркере: он выглядит живым. Telegram-бот на long-polling отвечает на сообщения, нажатия кнопок работают, /start работает. А фоновый пайплайн при этом стоит колом уже несколько дней. Я ловил это дважды за эти полторы недели, по-разному.
OOM на загрузке модели — crash loop на пять дней
Бот стартовал на машине 512 МБ. Этого хватало… пока пайплайн не доходил до первой обработки новости, где лениво грузится ONNX-модель эмбеддингов (fastembed). Загрузка пушит RSS до ~400 МБ, и на 512 МБ ядро убивало процесс прямо во время загрузки модели:
Out of memory: Killed process 644 (python) ... anon-rss:406396kBProcess appears to have been OOM killed!reboot: Restarting system
Дальше — петля: Fly рестартит машину → бот стартует → начинает поллить (выглядит живым!) → доходит до обработки → OOM → рестарт. Между рестартами _collect_sources успевал зафетчить RSS, и raw-новости копились сотнями, но ни одна не превращалась в Story.
Я заметил это только потому, что дневной дайджест пришёл пустым. Полез смотреть — последняя новость в базе была пятидневной давности. Пять дней бот «работал» и молчал.
Своп в fly.toml (swap_size_mb = 512) на Fly так и не активировался — SwapTotal: 0. Фикс банальный: 1 ГБ, а после наплыва с Habr — 2 ГБ.
И сразу про честность с ценой. В прошлом посте я писал «5, но честная цифра «под живым трафиком» — три чашки кофе, а не одна.
feedparser без таймаута заморозил пайплайн на четыре дня
Второй простой был хитрее и не имел отношения к памяти. В коде сбора источников был невинно выглядящий вызов:
feed = feedparser.parse(source.url)
feedparser.parse(url) делает блокирующий HTTP-запрос вообще без таймаута. Один мёртвый/медленный RSS-источник (или затормозивший self-hosted RSSHub) — и весь прогон пайплайна виснет навсегда. А поскольку у джобы стоял max_instances=1, каждый следующий запланированный прогон просто пропускался:
WARNING apscheduler: Execution of job "run_pipeline" skipped: maximum number of running instances reached (1)
Эта строчка повторялась в логах четыре дня. Бот всё это время отвечал на сообщения.
Фикс — два слоя. Первый: фетчим через httpx с жёстким таймаутом, а feedparser кормим уже готовыми байтами (он тогда только парсит, без сети):
async def _download(url: str) -> bytes: async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client: resp = await client.get(url) resp.raise_for_status() return resp.content# в fetch():content = await _download(source.url)feed = await asyncio.to_thread(feedparser.parse, content)
Второй слой — пояс поверх подтяжек: оборачиваю весь прогон в таймаут, чтобы зависший прогон в принципе не мог держать единственный слот планировщика дольше 10 минут:
async def run_pipeline_guarded() -> None: try: await asyncio.wait_for(run_pipeline(), timeout=600) except asyncio.TimeoutError: logger.error("run_pipeline exceeded 600s and was cancelled (stuck fetch?)")
Урок: воркер не умеет жаловаться
Общая нить у обоих простоев: процесс, который поллит, выглядит здоровым, даже когда функционально мёртв. У веб-сервиса упал бы хелсчек. У воркера хелсчека нет.
Поэтому я добавил watchdog — джобу, которая раз в 30 минут смотрит свежесть пайплайна и пишет мне в личку при простое:
async def check_pipeline_health(bot) -> None: age_h, pending = await _story_age_hours() stale = age_h is None or age_h >= 3.0 if stale and not _alerted: # edge-triggered, не спамит await _notify_admins(bot, f"🔴 Пайплайн молчит: последняя новость {age_h:.1f}ч назад")
Плюс ежедневный бэкап SQLite (онлайн-бэкап через sqlite3-API, ротация 7 дней) — потому что всё состояние в одном файле, и до этого момента у меня не было ни одной резервной копии. Оба фикса вместе поймали бы оба простоя за минуты, а не за дни.
Глава 2. Чтобы чинить релевантность, её сначала надо увидеть
Когда пайплайн стабилизировался и пошёл живой трафик, я уперся в вопрос поинтереснее: а лента-то хорошая? Глазами — вроде да. Но «вроде» — это не метрика.
Я сделал так, что кнопка «📊 Статистика» в боте присылает .md-файл с обширным отчётом: воронка активации, DAU/WAU, удержание, сентимент, топ-категории — с ASCII-барами и mermaid-графиками (рендерятся в GitHub/Obsidian). Почему файл, а не сообщение: его удобно открыть в нормальном просмотрщике, заархивировать, сравнить с прошлым.
Цифры через 10 дней (78 юзеров — масштаб маленький, но тренды читаются):
|
Метрика |
Значение |
|---|---|
|
Активация (хоть одно действие) |
87% |
|
Глубина |
~18 действий на активного |
|
Удержание (вернулись в другой день) |
~29% |
|
WAU / всего |
63 / 78 |
Для холодного старта — живо. Но был один показатель, который упрямо не двигался: отношение лайков к дизлайкам держалось ~50/50. Половина оценённых новостей отвергается. Вот его и пошёл чинить — и нарвался на два отдельных урока.
Глава 3. Аудитория сама сказала, что читать (а я не слышал)
Сначала я сделал «очевидное»: покрутил веса в скоринге, снизил долю серендипности (анти-бабла) с 35% до 15%, сделал так, что новичкам без сформированного вкуса показывается только «в точку», без случайных подмешиваний. Задеплоил. Через пару дней смотрю отчёт — 50/50 как стояло, так и стоит.
Проблема была в том, что я смотрел не туда. Общий лайк/дизлайк за всё время — слишком тупая линза. Я добавил в отчёт разрез по источникам — и картина стала очевидной:
RBC 70% 🙈 Habr +0.30 👍Lenta.ru 61% 🙈 TechCrunch +0.33 👍 Ars Technica +0.33 👍
Tech-аудитория (а пришли ко мне в основном с Habr — то есть разработчики) любит tech-источники и отвергает русские общие новости. А Lenta + RBC были двумя самыми крупными производителями — больше половины всего потока. То есть лента на 50% состояла из контента, который этой аудитории просто не нужен.
Тюнинг весов это не мог починить в принципе: у рекомендателя был affinity по категориям и тегам, но не было сигнала на уровне источника. Добавил — ровно по аналогии с категориями, только per-source:
# реакция двигает вес источника в -1..1if kind in ("like", "open"): source_interests[sid] = min(1.0, source_interests.get(sid, 0.0) + 0.3)elif kind == "dislike": source_interests[sid] = max(-1.0, source_interests.get(sid, 0.0) - 0.35)
И добавил в скоринг член + 0.15 * source_affinity. Плюс глобальный приор: для юзера, который ещё не оценивал источник, берём краудовый сигнал — чтобы даже новичок сразу видел меньше широко-нелюбимых источников.
Казалось бы, победа. Деплою, открываю отчёт через день — и…
Глава 4. Метрика, которую отравил один человек
…релевантность рухнула с 50% до 30%. Дизлайки взлетели с 62 до 228. В списке «худших источников» оказались вообще все, включая хорошие: dev.to 91% 🙈, Hacker News 80% 🙈, даже только что захваленный Habr.
Первая мысль — «я что-то сломал своим source-affinity, надо откатывать». Но прежде чем паниковать и откатывать, я сделал то, что должен был сделать раньше: посмотрел в данные.
SELECT user_id, count(*) FROM interactions WHERE kind='dislike'GROUP BY user_id ORDER BY 2 DESC LIMIT 3;
user 66 -> 106 дизлайков user 73 -> 31 дизлайк остальные -> единицы
Один пользователь поставил 106 дизлайков из 228 — почти половину. Вдвоём с вторым — 60%. Если их исключить, отношение возвращается к ~50%.
И вот в чём была настоящая ошибка: и метрика, и глобальный приор источников считали сырые реакции. Один масс-дизлайкер размазал свои 106 🙈 по всем источникам — и отравил оба: метрика показала 30% вместо реальных ~42%, а приор потерял всякую различимость (всё стало выглядеть «на 80-100% нелюбимым»).
Фикс — считать по уникальным юзерам, каждый вносит не больше ±1 на источник:
WITH per_user_source AS ( SELECT i.user_id, st.source_id, SUM(i.kind='like') - SUM(i.kind='dislike') AS net FROM interactions i JOIN stories st ON st.id = i.story_id WHERE i.kind IN ('like','dislike') GROUP BY i.user_id, st.source_id)SELECT source_id, SUM(CASE WHEN net>0 THEN 1 WHEN net<0 THEN -1 ELSE 0 END) AS net_users, COUNT(*) AS ratersFROM per_user_source GROUP BY source_id HAVING raters >= 3;
После этого правда проявилась. Релевантность по юзерам — 42% (а не 30%). И приор источников снова стал различимым:
ХОРОШО: TechCrunch +0.33 · Ars Technica +0.33 · Habr +0.11ПЛОХО: Habr Best -1.00 · BBC -0.50 · Hacker News -0.50 · RBC/Lenta -0.31
Урок дороже самой фичи: никогда не доверяй метрике, которую может перекосить один человек. На масштабе 78 юзеров один энтузиаст с 🙈 — это уже 46% сигнала. Робастность к выбросам важнее точности. И — диагностируй, прежде чем откатывать: я был в шаге от того, чтобы выкинуть рабочую фичу из-за артефакта измерения.
Глава 5. Меньше, но лучше
42% — честно, но всё ещё посредственно (нетто-юзер скорее недоволен). Зато сигнал по источникам теперь был кристально чист:
-
Habr Best— −1.00: 4 из 4 оценивших дизлайкнули. Он, к тому же, дублировал обычный Habr. -
dev.to, Lobsters — комьюнити-файрхоузы: много, шумно, низкий сигнал.
-
TechCrunch, Ars Technica, Habr — курируемый tech, всем заходит.
Вывод напрашивался сам: аудитория предпочитает курируемое — файрхоузу. Я выключил три худших источника (Habr Best, dev.to, Lobsters) — прямо в коде, списком «retired», который отключает их на старте, без ручного лезанья в прод-базу:
RETIRED_SOURCE_URLS = [ "https://habr.com/ru/rss/best/daily/?fl=ru", # -1.00, дублирует Habr "https://dev.to/feed", # файрхоуз "https://lobste.rs/rss", # ниша]
Бонусом это срезало объём пайплайна — файрхоузы давали заметную долю от ~1900 новостей/день. Меньше пресного контента → меньше 🙈. Глобальный приор + персональные веса добьют остальное, а отчёт покажет, поползла ли релевантность вверх.
Что я понял за полторы недели
-
Воркер, который поллит, может быть мёртв и выглядеть живым. Меряй свежесть результата (последняя обработанная единица), а не «процесс жив». Watchdog на 30 строк окупил бы себя дважды за полторы недели.
-
Любой блокирующий вызов без таймаута — это бомба замедленного действия.
feedparser.parse(url), я смотрю на тебя. -
Сначала измеряй, потом тюни. Я потерял пару дней на кручение весов, потому что смотрел в слишком общую метрику. Разрез по источникам сразу показал причину.
-
Метрику должно быть невозможно перекосить одним человеком. Считай по уникальным сущностям, а не по сырым событиям. На маленьком масштабе один выброс = катастрофа в цифрах.
-
Реакции — это редакторский сигнал. Аудитория сама курирует твой список источников, если её слушать. Мне не нужно было гадать, какие RSS оставить — пользователи проголосовали 🙈.
-
Честная цена под нагрузкой ≠ цена на лендинге.
15 живой. Всё ещё дёшево, но цифру стоит называть настоящую.
Попробовать
Бот живой: @futur_e_news_bot — /start, выбираешь язык, реагируешь на пару новостей, и лента начинает подстраиваться. По умолчанию — только хорошие новости, негатив включается тумблером.
Сейчас самое слабое место — релевантность на 42%, и я открыто за ней слежу по отчёту: source-affinity и курирование источников должны её поднять. Если интересно, во что это выльется — приходите через пару недель, выложу третий пост уже с цифрами «до/после». А в комментах с радостью обсужу sqlite-vec, робастные метрики или почему ваш воркер прямо сейчас, возможно, тоже мёртв и просто не сказал вам об этом.
ссылка на оригинал статьи https://habr.com/ru/articles/1045969/