LLM NPC в Ultima Online: интеграция с ServUO — гайд 2026 | AiManual
AiManual Logo Ai / Manual.
05 Июн 2026 Гайд

Как работают NPC на базе LLM в Ultima Online: интеграция с ServUO

Подробное техническое руководство по оживлению NPC в Ultima Online с помощью локальных LLM. Интеграция с ServUO, выбор модели, обработка JSON, тонкая настройка

Реклама
vec_recv1

В 2026 году бегать мимо NPC, которые бормочут «Приветствую, путник», уже не торт. Игроки хотят, чтобы торговец в Веспе жаловался на налоги, а стражник у ворот вспоминал, как ты вчера украл яблоко. Дело техники: берем Ultima Online, сервер ServUO (он же RunUO в новой обертке), подключаем локальную LLM — и вуаля.

Но есть нюанс: UO — это не Skyrim с модами. Тут C#, древний клиент и тонны легаси. LLM должна работать быстро, говрить в рамках лора и не крашить сервер тысячами запросов. Этот гайд — карта мин.

Почему именно Ultima Online и ServUO?

Ultima Online — до сих пор живая песочница. ServUO (форк RunUO) — полностью открытый эмулятор на C#. Ты можешь патчить любой аспект игры, от AI мобов до диалогов. Идеальный полигон для LLM: ты контролируешь всю цепочку — от клиент-серверного протокола до генерации ответов.

В отличие от современных асинхронных движков вроде Godot (кейс с снобом-магом), в ServUO все завязано на синхронный тик. LLM-запросы — асинхронные по своей природе. Значит, нужен прослойка: NPC получает запрос от игрока → шлет сообщение в LLM → ждет ответ → выполняет действие. Если сделать это в лоб, сервер зависнет на секунды.

