Анатомия молчания: как групповой Telegram-бот решает не отвечать. Часть 2

Пост опубликован в блогах iXBT.com, его автор не имеет отношения к редакции iXBT.com

В личной переписке бот отвечает осмысленно, а в живой группе начинает мешать: вклинивается в чужие разговоры, отвечает на подначку развёрнутым разбором и за вечер набивает столько реплик, что участники тянутся к mute. В первой части серии я разбирал, почему такое поведение чинится не промптом, а слоями кода вокруг него. Здесь открываю самый практичный из этих слоёв — механику молчания — и показываю, из чего она собрана: маршрутизация, подавление шума, bot-share, reply-chain и cooldown. По ходу — две ошибки, из-за которых бот флудил при формально работающих ограничениях, и минимальный воспроизводимый пример гонки, которую они породили.

Коротко о том, где мы остановились

Речь о «Джонни» — язвительном инженерном персонаже в Telegram, который начинался как развлечение на вечер, а дорос до системы с маршрутизацией, границами контекста и регрессионными проверками. Вывод первой части: удачный диалог один на один ничего не обещает про группу, а типичный сбой в живом чате — не в том, что бот сказал, а в том, кому и в какой момент он это сказал. За характер отвечает промпт, за поведение — обвязка вокруг него.

Эта часть — про один слой целиком. Не «как заставить модель говорить умно», а «как заставить её вовремя не говорить». В группе это важнее: внимание участников — общий ограниченный ресурс, и каждое лишнее сообщение его тратит.

Молчание — это не выключатель, а стек решений

Соблазн реализовать молчание одним условием («если сообщение адресовано боту — отвечай, иначе молчи») разбивается о первый же живой чат. Прямые обращения приходят без имени и без reply-флага. Короткое «че» формально адресовано, но отвечать на него нечего. Бот, ответивший пять раз подряд, формально прав каждый раз, но в сумме превращается в спамер. Дуэль с соседним ботом — каждая реплика уместна, а цикл бесконечен.

Поэтому в проекте молчание собрано из пяти независимых фильтров. Сообщение доходит до генерации, только если пройдёт их все; сработать может любой, и тогда обработка заканчивается тишиной.

Значения порогов ниже — это конфигурация конкретного бота в конкретных чатах, а не универсальные рекомендации. Их смысл — показать, какие ручки вообще существуют.

Фильтр Что отсекает Порог в проекте
Quiet mode неадресованные сообщения чат по умолчанию молчит
Low-value suppress адресованный шум без задачи «че», «нет», «ахах», «ау»
Bot-share доминирование бота в разговоре окно 50, soft 8, hard 20
Reply-chain серию ответов одному отправителю 4 (low/medium) или 6 (high) за 300 c
Per-chat cooldown ответы вплотную друг к другу 5 c тишины, дальше FIFO-очередь

Дальше — по фильтру за раз, с ошибками, которые всплыли на каждом.

Фильтры 1-2: не отвечать на пустое и не адресованное

Первый уровень — режим громкости чата. У бота их три (quiet, normal, loud), в проде по умолчанию стоит quiet: неадресованные сообщения игнорируются полностью. Дальше адресованность разбивается на несколько типов — reply на бота, триггер, упоминание и «отвечабельное намерение». Важная деталь: короткий адресованный шум не обязан получать ответ. Адресованность — не то же самое, что осмысленный запрос.

Второй уровень отсеивает этот шум. Реплики вроде «че», «нет», «ахах», «ау» подавляются: ни ответа, ни реакции, публичное поведение — молчание. Классификатор шума устроен так: сообщение считается шумом, только если оно короче ~40 символов, не содержит? и в нём меньше двух осмысленных токенов длиной от трёх символов. До введения этого правила девять из десяти коротких содержательных запросов ошибочно уходили в шум; после — в шум попадают только совсем пустые «ок» и «хорошо».

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

Отдельно стоит рассказать про неудачную попытку, потому что она хорошо показывает границу подхода. В какой-то момент на неоднозначный запрос бот стал отвечать заранее заготовленной строкой:

