AI Dungeon Master на Python: движок с JSON-состоянием и LLM | Гайд 2026 | AiManual
AiManual Logo Ai / Manual.
08 Мар 2026 Гайд

Создаём AI Dungeon Master на Python: разбор open-source движка для игр с управлением состоянием через JSON

Пошаговый разбор open-source движка на Python для создания игр с AI Dungeon Master. Управление состоянием через JSON, интеграция с Ollama и OpenAI API, многопот

Зачем вообще нужен свой Dungeon Master?

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

На 08.03.2026 модели стали умнее, но проблема контекста никуда не делась. Новейшие версии, вроде GPT-4.5-Turbo или открытых аналогов, хоть и поддерживают 128K токенов, но все равно дороги и медленны для реального времени. Значит, умное управление состоянием – наше все.

Корень проблемы: LLM не помнит, кто она и что происходит

Дашь модели чистый промпт – она начнет историю заново. Кинешь всю переписку – упрется в лимит токенов или начнет глючить. Нужен буфер. Нужна память. И самое главное – нужна единая точка истины для состояния игры. Все, от инвентаря персонажа до погоды в мире, должно жить не в промптах, а в структурированном хранилище. Отсюда и выросла идея движка, где ядро – это JSON-объект, который вертится между логикой игры, интерфейсом и LLM.

Решение: движок-посредник с JSON в сердце

Архитектура проста до безобразия. Есть класс DungeonMaster. В нем три главных компонента: State Manager (работает с JSON-состоянием), LLM Client (отправляет запросы к API) и Memory Loop (сжимает историю диалога). Интерфейс на Pygame рисует состояние и ловит действия игрока. Вся магия в том, что движок не завязан на конкретного провайдера LLM. Поддерживается любой OpenAI-совместимый API, будь то облачный сервис или локальный Ollama с моделью Llama 3.2 или новейшей DeepSeek-R1. Это дает свободу и экономит деньги.

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

1 Настраиваем окружение и задумываемся о структуре JSON

Создаем виртуальное окружение и ставим актуальные на 08.03.2026 библиотеки. openai версии 1.30.0+, ollama 0.5.0+, pygame 2.6.0+. Проект будет работать и на старых версиях, но зачем? Новые версии часто исправляют критические баги с потоковым выводом.

python -m venv venv
source venv/bin/activate  # или venv\\Scripts\\activate.bat на Windows
pip install openai ollama pygame httpx

Теперь самое важное – дизайн JSON-состояния. Он должен быть гибким, но не раздутым. Пример скелета:

{
  "world": {
    "name": "Эриндар",
    "time": "полдень",
    "weather": "ясно",
    "current_location": "Таверна 'Ржавый котёл'",
    "locations_visited": ["Стартовый лес", "Деревня Бри"],
    "active_quests": ["Найти пропавшего кузнеца"]
  },
  "player": {
    "name": "Аэлин",
    "race": "эльф",
    "class": "следопыт",
    "health": 85,
    "max_health": 100,
    "inventory": ["меч", "3 золотые монеты", "карта"],
    "abilities": ["Следопытство", "Маскировка"]
  },
  "npcs": {
    "tavern_keeper": {
      "name": "Барни",
      "disposition": "дружелюбный",
      "known_info": ["слышал о банде гоблинов на востоке"]
    }
  },
  "history": [
    "Вы вошли в таверну. Бармен кивает вам.",
    "Вы спросили о пропавшем кузнеце. Бармен почесал бороду."
  ],
  "last_actions": ["игрок вошел в таверну", "игрок спросил о кузнеце"],
  "summary": "Игрок только что прибыл в таверну и начал расследование."
}

Поле summary – ключевое. Это сжатое описание последних событий, которое будет вставляться в промпт вместо полной истории. Его будет обновлять наш цикл памяти.

2 Пишем ядро: класс DungeonMaster с провайдер-агностик клиентом

Класс должен уметь работать с разными LLM. Создаем базовый клиент, который определяет, локальная это модель через Ollama или облачная через OpenAI. На 08.03.2026 многие облачные API (Anthropic, Google Gemini) поддерживают OpenAI-совместимый формат, так что это универсальный подход.

import json
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
import httpx

class LLMClient(ABC):
    """Абстрактный клиент для работы с LLM."""
    @abstractmethod
    async def generate(self, prompt: str, system_prompt: str = "") -> str:
        pass

class OpenAIClient(LLMClient):
    def __init__(self, api_key: str, base_url: str = "https://api.openai.com/v1", model: str = "gpt-4.5-turbo"):
        # Используем новейшую доступную модель на 08.03.2026
        self.client = httpx.AsyncClient(headers={"Authorization": f"Bearer {api_key}"})
        self.base_url = base_url
        self.model = model

    async def generate(self, prompt: str, system_prompt: str = "") -> str:
        messages = []
        if system_prompt:
            messages.append({"role": "system", "content": system_prompt})
        messages.append({"role": "user", "content": prompt})

        try:
            response = await self.client.post(
                f"{self.base_url}/chat/completions",
                json={
                    "model": self.model,
                    "messages": messages,
                    "temperature": 0.8,
                    "max_tokens": 500,
                },
                timeout=30.0
            )
            response.raise_for_status()
            data = response.json()
            return data["choices"][0]["message"]["content"]
        except Exception as e:
            return f"Ошибка API: {e}"

