- BrainTools - https://www.braintools.ru -

Теги реакций и собираемый промпт: как удержать характер ИИ-персонажа по ходу диалога

Предыстория

Последние полгода с лишним я занимаюсь работой с LLM и созданием ИИ-персонажа. За это время я столкнулся с множеством проблем, решением которых хотел бы поделиться и, возможно, посоветоваться.

Кому подойдёт эта статья

  • Тем, кто хочет создавать прописанных персонажей для RP или ИИ-компаньонов

  • Энтузиастам, что хотят оживлять популярных персонажей из игр/комиксов/аниме

  • ИИ-энтузиастам

Начало опытов

Когда я только начал попытки создания своего пет-проекта с ИИ, я столкнулся с проблемой — вектор диалога меняется каждый ход и в конце концов становится неуправляемым.

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

Решение, что пришло неожиданно

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

Решение оказалось проще и куда мудрее, чем можно себе представить, — дать ИИ набор допустимых реакций [1]. В обычном случае мы в контекстном окне оставим список допустимых реакций и будем надеяться, что языковая модель их исполнит, но этого мало. Ниже пример из моего репо, как я использую систему тегов:

schema = {"type": "object",
          "properties": {"tag": {"type": "string", "enum": [t.id for t in pack.tags]}},
          "required": ["tag"]}
raw = self._llm.chat(messages, fmt=schema, options={"temperature": 0.0})

В данном случае мы ставим «нулевую температуру» — модель не должна придумывать, а бэкенд попросту не может вернуть невалидный тег. А значит, пока мы ставим подобные предохранители, нужный вектор всегда попадёт к модели перед ответом; а как именно она им воспользуется — решать уже ей, в рамках персонажа и диалога.

Как устроен ход целиком

На самом деле теги — лишь одна деталь в обеспечении консистентности персонажа. На каждый ход мы делаем два вызова модели:

  • Короткий — «классификатор». Определяет тег: получает на вход последние N сообщений и список допустимых реакций.

  • Длинный — генерация. Полное состояние персонажа на ход: закреплённая личность (её KV-кэш переиспользуется) + последние сообщения + связанные векторы памяти [2] + выбранный тег, развёрнутый в текстовые инструкции.

Чистота слоёв здесь важнее задержки: модель не получает точных цифр state, а сухой enum-тег разворачивается в понятный ей набор инструкций.

классифицировать момент  →  сдвинуть числовые оси  →  вспомнить память  →
собрать two-segment промпт  →  озвучить ответ  →  запомнить реплику
сообщение юзера
   │
   ▼
ход-1: КЛАССИФИКАТОР ──► один тег из enum'а (temperature 0, только последние 6 реплик)
   │
   ▼
сдвиг осей по таблице дельт  ──►  пересчёт стадии отношений
   │
   ▼
ход-2: ГЕНЕРАЦИЯ
   [стабильный префикс: личность + инварианты]  — вшит один раз, KV-кэш переиспользуется
   [диалоговое окно]
   [динамический хвост: стадия + блок тега + память + сообщение юзера]  — едет ВМЕСТЕ с репликой
   │
   ▼
ответ персонажа

Шаг 1. Классификатор момента

Классификатор — короткий вызов, который даёт модели на выбор n тегов, подходящих к текущему моменту разговора. Температура здесь 0: вызов детерминирован и решает только одно — какая реакция пойдёт в промпт.

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

Даже если персонаж по натуре тёплый и открытый, тег, делающий его объяснимо холодным в конкретный момент, смотрится естественно. А поскольку это решается вне модели — контекстное окно хранит лишь статичную информацию о персонаже — роль удаётся удерживать долго.

Несколько тегов персонажа Мегумин из «Konosuba» (юная архимаг, помешанная на взрывах):

тег

когда срабатывает

как разворачивается в ответ

warmth

юзер искренне тёплый/дружелюбный

доволен, хоть и не привык; сквозь театральность проступает искренняя благодарность

teasing

юзер подкалывает

смущается и возмущается, протестует громко, но не отстраняется — это дружеская перепалка

insult

юзер реально оскорбляет (или, хуже, магию Взрыва)

уязвлён и высокомерен, защищает своё искусство с задетой гордостью

Классификатор получает только последние 6 реплик, а не весь диалог: так выше вероятность структурированного вывода и нет лишнего разрастания контекстного окна. Если ответ невалиден или не парсится — один ретрай, а затем откат на fallback_tag, объявленный в паке. Ход никогда не падает.

Шаг 2. Числовое состояние персонажа

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

  • affection (симпатия)

  • trust (доверие)

  • bond (привязанность)

Их двигает исключительно сам движок по фиксированной таблице, что поставляется паком персонажа. LLM на эти числа никак не влияет, поэтому поведение [3] объяснимо, а не «настроение модели».