уточни объект: сервис, файл, лог, ошибку или цель

Идея была разумной: не молчать, но и не выдумывать. На практике вышло плохо. Стерильный canned-текст убивал характер персонажа, в код просочился NameError, а ban-list в промпте превращал генерацию в policy-шаблон. Всё откатили; вместо заготовки «дай команду» стало получать живое «команду на что?». Урок на будущее: подавление шума — это про решение «отвечать или нет», а не про подмену ответа фиксированной фразой. Как только в анти-флуд просачивается словарь готовых реплик, страдает то самое, ради чего бота писали.

Фильтр 3: bot-share — сколько эфира занимает бот

Даже если каждый отдельный ответ уместен, их суммарная доля может быть чрезмерной. За это отвечает bot-share: система смотрит на окно из последних 50 сообщений и считает, какую часть из них занял бот. Порогов два. Мягкий (soft, 8 из 50) отсекает низко- и среднеприоритетные ответы. Жёсткий (hard, 20 из 50) отсекает всё, кроме high-priority.

Здесь была показательная ошибка. В одной из выгрузок бот занимал около 37% дневных сообщений — при том, что hard-limit стоял на 20. Причина оказалась не в самом лимите, а в обходной ветке: «голый спор на high-приоритете» проходил мимо жёсткого зажима. После исправления эта ветка снова подчиняется hard-limit; исключения оставлены только для защищённого прямого reply и явной команды. Вывод из этого эпизода простой и повторяющийся по всему проекту: лимит, который где-то можно обойти, — это не лимит, а рекомендация. Обходные пути надо искать не по коду, а по логам: сколько бот наговорил на самом деле.

Фильтр 4: reply-chain — не заваливать одного собеседника

Этот фильтр ограничивает серию ответов одному отправителю в скользящем окне. Ключ у счётчика — пара (chat_id, sender_id), поэтому пользователи независимы. Окно — 300 секунд. Порог: 4 ответа на low/medium и отдельный, более щедрый лимит 6 на high. На седьмом high-сообщении при счётчике 6 решение становится «не отвечать» с причиной reply_chain_cooldown.

Звучит просто. На деле именно здесь всплыли две ошибки, из-за которых бот флудил, хотя механизм ограничения формально существовал.

Ошибка первая: счётчик, который не считал

Reply-chain был написан давно и по коду выглядел рабочим. Но для основного сценария — адресованное «отвечабельное» сообщение — он почти не ограничивал ответы. Сложилось несколько причин сразу:

  • почти каждое адресованное сообщение получало priority=high, а high сбрасывал счётчик;
  • функция проверки возвращала счётчик только для low-приоритета, а не общий;
  • отметка ответа выполнялась слишком поздно — уже после отправки сообщения;
  • CLI-прогон вообще не вызывал функцию отметки, поэтому дымовые тесты с несколькими сообщениями не воспроизводили накопление и создавали ложное ощущение исправности.

Исправление ввело единый общий счётчик для всех приоритетов, high перестал обнулять историю, пороги вынесли в конфиг (4 и 6), а вызов отметки в CLI добавили. Диагностический прогон подтвердил: седьмое high-сообщение подавляется. Казалось, готово.

Ошибка вторая: гонка на конкурентных сообщениях

После первого фикса живой burst всё равно дал до 19 успешных ответов при лимите 6. Первая гипотеза была про порядок обработки очереди — мол, отложенные сообщения копятся и разбираются в обратном порядке. Она не подтвердилась: очередь оказалась честной FIFO.

Настоящая причина — состояние гонки. Несколько обработчиков событий проходили предварительную проверку параллельно, читая одно и то же устаревшее значение счётчика, а увеличивался он позже — внутри пути генерации и отправки. Пока первый ответ генерировался, десяток других сообщений успевал пройти проверку «счётчик меньше лимита», потому что счётчик ещё стоял на нуле.