💡
Вместо блокирующего вызова используем фоновые задачи (C# Task.Run) или отдельный микросервис на Python. LLM-сервер запускаем локально — ollama или llama.cpp с моделью вроде Qwen 2.5-1.5B-Instruct (GGUF).

Выбор модели: Qwen 1.5B — золотая середина?

В статье SLM для NPC мы уже разобрали, почему гнаться за 7B+ не всегда разумно. Для NPC в UO нужна модель, которая:

  • Генерирует структурированный JSON (действие + текст).
  • Работает на CPU/GPU с малым потреблением.
  • Не уходит в «философские рассуждения» про смысл бытия.

Qwen 2.5-1.5B-Instruct — отличный кандидат. Да, она не сочинит трагическую поэму, но выдаст адекватный ответ за 200-400 мс на современном CPU. Если нужно больше качество — бери Llama 3.2 3B или GLM-4-9B-0414 (лучше, но тяжелее). Детальный анализ — в этом гайде по выбору модели. Главное: тестируй на диалогах из UO.

Архитектура интеграции: NKV (NPC Knowledge Vault)

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

  • Имя NPC, профессию, фракцию, текущее настроение.
  • Последние 3-5 реплик игрока и NPC (через кэш).
  • Результаты действий игрока (например, «убил орка»).
  • Время суток, локацию, события сервера.

LLM получает системный промпт и возвращает JSON:

{
  "reply": "Эй, я тебя помню! Ты тот, кто спер яблоко у пекаря. Вали отсюда!",
  "action": "aggressive",
  "give_item": null,
  "emote": "cracks knuckles"
}

ServUO парсит этот JSON — и NPC переключается в агрессивный статс, делает emote, а диалог завершается. Без JSON — хаос. Промпты, которые не структурируют ответ, приводят к тому, что NPC начинает сочинять стихи (реальная история).

Лайфхак: используй grammar в llama.cpp для принудительной генерации JSON. Это ускоряет ответы и убирает мусор.

Пошаговая интеграция (или как не сломать сервер)

1 Запускаем LLM-сервер

Самый простой путь — ollama с моделью вроде qwen2.5:1.5b-instruct-q4_K_M. Но я предпочитаю llama.cpp + llama-server — там полный контроль над контекстом и грамматикой.

# Загрузка модели (пример)
wget https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf

# Запуск сервера
./llama-server -m qwen2.5-1.5b-instruct-q4_k_m.gguf \
  --port 8081 \
  --ctx-size 8192 \
  --n-gpu-layers 20 \
  --grammar-file npc_json.gbnf

Файл грамматики npc_json.gbnf заставит модель генерировать только валидный JSON с нужными полями. Это избавит от парсинга «сырых» ответов.

2 Пишем C#-мост в ServUO

ServUO — это C# (.NET Framework 4.8, если что). Создаем класс LlmNpcController, который шлет HTTP-запросы к нашему серверу. Используем HttpClient с таймаутом 5 секунд.

public class LlmNpcController
{
    private static readonly HttpClient client = new HttpClient
    {
        BaseAddress = new Uri("http://localhost:8081"),
        Timeout = TimeSpan.FromSeconds(5)
    };

    public static async Task<string> GetResponse(string systemPrompt, string userMessage)
    {
        var payload = new
        {
            prompt = $"<|system|>\n{systemPrompt}\n<|user|>\n{userMessage}\n<|assistant|>",
            n_predict = 200,
            temperature = 0.7f,
            stop = new[] { "<|user|>", "<|assistant|>" }
        };

        var json = JsonSerializer.Serialize(payload);
        var content = new StringContent(json, Encoding.UTF8, "application/json");
        var response = await client.PostAsync("/completion", content);
        var result = await response.Content.ReadAsStringAsync();
        return result;
    }
}

Не вызывай это в основном потоке! Используй async и await, но помни: OnSpeech в ServUO синхронный. Придется поднять фоновый таск:

public override void OnSpeech(SpeechEventArgs e)
{
    Task.Run(async () =>
    {
        var responseJson = await LlmNpcController.GetResponse(BuildPrompt(), e.Speech);
        // парсим JSON и выполняем действия в основном потоке через Core.Timer
    });
}

Типичная ошибка: выполнять LLM-запрос в том же потоке, что и обработка речи. Сервер застревает на 5 секунд, игроки получают лаги и фейлы диалогов. Всегда асинхронно + блокируй повторный вызов, пока старый обрабатывается.

3 Промпт-инжиниринг: заставляем LLM помнить лор

Простой системный промпт не создает роль. Нужны поведенческие профили, которые переписываются после каждого действия. В нашей архитектуре профиль — это JSON с текущим состоянием: здоровье, золото, отношение к игроку (от -100 до 100).

{
  "npc": "Dulin the Fletcher",
  "personality": "grumpy but fair",
  "relationship": {
    "player_name": "SirKillsALot",
    "value": -30,
    "reason": "broke a bow last week"
  },
  "topics": ["bows", "taxes", "weather"]
}

Этот JSON вставляется в системный промпт. LLM видит отношение и генерирует соответствующую реплику. Отношение меняется после каждого взаимодействия: помог — +10, оскорбил — -20. За подробностями — читай Local Personality Engine.

Болевые точки и решения

Сделали, запустили — работает. Но не идеально. Вот реальные грабли, на которые я наступил:

Проблема Решение
Долгий ответ (1-2 сек) Уменьшить контекст до 4К, включить --no-mmap для GPU, использовать batch-обработку в одной сессии.
Модель забывает предыдущие диалоги Не хранить всю историю. Только итоговый профиль + 2-3 последние реплики. Остальное — в БД.
Мусор в JSON (лишние поля) Грамматика llama.cpp или парсер с игнорированием неизвестных полей.
LLM пишет «как бот» Понизить temperature до 0.2-0.4, добавить few-shot примеры из реальной игры, использовать модель, донастроенную на RPG.

От диалогов к действиям: NPC управляет миром

Самое интересное — когда ответ LLM влияет на игровой мир. Например, NPC может отдать предмет, начать бой, открыть сундук или сменить фракцию. Для этого в JSON-ответ добавляем поле action с параметрами. На стороне ServUO парсим и вызываем соответствующий метод.

var action = response.action.ToLower();
switch (action)
{
    case "give":
        var item = World.FindItem(int.Parse(response.give_item_id));
        if (item != null) item.MoveToWorld(player.Location, player.Map);
        break;
    case "aggressive":
        npc.Combatant = player;
        npc.Say("Бой!", 0x22);
        break;
    case "emote":
        npc.PublicOverheadMessage(MessageType.Emote, 0x482, false, response.emote);
        break;
    default:
        npc.Say(response.reply);
        break;
}

Такой паттерн позволяет строить сложные квесты без жесткого скриптования. AI Dungeon Master на Python делает похожее, но там все на Python, а у нас C# + LLM. Принцип один: state-driven генерация.

Муравейник: много NPC, один LLM

Когда в городе 20 NPC и каждый что-то говорит, LLM-сервер захлебывается. Решение — очередь запросов с приоритетами. Игроки в радиусе 10 тайлов от NPC получают приоритет, остальные — в низком приоритете или обходятся готовыми фразами (fallback).

Можно использовать один инстанс модели для всех NPC, меняя только системный промпт. Это экономит память, но создает риск «смешивания» контекстов в батчах. Лучше запустить несколько легких моделей (по одной на кластер NPC) или применить pooling: один процесс llama.cpp с одним контекстом, но быстрая подмена промпта.

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

Тупиковые ветки: чего не стоит делать

Я видел попытки вставлять LLM в каждую строчку диалога. Результат: сервер падает, игроки жалуются на «тормозных» NPC. Не делайте LLM для приветствий и прощаний. Оставьте простые фразы на скриптах, а LLM подключайте только для значимых сюжетных диалогов.

Вторая ошибка — пытаться эмулировать эмоции через тупой выбор из трех вариантов. Либо делаете полноценную LLM-модель, либо не делаете вообще. SillyTavern AI Game Master показывает, как можно гибко кастомизировать поведение через расширения, но там другой стек. Наша цель — нативный C# код, который не требует внешних инструментов от игрока.

И последнее: производительность. Модель Qwen 1.5B на CPU (Ryzen 5) выдает около 10 токенов/сек. Для реплики из 50 токенов — 5 секунд. Игроки ждать не любят. Решение: прегенерация нескольких вариантов ответов в фоне, когда NPC бездействует. Или использование спекулятивного декодинга (новые версии llama.cpp это поддерживают).

Будущее: генерация миров на лету

Один шаг от NPC — генерация целых квестовых цепочек и даже локаций. Instructor + локальная LLM уже позволяют генерировать датасеты миров. Мы можем пойти дальше: когда игрок просит NPC рассказать о кладе — LLM генерирует не просто текст, а JSON с координатами сундука, который реально появляется в мире. Проверено на практике: работает, но требует строгой валидации, чтобы сундук не оказался в стене.

К 2026 году граница между сценаристом и нейросетью стирается. ServUO + локальная LLM — это не хайп, а рабочий инструмент для моддеров, которые хотят удивить игроков. Не гонитесь за дорогими API — соберите все на своем железе. И помните: NPC должен быть интересным, а не идеальным. Идеальный — скучный.

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