Генерация игровых миров с Instructor и локальной LLM: создание RPG | AiManual
AiManual Logo Ai / Manual.
19 Янв 2026 Гайд

Генерация игровых миров с помощью Instructor и локальной LLM: полный туториал по созданию RPG-вселенной

Пошаговый гайд по генерации консистентных игровых миров с помощью Instructor, Pydantic и локальной LLM. Создаем RPG-вселенную с SQLite-реестром фактов.

Почему генерация игровых миров — это боль

Представьте: вы делаете RPG. Нужны города, персонажи, квесты, лор. Десятки часов уходят на создание контента. А потом вы понимаете, что в городе Эльдорадо живут 500 человек, но там всего три дома. Или что король Артур упоминается в диалогах, но его биография противоречит истории королевства.

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

Типичная ошибка: использовать LLM как черный ящик. Запрос "сгенерируй город" дает красивый текст, но бесполезный для кода. Нет структуры, нет связей, нет возможности проверить факты.

Решение: двухфазная генерация с реестром фактов

Вместо одного запроса — два этапа. Сначала создаем структуру мира (города, фракции, ключевые NPC). Сохраняем все в SQLite базу. Потом генерируем контент (диалоги, описания, квесты), сверяясь с реестром. Если нейросеть пытается сказать, что эльфы ненавидят магию, а в реестре написано "эльфы — магическая раса", система это отловит.

Инструменты:

  • Instructor — библиотека для структурированного вывода из LLM. Заставляет нейросеть возвращать данные в формате Pydantic моделей, а не просто текст.
  • Локальная LLM (Mistral, Llama, Qwen) — работает без интернета, дешево, можно генерировать тонны контента. Как в статье про запуск Llama 3.1 на 6 ГБ VRAM.
  • SQLite — легкая база для хранения фактов. Никаких монстров вроде PostgreSQL.
💡
Почему именно Instructor? Потому что без него вы получаете JSON, который то валиден, то нет. Instructor гарантирует структуру. Если нейросеть генерирует ерунду, библиотека перезапрашивает модель или исправляет ошибки автоматически.

Архитектура: как это работает под капотом

Система состоит из трех слоев:

  1. Слой генерации — LLM + Instructor. Принимает промпты, возвращает структурированные данные.
  2. Слой валидации — проверяет новые факты на противоречия с существующими. Город не может быть одновременно разрушенным и процветающим.
  3. Слой хранения — SQLite база с таблицами: locations, characters, factions, items, relationships.

Ключевая фишка: все связанные сущности хранят ID друг друга. Персонаж знает, в каком городе живет (location_id). Город знает, какая фракция им управляет (faction_id). Когда генерируем диалог для персонажа, система подтягивает контекст из базы.

1 Устанавливаем окружение

Сначала ставим зависимости. Работаем с Python 3.10+.

pip install instructor pydantic sqlite3 llama-cpp-python

Для локальной LLM я использую llama-cpp-python — обертку над GGUF моделями. Они работают на CPU, но если есть видеокарта, лучше взять трансформеры и ускорить через CUDA. Как выбрать модель, смотрите в сравнении Qwen2.5 и Mistral.

Не берите огромные 70B модели для генерации контента. Для структурированного вывода хватит 7B-13B параметров. Mistral 7B или Llama 3.1 8B отлично справляются.

2 Создаем Pydantic схемы

Определяем, какие данные нам нужны. Без схем Instructor бесполезен.

from pydantic import BaseModel, Field
from typing import List, Optional
from enum import Enum

class FactionType(str, Enum):
    KINGDOM = "kingdom"
    GUILD = "guild"
    CULT = "cult"
    BANDIT = "bandit"
    NEUTRAL = "neutral"

class Location(BaseModel):
    id: int = Field(description="Unique location ID")
    name: str = Field(description="Location name")
    type: str = Field(description="City, village, dungeon, forest")
    population: Optional[int] = Field(None, description="Approximate population")
    description: str = Field(description="Detailed description")
    faction_id: Optional[int] = Field(None, description="Controlling faction ID")
    notable_features: List[str] = Field(default_factory=list)

