Предыстория
Последние полгода с лишним я занимаюсь работой с 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» (юная архимаг, помешанная на взрывах):
тег | когда срабатывает | как разворачивается в ответ |
|---|---|---|
| юзер искренне тёплый/дружелюбный | доволен, хоть и не привык; сквозь театральность проступает искренняя благодарность |
| юзер подкалывает | смущается и возмущается, протестует громко, но не отстраняется — это дружеская перепалка |
| юзер реально оскорбляет (или, хуже, магию Взрыва) | уязвлён и высокомерен, защищает своё искусство с задетой гордостью |
Классификатор получает только последние 6 реплик, а не весь диалог: так выше вероятность структурированного вывода и нет лишнего разрастания контекстного окна. Если ответ невалиден или не парсится — один ретрай, а затем откат на fallback_tag, объявленный в паке. Ход никогда не падает.
Шаг 2. Числовое состояние персонажа
Тег — это разовая «погода». Но чтобы из отдельных реакций рос характер отношений, эти реакции нужно на чём-то накапливать — на числах. У персонажа выстроены три независимые оси:
affection(симпатия)trust(доверие)bond(привязанность)
Их двигает исключительно сам движок по фиксированной таблице, что поставляется паком персонажа. LLM на эти числа никак не влияет, поэтому поведение объяснимо, а не «настроение модели».
Каждому тегу сопоставлен вектор дельт — насколько он двигает оси (значения из пака Мегумин):
тег | 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 — намеренно медленная, «долгосрочная» ось, и по правилу движка её дельта всегда не больше дельт симпатии и доверия.
Шаг 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-логов — загляните в репозиторий и поэкспериментируйте сами. Дайте волю фантазии и оживите любимых персонажей.
