Анатомия молчания: как групповой Telegram-бот решает не отвечать. Часть 2
В личной переписке бот отвечает осмысленно, а в живой группе начинает мешать: вклинивается в чужие разговоры, отвечает на подначку развёрнутым разбором и за вечер набивает столько реплик, что участники тянутся к 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. Боевого кода, промптов и конфигурации там нет, только безопасные заметки к статьям. Код в тексте написан с нуля для иллюстрации.





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