class Character(BaseModel):
    id: int = Field(description="Unique character ID")
    name: str = Field(description="Character name")
    race: str = Field(description="Elf, human, dwarf, etc.")
    occupation: str = Field(description="Blacksmith, merchant, guard")
    location_id: int = Field(description="Where the character resides")
    faction_id: Optional[int] = Field(None, description="Faction affiliation")
    personality_traits: List[str] = Field(default_factory=list)
    secret: Optional[str] = Field(None, description="Hidden secret or quest hook")

Обратите внимание на поля с ID. Они создают связи между сущностями. Без них получится набор разрозненных объектов.

3 Настраиваем Instructor с локальной LLM

Подключаем модель через llama-cpp. Если у вас трансформеры, используйте from_pretrained.

from llama_cpp import Llama
import instructor
from instructor import llm_validator

# Загружаем GGUF модель
llm = Llama(
    model_path="./models/mistral-7b-instruct-v0.2.Q4_K_M.gguf",
    n_ctx=8192,  # Контекстное окно
    n_threads=8,  # Количество потоков CPU
    verbose=False
)

# Создаем клиент Instructor
client = instructor.from_llama(llm, mode=instructor.Mode.JSON)

# Функция для структурированной генерации
def generate_structured(prompt: str, response_model):
    """Генерируем данные по схеме Pydantic"""
    try:
        response = client.chat.completions.create(
            model="local-model",
            messages=[
                {"role": "system", "content": "You are a game world generator. Return valid JSON."},
                {"role": "user", "content": prompt}
            ],
            response_model=response_model,
            max_retries=3  # Автоматические повторные попытки при ошибках
        )
        return response
    except Exception as e:
        print(f"Generation failed: {e}")
        return None

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

4 Создаем SQLite реестр фактов

База данных — источник истины. Все сгенерированные факты попадают сюда.

import sqlite3
from contextlib import contextmanager

class FactRegistry:
    def __init__(self, db_path="world_facts.db"):
        self.db_path = db_path
        self.init_database()
    
    def init_database(self):
        """Создаем таблицы для хранения мира"""
        with self.get_connection() as conn:
            conn.execute("""
            CREATE TABLE IF NOT EXISTS locations (
                id INTEGER PRIMARY KEY,
                name TEXT UNIQUE NOT NULL,
                type TEXT NOT NULL,
                population INTEGER,
                description TEXT NOT NULL,
                faction_id INTEGER,
                notable_features TEXT
            )""")
            
            conn.execute("""
            CREATE TABLE IF NOT EXISTS characters (
                id INTEGER PRIMARY KEY,
                name TEXT NOT NULL,
                race TEXT NOT NULL,
                occupation TEXT NOT NULL,
                location_id INTEGER NOT NULL,
                faction_id INTEGER,
                personality_traits TEXT,
                secret TEXT,
                FOREIGN KEY (location_id) REFERENCES locations(id)
            )""")
            
            conn.execute("""
            CREATE TABLE IF NOT EXISTS factions (
                id INTEGER PRIMARY KEY,
                name TEXT UNIQUE NOT NULL,
                type TEXT NOT NULL,
                leader_id INTEGER,
                description TEXT NOT NULL
            )""")
            
            # Индексы для быстрого поиска
            conn.execute("CREATE INDEX IF NOT EXISTS idx_char_location ON characters(location_id)")
            conn.execute("CREATE INDEX IF NOT EXISTS idx_loc_faction ON locations(faction_id)")
    
    @contextmanager
    def get_connection(self):
        """Контекстный менеджер для соединения с БД"""
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        try:
            yield conn
            conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()
    
    def check_consistency(self, new_entity, entity_type):
        """Проверяем новые факты на противоречия с существующими"""
        with self.get_connection() as conn:
            if entity_type == "location":
                # Проверяем, нет ли уже города с таким названием
                existing = conn.execute(
                    "SELECT id FROM locations WHERE name = ?", 
                    (new_entity.name,)
                ).fetchone()
                if existing:
                    return False, f"Location '{new_entity.name}' already exists"
            
            elif entity_type == "character":
                # Проверяем, существует ли указанная локация
                location_exists = conn.execute(
                    "SELECT id FROM locations WHERE id = ?", 
                    (new_entity.location_id,)
                ).fetchone()
                if not location_exists:
                    return False, f"Location ID {new_entity.location_id} doesn't exist"
        
        return True, "OK"