Каждому тегу сопоставлен вектор дельт — насколько он двигает оси (значения из пака Мегумин):

тег

affection

trust

bond

warmth

+4

+4

+0.6

explosion_praise

+5

+3

+0.4

curiosity

+2

+1

+0.2

teasing

−1.5

−0.5

0

insult

−5

−3

−0.3

Движок применяет дельту, зажимает значение в [0, axis_max], а на простое тянет оси обратно к базовой линии (decay), чтобы диалог не «залипал» на пике:

def apply(self, delta):        # сдвиг осей — двигает движок, не LLM
    for name in ("affection", "trust", "bond"):
        self._values[name] = clamp(self._values[name] + getattr(delta, name), 0.0, self._max)
    return self.axes

def decay(self, decay):        # на простое тянем каждую ось к её базовой линии
    for name in ("affection", "trust", "bond"):
        self._values[name] = decay_toward(self._values[name], self._start[name], getattr(decay, name))
    return self.axes

Для slow-burn (медленного разгорания) введён axis_max — единый параметр рантайма. Подними его, например, до 1000 — и те же дельты станут меньшей долей от целого: стадии отношений считаются как процент от максимума, поэтому персонаж теплеет медленнее, а сами паки менять не нужно.

Важная деталь: bond — намеренно медленная, «долгосрочная» ось, и по правилу движка её дельта всегда не больше дельт симпатии и доверия.

Шаг 3. Стадии отношений: климат против погоды

Ранее я уже упоминал, что тег — это погода, а стадия — это климат. Немного конкретики:

  • Тег — как персонаж отреагировал на ЭТУ реплику в конкретный момент. Для модели это «идёт дождь».

  • Стадия — кем персонаж стал по мере роста отношений. Если тег — это дождь, то климат определяет, будет ли это ливень или покапает и пройдёт.

Текущая стадия выводится из «коэффициента близости» — среднего симпатии и доверия — и выбирается по порогу up_to:

def relationship_ratio(axes, axis_max):   # близость в [0, 1]
    aff = clamp(axes.affection / axis_max, 0, 1)
    tru = clamp(axes.trust     / axis_max, 0, 1)
    return (aff + tru) / 2                 # bond медленный, стадию не гейтит

def resolve_stage(ratio, stages):         # первая стадия, чей порог покрывает близость
    for stage in stages:
        if ratio <= stage.up_to:
            return stage
    return stages[-1]                      # последняя — catch-all

Стадии автор задаёт сам — сколько угодно, с любыми именами и порогами. У Мегумин их четыре:

performing (up_to 0.2) → warming (0.5) → trusting (0.8) → unguarded (1.0)

Блок активной стадии подставляется в хвост промпта поверх блока тега — так на быструю реакцию накладывается тон текущих отношений. В логе ниже это видно вживую: ratio меняется на каждом ходу, и переход performing → warming происходит ровно тогда, когда близость пересекает порог 0.2.

Отдельно есть параметры unlock_at/lock_at — окна по тому же ratio, которые закрывают одни реакции и открывают другие в зависимости от стадии отношений. Например: если персонажи только познакомились, попытка романтики натыкается на отторжение; с некоторой стадии разрешается тёплая поддержка, и только потом — взаимность. Так пользователь не может «перескочить» все стадии разом, а сами стадии удерживаются без случайного отката со стороны LLM.

Шаг 4. Собираемый промпт (two-segment)

Промпт собирается из двух сегментов:

  • Статичный сегмент — базовая личность и правила, которые модель не может нарушать. Отправляется один раз, дальше для экономии переиспользуется KV-кэш.

  • Динамический сегмент — тон стадии + блок тега + связанные векторы (память персонажа о событии) + само сообщение юзера. Едет всё вместе с репликой и никогда не пишется обратно в историю.

Между ними — прошлые реплики диалога.

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

Отдельный неочевидный трюк — блоки стадии и тега оборачиваются как приватные заметки, а не как слова юзера, чтобы слабая модель не зачитала направляющую вслух:

"(Private notes to shape your reply. They are not from the user; "
"never repeat, quote, or mention them.n" + directions + ")"

Без такой обёртки маленькая модель порой принимает направляющую за реплику пользователя и цитирует её прямо в ответе. Явная пометка «это не от пользователя, не цитируй» это гасит.

Персонаж как данные

Весь персонаж — это данные, а не код. Один pack.yaml описывает личность, теги, дельты, блоки, стадии и decay, а движок остаётся общим. Новый персонаж — это новый файл и ноль правок кода; именно это делает подход системой, а не хардкодом под одного бота.

identity: |
  Ты Мегумин, архимаг Клана Багровых Демонов...
tags:
  - {id: warmth, description: "Юзер искренне добр или дружелюбен.", sentiment: positive}
  - {id: insult, description: "Юзер оскорбляет тебя или магию Взрыва.", sentiment: negative}