Лечится это переносом отметки: слот в счётчике надо занимать до постановки задачи в очередь, а не после генерации. Тогда лимит резервируется до долгого похода в LLM, и конкурентные обработчики видят уже увеличенное значение. Заодно выяснилось, что тот же CLI-прогон подставлял искусственный chat_id=0 вместо реального — из-за чего тест снова не ловил проблему.

Вот минимальный воспроизводимый пример этой гонки. Python 3.10+, только стандартная библиотека. Код написан с нуля для иллюстрации и к боевому коду отношения не имеет — он показывает лишь механику «проверка/отметка».

import asyncio

LIMIT_HIGH = 5           # лимит ответов на high-приоритет в окне

class Throttle:
    def __init__(self):
        self.count = 0
    def is_allowed(self):            # только проверка, без изменения состояния
        return self.count < LIMIT_HIGH
    def mark(self):
        self.count += 1
    def reserve(self):               # атомарно: проверить и сразу занять слот
        if self.count < LIMIT_HIGH:  # между этими строками нет await,
            self.count += 1          # поэтому конкурентные корутины не увидят
            return True              # одно и то же устаревшее значение
        return False

async def generate():
    await asyncio.sleep(0.05)        # поход в LLM: здесь точка переключения

async def handle_buggy(t, i, sent):  # БАГ: отметка после генерации
    if not t.is_allowed():
        return
    await generate()                 # await разрывает "проверку" и "отметку"
    t.mark()
    sent.append(i)

async def handle_fixed(t, i, sent):  # ФИКС: слот занимается до генерации
    if not t.reserve():
        return
    await generate()
    sent.append(i)

async def burst(handler):
    t, sent = Throttle(), []
    # 12 сообщений от одного отправителя приходят почти одновременно
    await asyncio.gather(*(handler(t, i, sent) for i in range(12)))
    return len(sent)

async def main():
    print("отметка ПОСЛЕ генерации:", await burst(handle_buggy), "из 12 (лимит 5)")
    print("отметка ДО постановки :", await burst(handle_fixed), "из 12 (лимит 5)")

asyncio.run(main())

Вывод детерминированный:

отметка ПОСЛЕ генерации: 12 из 12 (лимит 5)
отметка ДО постановки : 5 из 12 (лимит 5)

Разница — в одной точке await. В handle_buggy между проверкой is_allowed() и вызовом mark() стоит await generate(), и на этом переключении управление уходит к следующей корутине. Все двенадцать успевают увидеть count == 0 и пройти проверку до того, как счётчик увеличится хоть раз. В handle_fixed метод reserve() проверяет и инкрементирует счётчик без единого await внутри — слот занимается атомарно, и после пятого вызова остальные получают отказ немедленно.

Это ровно та ситуация, которую легко проглядеть: сам по себе счётчик корректный, лимит выставлен, юнит-тест на одну корутину зелёный. Проблема живёт не в значениях, а во взаимодействии асинхронных обработчиков.

И ещё одна: окно, которое не сбрасывалось

Отдельно чинили поведение самого окна. Счётчик был фактически накопительным: по истечении 300 секунд обновлялось время, но значение не обнулялось. После достижения лимита чат мог остаться в вечном cooldown — молчал уже не по делу. Исправление превратило окно в настоящий tumbling window: спустя 300 секунд отсчёт начинается заново. Полная версия примера в приложенном файле включает и эту логику — там счётчик привязан к паре (chat, sender) и обнуляется по времени.

Фильтр 5: per-chat cooldown и очередь

Последний уровень — короткая пауза после ответа. Ответив в чат, бот закрывает его на 5 секунд; новые сообщения за это время не теряются, а уходят в FIFO-очередь и разбираются по порядку. Это сглаживает ситуацию, когда несколько человек пишут одновременно: бот не отвечает всем в одну секунду залпом.

Сюда же примыкает человекоподобная задержка перед ответом — чтение, «раздумье», индикатор набора и джиттер. Она не про анти-флуд напрямую, но работает на ту же цель: бот не выстреливает мгновенными ответами, которые в живой переписке выглядят машинно.

Обратная сторона: когда молчание — это баг