class OllamaClient(LLMClient):
    def __init__(self, base_url: str = "http://localhost:11434", model: str = "llama3.2:latest"):
        self.client = httpx.AsyncClient()
        self.base_url = base_url
        self.model = model  # На 08.03.2026 актуальны llama3.2, deepseek-r1:latest, command-r-plus

    async def generate(self, prompt: str, system_prompt: str = "") -> str:
        try:
            payload = {
                "model": self.model,
                "prompt": prompt,
                "system": system_prompt,
                "stream": False,
                "options": {"temperature": 0.8, "num_predict": 500}
            }
            response = await self.client.post(f"{self.base_url}/api/generate", json=payload, timeout=60.0)
            response.raise_for_status()
            data = response.json()
            return data.get("response", "Нет ответа")
        except Exception as e:
            return f"Ошибка Ollama: {e}"

Зачем абстракция? Потому что завтра вы захотите перейти с платного GPT на бесплатную локальную модель. И не захотите переписывать половину кода. (Совет: если боитесь асинхронности, можно использовать потоки, но об этом позже).

3 Собираем State Manager и Memory Loop – мозги системы

State Manager загружает, сохраняет и обновляет JSON. Memory Loop – это отдельная функция, которая раз в N ходов сжимает историю. Не делайте сжатие после каждого действия – это дорого. Лучше накапливать 10-15 сообщений, затем отправлять их модели с инструкцией "создай краткое резюме последних событий". Это резюме потом кладется в поле summary, а старая история частично очищается.

class StateManager:
    def __init__(self, filepath: str = "game_state.json"):
        self.filepath = filepath
        self.state = self._load_state()

    def _load_state(self) -> Dict[str, Any]:
        try:
            with open(self.filepath, 'r', encoding='utf-8') as f:
                return json.load(f)
        except FileNotFoundError:
            # Возвращаем состояние по умолчанию
            return {
                "world": {}, "player": {}, "npcs": {},
                "history": [], "last_actions": [], "summary": ""
            }

    def save_state(self):
        with open(self.filepath, 'w', encoding='utf-8') as f:
            json.dump(self.state, f, ensure_ascii=False, indent=2)

    def update_state(self, new_data: Dict[str, Any]):
        # Рекурсивное обновление словаря, чтобы не затереть несвязанные поля
        def deep_update(target, source):
            for key, value in source.items():
                if key in target and isinstance(target[key], dict) and isinstance(value, dict):
                    deep_update(target[key], value)
                else:
                    target[key] = value
        deep_update(self.state, new_data)
        self.save_state()

class MemoryLoop:
    def __init__(self, llm_client: LLMClient, max_history_length: int = 10):
        self.llm_client = llm_client
        self.max_history = max_history_length

    async def summarize_history(self, history: list) -> str:
        if len(history) < 5:
            return " "  # Не суммируем слишком короткую историю

        prompt = "Создай очень краткое резюме следующих событий (максимум 3 предложения):\n"
        for event in history[-self.max_history:]:
            prompt += f"- {event}\n"
        prompt += "\nРезюме:"

        summary = await self.llm_client.generate(prompt, system_prompt="Ты помощник, который создает сжатые резюме.")
        return summary.strip()
💡
Для более сложного управления внутренним диалогом персонажей и ускорения LLM посмотрите технику использования грамматик для оживления RPG-персонажей. Это может сильно улучшить качество ответов.

4 Соединяем всё в главном классе и добавляем многопоточность

Pygame работает в главном потоке. Если мы будем ждать ответа от LLM в этом же потоке, интерфейс зависнет. Решение – вызов LLM в отдельном потоке или асинхронной задаче. Я предпочитаю asyncio с asyncio.run_in_executor для совместимости с Pygame, который не асинхронный от природы. Это выглядит страшновато, но работает.

import asyncio
from concurrent.futures import ThreadPoolExecutor

