Код и конфигурация актуальны на 10–11 июля 2026 года. Метрики ниже получены в последнем цикле обучения модели.
Когда я начал развивать Discord-сообщество, обычной команды /ban хватило совсем ненадолго. Роли настраивались в одном месте, приветствия — в другом, логи — в третьем, а за уведомления о стримах отвечал отдельный бот. В какой-то момент я поймал себя на том, что управляю уже не сообществом, а зоопарком чужих интеграций — с разными правами, разными панелями и разными представлениями о том, что делать при сбое.
Автоматическая модерация только усугубила ситуацию. Ошибка в welcome-сообщении неприятна, но терпима. Ошибка классификатора — это уже удалённое нормальное сообщение или заблокированный человек. Подключить нейросеть к on_message и закрыть тему я себе позволить не мог: цена ошибки слишком высокая.
Так появился OmniBot. Я разделил решение на две части: модульный Discord-бот с панелью внутри Discord Activity и отдельный локальный AI Moderator. Первый отвечает за взаимодействие с Discord и реальные действия, второй — за анализ текста, правила, ruBERT, оценку риска и объяснимый план решения. В коде это два репозитория, в эксплуатации — один контур.
Дальше — не витрина функций, а решения, за которые пришлось платить сложностью: зачем модель вынесена в отдельный сервис, почему наказание не доверено одному классификатору, как выбирался checkpoint и где человек сознательно оставлен последней инстанцией.
Сначала — общий контур управления
Я выписал операции, которые постоянно повторялись на сервере:
-
настроить роли, приветствия и каналы
-
вести ручную модерацию и хранить историю наказаний
-
выдавать роли через панели
-
создавать временные голосовые комнаты
-
смотреть логи и статистику
-
публиковать уведомления Twitch, YouTube
-
управлять доступом администраторов к этим функциям
-
разбирать поток сообщений, не отдавая его внешнему ИИ-сервису
Для серверной части я взял Python 3.12, disnake, FastAPI и PostgreSQL, для панели — Vue 3. Команды никуда не делись, но главным интерфейсом стала Activity внутри Discord: администратору не нужно уходить во внешнюю панель, чтобы поменять приветствие, проверить логи или настроить AI Moderator.
В Activity добавлен отдельный RBAC. Синхронизированные Discord-роли сопоставляются с ролями creator, developer, moderator, administrator и разрешениями модулей. Запрет должен работать не только на уровне API — если у человека нет доступа, вкладка просто не появляется в интерфейсе.
Я не исхожу из того, что OmniBot автоматически «лучше» MEE6, Dyno или Carl-bot — эти продукты уже закрывают типовые задачи. MEE6 предлагает фильтры и автомодерацию, Dyno — модульный бот с веб-панелью, Carl-bot — reaction roles, automod, логи и команды. Встроенный Discord AutoMod блокирует ключевые слова и спам и выполняет настроенные действия.
Важны были другие критерии:
|
Критерий |
Публичные универсальные боты |
OmniBot |
|---|---|---|
|
Размещение |
Управляемая поставщиком инфраструктура |
Самостоятельное размещение бота, БД и AI API |
|
Интерфейс |
Обычно внешняя веб-панель и команды |
Discord Activity внутри клиента плюс команды |
|
Обработка текста ИИ |
Реализация и маршрут данных зависят от поставщика |
Локальный API и локально загружаемая ruBERT-модель |
|
Изменение логики |
В пределах настроек продукта |
Код, YAML-политики и пороги под контролем владельца |
|
Цена эксплуатации |
Бесплатный тариф или подписка |
Собственный сервер, PostgreSQL, обновления и мониторинг |
|
Готовность «из коробки» |
Высокая |
Требует развёртывания и технического сопровождения |
Поэтому я не позиционирую OmniBot как способ получить модерацию за пять минут — готовый публичный бот справится с этим лучше. Моё решение рассчитано на сообщество, которому нужна локальная обработка, собственная политика и возможность менять весь путь решения — от входного события до журнала действий.
В рабочей Activity на одном экране — готовность модулей, сигналы модерации, подключённые источники и задержка бота. Оттуда же доступны RBAC, welcome-сообщения, role panels, Creator Alerts, Dev Blog, AI Moderation, логи, статистика, голосовые комнаты, интеграции и health checks.
Панель работает с живым backend, а не с mock-данными. В редакторе welcome-сообщения есть Discord-preview до сохранения, а в Dev Blog — сборка до десяти embed-блоков с публикацией одним сообщением.
Дальше — граница между Discord и ИИ
Самое важное архитектурное решение здесь — разделение Discord-адаптера и анализа контента.
Сообщение Discord → фильтр каналов → очередь → локальный AI API правила + ruBERT + риск → политика сервера → действие и журнал
Слушаются только сообщения людей в текстовых и новостных каналах. Перед вызовом AI API бот проверяет, включена ли модерация для конкретного канала — покрытие сделано явным: по умолчанию система не забирает весь поток сервера.
Для выбранного сообщения формируется нормализованный запрос: идентификаторы сервера, канала, пользователя и сообщения, исходный текст, время, ссылка на reply, число упоминаний и сведения о вложениях. Snowflake-идентификаторы на границе Activity передаются строками — иначе JavaScript теряет точность больших целых чисел.
Модель не вызывается прямо из Discord-события — задержка API начала бы тормозить event loop. Между Discord и AI API стоит ограниченная asyncio.Queue:
def submit(self, request: AiModerationRequest) -> bool: if self._queue.full(): logger.warning( "AI moderation queue full guild_id=%s message_id=%s", request.guild_id, request.message_id, ) return False self._queue.put_nowait(request) return True
Сейчас размер очереди по умолчанию — 500 сообщений, воркеров — два, тайм-аут запроса — 12 секунд. При переполнении — отказ, а не бесконтрольный рост памяти: запрос отклоняется, событие уходит в лог. Цена решения — возможная потеря анализа во время пика. Следующий шаг здесь — метрика глубины очереди, счётчик отказов и явная стратегия деградации.
Почему модерация не доверена одному классификатору
Подключить модель к чату несложно технически. Сложнее решить, что делать с её вероятностью. Классификатор видит токсичность, но не знает локальный blacklist, допустимые домены, предел наказания и то, сколько неопределённости готов принять конкретный сервер.
Поэтому AI Moderator построен как гибридный конвейер:
Текст нормализуется, из него извлекаются URL, приглашения, упоминания и статистические признаки.
Детерминированные правила ищут flood, массовые упоминания, избыток заглавных букв и эмодзи, повторяющиеся символы, запрещённые слова, обход фильтров и другие сигналы.
Локальный multi-label классификатор на базе cointegrated/rubert-tiny2 возвращает оценки классов.
Сигналы приводятся к общей форме: источник, метка, уверенность, тяжесть, вес риска и evidence.
Rule Engine считает итоговый риск, разрешает конфликты и выбирает основную метку.
Decision Engine сопоставляет риск, уверенность и режим политики с действием.
В конфигурации зафиксирована ревизия базового ruBERT и 13 меток: SAFE, SPAM, ADVERTISEMENT, INVITE, SCAM, TOXIC, HATE, THREAT, NSFW, EVASION, FLOOD, URL, IMAGE_SCAM. Артефакты обученной модели намеренно не лежат в release-архиве — они разворачиваются отдельно, и версия модели не смешивается с версией приложения.
Вероятность нейросети не берётся как готовый риск — каждый сигнал даёт свой вклад:
Rᵢ = W_label · W_source · confidence · M_severity
Вклады суммируются и ограничиваются диапазоном политики. Детерминированное правило и модель участвуют в одном решении, но происхождение каждого сигнала остаётся видимым.
Компромисс в том, что веса и пороги превращаются в отдельную часть продукта: объяснимость и возможность менять политику без переобучения даются не бесплатно — параметры приходится калибровать на реальных ошибках, версионировать и проверять после каждого изменения.
Как обучался ruBERT и почему 0,9691 — не «точность»
Для multi-label классификатора подготовлено 200 000 примеров: 140 000 для обучения, 30 000 для валидации и 30 000 для теста. Финальная модель на validation split показала:
micro F1 — 0,9691
macro F1 — 0,8230
exact match — 0,9514
лучший macro F1 — 0,8232 на пятой эпохе
Цифра 0,9691 сначала порадовала, но для релизного решения сама по себе она почти бесполезна. Micro F1 агрегирует все решения, поэтому на него сильнее влияют многочисленные классы. Macro F1 сначала считает качество каждого класса отдельно и только потом усредняет — разрыв до 0,8230 показывает, что основная масса примеров классифицируется хорошо, а редкие классы ведут себя менее однородно.
Причина видна в распределении: примерно 80 тысяч примеров SAFE, больше 40 тысяч URL и около 10 тысяч EVASION. Формулировка «модель точна на 97%» скрыла бы разную представленность классов и разную цену ошибок — поэтому её тут не будет.
На валидации метрики росли до пятой эпохи, а потом вышли на плато. Между пятой и шестой epoch micro F1 и exact match слегка просели, macro F1 уже не улучшился — по macro F1 и выбрана пятая эпоха.
График loss заставил внимательнее отнестись к checkpoint. Training loss снижался почти до конца, а вот лучший validation loss — 0,0536 — появился примерно на 17,5 тысячи шагов и после начал расти. Модель продолжала подгоняться под train split, хотя обобщение уже не улучшалось.
На test split слабее всего оказался TOXIC: precision около 0,93, recall около 0,85, F1 около 0,89. Это важнее красивого среднего — модель не находит примерно 15% положительных токсичных примеров. У HATE, NSFW и SAFE F1 держится в районе 0,94–0,96, у остальных показанных меток — ближе к 0,98–1,00.
Тут обнаружился пробел, который не хочется маскировать: в test split нет примеров FLOOD и IMAGE_SCAM, и их качество этим экспериментом попросту не подтверждено. Почти идеальные результаты остальных меток — тоже повод для проверки, а не для рекламного заявления: дубликаты, синтетические шаблоны или слишком похожие train/test-примеры могут завышать оценку.
На графике баланса виден ещё один систематический эффект: модель недопредсказывает TOXIC и переоценивает SAFE. Это согласуется с recall токсичности и напрямую влияет на эксплуатацию — часть токсичных сообщений может не попасть в автоматический контур.
Вывод из этого эксперимента простой: checkpoint нельзя выбирать по одной цифре. Приходится одновременно смотреть на macro F1, recall критичных классов, validation loss и стоимость ошибки — пропуск угрозы и ошибочное удаление безопасного сообщения стоят по-разному, и в среднем значении это растворяться не должно.
Как ограничено право модели на наказание
После обучения предстояло решить более опасную задачу — как превратить оценку в действие. В базовой decision policy риск ниже 10 игнорируется, с 10 — только логирование, с 40 — отправка на проверку. Более высокие диапазоны соответствуют предупреждению, удалению, тайм-ауту и бану. Для THREAT, HATE и SCAM добавлены переопределения, для NSFW — обязательный review.
Высокого риска недостаточно для разрушительного действия. Если confidence ниже минимума, удаление, тайм-аут или бан понижаются до REVIEW — так же обрабатывается высокоуверенный конфликт сигналов. В пассивном режиме подниматься выше проверки человеком запрещено вообще.
После ответа AI Moderator применяется вторая политика — уже на стороне конкретного Discord-сервера. Для каждой метки администратор задаёт:
-
минимальное и максимальное действие
-
порог риска
-
собственный blacklist
-
список разрешённых доменов
-
реакцию на запрещённое слово или внешний домен
Это второй предохранитель. Даже если общий Decision Engine выбрал BAN, политика сервера может ограничить класс менее жёстким действием. И наоборот — явный blacklist или запрещённый домен могут потребовать более строгой реакции, чем предложила модель.
dry_run протянут до самого исполнителя. В этом режиме решение рассчитывается и журналируется, но Discord-действие не выполняется — включать новую модель сразу в автоматический бан не хочется, сначала она должна поработать в тени и показать реальные ложные срабатывания.
В текущей конфигурации остался неприятный технический долг: action_policy.yaml содержит dry_run: true, а decision_policy.yaml — dry_run: false. До production-развёртывания нужно оставить один источник истины и закрыть весь путь интеграционным тестом. Такой дефект не виден на графике F1, но именно он определяет безопасность системы.
Что возвращается модератору вместо одного label
Возвращать из API только TOXIC: 0.91 не хотелось. В доменный ответ включено:
-
итоговый risk score
-
основная и дополнительные метки
-
confidence
-
причины и evidence
-
конфликты сигналов
-
версия политики
-
execution plan
-
признак dry-run
После исполнения OmniBot отправляет статус обратно в AI API, сохраняет событие и публикует embed в выделенном канале логов. Модератор видит пользователя, риск, действие, метки, идентификаторы сообщения и события, а также результат выполнения.
До полноценной explainability-панели дело пока не дошло — текущий embed не показывает разбивку риска и evidence. Но эти данные уже есть в контракте, так что следующий интерфейс «почему принято решение» не потребует менять классификатор.
Почему модерация не отделена от остального бота
AI Moderator — самая насыщенная инженерная часть проекта, но отдельно от бота он был бы ещё одним сервисом, который администратор обслуживает вручную.
Поэтому автоматический контур связан с ручными командами warn, mute, kick, ban, историей наказаний и общим журналом. Activity использует тот же RBAC, настройки каналов определяют место для AI-логов, Health проверяет состояние API, а политики меняются рядом с остальными настройками сервера.
В одном контуре оказались динамические голосовые комнаты, role panels, Creator Alerts для Twitch, YouTube и Kick, Dev Blog, welcome-сообщения и статистика. Количество модулей само по себе ничего не доказывает — важнее то, что AI-модерация использует те же права, каналы, health checks и аудит, что и остальная система.
Что получилось и чем за это пришлось заплатить
Что сработало
-
Локальная обработка текста: маршрут под контролем, API слушает localhost, защищён внутренним ключом, модель грузится на CPU или CUDA-устройство.
-
Discord-адаптер и moderation core разведены по разным сервисам — граница чёткая, а не размытая. Гибрид вместо магии одной модели: детерминированные правила дополняют ruBERT, политика сервера применяется уже после модельного решения.
-
Радиус ошибки ограничен сразу несколькими механизмами: выбор каналов, bounded queue, тайм-ауты, downgrade до review, максимальные действия, dry-run.
-
Один рабочий интерфейс: Activity использует Discord-контекст и общий RBAC вместо ещё одной внешней админки.
-
Аудит: идентификаторы решений, действий и статусов сохраняются в PostgreSQL, у политики есть id и version.
Технический долг и ограничения
-
Эксперимент пока не полностью воспроизводим из репозитория — нужно опубликовать версию датасета, seed, итоговые thresholds, checkpoint и машиночитаемый evaluation report.
-
Для FLOOD и IMAGE_SCAM тестовых примеров нет, их качество этим экспериментом не подтверждено.
-
Есть признаки переобучения: после лучшего validation loss тренировочная ошибка продолжает падать, а validation loss растёт — checkpoint приходится выбирать по совокупности метрик.
-
Нагрузочный отчёт ещё не опубликован. Load-test runner есть, но без p95/p99 обещать конкретную пропускную способность рано.
-
Эксплуатационная цена выросла: контроль получен, но теперь на мне PostgreSQL, секреты, миграции, обновления, backup, мониторинг и сама модель.
-
Очередь живёт в памяти — при перезапуске необработанные элементы теряются, при переполнении новые запросы отклоняются.
-
Контекст пока ограничен: передаются признаки текущего сообщения и ссылка на reply, но не полноценная история диалога.
-
Вложения — это ещё не мультимодальность: передаётся факт и количество вложений, но содержимое изображения на этом пути не анализируется, несмотря на метку IMAGE_SCAM.
-
Публичный код — не значит open source: оба проекта распространяются по проприетарной лицензии, production-использование и модификация требуют отдельного разрешения.
-
Пороги нужно калибровать: значения в YAML — стартовая политика, а не универсальная истина для любого сообщества.
Что уже можно доказать, а что ещё нужно измерить
В двух репозиториях 276 тестовых функций: 131 в OmniBot и 145 в AI Moderator. Покрыты RBAC, безопасность Activity, очередь, применение политики, API-клиент, схемы, preprocessing, taxonomy, dataset pipeline и training configuration.
Это доказывает наличие тестовых сценариев, но не зелёный CI. В среде подготовки статьи прогон не состоялся из-за отсутствия pytest — поэтому фразы «все тесты проходят» тут не будет.
Перед production-запуском в планах:
-
Опубликовать версионированный машиночитаемый evaluation report, добавить примеры FLOOD и IMAGE_SCAM, матрицу ошибок и отдельный hard set с обходами фильтров.
-
Провести shadow/dry-run период на реальном сервере и измерить долю решений, отменённых модераторами.
-
Опубликовать нагрузочный отчёт с p50/p95/p99, глубиной очереди и долей отклонённых сообщений.
-
Добавить интеграционный тест, однозначно проверяющий итоговый dry_run и максимальное разрешённое действие на всём пути.
Для кого строится OmniBot
OmniBot — для команд, готовых сопровождать собственный сервис, которым нужны контролируемая обработка текста, изменяемая политика, Discord Activity и единый контур процессов сообщества.
Если нужны стандартные фильтры, reaction roles и логи за несколько минут — честнее посоветовать зрелый публичный бот или встроенный AutoMod. Этот вариант имеет смысл, когда важны локальная модель, доступ к коду принятия решений и возможность встроить модерацию в собственный workflow.
Главный результат тут не в том, что ruBERT научился ставить метку токсичности. Модель помещена между наблюдаемыми правилами и двумя уровнями политики, и разрушительное действие не стало естественным продолжением её вероятности.
Вот в этой границе, кажется, и есть разница между демонстрацией классификатора и рабочей инженерной системой.
Вопрос для обсуждения
Где вы бы провели эту границу — разрешили бы модели удалять сообщения после dry-run периода или навсегда оставили бы разрушительные действия за человеком?
ссылка на оригинал статьи https://habr.com/ru/articles/1059306/