Кому именно отвечает бот: адресация, референт и заметка участника в групповом чате. Часть 3

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

Третья часть серии про инженерию 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 и сигнала источника легко заменить своими — идея не в конкретной эвристике, а в том, где именно проходит граница между «написал резолвер» и «резолвер реально работает в чате».

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

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

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

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

Новости

Публикации

Почему на помидорах появляется черное пятно и как спасти урожай

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

Велосипедист не спешился на переходе и попал под машину: кого признают виновным

Весна и лето традиционно приносят на городские дороги множество любителей двухколесного транспорта. Вместе с этим закономерно возрастает количество ДТП с велосипедистами. Самым спорным и широко...

Как 242-метровая плотина Саяно-Шушенской ГЭС удерживает 30 миллиардов тонн воды

31 миллиард кубометров воды — это гигантское рукотворное море, запертое в глубоком горном каньоне Енисея. На нижние слои плотины давит колоссальная масса, способная снести любое обычное...

74 килограмма урана и миллионы расходов: почему американский атомный корабль не окупился

В начале 1960-х годов американское судно «Savannah» выглядело образцом будущего. Ему не требовались огромные запасы мазута, оно могло месяцами обходиться без дозаправки и развивало скорость около...

40 этажей без единой колонны внутри: как внешняя стальная сетка удержала лондонский «Огурец»

В 2004 году, когда жителям Лондона впервые открылся вид на 180-метровый гигант на улице Сент-Мэри Экс 30, прозванный в народе «Огурцом», инженерное сообщество затаило дыхание. Построить 40-этажную...

Откуда у Боинга 747 характерный «горб» на носу?

Если нарисовать самолёт одним росчерком карандаша, большинство людей изобразят трубу с крыльями. Но стоит попросить нарисовать Боинг 747, и рука сама выводит характерную выпуклость над носовой...