class DungeonMaster:
    def __init__(self, llm_client: LLMClient, state_file: str = "game_state.json"):
        self.llm_client = llm_client
        self.state_manager = StateManager(state_file)
        self.memory_loop = MemoryLoop(llm_client)
        self.executor = ThreadPoolExecutor(max_workers=2)
        self.loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self.loop)

    def generate_response_sync(self, player_action: str) -> str:
        """Блокирующий метод для использования из Pygame."""
        # Запускаем асинхронную задачу в отдельном потоке
        future = asyncio.run_coroutine_threadsafe(self._generate_response(player_action), self.loop)
        return future.result(timeout=45)  # Ждем ответа до 45 секунд

    async def _generate_response(self, player_action: str) -> str:
        # 1. Обновляем состояние: добавляем действие в историю
        self.state_manager.update_state({
            "last_actions": [player_action],
            "history": self.state_manager.state.get("history", []) + [f"Игрок: {player_action}"]
        })

        # 2. Формируем промпт с актуальным состоянием
        state = self.state_manager.state
        prompt = f"""
Ты мастер игры в фэнтезийном мире. Вот текущее состояние:
Мир: {state['world']}
Игрок: {state['player']}
Последние события: {state.get('summary', 'Нет данных')}

Последнее действие игрока: {player_action}

Опиши, что происходит дальше, в 2-3 предложениях. Будь креативным, но учитывай контекст.
"""

        system_prompt = "Ты креативный и внимательный мастер настольной RPG. Ты следишь за логикой мира и персонажей."

        # 3. Запрос к LLM
        response = await self.llm_client.generate(prompt, system_prompt)

        # 4. Обновляем историю ответом мастера
        self.state_manager.update_state({
            "history": state.get("history", []) + [f"Мастер: {response}"]
        })

        # 5. Проверяем, не пора ли сжать историю
        if len(state.get("history", [])) > 12:
            summary = await self.memory_loop.summarize_history(state["history"])
            if summary:
                self.state_manager.update_state({
                    "summary": summary,
                    "history": state["history"][-6:]  # Оставляем только последние 6 реплик
                })

        return response

Теперь главный цикл Pygame может вызывать dm.generate_response_sync(action), не блокируя отрисовку. Да, это костыль с двумя циклами событий, но что поделать – таковы реалии.

Где всё ломается: нюансы и типичные ошибки

  • Токены убегают. Даже с циклом памяти нужно жестко ограничивать длину промпта. Всегда считайте приблизительное количество токенов в промпте (можно использовать библиотеку tiktoken для OpenAI или эмпирически – 1 токен ≈ 4 символа для английского).
  • LLM игнорирует состояние. Модель может прочитать ваш JSON и сделать вид, что его нет. Виной слабый промпт. Нужно явно указывать: "Вот текущее состояние: ... УЧТИ ЭТО В СВОЕМ ОТВЕТЕ". Используйте грамматики, как в нашем руководстве, чтобы форсировать структуру ответа.
  • Ollama падает под нагрузкой. Локальные модели требуют оперативной памяти. На 08.03.2026 для более-менее качественной игры нужна хотя бы модель на 7-13 миллиардов параметров (например, Llama 3.2 11B) и 16+ ГБ ОЗУ. Иначе генерация будет медленной, а игра – прерывистой.
  • JSON-состояние становится монстром. Не храните в состоянии всё подряд. Выделите только изменяемые данные. Статические описания локаций держите в отдельных файлах.
  • Асинхронность vs потоки. Если вы не готовы разбираться с asyncio, используйте простые потоки (threading.Thread). Но тогда остерегайтесь состояния гонки при обновлении JSON. Используйте блокировки (threading.Lock).

Интересуетесь генерацией игровых миров? Мы подробно разбирали, как с помощью Instructor и локальной LLM создавать целые RPG-вселенные в отдельном туториале. Этот подход можно интегрировать в движок для процедурной генерации контента.

Частые вопросы (FAQ)

Какую модель LLM выбрать в 2026 году?

Для облака: если бюджет позволяет, используйте новейшую GPT-4.5-Turbo или Claude 3.7 (если у Anthropic появится OpenAI-совместимый API). Для локального запуска: Llama 3.2 11B (качество/скорость), DeepSeek-R1 14B (лучше для рассуждений), или Command R+ 12B для многоязычных сценариев. Следите за обновлениями в сообществе – каждый месяц появляются новые кандидаты.

Можно ли использовать движок без графического интерфейса, для текстовой игры?

Да, конечно. Выбросьте Pygame, оставьте только класс DungeonMaster и запускайте цикл ввода-вывода в консоли. Ядро от этого не зависит. Более того, так его проще тестировать.

Как интегрировать механику боя или инвентаря?

Добавьте в JSON-состояние соответствующие поля (например, player.health, player.equipped_weapon). Затем в промпте явно опишите правила механики. Но лучше – вынесите игровую логику в отдельный модуль на Python, а LLM используйте только для нарратива. LLM – плохой арифмометр. Для сложной логики посмотрите кейс «Битвы Големов», где используется текстовое представление состояния для AI бота.

Проект действительно будет open-source?

Да. Полный код с расширенными функциями (загрузка миров, система квестов, конфигурационный файл) будет выложен на GitHub. Следите за обновлениями в блоге.

Последний совет – не пытайтесь сделать идеально с первого раза. Соберите минимальную рабочую версию: консольный ввод, вывод, простейшее состояние. Убедитесь, что цепочка (действие -> обновление JSON -> запрос к LLM -> ответ) работает. Затем постепенно наращивайте сложность. Иначе утонете в деталях и забросите проект. Удачи в создании своих миров!

Подписаться на канал