Управление персонажем в RPG с помощью языковой модели

Прокомментировать Просмотры: 11

Взаимодействие потоков во времени

Ранее я рассказывал о разработке собственной RPG. Уже тогда в комментариях мне советовали использовать LLM для генерации квестов, но к этой затее я отнёсся скептически. С тех пор технологии шагнули вперёд: появился бесплатный доступ к API языковых моделей, например Google Gemini. Почему бы теперь не поэкспериментировать с LLM? Тем более что в дополнение к браузерному клиенту у меня был текстовый клиент на Python, который можно приспособить для формирования промптов.

Сразу обозначу отличие моего проекта от других работ, появившихся на SE7ENе, и модификаций вроде Herika (Skyrim GPT). В большинстве таких проектов языковая модель отвечает только за общение с NPC. У меня же её основная задача — совершать действия, а диалог служит приятным дополнением. На первый взгляд разница невелика: какая разница, спросить нейросеть «что бы ты сказал?» или «что бы ты сделал?». Но на практике она принципиальна: сказать можно что угодно, а вот сделать — далеко не всё.

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

Общая схема

Игра сетевая: обычно участники подключаются к серверу через браузерный клиент на JavaScript. В отличие от проектов наподобие NetHackers, среды minigrid для обучения с подкреплением или, скажем, последнего теста на AGI, онлайн-игра работает асинхронно. Сервер не ожидает команды игрока: пока вы бездействуете, мимо могут пробежать другие персонажи или гоблины — последние к тому же способны и навалять. Из-за асинхронности и не самой высокой скорости ответа LLM приходится использовать потоки. Нужно как минимум три:

  1. основной поток, который получает строки от сервера и преобразует их в структурированные данные;

  2. поток для отправки запросов в LLM и ожидания ответов;

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

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

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

Лимиты

С ограничениями я столкнулся почти сразу. В тарифе указаны привычные лимиты: количество запросов в минуту (RPM), запросов в день (RPD) и токенов в минуту (TPM). Все они рассчитываются по скользящему окну, а не сбрасываются каждую минуту или раз в сутки. Чтобы не превышать минутную норму запросов, я разделил 60 на значение лимита. Если ответ LLM приходит раньше, поток приостанавливается на оставшееся время. На бесплатном тарифе для lite-моделей разрешён один запрос раз в четыре секунды. Для комфортной игры я установил период дискретизации сервера в 0,45 с, поэтому в лучшем случае LLM успевает обработать лишь каждый десятый игровой шаг. С суточным ограничением ничего не поделать: после 500 запросов приходится переключать модель. Сам Gemini предложил создать несколько аккаунтов, но я не стал этим заниматься, тем более что обращения с одного IP легко обнаружить. Токенов я расходую сравнительно мало, поэтому до соответствующего лимита далеко. Есть, однако, и ещё одно ограничение: API Gemini позволяет передавать идентификатор ответа в следующий запрос, продолжая диалог, но длина такой цепочки тоже ограничена. При её превышении возникает ошибка «Conversation ancestor chain exceeds maximum validation depth.» Возможно, предел не фиксирован и зависит от заполнения контекстного окна. По моим наблюдениям, он достигается примерно за 100 итераций.

Генерация промптов

При запуске потока для работы с LLM я сразу отправляю статический запрос с общими правилами игры: какие в ней есть предметы, какие действия доступны, в каком формате следует отвечать и так далее. Я назвал его MASTER-PROMPT. Часть текста написана вручную, а часть подставляется из данных, созданных системой крафта: это предметы, рецепты, здания и прочее. Не тратить же время на самостоятельный поиск рецептов? Когда модель отвечает, мы берём идентификатор ответа и передаём его в следующем запросе, как показано ниже.

Устройство потока для взаимодействия с LLM
Устройство потока для взаимодействия с LLM

Современные API языковых моделей позволяют задавать схему ответа; в Gemini она называется response_format. По сути, это требование вернуть корректный JSON с заданными полями, некоторые из которых можно сделать обязательными. Для полей также задаются типы данных и дополнительные свойства. Такой формат значительно упрощает разбор ответа и избавляет от длинных ненужных текстов, а возможно, и от лишнего расхода токенов. Я испытал два способа получения действия персонажа от LLM:

  1. Для каждого возможного действия создаю числовое поле от 0 до 1 и в MASTER-PROMPT прошу указать вероятность его выбора. При разборе ответа просматриваю все числовые поля и выбираю действие с наибольшим значением.

  2. Создаю текстовое поле «action» и перечисляю допустимые варианты через enum. Модель выбирает один из них.

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

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

Память

Для полноценной работы таким «агентам», разумеется, нужна память. Я добавил «ячейковую память» для важных объектов и их координат, «событийную память» и память о предыдущих действиях. Модель приходится регулярно проверять: она вполне способна заявить, что «кладёт медь, олово, уголь и литейную форму» в плавильню, хотя половины перечисленного у неё нет. Поэтому появилась и «память о дефиците»: если при разборе списка предметов обнаруживается несоответствие, недостающий предмет записывается в неё. Затем можно попытаться отфильтровать действия, которые противоречат сведениям о дефиците. Например, если у персонажа нет угля, вряд ли стоит пытаться разжечь печь или загрузить в неё ингредиенты. Иными словами, если предыдущая попытка уже показала отсутствие угля, повторять её до добычи ресурса бессмысленно.

Пример сгенерированного промпта

