В 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 → ждет ответ → выполняет действие. Если сделать это в лоб, сервер зависнет на секунды.
Выбор модели: 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 должен быть интересным, а не идеальным. Идеальный — скучный.