Функция check_consistency — страж консистентности. Она не даст создать персонажа в несуществующем городе или добавить второй город с тем же названием.

5 Генерируем мир: двухфазный подход

Фаза 1: создаем каркас мира — королевства, крупные города, фракции.

def generate_world_foundation(registry):
    """Генерируем основу мира"""
    
    # 1. Создаем королевство
    kingdom_prompt = """
    Create a fantasy kingdom with:
    - Name and description
    - Capital city details
    - Ruling faction
    - 3 notable features
    """
    
    # Используем более сложную схему для королевства
    class Kingdom(BaseModel):
        name: str
        capital_city: Location
        ruling_faction: str
        features: List[str]
    
    kingdom = generate_structured(kingdom_prompt, Kingdom)
    
    if kingdom:
        # Сохраняем столицу
        is_valid, message = registry.check_consistency(kingdom.capital_city, "location")
        if is_valid:
            registry.save_location(kingdom.capital_city)
            print(f"Created capital: {kingdom.capital_city.name}")
        
        # 2. Генерируем дополнительные города
        cities_prompt = f"""
        Generate 3 additional cities for the kingdom of {kingdom.name}.
        Each city should be distinct and have:
        - Unique name
        - Population estimate
        - Primary industry or trade
        - Relation to the capital
        """
        
        class CityList(BaseModel):
            cities: List[Location]
        
        cities = generate_structured(cities_prompt, CityList)
        
        for city in cities.cities:
            is_valid, msg = registry.check_consistency(city, "location")
            if is_valid:
                registry.save_location(city)
                print(f"Created city: {city.name}")
            else:
                print(f"Skipped city {city.name}: {msg}")

Фаза 2: наполняем мир деталями — персонажи, квесты, диалоги.

def populate_location(registry, location_id):
    """Населяем локацию персонажами"""
    
    # Получаем информацию о локации
    location = registry.get_location(location_id)
    
    characters_prompt = f"""
    Generate 5 interesting characters for {location['name']}, a {location['type']}.
    Characters should include:
    - 1 authority figure (mayor, captain)
    - 2 commoners with distinct personalities
    - 1 mysterious stranger
    - 1 character with a secret quest hook
    """
    
    class CharacterList(BaseModel):
        characters: List[Character]
    
    characters = generate_structured(characters_prompt, CharacterList)
    
    for char in characters.characters:
        # Устанавливаем location_id из параметра
        char.location_id = location_id
        
        is_valid, msg = registry.check_consistency(char, "character")
        if is_valid:
            registry.save_character(char)
            print(f"Created character: {char.name} ({char.occupation})")
        else:
            print(f"Skipped character {char.name}: {msg}")
💡
Генерация в два этапа решает проблему контекста. Сначала создаем "скелет" мира (2000 токенов), потом генерируем контент для каждой части отдельно (еще по 1000-2000 токенов). Так мы не перегружаем контекстное окно модели. Подробнее о борьбе с деградацией контекста читайте в практическом руководстве по управлению контекстом.

Ошибки, которые сломают вашу систему

Я видел десятки попыток сделать подобное. Вот что идет не так:

