NPC больше не читают с бумажки
В 2026 году смотреть на NPC, которые повторяют три заученные фразы, уже просто больно. Игроки хотят живого мира, где стражник может поспорить с кузнецом о погоде, а торговка — шепнуть прохожему сплетню. Но писать тысячи строк диалогов вручную — адский труд. А если NPC должны общаться друг с другом без участия игрока? Тут классические скрипты ломаются.
Решение — локальная LLM. Запускаете модель у себя на сервере, прикручиваете бэкенд, который управляет диалогами NPC-NPC, и получаете динамический мир. Никаких ежемесячных платежей за API, никакой цензуры, полный контроль над данными. Звучит как магия? На деле — пара файлов Python и одна команда docker-compose up.
Краткая анатомия бэкенда NPC-NPC
Представьте себе движок, который сидит между игрой и LLM. Его задачи:
- Принимать запросы на диалог от двух (или больше) NPC.
- Формировать промпт с описанием персонажей, контекста, истории.
- Отправлять запрос к LLM (через Ollama, llama.cpp или LocalAI).
- Парсить ответ — обычно это структурированный JSON с репликами.
- Обновлять долговременную память NPC (база фактов, эмоциональные шкалы).
- Отдавать результат в игровой движок (Unity, Godot, Ren'Py, да хоть консоль).
Самое сложное — заставить LLM не забывать, что сказал NPC две минуты назад. Стандартное окно контекста модели в 32K токенов быстро забивается. Поэтому нужен внешний модуль памяти — об этом мы уже писали в статье «NPC с характером».
Наш бэкенд мы построим на FastAPI + Ollama. Он будет принимать POST-запрос с ID двух NPC, вытаскивать их профили из SQLite, склеивать промпт и возвращать сгенерированный диалог. Вуаля.
Сравнение с альтернативами: почему не готовые решения?
На рынке уже есть проекты, которые делают похожие вещи. Давайте пробежимся по главным:
| Инструмент | Подход | Диалоги NPC-NPC | Локальность |
|---|---|---|---|
| Наш бэкенд | Самописный на FastAPI + Ollama | Да, полностью | Да |
| SillyTavern AI Game Master | Плагин к SillyTavern | Ограничен (через карточки персонажей) | Да |
| Personica AI | Готовый модуль для Unreal Engine | Да, но привязан к UE | Да |
| Open-source RPG с генерацией квестов | Целая RPG на локальной LLM | Встроено, но монолитно | Да |
| Облачные решения (Inworld AI, Convai) | SaaS | Да | Нет |
SillyTavern — отличная штука для текстовых приключений, но встраивать его в игру с нативной поддержкой NPC-NPC сложно: он заточен под игрока-человека. Personica AI решает проблему, но только для Unreal Engine — если ваш движок Godot или собственный, придется пилить свое. А наша реализация — легковесный бэкенд, который можно воткнуть в любой проект через REST.
Пример использования: два NPC обсуждают кражу в таверне
Допустим, в игре есть трактирщик Билл и стражник Джек. Игрок заходит в таверну, слышит их разговор. Вот как это выглядит в коде бэкенда.
1 Структура NPC в базе
{
"npc_id": "bill",
"name": "Билл",
"role": "трактирщик",
"personality": "ворчливый, жадный, но справедливый",
"memory": [
"Вчера у него украли бочонок эля",
"Подозревает местного вора Лиса"
]
}{
"npc_id": "jack",
"name": "Джек",
"role": "стражник",
"personality": "ленивый, любит выпить, но службу несет",
"memory": [
"Должен найти вора, но не хочет напрягаться"
]
}2 Код бэкенда (FastAPI + Ollama)
from fastapi import FastAPI
from pydantic import BaseModel
import ollama
app = FastAPI()
class DialogRequest(BaseModel):
npc_a: str
npc_b: str
context: str = ""
@app.post("/dialog")
async def generate_dialog(req: DialogRequest):
profile_a = get_npc_profile(req.npc_a) # вытаскиваем из БД
profile_b = get_npc_profile(req.npc_b)
prompt = f"""Ты — генератор диалогов для RPG. Напиши диалог между двумя NPC.
NPC 1: {profile_a['name']} ({profile_a['role']})
Характер: {profile_a['personality']}
Память: {'; '.join(profile_a['memory'])}
NPC 2: {profile_b['name']} ({profile_b['role']})
Характер: {profile_b['personality']}
Память: {'; '.join(profile_b['memory'])}
Контекст сцены: {req.context}
Ответь строгим JSON-массивом реплик:
[
{{"speaker": "имя", "text": "реплика"}},
...
]
"""
response = ollama.chat(
model="llama4:latest", # на июль 2026 актуальна Llama 4
messages=[{"role": "user", "content": prompt}]
)
# Парсим JSON из ответа модели
import json
raw = response['message']['content']
dialog = json.loads(raw)
# Обновляем память NPC (например, добавляем факт о разговоре)
update_memory(req.npc_a, f"Обсуждал кражу с {profile_b['name']}")
update_memory(req.npc_b, f"Обсуждал кражу с {profile_a['name']}")
return dialog3 Что вернет модель
[
{"speaker": "Билл", "text": "Джек, ты когда уже найдешь того, кто спер мой эль? Я тебе не король, чтобы ждать!"},
{"speaker": "Джек", "text": "Да ищу я, ищу... Может, это Лис опять? Он вчера крутился у твоих бочек."},
{"speaker": "Билл", "text": "Лис? Да он же из деревни сбежал позавчера! Ты бы хоть патрулировал, а не пил у меня каждый вечер."}
]Диалог динамический, зависит от памяти. Если бы Билл не помнил про кражу — разговор был бы о погоде. Модель сама решает, как развивать сцену, а мы лишь подсовываем факты.
Важный нюанс: structured output. Если модель вернет невалидный JSON — бэкенд упадет. Используйте грамматики (например, авторитарный бэкенд с structured output) или библиотеку outlines для гарантии формата.
Как это интегрировать в реальную игру
Бэкенд не привязан к движку. Вы можете дергать его из Unity через UnityWebRequest, из Godot — через HTTPRequest, из Ren'Py — через renpy.http. Вот пример вызова из скрипта Godot (GDScript):
var http = HTTPRequest.new()
func _ready():
add_child(http)
http.request_completed.connect(_on_dialog_ready)
var body = JSON.stringify({"npc_a": "bill", "npc_b": "jack"})
http.request("http://localhost:8000/dialog", [], HTTPClient.METHOD_POST, body)
func _on_dialog_ready(result, code, headers, body):
var dialog = JSON.parse_string(body.get_string_from_utf8())
for line in dialog:
print(line.speaker + ": " + line.text)Если игра — мод для S.T.A.L.K.E.R. Anomaly, можно использовать тот же принцип, только вместо HTTP — вызов через асинхронный скрипт Lua. Подробный разбор такой интеграции уже есть в нашей статье.
Кому это реально нужно
Инструмент не для всех. Если вы делаете казуальную игру на пару уровней — проще нарисовать ветки диалогов в Articy. Но если ваш проект — immersive sim, RPG с открытым миром или мод к старой игре (да хоть к Ultima Online — вот пример), то такой бэкенд спасет сотни часов работы.
Типичные сценарии:
- Инди-команды без сценариста — LLM пишет диалоги сама, вы только правите промпты.
- Моддеры, оживляющие старые игры — никаких ограничений оригинального движка.
- Прототипирование: набросали NPC, запустили — через минуту они уже болтают.
Еще один неочевидный юзкейс — тестирование. Запустили пару десятков NPC и смотрите, не повторяются ли диалоги, не зацикливаются ли модели. Это выявит проблемы с памятью и контекстом до релиза.
Модель имеет значение
Не любая LLM подойдет. Для диалогов NPC-NPC важна способность удерживать нить разговора и генерировать короткие, естественные реплики. Из личного опыта: на момент 2026 года для русского языка лучшие результаты дают Llama 4 70B (через Ollama) и Qwen 2.5 32B — они не «забывают» контекст в течение 10-15 реплик даже без внешней памяти. Mistral Large 2 тоже хорош, но чувствителен к формату JSON — приходится допиливать грамматики. Выбор модели мы подробно разбирали в соответствующем гайде.
Пару слов про оптимизацию
Бэкенд будет дергаться часто — каждый раз, когда два NPC встречаются. Если у вас 1000 NPC, они могут генерировать диалоги десятками в секунду. Одноядерный CPU не вывезет. Решения:
- Кешируйте типовые диалоги (например, приветствия) — пусть модель генерирует только уникальные.
- Используйте очередь задач (Celery или Redis Queue).
- Запускайте несколько инстансов Ollama для параллельной обработки.
- Для нетребовательных диалогов ставьте модель поменьше (например, Qwen 2.5 7B), а для ключевых сцен — прогоняйте через большую.
Кстати, если у вас несколько NPC в одной сцене, можно генерировать сразу целую сцену, указав в промпте всех участников. Это дешевле, чем запрашивать пары.
Типичные грабли и как их обойти
- Модель начинает галлюцинировать имена. Жестко фиксируйте имена в промпте и используйте structured output.
- Диалоги уходят в бесконечность. Ставьте лимит реплик (например, макс. 6) и параметр
max_tokens. - NPC забывают, что только что сказали. Включайте последние 2-3 реплики в промпт (скользящее окно).
- Память забивается мусором. Используйте векторную базу (Chroma) — храните только значимые факты, извлеченные через sumarizer.
Если грабли все равно прилетают — вспомните про авторитарный бэкенд: он перехватывает управление и не дает LLM уйти в разнос.
Итоговая мысль: не делайте «говорящие головы», делайте мир
Локальный бэкенд для диалогов NPC-NPC — не просто замена диалоговому редактору. Это способ сделать игровой мир живым, независимым от игрока. Когда два NPC спорят, торгуются или сплетничают — игрок чувствует, что он не в парке аттракционов, а в настоящем сообществе. И для этого не нужно ждать, пока большая студия наняла армию сценаристов.
Соберите бэкенд за вечер, воткните в свой проект и посмотрите, как заискрят диалоги. А если что-то пойдет не так — у нас на сайте целая серия статей про архитектуру NPC на LLM, эксперименты с Godot, Ren'Py и Unreal. Вперед, мир ждет своих цифровых жителей.