Анатомия молчания: как групповой 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 комментариев

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

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

Новости

Публикации

Обзор эргономичного кресла ZONE 51 CORE X-Weave

Рынок игровых кресел долгие годы живет по собственным, почти неизменным законам. «Гоночный» ковш, боковые «уши» и обивка из экокожи, которая через год-два превращается в тряпку, — этот...

Почему половина жителей Монголии живёт в одном городе, а в остальной части старны практически никто не живет

Монголию часто представляют бесконечной степью, юртами и табунами лошадей до горизонта. Цифры рисуют иную картину. Около 1,8 миллиона, почти половина всех жителей, сосредоточены только в столице...

Что означает жест на суде в западных странах — поднятие правой руки

Поднятие правой руки в зале суда — довольно узнаваемый ритуал западной правовой культуры. Сегодня этот жест чаще всего сопровождает дачу присяги: свидетель, эксперт или иное лицо...

✦ ИИ  Почему нейросеть забывает начало разговора и как на самом деле работает её память

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

Почему пропали музыкальные центры, популярные в 90-е

У музыкального центра Sony MHC-RX50 было два кассетных отсека, CD-проигрыватель и передняя панель с многочисленными кнопками управления. Аппарат позволял переписывать кассеты с повышенной...