- 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-кэш переиспользуется
[диалоговое окно]
[динамический хвост: стадия + блок тега + память + сообщение юзера] — едет ВМЕСТЕ с репликой
│
▼
ответ персонажа
Классификатор — короткий вызов, который даёт модели на выбор n тегов, подходящих к текущему моменту разговора. Температура здесь 0: вызов детерминирован и решает только одно — какая реакция пойдёт в промпт.
Длинный запрос решает уже, «как отыграть» выбранную реакцию на действия пользователя. Тут температура выше — можно позволить модели импровизировать в рамках заданного тега.
Даже если персонаж по натуре тёплый и открытый, тег, делающий его объяснимо холодным в конкретный момент, смотрится естественно. А поскольку это решается вне модели — контекстное окно хранит лишь статичную информацию о персонаже — роль удаётся удерживать долго.
Несколько тегов персонажа Мегумин из «Konosuba» (юная архимаг, помешанная на взрывах):
|
тег |
когда срабатывает |
как разворачивается в ответ |
|---|---|---|
|
|
юзер искренне тёплый/дружелюбный |
доволен, хоть и не привык; сквозь театральность проступает искренняя благодарность |
|
|
юзер подкалывает |
смущается и возмущается, протестует громко, но не отстраняется — это дружеская перепалка |
|
|
юзер реально оскорбляет (или, хуже, магию Взрыва) |
уязвлён и высокомерен, защищает своё искусство с задетой гордостью |
Классификатор получает только последние 6 реплик, а не весь диалог: так выше вероятность структурированного вывода и нет лишнего разрастания контекстного окна. Если ответ невалиден или не парсится — один ретрай, а затем откат на fallback_tag, объявленный в паке. Ход никогда не падает.
Тег — это разовая «погода». Но чтобы из отдельных реакций рос характер отношений, эти реакции нужно на чём-то накапливать — на числах. У персонажа выстроены три независимые оси:
affection (симпатия)
trust (доверие)
bond (привязанность)
Их двигает исключительно сам движок по фиксированной таблице, что поставляется паком персонажа. LLM на эти числа никак не влияет, поэтому поведение [3] объяснимо, а не «настроение модели».
Каждому тегу сопоставлен вектор дельт — насколько он двигает оси (значения из пака Мегумин):
|
тег |
affection |
trust |
bond |
|---|---|---|---|
|
|
+4 |
+4 |
+0.6 |
|
|
+5 |
+3 |
+0.4 |
|
|
+2 |
+1 |
+0.2 |
|
|
−1.5 |
−0.5 |
0 |
|
|
−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 — намеренно медленная, «долгосрочная» ось, и по правилу движка её дельта всегда не больше дельт симпатии и доверия.
Ранее я уже упоминал, что тег — это погода, а стадия — это климат. Немного конкретики:
Тег — как персонаж отреагировал на ЭТУ реплику в конкретный момент. Для модели это «идёт дождь».
Стадия — кем персонаж стал по мере роста отношений. Если тег — это дождь, то климат определяет, будет ли это ливень или покапает и пройдёт.
Текущая стадия выводится из «коэффициента близости» — среднего симпатии и доверия — и выбирается по порогу 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.
Промпт собирается из двух сегментов:
Статичный сегмент — базовая личность и правила, которые модель не может нарушать. Отправляется один раз, дальше для экономии переиспользуется 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}
В этом примере:
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
Нажмите здесь для печати.