You stay at coordinates [0, 0] and facing East. You are staying, you HP = 50. Your inventory has changed. It is now as follows: bone(x133), stone(x298, equipped), piece of copper ore(x4), flint stone(x14), bone knife(x30), brick(x5), berry(x3), unfired brick(x16), unfired bowl(x1). You will need the following materials and tools to achieve your aim: clay (2x), coal (3x), tin_ore (1x), handle (1x), flammable (12x), fuel (9x). Pay attention to the places where they can be found or purchased. It probably doesn’t make sense to build anything until all ingredients are found. You need to produce or buy these things to achieve your aim: bronze_pickaxe_head[1], copper[2], tin[1], pickaxe_mold[1], brick[10], bowl[1]. You see: clay wall at[2, 1], wall at[2, 2], wall at[4, 0], clay wall at[4, -2], damaged granite wall at[3, 4], fire furnace at[2, 0], stone-like objects on the floor at[2, -1], stone-like objects on the floor at[1, 2], stone-like objects on the floor at[3, 0], stone-like objects on the floor at[3, -1], stone-like objects on the floor at[2, 3], stone-like objects on the floor at[3, -3], stone-like objects on the floor at[3, 4] and free cells at [2, -1], [1, -1], [1, 2], [3, 0], [3, -1], [3, -2], [2, -2], [2, 3], [4, -1], [4, -3], [3, -3], [5, -2], [0, 0], [0, 1], [1, 0], [1, 1]. The clay wall is in front of you. This could get you the resources you need. Mining the wall at coordinates [2, 2] may yield the resources you need. Mining the wall at coordinates [4, 0] may yield the resources you need. Mining the wall at coordinates [4, -2] may yield the resources you need. The fire furnace is in front of you. You can craft: bone_knife, bone_awl. Negative experience with the furnace: you put in 15 unfired bricks and 1 coal, ignite it, and the bricks remained unfired. Positive experience with the furnace: you put in 3 unfired bricks 1 coal and 1 kindling, ignite it, and the bricks became fired. Recent events: timestep 291: the furnace fired up at [(2, 0)]; timestep 295: the furnace cooled down at [(2, 0)]; timestep 316: You cancel exchange/trade, there are no useful items to take or to give, don’t trade/exchange with the same person/object; timestep 332: the furnace fired up at [(2, 0)]; timestep 336: the furnace cooled down at [(2, 0)]; timestep 358: You cancel exchange/trade, there are no useful items to take or to give, don’t trade/exchange with the same person/object; timestep 374: the furnace fired up at [(2, 0)]; timestep 378: the furnace cooled down at [(2, 0)]; timestep 400: You cancel exchange/trade, there are no useful items to take or to give, don’t trade/exchange with the same person/object; timestep 417: the furnace fired up at [(2, 0)]. Your previous actions were: use_flint_stone, mine, cancel, use_flint_stone, mine, cancel, use_flint_stone, mine, cancel, use_flint_stone. Your primary objective right now is to obtain piece of coal, bundle of kindling. Resolve this before moving on to the main goal. Your last aim was «Obtain piece of coal and bundle of kindling», set 240 steps ago.

Результаты

В целом система кое-как работает: персонаж под управлением LLM перемещается, собирает предметы, пытается выплавлять металлы из руды — иногда успешно — и возводит здания. Однажды ему даже удалось построить плавильню, загрузить нужные предметы и отлить наконечник кирки. Это один из самых сложных рецептов в нынешней системе крафта. Правда, перед этим персонаж долго блуждал и нашёл множество полезных вещей, оставшихся от погибшего дварфа, так что немало ингредиентов досталось ему бесплатно.

При этом назвать поведение агента разумным никак не получается. Он часто зацикливается: то снова и снова создаёт не особенно нужный предмет, то вращается на месте, то застревает в обменах с объектами. С координатным описанием он тоже справляется плохо и проходит мимо полезных ресурсов, даже когда в промпте прямо сказано: «справа стена с нужными минералами». Может пройти мимо, даже не повернувшись.

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

Небольшой пример движения в виде гифки

Торговля

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

Мой агент несколько раз пытался заключить сделку с дварфами, но те обычно отказывались принимать предлагаемый им хлам. Тем не менее однажды обмен состоялся, хотя и оказался невыгодным для агента: всё имеющееся олово он отдал за четыре порции медной руды, один уголь и две порции глины. Иными словами, редкий ресурс был обменян на значительно более распространённые.

Поля JSON-ответа, отвечающие за обмен
"give_list": "7 piece of tin",
"take_list": "4 piece of copper ore; 1 piece of coal; 2 piece of clay",

Заключение

  • Самая очевидная и одновременно самая серьёзная проблема — скорость. Если бы обработка запроса занимала полсекунды, всё можно было бы организовать синхронно и, главное, отслеживать события последовательно, без пропусков. Возможно, стоит попробовать запустить небольшую локальную LLM.

  • Как и ожидалось, с пространственным представлением у LLM дела обстоят неважно: передача топографических данных в виде списка координат работает плохо.

  • Можно добавить память диалогов и понаблюдать за общением агентов, но лимитов Gemini для этого наверняка не хватит — придётся искать альтернативу.

  • Пока складывается впечатление, что LLM можно доверить формирование высокоуровневых команд и реплик, а их исполнение поручить более эффективным методам. Например, модель способна решить, к какому объекту нужно подойти, но поиск пути лучше оставить другому алгоритму.

 

Источник

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов