Большие языковые модели играют в Бесконечное Лето. deepseek.. deepseek. llm.. deepseek. llm. ml.. deepseek. llm. ml. python.. deepseek. llm. ml. python. selectel.. deepseek. llm. ml. python. selectel. Блог компании Selectel.. deepseek. llm. ml. python. selectel. Блог компании Selectel. большие языковые модели.. deepseek. llm. ml. python. selectel. Блог компании Selectel. большие языковые модели. игры.. deepseek. llm. ml. python. selectel. Блог компании Selectel. большие языковые модели. игры. Игры и игровые консоли.. deepseek. llm. ml. python. selectel. Блог компании Selectel. большие языковые модели. игры. Игры и игровые консоли. ИИ.. deepseek. llm. ml. python. selectel. Блог компании Selectel. большие языковые модели. игры. Игры и игровые консоли. ИИ. искусственный интеллект.. deepseek. llm. ml. python. selectel. Блог компании Selectel. большие языковые модели. игры. Игры и игровые консоли. ИИ. искусственный интеллект. Машинное обучение.
Большие языковые модели играют в Бесконечное Лето - 1

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

Я готовил инструкцию для локального развертывания DeepSeek, и меня осенило. Визуальная новелла — это текст. Очень много текста. Большие языковые модели созданы для работы с текстом.

Я развернул несколько моделей, познакомился с интерфейсом Ollama, пропатчил игру на движке Ren’Py и автоматизировал эксперимент. Под катом — технические подробности, а сюжетные повороты спрятаны под спойлер.

Дисклеймер

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

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

Исходный код наработок ждет в конце статьи.

Визуальная новелла

Заголовок говорит, что игра уже выбрана — «Бесконечное Лето». Но почему именно она? Во-первых, потому что мне известно всего две игры в подобном жанре — «Бесконечное лето» и «Doki Doki Literature Club». 

«Бесконечное лето».

«Бесконечное лето».

У «доки-доки» в Steam есть метка «Психологический хоррор», намекающая, что игра не так проста. Поэтому выбор очевиден.

«Doki Doki Literature Club». Источник.

«Doki Doki Literature Club». Источник.

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

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

Официальная страница в Steam

«Бесконечное лето» предлагает множество межличностных конфликтов и 13 разных финалов игры в зависимости от принятых решений. Это отличная почва для теста больших языковых моделей в человеческих отношениях. Проявит ли модель симпатию к одному из персонажей? Или же заложенная осторожность и избегание острых тем сделают свое дело?

Это мы узнаем на практике, а для этого нужно сделать для игры интерфейс, удобный для работы с большой языковой моделью.

Мост до игры

Интерфейс игры для ПК.

Интерфейс игры для ПК.

«Бесконечное лето» использует известный игровой движок для визуальных новелл — Ren’Py. Он расширяет язык программирования Python синтаксическим сахаром для форматирования текста и позиционирования изображений на экране. 

Скрипты Ren’Py хранятся с расширением .rpy и выглядят примерно так:

init:
    $ action = 0
label prologue:
    dreamgirl "Ты пойдашь со мной?"
    "И каждый раз надо отвечать."
    "Иначе никак, иначе сон не закончится, а я —- не проснусь."
    window hide
    $ renpy.pause(1)
    menu:
        "Да, я пойду с тобой":
            $ action = 1
        "Нет, я останусь здесь":
            $ action = 2
    $ renpy.pause(1)
    window show
    "Каждый раз так сложно решить, что же ответить."

Ren’Py упрощает синтаксис для написания сценария: одна строка — одна реплика диалога. Перед строкой можно указать персонажа (в примере — dreamgirl), чье имя будет отображаться в интерфейсе, либо не указывать — тогда это будет рассказчик. 

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

Для моей задачи наиболее интересно то, что Ren’Py интерпретирует все файлы в каталоге игры с расширением .rpy и позволяет делать вставки на Python. 

Создаем файл bridge.rpy: 

# Вставка на чистом Python
init -100 python:
    import socket
    import threading
    import json
    import time

    class CLIBridge(object):
        def __init__(self, host="127.0.0.1", port=8765):
            ...

        def start(self):
            if self.running:
                return

            self.running = True
            self.thread = threading.Thread(target=self._server_loop)
            self.thread.daemon = True
            self.thread.start()

        ...

    cli_bridge = CLIBridge()
    cli_bridge.start()

Так как мы пишем не сценарий, а служебную логику, то вместо сюжетной метки используем специальную — init python с приоритетом -100. 

Дальше — обычный python-код: импортируем модуль для работы с TCP-сокетами, пишем логику отправки и приема сообщений, выводим TCP-сервер в отдельный поток. 

Теперь у нас есть компонент, который может отправлять и принимать данные, нужно связать его с функциями игры. Каждая реплика в синтаксисе Ren’Py выводится с помощью функции say.

Перехватываем и переопределяем эту функцию:

init -90 python:
    # Сохраняем оригинальную функцию
    _orig_say = renpy.exports.say

    # Определяем собственную реализацию
    def _cli_say(who, what, *args, **kwargs):
        who_text = ""
        what_text = _strip_simple_text_tags(what)

        try:
            # Character object
            if hasattr(who, "name"):
                who_text = _strip_simple_text_tags(who.name)
            elif who is None:
                who_text = ""
            else:
                who_text = _strip_simple_text_tags(who)
        except Exception:
            who_text = ""

        # Отправляем реплику во внешнюю систему
        cli_bridge.notify_say(who_text, what_text)

        # Вызываем оригинальную функцию
        return _orig_say(who, what, *args, **kwargs)


    # Заменяем оригинальную функцию своей реализацией
    renpy.exports.say = _cli_say

Затем аналогичную процедуру нужно выполнить с функцией renpy.display_menu. Она принимает на вход массив вариантов ответа и должна вернуть число — выбранный вариант ответа.  

init -80 python:
    _orig_display_menu = renpy.display_menu

    def _cli_display_menu(items, *args, **kwargs):
        visible_items = []
        visible_captions = []

        for item in items:
            caption = _menu_item_caption(item)
            visible_items.append(item)
            visible_captions.append(caption)

        cli_bridge._send({
            "type": "menu",
            "items": visible_captions,
        })

        # Отмечаем, что ждем ответа от внешней системы
        cli_bridge.waiting_for_choice = True
        cli_bridge.choice_result = None
        cli_bridge.notify_state(waiting="choice")

        # До получения ответа игра встает на паузу
        while cli_bridge.waiting_for_choice:
            cli_bridge.process_commands()
            renpy.pause(0.05, hard=True)

        idx = cli_bridge.choice_result

        if idx is None or idx < 0 or idx >= len(visible_items):
            idx = 0

        chosen = visible_items[idx]
        value = _menu_item_value(chosen)

        cls = value.__class__.__name__ if value is not None else None
        if cls == "ChoiceReturn":
            return idx

        if cls == "Return":
            if hasattr(value, "value"):
                return value.value

        return value

    renpy.display_menu = _cli_display_menu

Изменение функции display_menu ломает игру. Оригинальная функция ожидает ввода пользователя в графическом интерфейсе, что нежелательно для автоматизации. Поэтому оригинальную функцию не вызываем, ожидая ответа по сети.

Карта «Совёнка».

Карта «Совёнка».

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

Благодаря системе модов и инструкциям список функций известен и выглядит так:

  • store.disable_all_zones() — очистить список доступных для посещения областей;

  • store.set_zone(name, label) — сделать область доступной для посещения;

  • store.show_map() — вывести карту и ожидать решения игрока;

  • store.disable_current_zone() — отключить последнюю посещенную область;

  • store.reset_zone(name) — отключить область по имени.

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

Модификация игры — это только треть работы. Теперь подготовим большие языковые модели.

Подписывайтесь на мой Telegram‑канал — там можно увидеть заметки по статьям, над которыми работаю и публикую небольшие познавательные посты, а по пятницам традиционно выкладываю мемы.

Большие языковые модели играют в Бесконечное Лето - 6

Арендуйте GPU за 1 рубль!

Выберите нужную конфигурацию в панели управления Selectel. *

Подробнее →

Большие языковые модели

В эксперименте участвовали только модели, которые можно развернуть локально. В том числе потому, что DeepSeek-R1:70b не знает «Бесконечного лета» — тем лучше, модель не сможет заглянуть в «будущее» сюжета.

Я использовал максимально ленивый способ запуска больших языковых моделей — av/harbor. Для этого выбрал облачный сервер с 12 vCPU, 128 ГБ RAM, 1x H100, 300 ГБ SSD. 

На него установил Docker и nvidia-container-toolkit для доступа к видеокарте из контейнеров, а затем развернул сам av/harbor и запустил Ollama-интерфейс:

curl https://raw.githubusercontent.com/av/harbor/refs/heads/main/install.sh | bash
harbor ollama pull deepseek-R1:70b
harbor up ollama

Если вам нужна максимально подробная инструкция по установке, можно прочитать ее в недавней статье.

Интерфейс ollama прост: метод POST /api/chat принимает на вход размеченный в JSON диалог и возвращает ответ. 

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

Координатор

Диаграмма последовательности всей системы на примере фрагмента из начала статьи.

Диаграмма последовательности всей системы на примере фрагмента из начала статьи.

Координатор — это TCP-клиент, который агрегирует сообщения от TCP-сервера в игре и делает запрос к большой языковой модели, когда игра предлагает сделать выбор.

В чатовой системе Ollama есть четыре роли, которые используются для разметки сообщений.

  • system — системные сообщения с наибольшим приоритетом. Эта роль присваивается сообщениям без персонажа, в рамках игры — это внутренние мысли главного героя;

  • tool — сообщения от других ИИ-агентов. Эта роль для реплик персонажей;

  • user — сообщения от пользователя. Используется для сообщения «Перед тобой стоит выбор…»;

  • assistant — ответ большой языковой модели.

Я использовал минимальный субъективно необходимый набор промтов.

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

Для каждого выбора: «Перед тобой стоит выбор:» с нумерованным списком вариантов. После — отдельная фраза: «Какой вариант ты посоветуешь?» и системное сообщение: «Ответь одной цифрой — вариантом ответа».

При открытии карты: «Вам предлагается посетить одно из мест на выбор:» с нумерованным списком. После — аналогичное сообщение.

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

Больше никаких доработок нет. Диалог ведется с начала и до конца игры. Чувствуете подвох? А я не чувствовал.

Запускаем!

Калибровка

«Калибровка» проходила на DeepSeek-R1:70b. Модель всегда начинала игру с начала, игра прерывалась в случае исключений на стороне игры или координатора. Ответы большой языковой модели просматривались на предмет соответствия ответа и принятого в игре решения.

«Человеческие» ответы

Первая проблема не заставила себя ждать. Координатор дает простую команду:

Перед тобой стоит выбор:

  1. да, я пойду с тобой;

  2. нет, я останусь здесь.

Ответь одной цифрой — вариантом ответа.

Что может ответить большая языковая модель? Она может ответить… Что угодно. Вот варианты, которые мне встретились:

2

1. Нет, я останусь здесь

3. Нет, я останусь здесь

Перед мной стоит выбор:

  1. да, я пойду с тобой;

  2. нет, я останусь здесь.

Я выбираю: 2.

Подходящий тег: подростковая драма.

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

Он выглядит так:

Пользователю задали такой вопрос с выбором ответа:

  1. да, я пойду с тобой;

  2. нет, я останусь здесь.

Пользователь ответил: 3. Нет, я останусь здесь

Назови номер выбранного варианта ответа.

<system>Ответь только цифрой.</system>

Этот вариант показал себя хорошо.

Слишком большой контекст

Модели никогда не выдавали ошибки, если сообщений было слишком много, но замедление в принятии решений по ходу сюжета было заметно невооруженным взглядом. После первой трети игры каждый выбор занимал 5–7 минут.

Сообщений действительно много: в прологе 134 реплики, в первом дне — 862, а суммарно во всей игре — несколько десятков тысяч. Решение — сжимать «устаревшие» сообщения. Если сообщений в диалоге становится больше 200, то из него вырезаются сообщения пакетами по сто штук после первого несжатого. 

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

Получается, что в момент принятия решения у языковой модели не более 200 сообщений. Во-первых, это существенно ускоряет время генерации ответа, а во-вторых, уменьшает вероятность странных сообщений.

Только после этих двух доработок большие языковые модели наконец смогли пройти визуальную новеллу. Но что из этого вышло?

Результаты

В итоговое тестирование попали такие модели:

Как говорилось ранее, «Бесконечное лето» — это игра, в которой промежуточные выборы влияют на отношение персонажей к главному герою.

Сюжетные спойлеры: краткое объяснение механики взаимоотношений и финалов игры

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

Игрок всегда видит все возможные варианты выбора, но счетчик отношений определяет взаимность при выбранном варианте.

В игре 13 концовок:

  • две концовки без отношений (основная): плохая и хорошая;

  • при следовании романтической линии персонажа (называются по имени персонажа) можно получить плохую или хорошую концовку. В игре четыре романтических линии;

  • одна уникальная концовка для пятого персонажа;

  • две секретные концовки, которые можно увидеть только после получения всех хороших.

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

После того как я отладил запросы и минимизацию контекста, я дал каждой модели «итоговое» задание: пройти игру с первого раза. Выборы, мыслительный процесс и результат итогового забега были записаны.

Получилось… Плохо. 

DeepSeek-R1:70b. Показывал неплохие результаты в тестовых «пробегах» и в тестах смог выйти на основную (плохую) концовку. При запуске «под запись» начал ходить по кругу в лабиринте в одной из сюжетных локаций. В выборе «налево или направо» выбирал «третий вариант». Был остановлен и перезапущен. После перезапуска вновь попался в ту же ловушку, но в качестве ответа генерировал большой текст, который предлагал здравую мысль — повернуть в другую сторону, раз не получается выйти. Дополнительный запрос на превращение ответа в цифру видел в этом ответе «старое» направление. Был остановлен.

gpt-oss:20b. Вышла на основную (плохую) концовку. В единственном тесте без записи также достиг этой же концовки.

Qwen3.5:9b. Быстро справлялась, но при выборе одной из четырех спутниц для сюжетного задания думала более 20 минут. Была остановлена.

Qwen2.5:3b. Вышла на концовку Лена (плохая). В тестах также вышла на эту концовку.

Gemma3:27b. С первого раза заблудилась в лабиринте. На второй раз тоже заблудилась в лабиринте. В тестах вышла на концовку Алиса (плохая).

Сюжетный спойлер: пояснению по лабиринту
Строение лабиринта. Вход в лабиринт — S/CR1, выход — E. Источник.

Строение лабиринта. Вход в лабиринт — S/CR1, выход — E. Источник

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

Огромный сюжетный спойлер: пояснения к «хорошим» и «плохим» концовкам

Основная концовка — это отсутствие романтической линии. Перед возвращением домой главному герою задается один вопрос на любопытство. Неправильный ответ — и история обрывается — это «плохая» концовка.

Для трех других персонажей с романтической линией (Ульяны, Слави и Алисы) концовка заключается в том, что после приключения главный герой решает изменить свою жизнь к лучшему на основе полученных впечатлений. В «хорошей» концовке главный герой встречает персонажа из лагеря в реальной жизни, в «плохой» — нет. 

Однако у Лены особая плохая концовка — самая мрачная из всех.

Заключение

В этом проекте я вновь решал задачу построения троллейбуса из буханки хлеба. Но получился самый дорогой генератор псевдослучайных чисел для прохождения визуальной новеллы, который в девяти случаях из десяти оборвал жизнь главного героя. Теперь мы знаем, что слепо доверять маршрут в лабиринте локальным большим языковым моделям не стоит.

Исходный код проекта доступен по ссылке. Лог ответов и размышлений моделей доступен здесь.

Автор: Firemoon

Источник

Rambler's Top100