Бот с характером живёт не в промпте: где находится поведение LLM-бота в групповом чате. Часть 1

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

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

Знакомая ловушка: вы дописали system prompt, придумали боту характер, задали ему пару вопросов в личной переписке — и получили осмысленные, точные, местами остроумные ответы. Возникает ощущение законченной работы.

Затем тот же бот оказывается в настоящей группе — и ощущение рассыпается. Прямой вопрос повисает без ответа. Зато в разговор двух людей, где бота никто не звал, он вклинивается. К концу вечера его реплик становится столько, что участники тянутся к кнопке mute. Разбор каждого такого случая приводит к одному и тому же наблюдению: сбой почти никогда не в содержании реплики. Сбой — в выборе момента и адресата.

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

Приватный тест проверяет не то

У диалога тет-а-тет с ботом отсутствует почти вся сложность группы. Любая реплика по определению обращена к нему. Параллельных веток нет. Чужих вопросов, которые можно ошибочно принять на свой счёт, нет. Следить за собственной частотой не нужно. Соседних ботов не существует. Фактически личка испытывает единственный навык — собрать связный ответ. Для современных моделей это давно решённая задача, никакой инженерии тут не требуется.

Группа же заставляет принимать решения, которых в привате не бывает в принципе:

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

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

Самый ценный навык — вовремя не ответить

Следующее открытие мало кому нравится. Стоит боту стать по-настоящему живым, как на первое место выходит умение промолчать.

Механика простая, если смотреть глазами участников. Внимание группы — ресурс, и он ограничен. Каждая реплика бота этот ресурс расходует, а окупается далеко не каждая. Пропущенный уместный ответ огорчит одного человека однократно. Бот-говорун, встревающий повсюду, утомляет всех и непрерывно — финал предсказуем: mute или исключение. Несимметричность очевидна: лишнее сообщение обходится дороже недостающего.

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

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

Слоевая модель: сначала диагноз, потом лечение

Первая реакция на любой сбой — дописать в system prompt очередное указание: «игнорируй короткие реплики», «не встревай в чужие обсуждения», «не дублируй себя». Пару дней помогает. Потом инструкции начинают противоречить друг другу, промпт распухает, а стабильности не прибавляется. Корень в том, что подавляющая часть дефектов вообще не лежит в плоскости формулировок.

Рабочей оказалась привычка перед любым фиксом отвечать на вопрос: какому слою принадлежит проблема. Раскладка выглядит так:

  • routing — определяет, касается ли сообщение бота вообще;
  • suppress — отсекает случаи, где отвечать не следует, даже при прямом обращении;
  • context — отделяет реплику-задачу от фонового шума беседы;
  • memory — обеспечивает преемственность, не протаскивая лишнее состояние;
  • reaction — заменяет текст лёгким эмодзи там, где текст избыточен;
  • side-effect — всякое публичное действие: отправка, реакция, файл, изображение, запись в базу;
  • service — эксплуатационный контур: рестарты, журналы, бэкапы, проверки.

Сила не в самом перечне, а в дисциплине его применения: сперва симптом привязывается к слою, затем чинится внутри этого слоя. Как это выглядит на живых случаях:

Симптом Первый порыв Где дефект на самом деле
Бот подхватил чужой вопрос из истории переписать промпт context (граница «задача vs фон»)
Проигнорировал нормальное уточнение смягчить инструкции routing/suppress
Слишком частые реплики попросить «быть лаконичнее» cooldown/bot-share
Эмодзи-реакция не к месту добавить правило в промпт reaction (это side-effect)
Продублировал собственный ответ поднять «разнообразие» suppress/anti-duplicate

Правило, сэкономившее в проекте больше всего часов: дефект маршрутизации не лечится стилем, а дыра в suppress не закрывается инструкцией. Раз бот заговорил там, где обязан был молчать, — виноват routing либо suppress, и точка; «модель не так поняла» — неверный диагноз. Голос — это промпт. Поведение — это слои под ним.

Тот же принцип в тридцати строках кода

Чтобы рамка не осталась теорией, ниже — нарочито упрощённая модель выбора между «ответить», «поставить реакцию», «отложить» и «промолчать». Подчеркну: это не фрагмент бота, а учебная игрушка, собранная специально для статьи. Чистый Python 3.10+, ни одной зависимости — запускайте и ломайте.

from dataclasses import dataclass

@dataclass
class Msg:
    text: str
    addressed: bool        # адресовано боту: reply, упоминание, имя
    is_reply_to_bot: bool  # это reply на сообщение бота
    bot_share: float       # доля сообщений бота в недавнем окне, 0..1
    in_cooldown: bool      # бот недавно уже отвечал

LOW_VALUE = {"пон", "спс", "ок", "ясно", "угу", "+"}

def decide(m: Msg) -> str:
    # routing: наш ли это ход вообще
    if not (m.addressed or m.is_reply_to_bot):
        return "silent"
    # suppress: не доминировать и не отвечать текстом на пустое
    if m.bot_share > 0.5:
        return "silent"
    if m.text.strip().lower() in LOW_VALUE:
        return "reaction"
    # cooldown: задержать, но не потерять адресную задачу
    if m.in_cooldown:
        return "pending"
    return "answer"

Прогон на пяти сценариях:

cases = [
    Msg("как поднять systemd-сервис?", True,  False, 0.1, False),
    Msg("спс",                         False, True,  0.1, False),
    Msg("это вообще не боту",          False, False, 0.1, False),
    Msg("ещё вопрос",                  False, True,  0.1, True),
    Msg("а тут?",                      True,  False, 0.7, False),
]
for m in cases:
    print(f"{decide(m):8} <- {m.text}")

Результат:

answer   <- как поднять systemd-сервис?
reaction <- спс
silent   <- это вообще не боту
pending  <- ещё вопрос
silent   <- а тут?

Та же логика, что в коде выше, но по шагам: видно, на какой развилке каждый сценарий получает свой вердикт.
Та же логика, что в коде выше, но по шагам: видно, на какой развилке каждый сценарий получает свой вердикт.

Присмотритесь к двум финальным строкам. Реплика «ещё вопрос» обращена к боту, однако попадает под cooldown — и функция возвращает pending: задачу откладываем, но не теряем. Наивная реализация здесь бы просто выбросила сообщение, и человек получил бы игнор на прямой вопрос. Реплика «а тут?» тоже адресная, но к этому моменту бот занял 70% недавнего эфира — и suppress глушит даже прямое обращение. Обе развилки пройдены до какого-либо контакта с моделью; текстом инструкции такие решения не воспроизводятся.

Оговорка ради честности: жёсткий набор LOW_VALUE оставлен исключительно ради компактности примера. Боевому боту словарь из «пон / спс / ок» противопоказан — подобные списки хрупкие и вечно отстают от живой речи. Разбору того, почему лексические триггеры проигрывают семантическим и что ставить взамен, отведена отдельная часть серии.

Границы возможностей промпта

Резонный вопрос: модели нынче умные — почему бы не переложить всю эту механику в system prompt? Потому что промпт слеп к состоянию. Ему неоткуда узнать текущий bot_share, он не в курсе, что бот высказывался двадцать секунд назад, у него нет доступа к очереди отложенных задач, и гарантированно заменить текст реакцией он тоже не в силах. Модель принимает собранный вами контекст и отдаёт текст — на этом её полномочия заканчиваются. Контроль темпа, идемпотентность, защита публичных действий — всё, что опирается на память о недавних событиях и на ограничения частоты, обитает в коде вокруг вызова, а не в строках внутри него.

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

Куда движется серия

Перед вами общая рамка; дальше — по слою за раз, с подробностями:

  • анатомия «молчания»: routing, suppress, cooldown, bot-share и защита от зацикливания с соседним ботом;
  • отчего бот отвечает не тому: развод текущей задачи и фонового контекста, устаревшая история, память с привязкой к участнику;
  • лексика против семантики: где списки готовых фраз подводят и в каких нишах всё же работают;
  • прод-дисциплина для одного бота: побочные эффекты, dry-run-проверки, недостаточность компиляции как критерия и матрица регрессий.

Вывод

Групповой LLM-бот — задача не из области prompt engineering. Инструкции формируют характер и интонацию — верхний, витринный слой. Устойчивость же обеспечивается этажами ниже: маршрутизацией, подавлением лишнего, границами контекста, контролем публичных действий, дисциплиной проверок.

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

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

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

2 комментария

Arsmerk
Джонни ван лав!
LexLuthor
лучше бы ты с такой же страстью логи мониторил, а то от любви до первого упавшего кластера — один шаг :D

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

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

Новости

Публикации

Как работает память осьминога: ученые нашли в его мозге механизм, которого нет у человека

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

Мы проводим 10% дня с закрытыми глазами: почему моргание не мешает нам видеть мир

Человек моргает каждые несколько секунд. Если сложить длительность всех этих коротких пауз за день, окажется, что до 10% времени бодрствования мы проводим с полностью закрытыми глазами. При...

Перфокарты считают население: как машина Холлерита обрабатывала перепись США 1890 года

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

Почему в СССР ступени в подъездах красили только по краям

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

Морских выдр почти выбили ради меха — после этого морские ежи начали уничтожать подводные леса

В XVIII веке морская выдра была обычным хищником огромной полосы побережья северной части Тихого океана — от Японии и Курильских островов через Алеуты и Аляску до Калифорнии. Через...

Карьера Эстебана Окона в Формуле-1 подошла к концу: что случилось с французским гонщиком?

Эстебан Окон был досрочно уволен из «Хааса» и остался без места в Формуле-1. Поклонники гонок и так знали, что француз покинет американскую команду по окончании текущего сезона, но ситуация резко...