Почему NPC в играх до сих пор разговаривают как роботы?
Открываешь диалог с торговцем в таверне. Он говорит одну из пяти заготовленных фраз. Спросишь про вчерашний праздник - он посмотрит на тебя пустым взглядом. Он не помнит, что ты спас его дочь неделю назад. Он не злится, что ты назвал его скрягой. Он просто декорация. Надоело? Мне тоже.
Проблема в архитектуре. Традиционные диалоговые системы - это конечные автоматы или дерево решений. Каждая реплика прописана вручную. Память? Только через флаги. Голос? Заранее записанные аудиофайлы. Масштабировать такое - ад. А хочется живых персонажей, которые помнят прошлые взаимодействия, имеют характер и говорят естественным голосом. И чтобы все это работало локально, без ежемесячных счетов от OpenAI.
Весь стек в одной коробке: локально, дешево, масштабируемо
Решение - собрать собственный пайплайн из open-source инструментов. Звучит сложно? На практике это три сервиса и скрипт-прослойка в Unity. Архитектура проста:
- Whisper (v4-large) - преобразует речь игрока в текст. Локально, на вашем GPU.
- Ollama (0.5.0 с поддержкой Llama 3.2) - локальная LLM, которая генерирует ответы NPC. Помнит контекст, обладает характером.
- edge-tts - синтезирует речь из текста. Без GPU, использует нейронки от Microsoft через их бесплатный API (но локально можно заменить на Pocket-TTS).
- Unity - движок, который все это связывает, управляет диалоговым интерфейсом и состоянием игры.
Ключевое слово - локально. Никаких API-ключей, никаких лимитов на запросы, никакой отправки данных игроков в облако. Весь процесс идет на вашем ПК или сервере. Для продакшена это значит предсказуемые затраты и полный контроль.
1 Готовим движок: Ollama и свежая модель
Сначала ставим Ollama. На 16.03.2026 актуальная версия - 0.5.0. Она поддерживает новые квантованные форматы моделей (например, Q4_K_M), что экономит память без сильной потери качества.
# Установка Ollama на Linux/macOS
curl -fsSL https://ollama.ai/install.sh | sh
# Запуск сервиса
ollama serve &
# Скачиваем модель. Llama 3.2 8B - хороший баланс скорости и интеллекта для NPC.
ollama pull llama3.2:8b
Почему не Mistral или другие? Llama 3.2 лучше понимает инструкции по формированию личности персонажа и стабильнее ведет себя в длинных диалогах. Но если у вас слабая видеокарта, попробуйте Phi-4:3.8b - новая мини-модель от Microsoft, которая удивляет качеством.
npc_merchant.Modelfile с инструкцией: "Ты - Гарольд, скупой торговец в таверне 'Гномий кубок'. Ты подозрительный и жаден. Отвечай коротко, используй сленг. Помни все прошлые сделки с игроком." Затем создайте модель: ollama create merchant -f ./npc_merchant.Modelfile. Теперь при каждом запросе характер будет вшит в контекст.2 Уши для NPC: настраиваем Whisper для распознавания речи
Whisper от OpenAI - все еще король распознавания речи. Берем последнюю версию v4-large через Hugging Face. Но есть нюанс: оригинальный Whisper жрет много памяти. Вместо него используйте faster-whisper - та же точность, но в 4 раза быстрее и с поддержкой GPU через CTranslate2.
pip install faster-whisper
# minimal_whisper_server.py
from faster_whisper import WhisperModel
import numpy as np
from flask import Flask, request, jsonify
app = Flask(__name__)
model = WhisperModel("large-v4", device="cuda", compute_type="float16")
@app.route('/transcribe', methods=['POST'])
def transcribe():
audio_data = request.files['audio'].read()
# Конвертируем raw bytes в numpy array (предполагаем 16kHz mono)
audio_np = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) / 32768.0
segments, info = model.transcribe(audio_np, beam_size=5, language='ru')
text = ' '.join([seg.text for seg in segments])
return jsonify({'text': text})
if __name__ == '__main__':
app.run(port=5001)
Запустите этот сервер. Он будет слушать порт 5001 и ждать аудио в формате raw PCM. Из Unity вы будете отправлять запись микрофона. Задержка? На RTX 4070 - около 0.8 секунды на 5 секунд речи. Приемлемо для диалога.
3 Голос из ничего: синтез речи через edge-tts
Тут вариантов много. edge-tts - простой, с кучей голосов (даже русские), но требует интернета. Если нужна полная локальность, смотрите в сторону XTTS-v2 или Piper. Но для прототипа edge-tts идеален.
# tts_server.py
import asyncio
import edge_tts
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
VOICE = "ru-RU-SvetlanaNeural" # Попробуйте также ru-RU-DmitryNeural
async def generate_speech(text, output_file):
communicate = edge_tts.Communicate(text, VOICE)
await communicate.save(output_file)
@app.route('/synthesize', methods=['POST'])
def synthesize():
data = request.json
text = data.get('text', '')
if not text:
return jsonify({'error': 'No text'}), 400
output_path = f"temp_{hash(text)}.mp3"
asyncio.run(generate_speech(text, output_path))
# Читаем файл и отправляем байты
with open(output_path, 'rb') as f:
audio_bytes = f.read()
os.remove(output_path)
return audio_bytes, 200, {'Content-Type': 'audio/mpeg'}
if __name__ == '__main__':
app.run(port=5002)
Сервер возвращает готовый MP3. В Unity вы его проигрываете через AudioSource. Задержка синтеза - около 1-2 секунд. Можно кэшировать часто используемые фразы.
4 Мозги и память: архитектура Generative Agents в Unity
Самое интересное. Нужно, чтобы NPC не просто отвечал на последнюю реплику, а помнил весь диалог и ключевые события. Берем идеи из Stanford Generative Agents. Упрощенная реализация:
- Память как векторная база: Каждое высказывание или событие ("игрок купил меч", "игрок оскорбил торговца") преобразуем в эмбеддинг через какую-нибудь легкую модель (например, all-MiniLM-L6-v2) и кладем в векторную БД типа Chroma.
- Поиск релевантных воспоминаний: Перед генерацией ответа, преобразуем текущий запрос игрока в эмбеддинг, ищем в БД топ-5 самых похожих прошлых событий.
- Системный промпт с контекстом: В промпт для Ollama включаем найденные воспоминания и инструкцию характера. Это создает иллюзию долгосрочной памяти.
# agent_brain.py (упрощенно)
import ollama
import chromadb
from sentence_transformers import SentenceTransformer
embedder = SentenceTransformer('all-MiniLM-L6-v2')
chroma_client = chromadb.PersistentClient(path="./npc_memory")
collection = chroma_client.get_or_create_collection(name="harold_memories")
class NPCBrain:
def __init__(self, npc_name):
self.npc_name = npc_name
self.conversation_history = [] # Последние 10 реплик
def add_memory(self, description):
# Сохраняем событие в векторную БД
embedding = embedder.encode(description).tolist()
collection.add(
documents=[description],
embeddings=[embedding],
ids=[str(hash(description))]
)
def recall(self, query, n=5):
# Вспоминаем похожие события
query_embedding = embedder.encode(query).tolist()
results = collection.query(
query_embeddings=[query_embedding],
n_results=n
)
return results['documents'][0] if results['documents'] else []
def generate_response(self, player_input):
# 1. Вспоминаем
memories = self.recall(player_input)
# 2. Формируем промпт
prompt = f"""Ты - {self.npc_name}. Твои воспоминания: {', '.join(memories)}.
Текущий разговор: {' '.join(self.conversation_history[-5:])}
Игрок: {player_input}
{self.npc_name}: """
# 3. Запрос к Ollama
response = ollama.chat(model='merchant', messages=[{'role': 'user', 'content': prompt}])
npc_text = response['message']['content']
# 4. Сохраняем реплику в историю и память
self.conversation_history.append(f"Игрок: {player_input}")
self.conversation_history.append(f"{self.npc_name}: {npc_text}")
self.add_memory(f"Диалог: {player_input[:50]}...")
return npc_text
Этот скрипт - мозг NPC. Он работает как отдельный сервис на порту 5003. Unity отправляет сюда текст от игрока, получает ответ и аудио от TTS сервера.
5 Связываем все в Unity: C# скрипт-оркестратор
В Unity создаем пустой GameObject "NPCDialogueManager". Вешаем на него скрипт.
// NPCDialogueController.cs
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.Networking;
using System;
public class NPCDialogueController : MonoBehaviour
{
public AudioSource audioSource;
public Button recordButton;
public Text dialogueText;
private string whisperUrl = "http://localhost:5001/transcribe";
private string brainUrl = "http://localhost:5003/generate";
private string ttsUrl = "http://localhost:5002/synthesize";
private AudioClip recordingClip;
private bool isRecording = false;
void Start()
{
recordButton.onClick.AddListener(ToggleRecording);
}
void ToggleRecording()
{
if (!isRecording)
{
StartRecording();
}
else
{
StopRecordingAndProcess();
}
}
void StartRecording()
{
recordingClip = Microphone.Start(null, false, 10, 16000);
isRecording = true;
recordButton.GetComponentInChildren().text = "Стоп...";
}
async void StopRecordingAndProcess()
{
Microphone.End(null);
isRecording = false;
recordButton.GetComponentInChildren().text = "Говорить";
// 1. Отправляем аудио в Whisper
byte[] audioBytes = AudioClipToRawBytes(recordingClip);
string playerText = await TranscribeAudio(audioBytes);
dialogueText.text = "Игрок: " + playerText;
// 2. Отправляем текст в мозг NPC
string npcText = await GetNPCResponse(playerText);
dialogueText.text += "\nNPC: " + npcText;
// 3. Синтезируем и проигрываем речь
AudioClip npcSpeech = await SynthesizeSpeech(npcText);
audioSource.clip = npcSpeech;
audioSource.Play();
}
byte[] AudioClipToRawBytes(AudioClip clip)
{
float[] samples = new float[clip.samples * clip.channels];
clip.GetData(samples, 0);
byte[] bytes = new byte[samples.Length * 2];
for (int i = 0; i < samples.Length; i++)
{
short intSample = (short)(samples[i] * 32767);
BitConverter.GetBytes(intSample).CopyTo(bytes, i * 2);
}
return bytes;
}
// Далее методы TranscribeAudio, GetNPCResponse, SynthesizeSpeech с использованием UnityWebRequest
// Для асинхронности используйте UniTask или корутины
}
Это базовая структура. В реальном проекте нужно добавить очередь задач, обработку ошибок, индикацию загрузки. Но даже этот код уже работает.
Где собака зарыта: нюансы, которые сведут с ума
| Проблема | Причина | Решение |
|---|---|---|
| Задержка 5-10 секунд между репликами | Whisper медленный, LLM думает, TTS генерирует. Все последовательно. | Сделать пайплайн параллельным: пока Whisper работает, можно показывать анимацию "NPC думает". Кэшировать частые ответы. |
| NPC забывает разговор после перезагрузки | Векторная БД в памяти, Chroma на диске но не интегрирована с игровыми событиями. | Привязывать сохранения памяти к системе сохранений игры. Сериализовать ключевые воспоминания в файл сцены. |
| Голос звучит как робот, нет эмоций | edge-tts использует стандартные нейронные голоса без настройки. | Перейти на локальный XTTS-v2 и добавлять в промпт эмоциональные метки: [грустно], [зло] и т.д. Или использовать AnyTTS для гибкости. |
Вопросы, которые вы зададите через час отладки
Сколько VRAM нужно для всего этого? Минимум 8 GB. Разброс: Whisper large-v4 - 4 GB, Llama 3.2 8B в 4-битном квантовании - 5 GB, модель для эмбеддингов - 1 GB. Итого 10 GB. Если видеокарты слабее, берите smaller модели: Whisper medium, Llama 3.2 3B, эмбеддер tiny.
Как запустить это на продакшене для MMO? Никак. Эта архитектура для синглплеера или кооператива на 4 человека. Для MMO нужно выносить сервисы Ollama и Whisper на отдельный сервер с GPU, масштабировать через очередь запросов и балансировщик. И тогда считайте стоимость инстансов с A100. Но для инди-игры - то что надо.
NPC начал нести чушь или говорит слишком долго? Ограничивайте ответ 3-4 предложениями в системном промпте. Добавьте инструкцию: "Отвечай кратко, 1-2 предложениями". Если NPC уходит в философские рассуждения, обрежьте ответ на стороне Unity после 100 символов.
Что дальше? Эволюция агента
Базовая система работает. Теперь можно улучшать:
- Визуальная связь: Подключите PersonaPlex от NVIDIA для синхронизации мимики рта с речью.
- Мультиагентность: Несколько NPC могут общаться между собой. Нужен координатор, как в этой архитектуре.
- Планирование: NPC с целями ("заработать 100 золотых") и автономными действиями. Сложно, но возможно.
Главное - начать. Соберите минимальный работающий прототип за выходные. Пусть ваш торговец сначала будет просто отвечать на вопросы о погоде. Но он уже будет делать это своим голосом. И, возможно, запомнит, что вы спросили об этом три раза подряд. А в следующий раз скажет: "Опять про погоду? Купи лучше зелье, простудишься." Вот это уже характер.