Настройка подавления режет в обе стороны. Пережмёшь — бот пропускает то, на что должен ответить. Три эпизода из проекта, где «молчание» или «слишком длинный ответ» оказались дефектом, а не фичей.

Зависание перед fallback. Самая частая жалоба «бот замолчал» имела причину не в анти-флуде, а в работе с API. Основная модель при таймауте могла зависать до четырёх попыток по 120 секунд, прежде чем управление доходило до резервной. Снаружи это выглядело как немота. Лечение — быстрый переход к следующей модели при таймауте и ошибках доступности, без повторов на той же модели. Это отдельный слой (fail-soft работа с API), и ему будет посвящён отдельный разбор.

Подначка вместо вопроса. Вопросительная форма и технические термины превращали провокацию в «настоящий технический вопрос». Дальше профиль длины выдавал развёрнутый ответ — вплоть до тысячи с лишним символов — на реплики вроде спора про AsyncClient/httpx или «у тебя атомарная база?». Аудит нашёл целую серию таких случаев. Починка — не в стиле и не запретом форм, а в слое классификации: добавили категорию «прочее» для технических подначек и демонстрации кода без запроса помощи. Симптом (длинный ответ) лечился в источнике (классификатор), а не косметикой на выходе.

Слишком широкий маркер. Ложную маршрутизацию в «технический high» какое-то время вызывал поиск подстроки «лог». Она встречается внутри обычных слов — «диалог», «каталог», «налог» — и нейтральные сообщения ошибочно принимались за просьбу «проверь логи», после чего бот отвечал слишком часто и обстоятельно.

Переход на совпадение по границе слова убрал ложные срабатывания. В набор регрессий добавили восемь отрицательных boundary-кейсов и три сценария, фиксирующих generic-классификацию.

Зацикливание с соседним ботом

Отдельная задача — не уйти в бесконечную пикировку с другим ботом. Каждая реплика по отдельности уместна, а цикл не заканчивается. Честный статус в проекте такой: полноценной защиты «по флагу отправителя» пока нет. Telegram отдаёт признак sender. bot, но повесить на него жёсткое правило отложили до появления реального второго бота в живом чате — расширять словарь имён внешних ботов сознательно не стали, это противоречит принципу проекта «не решать словарями то, что можно решить структурно».

Что работает сейчас: bot-share ограничивает суммарную долю бота в окне независимо от того, кому он отвечает, — это гасит и человеко-, и бото-флуд. Плюс контекстный «стоп» по команде человека: реплики вроде «хватит спорить» или «не корми бота» прекращают дуэль. Это не полное решение, а рабочий компромисс с явно обозначенной границей.

Как это проверять, чтобы не сломать

Все описанные ошибки объединяет одно: код компилировался, а на первый взгляд выглядел рабочим. Поэтому в проекте на каждый исправленный класс заводится тест, а набор routing-проверок вырос с полусотни кейсов до 75.

Тесты покрывают классификацию, «хочет ли собеседник детали», отвечабельное намерение, low-value, приоритет и стадию предварительной проверки. Прыжок с 64 до 75 — это как раз восемь boundary-кейсов на границу слова «лог» и три generic-lock-сценария.

Отдельный урок — про то, что именно тестировать. История с CLI-прогоном, который не вызывал функцию отметки, показала: зелёный тест на вспомогательную функцию ничего не доказывает, если боевая точка вызова эту функцию не дёргает. Проверять надо call-site — реальную точку, где принимается решение, — с положительным и отрицательным путём, а не только helper в отрыве от него. Иначе тест подтверждает работоспособность кода, который в проде просто не исполняется.

Ограничения

Стоит назвать честно, где этот механизм не дотягивает.

Состояние reply-chain и bot-share живёт в памяти процесса и обнуляется при рестарте. Значит, сразу после перезапуска сервиса окно ограничений пустое, и короткий флуд снова возможен, пока счётчики не наберутся. Часть runtime-состояния в проекте всё ещё restart-lossy — это принятый, но не закрытый риск.

