Кому именно отвечает бот: адресация, референт и заметка участника в групповом чате. Часть 3
Третья часть серии про инженерию LLM-бота с характером. В личке всё однозначно: одно сообщение — один ответ. В группе появляется вопрос, которого раньше не было: на что именно и про кого отвечать. Разбираем, как бот отделяет цель от фона, как разрешает «она» в конкретного человека и почему аккуратно написанный резолвер может простаивать, хотя его тесты зелёные.
В прошлых частях была общая мысль: поведение бота живёт в слоях под промптом, а промпт задаёт только голос. Одна из таких подсистем — адресация. Она отвечает не за то, как сформулирован ответ, а за то, чему он адресован. В личном диалоге этой подсистемы почти не видно. В групповом чате без неё бот регулярно отвечает не тому и не на то.
Дальше — авторский разбор одной подсистемы моего бота (проект «Джонни»). Код-примеры написаны с нуля для иллюстрации и не являются кодом бота; названия моделей, пороги и числовые параметры приводятся по журналу проекта и не проверялись независимо — отношусь к ним как к авторским данным.
Что ломается в группе
Возьмём короткую цепочку из живого чата. Павел жалуется на облака, Арина спорит про дженерики, бот вставляет реплику, кто-то спрашивает про бенчмарки, а вы пишете короткое «а она?».
Если сложить последние сообщения в один плоский блок и отдать модели, произойдёт предсказуемое. Модель выберет самый «удобный» для ответа фрагмент — обычно последний развёрнутый вопрос, а не вашу реплику. «А она?» без опоры на контекст не несёт задачи, поэтому теряется. В итоге бот бодро отвечает про бенчмарки, а на ваш вопрос — нет.
Три типовых симптома, которые я наблюдал в этом слое:
- бот отвечает на старый вопрос из хвоста истории, а не на текущий;
- короткая новая реплика активирует уже закрытую тему;
- пропущенные во время паузы обращения «догоняются» не в том месте.
Все три — это не про качество формулировок и не про промпт. Это про то, что у сообщений в блоке нет ролей: модель не знает, какое из них цель, а какие — фон.
Слева поток отдан модели одним блоком: она отвечает на удобный вопрос. Справа у сообщений есть явные роли, и цель отделена от фона.
Первое решение и его цена
Самый прямой ход — перестать кормить модель безымянной кучей. В проекте это вылилось в явную разметку:
TARGET_MESSAGE: <то, на что нужно ответить сейчас>
RECENT_CONTEXT_BEGIN
... недавние реплики как фон ...
[ARCHIVE_USER_BACKGROUND_ONLY] ... старые пользовательские сообщения ...
[ARCHIVE_ASSISTANT_ALREADY_SENT] ... то, что бот уже отправлял ...
RECENT_CONTEXT_END
Плюс жёсткое правило: на архив нельзя отвечать как на новую задачу. Это сразу убрало часть промахов — бот перестал хвататься за удобный вопрос из хвоста.
Дальше выяснилось неприятное. Чем жёстче target, тем беднее контекст. Слишком строгая изоляция цели ломала естественные продолжения: короткая реплика вроде «для джунов ты имел в виду?» оставалась без референта, потому что связь с предыдущим сообщением была отрезана вместе с фоном. Появился классический конфликт: свободная адресация тянет старые темы, жёсткая — теряет связность.
Соблазн был откатить разметку. Решение оказалось другим: hardening не откатывать, а контекст возвращать отдельным, узким слоем — reference-резолвером, который подмешивает нужную связь точечно и под контрактом, а не расширяет фон обратно.
Обе крайности дают промахи. Рабочая точка — жёсткий target плюс отдельный слой, который возвращает ровно ту связь, которая нужна.
Референт живёт не в текущей фразе
Ключевое наблюдение этого слоя: референт короткой реплики почти никогда не содержится в ней самой. «А она?», «тот же вопрос выше», «разверни это» — все они указывают наружу, на предыдущее сообщение или на конкретного участника. Значит, резолверу нужно смотреть не только на текст цели, но и на источник цепочки ответов (в Telegram это метаданные reply_to).
Резолвер в проекте семантический, а не словарный. Это осознанный принцип серии: не заводить бесконечные списки готовых фраз там, где задачу решает семантика плюс структурный сигнал. У резолвера несколько ограничителей:
- он применяется только к короткому прямому ответу, а не к каждому сообщению;
- работает при высокой уверенности (по журналу проекта — порог около 0.9);
- не переносит цель на самого отправителя;
- на самоссылку бота override не выдаёт.
Отдельная поучительная ошибка была не в логике резолвера, а в порядке сборки. Флаг «это ссылка на недавний вопрос» добавлялся к системному сообщению уже после того, как сборщик промпта отработал. Сборщик его не видел и выбирал не тот процитированный контекст. Лечится это не новым правилом, а передачей флага внутрь сборщика до сборки — и установкой блока ACTIVE_REFERENCE_FOR_TARGET вплотную к цели, чтобы модель не искала связь по всему фону.
Вывод, который стоит забрать: если резолвер «иногда не срабатывает», прежде чем усложнять его семантику, проверьте, доходит ли до него нужный сигнал и в каком порядке собирается промпт. Часто чинить надо конвейер, а не резолвер.
Местоимение вместо имени
Самый показательный случай в этом слое — местоименная ссылка на человека. Публичная цепочка сообщений показала, что бот не понял, к кому относится «она». В чате была участница Арина, о ней имелась заметка, но на «а она?» бот ответил мимо.
Первое, что важно, — данные были в порядке. В базе нашлось 13 заметок об участниках, целостность не пострадала. Проблема была не в потере данных, а в выборе заметки. Старый загрузчик сначала брал заметки на уровне всего чата, а потом пытался сопоставить цель с отображаемым именем. Кириллическое «Арина» и местоимение «она» не разрешались в точный ключ заметки — и подходящая запись просто не выбиралась.
Новый резолвер участника устроен так:
- кандидаты ограничены whitelist-ом ключей из таблицы заметок — произвольное имя не может породить широкий запрос;
- семантический резолвер выбирает точный ключ участника при высокой уверенности (по журналу — около 0.88);
- референт определяется по источнику reply-цепочки, а не только по текущей короткой фразе;
- после выбора идёт точный SQL-запрос по ключу;
- при неуверенном или неизвестном результате срабатывает fail-safe: чужая заметка не подмешивается.
Два входа — цель и источник цепочки — сходятся в семантическом резолвере. Уверенный путь ведёт к точной заметке, неуверенный — к безопасному отказу без подстановки.
Отдельно стоит сказать, что сознательно не делали. Не добавляли транслитерацию и fuzzy-поиск по имени, не грузили все заметки скопом, не «чинили» исправную базу. Все три варианта расширяют область ошибки: fuzzy-поиск начнёт путать похожие имена, загрузка всех заметок утянет в промпт лишних людей, а правка целой базы — это лечение симптома там, где данные и так корректны.
Демо: зелёный тест, который ничего не доказывает
Теперь самое интересное — и то, ради чего стоит смотреть на этот слой внимательно. Резолвер участника написан, покрыт тестами и проходит их. Но в боевом пути он всё ещё не срабатывает на естественных продолжениях вроде «а она?». Причина не в резолвере, а в вызывающем коде.
Резолвер вызывается только если до него другой gate решил, что заметки об участниках вообще нужны. Этот gate узкий: он ловит явные упоминания (имя, слова «участник», «про кого»), но не голые местоимения. В результате «а она?», «ну и как тебе она?», «что насчёт неё?» до резолвера не доходят — хотя источник цепочки уже содержит достаточный сигнал.
Соберём минимальный воспроизводимый пример. Он показывает три вещи: helper решает задачу в изоляции, старый gate роняет местоимения до его вызова, а fail-safe не подставляет заметку при неоднозначном источнике. Заглушка вместо семантики — чтобы пример запускался без сети и зависимостей.
from dataclasses import dataclass
PARTICIPANT_NOTES = {
"arina": "Арина: пишет на Go, спорит про дженерики, недолюбливает ORM.",
"pavel": "Павел: девопс, топит за bare-metal, троллит облачных.",
}
@dataclass
class ReplySource:
participant_key: str | None # к какому участнику привязан источник цепочки
text: str
FEMININE_PRONOUNS = {"она", "её", "ее", "неё", "нее", "ней"}
def looks_like_referent_question(text: str) -> bool:
tokens = {t.strip("?!.,").lower() for t in text.split()}
return bool(tokens & FEMININE_PRONOUNS) and len(text) <= 40
def resolve_referent(text: str, source: ReplySource) -> tuple[str | None, float]:
"""Helper. Опирается на источник цепочки. Fail-safe при неоднозначности."""
if not looks_like_referent_question(text):
return (None, 0.0)
if source.participant_key and source.participant_key in PARTICIPANT_NOTES:
return (source.participant_key, 0.94)
return (None, 0.0)
def should_use_participant_notes(text: str) -> bool:
"""Боевой gate ДО резолвера. Узкий: ловит имена, но не голые местоимения."""
low = text.lower()
return any(w in low for w in ("арин", "павел", "участник", "про кого"))
def reply_source_signal(source: ReplySource) -> bool:
"""Дешёвый структурный сигнал: источник привязан к участнику из whitelist."""
return bool(source.participant_key and source.participant_key in PARTICIPANT_NOTES)
def orchestrate(text: str, source: ReplySource, *, with_fix: bool) -> str | None:
gate = should_use_participant_notes(text)
if with_fix:
gate = gate or reply_source_signal(source) # фикс: OR со структурным сигналом
if not gate:
return None
key, conf = resolve_referent(text, source)
return key if (key and conf >= 0.90) else None
Проверка. Сначала — helper в изоляции, затем боевой путь на четырёх репликах, включая контрольную с неоднозначным источником:
arina = ReplySource("arina", "Арина: опять спорит про дженерики")
unknown = ReplySource(None, "кто-то в общем потоке, без reply")
# 1. unit-тест helper-а
key, conf = resolve_referent("а она?", arina)
assert key == "arina" and conf >= 0.90 # проходит: helper работает
# 2. боевой путь
for text, src in [("а она?", arina),
("ну и как тебе она?", arina),
("что насчёт неё?", arina),
("а она?", unknown)]:
old = orchestrate(text, src, with_fix=False)
new = orchestrate(text, src, with_fix=True)
print(f"{text!r:24} старый={old} фикс={new}")
Вывод:
'а она?' старый=None фикс=arina
'ну и как тебе она?' старый=None фикс=arina
'что насчёт неё?' старый=None фикс=arina
'а она?' старый=None фикс=None
Картина видна сразу. Unit-тест helper-а зелёный — резолвер разрешает «а она?» в arina. Но в старом боевом пути все три местоименных продолжения возвращают None: gate не пустил их к резолверу. Фикс — добавить в gate структурный сигнал из источника цепочки через OR — возвращает референт. При этом контрольная строка с неоднозначным источником и после фикса даёт None: fail-safe не подставляет заметку наугад.
Разрыв живёт в вызывающем коде: helper зелёный, но production-gate его не зовёт. Фикс — объединить gate с сигналом источника.
Это ровно тот случай, о котором была первая статья серии: один зелёный тест ничего не доказывает про поведение системы. Здесь тест helper-а проверяет helper, а не то, что боевой gate до него доходит. В журнале проекта этот класс ошибок вынесен отдельно — «helper-vs-call-site blindness»: зелёный тест helper-а не значит, что вызывающий код его вызывает.
Где это всё ещё не работает
Честный статус подсистемы на момент подготовки материала — разрыв не закрыт в бою.
- Резолвер участника лежит на диске, проходит регрессионный набор (по журналу — 91/91) и роутинг-тесты (75/75), но не развёрнут: последним подтверждённым runtime остаётся предыдущая версия. Пока нет нового рабочего процесса и live-проверки, считать патч активным нельзя.
- Unit-тесты покрывают helper, но не покрывают тот самый call-site gate. Нужный следующий шаг — объединить структурный триггер из источника цепочки с текущим gate и добавить тесты именно на вызывающий код: положительные для «а она?», «как тебе она?», «что насчёт неё?» и отрицательные для обычных местоимений без однозначного источника.
- Дополнительный семантический вызов резолвера — это латентность и риск смещения (semantic drift). Высокая планка уверенности и whitelist его ограничивают, но не убирают полностью.
- В одном публичном ответе всплыл буквальный TARGET_MESSAGE — служебная разметка утекла в текст. Отдельный старый guard против этого не был реактивирован.
Ни один из этих пунктов не «замазан» в коде. Они перечислены здесь, потому что для чужого проекта полезнее знать границы решения, чем видеть красивую, но неполную картину.
Что забрать в свой проект
Адресация — это отдельный слой, а не деталь промпта. Если ваш бот хорошо работает один на один и разваливается в группе, начинать стоит не с переписывания системного сообщения.
Три переносимых принципа:
- Дайте сообщениям роли. Явные TARGET_MESSAGE и фон с разметкой архива снимают большинство промахов «ответил не на то». Но заложите цену: слишком жёсткая цель рвёт связность, поэтому контекст возвращайте отдельным узким слоем, а не расширением фона.
- Ищите референт в источнике, а не в тексте реплики. Короткие «а она?» и «разверни это» указывают наружу. Структурный сигнал из reply-цепочки дешевле и надёжнее, чем попытка угадать референт по самой фразе.
- Проверяйте call-site, а не только helper. Зелёный тест функции ничего не говорит о том, вызывается ли она в бою. Разрыв между «helper работает» и «система работает» — это отдельный, регулярно повторяющийся класс ошибки, и ловится он только тестами на вызывающий код.
Демо из статьи запускается на Python 3.10+ без зависимостей: скопируйте оба блока в один файл и запустите. Логику gate и сигнала источника легко заменить своими — идея не в конкретной эвристике, а в том, где именно проходит граница между «написал резолвер» и «резолвер реально работает в чате».





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