Теги реакций и собираемый промпт: как удержать характер ИИ-персонажа по ходу диалога. constrained decoding.. constrained decoding. gemma.. constrained decoding. gemma. llm.. constrained decoding. gemma. llm. Natural Language Processing.. constrained decoding. gemma. llm. Natural Language Processing. ollama.. constrained decoding. gemma. llm. Natural Language Processing. ollama. Structured Output.. constrained decoding. gemma. llm. Natural Language Processing. ollama. Structured Output. ии-персонажи.. constrained decoding. gemma. llm. Natural Language Processing. ollama. Structured Output. ии-персонажи. консистентность диалога.. constrained decoding. gemma. llm. Natural Language Processing. ollama. Structured Output. ии-персонажи. консистентность диалога. Ненормальное программирование.. constrained decoding. gemma. llm. Natural Language Processing. ollama. Structured Output. ии-персонажи. консистентность диалога. Ненормальное программирование. промпт-инжиниринг.. constrained decoding. gemma. llm. Natural Language Processing. ollama. Structured Output. ии-персонажи. консистентность диалога. Ненормальное программирование. промпт-инжиниринг. чат-боты.

Предыстория

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

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

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

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

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

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

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

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

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

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

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

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-кэш переиспользуется) + последние сообщения + связанные векторы памяти + выбранный тег, развёрнутый в текстовые инструкции.

Чистота слоёв здесь важнее задержки: модель не получает точных цифр 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 на эти числа никак не влияет, поэтому поведение объяснимо, а не «настроение модели».

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

тег

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

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

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

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

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

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

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

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

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

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

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

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

Выводы

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

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

Автор: HontoUKI

Источник