Bot-share считает долю, а не смысл. Он не отличает двадцать уместных ответов от двадцати лишних — только их количество в окне. Смысловую уместность решают другие слои.

Защита от зацикливания с соседним ботом, как сказано выше, пока держится на bot-share и команде человека, а не на надёжном признаке sender.bot.

И общий момент: подавление всегда даёт риск ложного молчания. Любой из порогов при неудачной настройке отрежет то, на что стоило ответить. Пороги в статье — это значения одного конкретного бота, подобранные по его логам; переносить их на свой проект без собственных измерений смысла нет.

Что забрать с собой

Молчание группового бота — не одно условие, а стек из нескольких независимых, дешёвых и по отдельности тестируемых решений в слоях маршрутизации и подавления. Если собираете похожее, из этого разбора полезно вынести несколько вещей.

Разделяйте фильтры и не пытайтесь сделать «одно умное условие»: не адресовано, шум, доля бота, серия ответов одному, пауза после ответа — это разные вопросы с разными порогами. Занимайте слот в счётчике до await, а не после, иначе конкурентные обработчики устроят гонку. Делайте окна ограничений сбрасывающимися по времени, чтобы бот не залипал в вечном cooldown. Тестируйте реальную точку вызова, а не вспомогательную функцию рядом с ней. И держите анти-флуд свободным от заготовленных фраз — как только туда просачивается словарь, страдает характер, ради которого всё затевалось.

Отвечать бот умеет и в одиночной переписке — это несложно. Жить в группе его учит обратный навык: вовремя не отвечать. И почти весь этот навык находится в коде вокруг вызова модели, а не в строчках промпта внутри него.

Публичные обезличенные материалы проекта — высокоуровневая архитектура, слоевая модель runtime и открытый чек-лист регрессий — собраны в репозитории: https://github.com/UtochkaI/johnny-telegram-bot-habr. Боевого кода, промптов и конфигурации там нет, только безопасные заметки к статьям. Код в тексте написан с нуля для иллюстрации.

Автор не входит в состав редакции iXBT.com (подробнее »)
Об авторе
После 15 лет разработки учусь вайбкодить. Пытаюсь забыть Python, чтоб освободить память для промптов.

0 комментариев

Добавить комментарий

Сейчас на главной

Новости

Публикации

Как устроена раскаленная «планета-зефир»: «Джеймс Уэбб» нашел кварцевые облака и аномальный климат на WASP-17 b

Когда астрономы изучают атмосферы планет за пределами Солнечной системы, они чаще всего используют метод спектроскопии пропускания. В моменты, когда экзопланета проходит между Землей и своей...

Почему волк покидает родную стаю, даже когда в ней достаточно пищи

Волчья семья может годами жить на одной территории. Взрослые животные знают, где искать пищу, молодые постепенно осваивают окружающую местность, а подросшие волчата помогают заботиться о младших....

Илон Маск обещает летающий автомобиль к 2028 году. Эту сказку нам слово в слово рассказывали в 1957-м

На испытательном полигоне компании SpaceX в городе Макгрегор, штат Техас, состоялась закрытая презентация новой версии спортивного автомобиля Tesla Roadster. Во время показа машина заехала на...

ИИ декодировал язык клеточных сигналов — и подсказал, как вырастить живые ткани в пробирке

В процессе развития эмбриона исходно одинаковые клетки постепенно превращаются в десятки специализированных типов: нейроны, мышечные волокна, клетки печени или кожи. Выбор пути зависит от...

Плейлисты на компакт-кассете: как дека Uher CG 355 запоминала порядок треков

В конце 1970-х слушать кассету значило постоянно держать руку на клавишах. Нужный трек искали перемоткой вперёд или назад, глядя на механический счётчик или просто на слух. Мюнхенская фирма Uher...

Как остановить опасный астероид, если таран не сработает: суперкомпьютер нашел решение

Падение крупного небесного тела на Землю — единственный вид глобальной природной катастрофы, который человечество способно предотвратить с помощью доступных технологий. Атмосфера планеты...