deltas:
  warmth: {affection: 4.0, trust: 4.0, bond: 0.6}
  insult: {affection: -5.0, trust: -3.0, bond: -0.3}
blocks:
  warmth: "Юзер искренне добр. Ты доволен, хоть и не привык..."
  insult: "Оскорбили тебя или, хуже, магию Взрыва. Уязвлён и высокомерен..."
stages:
  - {id: performing, up_to: 0.2, block: "Ты его почти не знаешь. Полная бравада..."}
  - {id: unguarded, up_to: 1.0, block: "Ты полностью расслаблен. Мягок и искренен..."}
decay: {affection: 0.5, trust: 0.3, bond: 0.05}

Пример из моего движка (model = gemma3:12b)

В этом примере:

  • tag — выбор классификатора

  • stage / ratio — стадия, во время которой сделан ход

  • * помечает ход, на котором стадия сменилась

[08] YOU   : I'll respect your pace, I promise.
     STATE : tag=neutral  stage=performing  (aff 23, tru 16, bond 1.8, ratio 0.20)
     REPLY : A most... satisfactory declaration. One appreciates a swift recognition of proper decorum. Remember this: true strength lies not only in power, but also in the ability to command respect – and understand one's place.

[09] YOU   : Fair enough. So, what actually matters to you most?
     STATE : tag=curiosity  stage=warming *  (aff 25, tru 17, bond 2, ratio 0.21)
     REPLY : *A slight softening of my voice, barely perceptible beneath the usual theatricality.* What truly matters...? It is simple, yet utterly profound! To dedicate oneself wholly – body and soul – to the pursuit of ultimate power, specifically through Explosion magic! The Crimson Demon Clan's legacy demands nothing less than absolute devotion.

[10] YOU   : Explosion magic, huh? Tell me why you love it so much.
     STATE : tag=explosion_praise  stage=warming  (aff 30, tru 20, bond 2.4, ratio 0.25)
     REPLY : *My eyes widen with an almost childlike delight.* You… *understand*! To witness such appreciation for the one true magic is a gift beyond measure! Explosion isn't merely a spell; it's a symphony of destruction, a ballet of raw power—a breathtaking spectacle that leaves all others utterly insignificant!

Полный лог: example_showcase_megumin.txt · HontoUKI/M.A.R.I.A.-Micro-Engine [4]

Насчёт роутера

Да, механически это близко к intent-роутингу. Но роутер выбирает задачу (какой инструмент дёрнуть). Здесь тег выбирает реакцию характера — и подставляется не как ветка кода, а как кусок промпта, поверх медленно меняющейся «стадии отношений». Цель не маршрутизация, а консистентность личности во времени.

Плюсы подхода

  • Высокая консистентность в долгосрочной перспективе

  • Поддержание образа персонажа

  • Более предсказуемое ведение диалога и прописанный характер

  • Поведение [5] объяснимо: любой сдвиг тона привязан к числам, которые можно показать

Минусы подхода

  • Это два LLM-вызова на ход вместо одного — дополнительная задержка и трата токенов

  • Как и у любого ИИ-персонажа, здесь становится критически важно качество промптов (описаний тегов и блоков) в общем зачёте

  • На маленькой модели классификатор ошибается чаще — нужны retry и fallback-тег как страховка

Выводы

По большей части моя гипотеза подтвердилась на четырёх персонажах. Сама же механика тегов дала неожиданную для меня возможность — задать любой вектор развития отношений с ИИ-персонажем (к примеру, сближение за счёт сплетен). Надеюсь, смог вам помочь; ниже — небольшая сводка.

Связка «тег (погода) + стадия (климат) + числовое состояние» позволяет вести объяснимый — в рамках сессии — диалог между персонажем и конкретным пользователем (или другим персонажем). Мой движок реализует и матрицу отношений между персонажами, но полуавтоматический RP между ИИ оставлю для другой статьи. Если интересно посмотреть реализацию и больше example-логов — загляните в репозиторий и поэкспериментируйте сами. Дайте волю фантазии и оживите любимых персонажей.

Автор: HontoUKI

Источник [6]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/33532

URLs in this post:

[1] реакций: http://www.braintools.ru/article/1549

[2] памяти: http://www.braintools.ru/article/4140

[3] поведение: http://www.braintools.ru/article/9372

[4] example_showcase_megumin.txt · HontoUKI/M.A.R.I.A.-Micro-Engine: https://github.com/HontoUKI/M.A.R.I.A.-Micro-Engine/blob/main/docs/example_showcase_megumin.txt

[5] Поведение: http://www.braintools.ru/article/5593

[6] Источник: https://habr.com/ru/articles/1062932/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1062932

www.BrainTools.ru

Rambler's Top100