Ошибка Последствие Решение
Нет проверки консистентности Персонажи ссылаются на несуществующие локации, мир противоречит сам себе Добавить check_consistency перед сохранением каждой сущности
Одна большая генерация всего мира LLM теряет контекст, качество падает после 3000 токенов Использовать двухфазный подход: сначала структура, потом детали
Хранение фактов в JSON файлах Медленный поиск, сложные связи, проблемы с параллельным доступом Использовать SQLite с индексами и внешними ключами
Отсутствие ID связей Сущности существуют изолированно, нельзя построить отношения Добавить location_id, faction_id во все связанные модели

Как интегрировать с игровым движком

Сгенерированный мир — это данные в SQLite. Экспортируем их в формат, понятный вашей игре.

Для Unity:

def export_to_unity_json(registry, output_path):
    """Экспортируем мир в JSON для Unity"""
    with registry.get_connection() as conn:
        # Получаем все локации с персонажами
        locations = conn.execute("""
            SELECT l.*, 
                   json_group_array(
                       json_object(
                           'name', c.name,
                           'race', c.race,
                           'occupation', c.occupation
                       )
                   ) as characters
            FROM locations l
            LEFT JOIN characters c ON l.id = c.location_id
            GROUP BY l.id
        """).fetchall()
    
    # Конвертируем в словарь
    world_data = {
        "locations": [dict(loc) for loc in locations]
    }
    
    import json
    with open(output_path, 'w', encoding='utf-8') as f:
        json.dump(world_data, f, ensure_ascii=False, indent=2)
    
    print(f"Exported {len(locations)} locations to {output_path}")

Для Godot данные можно загружать напрямую из SQLite с помощью GDScript плагина. Для веб-игр — отдавать через REST API.

Что делать, когда мир создан

База данных с миром — это не конечная точка. Это отправная.

  • Динамические события — используйте реестр фактов как контекст для генерации случайных событий. Как в интеграции LLM в S.T.A.L.K.E.R. Anomaly.
  • Персонализированные диалоги — генерируйте реплики NPC на основе их личности, отношений с игроком, текущих событий в мире.
  • Процедурные квесты — создавайте цепочки заданий, которые учитывают состояние мира. Если в городе эпидемия, NPC могут просить найти лекарство.
  • Эволюция мира — обновляйте реестр по мере развития сюжета. Город захвачен? Обновите faction_id. Персонаж погиб? Отметьте в базе.

Не генерируйте все заранее. Генерируйте по мере необходимости. Когда игрок впервые посещает город — создайте его ключевых NPC. Когда заговорит с торговцем — сгенерируйте ассортимент товаров. Это экономит ресурсы и делает мир живым.

Производительность: сколько это стоит

Локальная LLM на CPU (Mistral 7B Q4):

  • Генерация города с описанием: 3-5 секунд
  • 5 персонажей для локации: 8-12 секунд
  • Полный мир (10 локаций, 50 NPC): 2-3 минуты
  • Потребление памяти: 4-6 ГБ ОЗУ

На GPU (RTX 4060, 8 ГБ):

  • Генерация города: 1-2 секунды
  • 5 персонажей: 3-5 секунд
  • Полный мир: 40-60 секунд

Сравните с оплатой OpenAI API: генерация аналогичного мира обошлась бы в $2-5. При активной разработке, когда мир пересоздается десятки раз, локальная модель окупается за неделю.

Следующий уровень: RAG для игровых миров

Реестр фактов — это уже база знаний. Подключите к ней RAG (Retrieval-Augmented Generation), чтобы нейросеть отвечала на вопросы о мире в контексте игры.

Игрок: "Кто правит в этом городе?"

Система:

  1. Ищет в SQLite информацию о текущей локации игрока
  2. Находит faction_id
  3. Ищет фракцию и ее лидера
  4. Генерирует ответ: "Городом правит Гильдия торговцев во главе с лордом Эдгаром."

Технически это похоже на RAG за 15 минут, но вместо документов — игровые сущности.

Самый интересный вариант: дать игроку возможность влиять на мир через диалоги. Сказал что-то важное NPC — система обновляет реестр фактов. Мир становится реактивным.

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

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