Я всегда хотел сделать умных NPC.
Не тех, которые стоят возле таверны и повторяют одну и ту же фразу:
«Добро пожаловать в деревню.»
А таких, как персонажи в аниме-исекаях или фэнтези-историях — тех, кто:
помнит, что ты говорил им вчера
делае�� выводы из наблюдений
планирует свои действия
может поддержать живой разговор
В обычных RPG NPC работают по простой схеме: дерево диалогов, заранее написанные ответы, триггеры квестов. Это работает — но всегда чувствуется, что персонаж живёт по сценарию.
Когда появились большие языковые модели (LLM), появилась мысль:
А что если дать NPC не просто язык, а память и характер?
Эта статья — пошаговый гайд, как построить такую систему.
Я изучил архитектуру Generative Agents от Stanford, разобрался с локальными LLM через Ollama, настроил распознавание речи через Whisper и голосовой ответ через edge-tts — и собрал всё это в рабочую систему в Unity. Финальная версия и ключевые этапы проверены на практике. Это не production-архитектура, а рабочий прототип — понятный, воспроизводимый и готовый к развитию.
Мы начнём с минимального эксперимента и дойдём до полноценной архитектуры с голосом, памятью и рефлексией:
Unity → AI-сервер → Memory + Reflection + Planning → LLM → решение NPC
В итоге получится NPC, который:
понимает голос игрока
знает мир игры
помнит прошлые разговоры
делает выводы из наблюдений
принимает решения
отвечает голосом
Вдохновение: Generative Agents
Прежде чем писать код, стоит понять, на чём основана архитектура.
В 2023 году вышла работа:
Generative Agents: Interactive Simulacra of Human Behavior Park J.S., O'Brien J.C., Cai C.J., Morris M.R., Liang P., Bernstein M.S. arXiv:2304.03442
Исследователи создали небольшой виртуальный город с 25 AI-агентами, каждый из которых мог ходить по городу, разговаривать, планировать день и запоминать события.
Показательный пример из статьи: пользователь задал одному агенту цель — устроить вечеринку в честь Дня святого Валентина. Дальше агенты действовали сами: рассылали приглашения, договаривались о свиданиях, координировали время. Никто не программировал это поведение — оно возникло из архитектуры.
Ключевой вывод авторов: просто дать агенту LLM недостаточно. Нужны три компонента:
┌──────────────────────────────────────────┐ │ Архитектура агента │ ├─────────────┬────────────┬───────────────┤ │ Memory │ Reflection │ Planning │ │ Stream │ │ │ │ │ │ │ │ Все события │ Выводы из │ План действий │ │ в виде │ накоплен- │ на основе │ │ текста с │ ных воспо- │ рефлексии │ │ временными │ минаний │ │ │ метками │ │ │ └─────────────┴────────────┴───────────────┘
Авторы доказали через ablation study: без рефлексии поведение агентов деградирует. Каждый компонент критичен.
В этой статье мы построим упрощённую, но верную по духу версию этой архитектуры внутри Unity.
Окружение и ограничения
Тестировалось на:
Unity 6.3 LTS, Windows 11
Python 3.14, Ollama + Mistral 7B
Тестировалось без GPU, только на CPU
Микрофон: встроенный Windows
Ограничения прототипа:
Это демо-сборка, а не production. Перед тем как двигаться дальше, важно понимать:
TTS требует интернет — edge-tts использует серверы Microsoft, голос генерируется онлайн
Фиксированные имена файлов —
voice.wavиreply.mp3перезаписываются при каждом запросе, параллельные запросы сломают логикуПамять без блокировок —
memories/{npc_id}.jsonне защищён от гонок записи при параллельных запросахlocalhost — работает только если Unity и сервер на одной машине
Нет vector retrieval — память передаётся как последние N записей, а не по релевантности
Задержка заметна — STT + LLM + TTS суммарно дают 5-15 сек на CPU без GPU, в зависимости от длины реплики и скорости процессора
Шаг 1. Минимальный эксперимент: Unity ↔ LLM
Прежде чем строить сложную систему, проверим базовую вещь:
Может ли Unity вообще общаться с LLM?
Настройка AI-сервера
Устанавливаем зависимости:
pip install fastapi uvicorn ollama
Запускаем локальную модель через Ollama:
ollama run mistral
Создаём простой сервер:
# main.py from fastapi import FastAPI import ollama app = FastAPI() @app.get("/npc") def npc_reply(text: str): resp = ollama.chat( model="mistral", messages=[{"role": "user", "content": text}] ) return {"reply": resp["message"]["content"]}
Запускаем:
uvicorn main:app --reload
Проверяем в браузере:
http://localhost:8000/npc?text=hello
Если видим JSON с ответом — фундамент готов.
Важно:
localhostработает только если Unity и FastAPI запущены на одной машине. Если Unity на другом устройстве (например Quest) — замени на IP компьютера в локальной сети, напримерhttp://192.168.1.100:8000. Не забудь проверить firewall.
✅ Шаг проверен: сервер запущен, LLM отвечает на запросы.
Шаг 2. Подключаем Unity
Создадим скрипт, который отправляет текст на сервер. Сразу добавим управление через пробел — эту же логику будем использовать для голоса.
// NPCClient.cs using UnityEngine; using UnityEngine.Networking; using UnityEngine.InputSystem; using System.Collections; public class NPCClient : MonoBehaviour { public string serverUrl = "http://localhost:8000/npc"; private bool _isBusy = false; void Update() { // Пробел — тест связи с сервером if (!_isBusy && Keyboard.current.spaceKey.wasPressedThisFrame) { AskNPC("Where is the tavern?"); } } public void AskNPC(string text) { StartCoroutine(Send(text)); } IEnumerator Send(string text) { _isBusy = true; string encodedText = UnityWebRequest.EscapeURL(text); string fullUrl = serverUrl + "?text=" + encodedText; using UnityWebRequest www = UnityWebRequest.Get(fullUrl); yield return www.SendWebRequest(); if (www.result == UnityWebRequest.Result.Success) { Debug.Log("NPC: " + www.downloadHandler.text); } else { Debug.LogError("Ошибка запроса: " + www.error); } _isBusy = false; } }
Частая ошибка: забыть
UnityWebRequest.EscapeURL(). Текст вроде"Where is the tavern?"будет обрезан на первом пробеле.
Тест
Повесь скрипт на любой GameObject. Нажми Play → нажми пробел.
Ожидаемый ответ в Console:
{"reply": "I'm an AI and don't have the ability to physically go to places. However, I can tell you that a tavern is a type of establishment serving alcoholic drinks, food, and sometimes lodging, and it's common in fantasy settings or historical contexts."}
✅ Шаг проверен: Unity → сервер → LLM → Unity работает.
Шаг 3. Добавляем роль NPC
Сейчас модель отвечает как обычный ассистент. Добавим персонажа.
На сервере:
@app.get("/npc") def npc_reply(text: str): resp = ollama.chat( model="mistral", messages=[ { "role": "system", "content": "You are a villager in a fantasy kingdom. Speak like a friendly NPC. Keep answers short." }, {"role": "user", "content": text} ] ) return {"reply": resp["message"]["content"]}
Теперь ответы выглядят иначе:
The tavern, good traveler, is just beyond the market square, to your right, where the laughter and music are the loudest. May your mead be frothy and your tales be grand!
✅ Шаг проверен: NPC отвечает как персонаж фэнтези-мира.
Шаг 4. Push-to-Talk: добавляем голос в NPCClient.cs
Не создаём новый файл — расширяем NPCClient.cs. Добавляем запись микрофона прямо в него.
Логика Push-to-Talk:
wasPressedThisFrame → Microphone.Start() wasReleasedThisFrame → Microphone.End() → обрезать неиспользованный буфер → WAV → сервер
Важно: добавьте в проект WavUtility.cs — он конвертирует
AudioClipв байты WAV.
// NPCClient.cs — расширенная версия using UnityEngine; using UnityEngine.Networking; using UnityEngine.InputSystem; using System.Collections; public class NPCClient : MonoBehaviour { public string serverUrl = "http://localhost:8000/npc"; public string voiceUrl = "http://localhost:8000/voice"; public string npcId = "villager_01"; public int maxSeconds = 10; private bool _isBusy = false; private bool _isRecording = false; private AudioClip _clip; void Update() { if (_isBusy) return; // Голос — зажать пробел, говорить, отпустить if (Keyboard.current.spaceKey.wasPressedThisFrame) StartRecording(); if (Keyboard.current.spaceKey.wasReleasedThisFrame) StartCoroutine(StopAndSend()); } // ── Текстовый запрос ───────────────────────────────────── public void AskNPC(string text) { StartCoroutine(Send(text)); } IEnumerator Send(string text) { _isBusy = true; string encodedText = UnityWebRequest.EscapeURL(text); string fullUrl = serverUrl + "?text=" + encodedText; using UnityWebRequest www = UnityWebRequest.Get(fullUrl); yield return www.SendWebRequest(); if (www.result == UnityWebRequest.Result.Success) Debug.Log("NPC: " + www.downloadHandler.text); else Debug.LogError("Ошибка запроса: " + www.error); _isBusy = false; } // ── Голосовой запрос ──────────────────────────────────── void StartRecording() { if (_isRecording) return; _isRecording = true; _clip = Microphone.Start(null, false, maxSeconds, 16000); // 16000 Гц — стандарт для STT. Для надёжности на разных устройствах // проверяй ограничения через Microphone.GetDeviceCaps(null, out int minFreq, out int maxFreq) Debug.Log("[Voice] Запись... (держи пробел)"); } IEnumerator StopAndSend() { if (!_isRecording) yield break; int recordedSamples = Microphone.GetPosition(null); Microphone.End(null); _isRecording = false; // Один кадр паузы — Microphone успевает дописать последние сэмплы yield return null; AudioClip trimmed = TrimClip(_clip, recordedSamples); if (trimmed == null || recordedSamples < 1600) { Debug.LogWarning("[Voice] Слишком короткая запись, игнорируем."); yield break; } Debug.Log($"[Voice] Записано {recordedSamples / 16000f:F1} сек. Отправляем..."); _isBusy = true; byte[] wav = WavUtility.FromAudioClip(trimmed); WWWForm form = new WWWForm(); form.AddBinaryData("file", wav, "voice.wav", "audio/wav"); form.AddField("npc_id", npcId); using UnityWebRequest www = UnityWebRequest.Post(voiceUrl, form); yield return www.SendWebRequest(); if (www.result == UnityWebRequest.Result.Success) Debug.Log("[Voice] Ответ: " + www.downloadHandler.text); else Debug.LogError("[Voice] Ошибка: " + www.error); _isBusy = false; } AudioClip TrimClip(AudioClip source, int samples) { if (source == null || samples <= 0) return null; float[] data = new float[samples * source.channels]; source.GetData(data, 0); AudioClip trimmed = AudioClip.Create( "voice_trimmed", samples, source.channels, source.frequency, false ); trimmed.SetData(data, 0); return trimmed; } }
Тест
Нажми Play, зажми пробел, скажи «Where is the tavern?», отпусти.
В Console должно появиться:
[Voice] Запись... (держи пробел) [Voice] Записано 2.1 сек. Отправляем... [Voice] Ошибка: ... ← нормально, endpoint /voice ещё не существует
Ошибка ожидаема — сервер пока не умеет принимать аудио. Это следующий шаг.
✅ Шаг проверен: микрофон записывает, WAV формируется, запрос уходит.
С этого момента
/npcбольше не используется — весь диалог идёт через/voice. Endpoint можно оставить вmain.pyили удалить.
Шаг 5. Добавляем STT в main.py
Не создаём новый файл — заменяем содержимое main.py полной версией с обоими endpoint-ами:
Устанавливаем:
pip install faster-whisper python-multipart
# main.py — полная версия from fastapi import FastAPI, UploadFile, Form from faster_whisper import WhisperModel import ollama app = FastAPI() whisper = WhisperModel("base", device="cpu", compute_type="int8") # device="cpu" — запускаем на процессоре, без GPU # compute_type="int8" — квантизация для скорости на CPU # Если есть CUDA и установлен cudnn — можно поменять на device="cuda", compute_type="float16" @app.get("/npc") def npc_reply(text: str): resp = ollama.chat( model="mistral", messages=[ {"role": "system", "content": "You are a villager in a fantasy kingdom. Keep answers short. You have a memory of past conversations — use it naturally in your responses."}, {"role": "user", "content": text} ] ) return {"reply": resp["message"]["content"]} @app.post("/voice") async def voice( file: UploadFile, npc_id: str = Form("default") ): path = "voice.wav" with open(path, "wb") as f: f.write(await file.read()) segments, _ = whisper.transcribe(path) player_text = " ".join(s.text for s in segments).strip() reply = ollama.chat( model="mistral", messages=[ {"role": "system", "content": "You are a villager in a fantasy kingdom. Keep answers short. You have a memory of past conversations — use it naturally in your responses."}, {"role": "user", "content": player_text} ] ) return { "player_text": player_text, "reply": reply["message"]["content"] }
Перезапускаем сервер:
python -m uvicorn main:app --reload
Тест
Снова зажми пробел, скажи «Where is the tavern?», отпусти. Теперь в Console:
[Voice] Записано 2.1 сек. Отправляем... [Voice] Ответ: {"player_text": "Where is the tavern?", "reply": "..."}
✅ Шаг проверен: речь распознана, LLM ответил.
Шаг 6. NPC отвечает голосом (TTS)
Замкнём цикл. Дописываем TTS в main.py и воспроизведение в NPCClient.cs.
Что такое edge-tts
edge-tts — Python библиотека которая использует TTS движок Microsoft Edge браузера. Внутри Edge работает Azure Neural TTS — те же нейросетевые голоса что в Azure Cognitive Services, но бесплатно и без API-ключей.
Качество хорошее: естественные нейросетевые голоса, много языков. en-GB-RyanNeural — британский мужской голос, звучит уместно для фэнтези NPC.
Единственный минус — требует интернет-соединения. То есть TTS в этом прототипе не локальный — голос генерируется на серверах Microsoft. Для демо это нормально. Для офлайн-игры смотри в сторону Piper (локальный, быстрый, без интернета).
pip install edge-tts
Обновляем main.py
# main.py — полная версия from fastapi import FastAPI, UploadFile, Form from fastapi.responses import FileResponse from faster_whisper import WhisperModel import edge_tts import ollama app = FastAPI() whisper = WhisperModel("base", device="cpu", compute_type="int8") @app.get("/npc") def npc_reply(text: str): resp = ollama.chat( model="mistral", messages=[ {"role": "system", "content": "You are a villager in a fantasy kingdom. Keep answers short. You have a memory of past conversations — use it naturally in your responses."}, {"role": "user", "content": text} ] ) return {"reply": resp["message"]["content"]} @app.post("/voice") async def voice( file: UploadFile, npc_id: str = Form("default") ): # STT path = "voice.wav" with open(path, "wb") as f: f.write(await file.read()) segments, _ = whisper.transcribe(path) player_text = " ".join(s.text for s in segments).strip() # LLM reply = ollama.chat( model="mistral", messages=[ {"role": "system", "content": "You are a villager in a fantasy kingdom. Keep answers short. You have a memory of past conversations — use it naturally in your responses."}, {"role": "user", "content": player_text} ] ) reply_text = reply["message"]["content"] # TTS → MP3 communicate = edge_tts.Communicate(reply_text, voice="en-GB-RyanNeural") await communicate.save("reply.mp3") return FileResponse("reply.mp3", media_type="audio/mpeg")
Упрощение для демо:
voice.wavиreply.mp3— фиксированные имена. Работает для одного пользователя, но сломается при параллельных запросах. В реальном проекте используйuuid4()для уникальных имён илиtempfile.NamedTemporaryFile().
Обновляем NPCClient.cs
На GameObject должен быть компонент AudioSource — добавь через Add Component → Audio → Audio Source.
Обновляем StopAndSend() — заменяем downloadHandler на DownloadHandlerAudioClip:
_isBusy = true; byte[] wav = WavUtility.FromAudioClip(trimmed); WWWForm form = new WWWForm(); form.AddBinaryData("file", wav, "voice.wav", "audio/wav"); form.AddField("npc_id", npcId); using UnityWebRequest www = UnityWebRequest.Post(voiceUrl, form); www.downloadHandler = new DownloadHandlerAudioClip(voiceUrl, AudioType.MPEG); yield return www.SendWebRequest(); if (www.result == UnityWebRequest.Result.Success) { AudioClip clip = DownloadHandlerAudioClip.GetContent(www); AudioSource audioSource = GetComponent<AudioSource>(); audioSource.clip = clip; audioSource.Play(); Debug.Log("[Voice] NPC говорит..."); } else { Debug.LogError("[Voice] Ошибка: " + www.error); } _isBusy = false;
Тест
Зажми пробел, скажи «Where is the tavern?», отпусти. В Console:
[Voice] Запись... (держи пробел) [Voice] Записано 2.4 сек. Отправляем... [Voice] NPC говорит...
И из динамиков должен прозвучать голос NPC.
✅ Шаг проверен: полный голосовой цикл работает:
Игрок говорит → WAV → STT → LLM → TTS → MP3 → NPC говорит
Это уже демо-достойная система. Следующие шаги добавляют интеллект из Generative Agents.
Шаг 7. Добавляем контекст мира
Пока NPC не знает мир игры. Исправим это — добавим WorldContextBuilder.cs и передачу контекста в уже существующих файлах.
Создаём отдельный компонент на том же GameObject:
// WorldContextBuilder.cs — новый файл using UnityEngine; [System.Serializable] public class Place { public string id; public string name; } [System.Serializable] public class WorldContext { public string time_of_day; public string village; public Place[] places; } public class WorldContextBuilder : MonoBehaviour { public string GetContextJson() { var ctx = new WorldContext { time_of_day = GetTimeOfDay(), village = "Ashen Pass", places = new Place[] { new Place { id = "tavern", name = "The Drunk Dragon Tavern" }, new Place { id = "blacksmith", name = "The Red Forge Blacksmith" }, new Place { id = "market", name = "The Silver Crossroads Market" }, new Place { id = "tower", name = "The Silent One Tower" }, new Place { id = "crypt", name = "The Founders Crypt" }, new Place { id = "alchemist", name = "The Crooked Mirror Alchemist" } } }; return JsonUtility.ToJson(ctx); } private string GetTimeOfDay() { int hour = System.DateTime.Now.Hour; if (hour < 6) return "night"; if (hour < 12) return "morning"; if (hour < 18) return "afternoon"; return "evening"; } }
Обновляем StopAndSend() в NPCClient.cs — добавляем world_context в форму:
// NPCClient.cs — добавляем в StopAndSend() перед отправкой string worldJson = GetComponent<WorldContextBuilder>().GetContextJson(); WWWForm form = new WWWForm(); form.AddBinaryData("file", wav, "voice.wav", "audio/wav"); form.AddField("world_context", worldJson); form.AddField("npc_id", npcId);
Обновляем /voice в main.py — принимаем и используем контекст:
# main.py — полная версия from fastapi import FastAPI, UploadFile, Form from fastapi.responses import FileResponse from faster_whisper import WhisperModel import edge_tts import ollama app = FastAPI() whisper = WhisperModel("base", device="cpu", compute_type="int8") @app.get("/npc") def npc_reply(text: str): resp = ollama.chat( model="mistral", messages=[ {"role": "system", "content": "You are a villager in a fantasy kingdom. Keep answers short. You have a memory of past conversations — use it naturally in your responses."}, {"role": "user", "content": text} ] ) return {"reply": resp["message"]["content"]} @app.post("/voice") async def voice( file: UploadFile, world_context: str = Form(""), npc_id: str = Form("default") ): # STT path = "voice.wav" with open(path, "wb") as f: f.write(await file.read()) segments, _ = whisper.transcribe(path) player_text = " ".join(s.text for s in segments).strip() # LLM reply = ollama.chat( model="mistral", messages=[ {"role": "system", "content": "You are a villager in a fantasy kingdom. Keep answers short. You have a memory of past conversations — use it naturally in your responses."}, {"role": "system", "content": f"WORLD_CONTEXT: {world_context}"}, {"role": "user", "content": player_text} ] ) reply_text = reply["message"]["content"] # TTS → MP3 communicate = edge_tts.Communicate(reply_text, voice="en-GB-RyanNeural") await communicate.save("reply.mp3") return FileResponse("reply.mp3", media_type="audio/mpeg")
✅ Шаг проверен: спросите «What places are here?» — NPC ответит про Drunk Dragon Tavern и Red Forge Blacksmith.
Важная деталь: тип места в name даёт модели семантику. В моих тестах Mistral обычно сопоставлял смысл вопроса с Tavern в названии и указывал на Drunk Dragon, даже если слова «drink» в данных не было. Это не поиск по ключевым словам — это понимание контекста, хотя результат может варьироваться в зависимости от модели.
Почему просто хранить историю диалога недостаточно
На этом этапе NPC умеет говорить голосом и знает мир. Но у него нет памяти — каждый разговор начинается с чистого листа.
Очевидное решение: просто передавать историю диалогов в контекст LLM. Это работает, но быстро упирается в проблему.
Представь: игрок три раза спрашивал про таверну, один раз про кузнеца, один раз про «опасные места рядом». NPC помнит все пять фраз — но не понимает, что за ними стоит. Это просто лог.
Авторы Generative Agents показали: нужен ещё один шаг — рефлексия. LLM смотрит на накопленные наблюдения и делает вывод более высокого уровня:
«Этот путник явно новичок в деревне — он спрашивал о базовых местах. Вопрос про опасные места говорит о том, что он готовится к чему-то рискованному. Возможно, ищет приключений.»
Теперь это уже не лог — это образ игрока. И в следующем разговоре NPC реагирует на этот образ: сам предлагает помощь, предупреждает об опасности, ведёт себя как персонаж, который действительно знает собеседника.
Именно это авторы называют словом «believable» — правдоподобный. Не «умный», а ведущий себя как живой.
Три компонента, которые мы добавим, решают три разные проблемы:
Компонент | Проблема | Решение |
|---|---|---|
Memory Stream | NPC забывает | Записывать все наблюдения |
Reflection | NPC не понимает | Синтезировать инсайты из памяти |
Planning | NPC не инициирует | Формировать намерения из инсайтов |
Шаг 8. Memory Stream — NPC запоминает события
Это первый из трёх ключевых компонентов архитектуры Generative Agents.
Идея проста: каждое наблюдение NPC записывается в поток памяти — список событий на естественном языке с временной меткой.
Создаём отдельный файл memory.py рядом с main.py:
Примечание: здесь показана упрощённая структура памяти для понимания концепции. В шаге 11 мы заменим её на финальную версию со счётчиком разговоров для точных триггеров рефлексии и планирования. Структура файла тоже изменится: вместо плоского списка будет объект
{"interaction_count": 0, "memories": [...]}.
# memory.py import json import os from datetime import datetime MEMORY_DIR = "memories" os.makedirs(MEMORY_DIR, exist_ok=True) def add_memory(npc_id: str, event: str): """Добавить наблюдение в поток памяти NPC.""" path = f"{MEMORY_DIR}/{npc_id}.json" memories = load_memories(npc_id) memories.append({ "time": datetime.now().isoformat(), "event": event }) # Храним последние 50 воспоминаний if len(memories) > 50: memories = memories[-50:] with open(path, "w", encoding="utf-8") as f: json.dump(memories, f, ensure_ascii=False, indent=2) def load_memories(npc_id: str) -> list: """Загрузить все воспоминания NPC.""" path = f"{MEMORY_DIR}/{npc_id}.json" if not os.path.exists(path): return [] with open(path, encoding="utf-8") as f: return json.load(f) def get_recent_memories(npc_id: str, n: int = 10) -> str: """Вернуть N последних воспоминаний в виде текста.""" memories = load_memories(npc_id) recent = memories[-n:] if len(memories) >= n else memories if not recent: return "No memories yet." return "\n".join(f"- [{m['time'][:16]}] {m['event']}" for m in recent)
Но чтобы это заработало — нужно подключить память в main.py. Добавляем импорт и обновляем /voice:
# main.py — добавляем импорт from memory import add_memory, get_recent_memories # В /voice endpoint обновляем после STT: @app.post("/voice") async def voice( file: UploadFile, world_context: str = Form(""), npc_id: str = Form("default") ): # STT path = "voice.wav" with open(path, "wb") as f: f.write(await file.read()) segments, _ = whisper.transcribe(path) player_text = " ".join(s.text for s in segments).strip() # Memory add_memory(npc_id, f"Player said: '{player_text}'") recent = get_recent_memories(npc_id, n=5) # LLM с памятью reply = ollama.chat( model="mistral", messages=[ {"role": "system", "content": "You are a villager in a fantasy kingdom. Keep answers short. You have a memory of past conversations — use it naturally in your responses."}, {"role": "system", "content": f"YOUR RECENT MEMORIES:\n{recent}"}, {"role": "system", "content": f"WORLD_CONTEXT: {world_context}"}, {"role": "user", "content": player_text} ] ) reply_text = reply["message"]["content"] add_memory(npc_id, f"I replied: '{reply_text}'") # TTS → MP3 communicate = edge_tts.Communicate(reply_text, voice="en-GB-RyanNeural") await communicate.save("reply.mp3") return FileResponse("reply.mp3", media_type="audio/mpeg")
Тест
Поговори с NPC дважды — во втором разговоре NPC должен упомянуть первый. В папке memories/ появится villager_01.json:
[ { "time": "2026-03-11T18:45:00", "event": "Player said: 'Where is the tavern?'" }, { "time": "2026-03-11T18:45:10", "event": "I replied: 'The Drunk Dragon Tavern is just past the market square.'" } ]
✅ Шаг проверен: после разговора в папке memories/ появится файл villager_01.json с записями.
Шаг 9. Reflection — NPC делает выводы
В оригинальном исследовании рефлексия — это периодический процесс: агент берёт последние N воспоминаний и просит LLM вывести из них высокоуровневые инсайты. Например, из воспоминаний «Player asked about the blacksmith three times» модель делает вывод: «This player is looking for a weapon».
Авторы показали: без рефлексии поведение агентов становится менее связным. Рефлексия — это то, что превращает набор фактов в понимание.
Добавляем в memory.py:
# memory.py — добавляем в конец файла import ollama # добавить в начало файла def reflect(npc_id: str) -> str: """ Синтезировать воспоминания в высокоуровневые выводы. Вызывается каждые N взаимодействий. """ memories = load_memories(npc_id) if len(memories) < 5: return "" # Ещё недостаточно данных recent = memories[-20:] memory_text = "\n".join(f"- {m['event']}" for m in recent) response = ollama.chat( model="mistral", messages=[ { "role": "system", "content": "You are analyzing an NPC's memories. Be concise." }, { "role": "user", "content": ( f"Based on these recent memories of NPC '{npc_id}':\n" f"{memory_text}\n\n" "Write 3 short insights about this player and situation. " "Format: bullet points, max 1 sentence each." ) } ] ) insights = response["message"]["content"] add_memory(npc_id, f"[REFLECTION] {insights}") return insights
Добавляем вызов рефлексии в main.py. Считаем реплики игрока по строкам Player said: — в шаге 11 мы заменим этот подсчёт на отдельн��е поле interaction_count в памяти NPC:
# main.py — обновляем импорт from memory import add_memory, get_recent_memories, load_memories, reflect # В /voice endpoint обновляем после add_memory: add_memory(npc_id, f"Player said: '{player_text}'") # Рефлексия каждые 5 разговоров — считаем реплики игрока memories = load_memories(npc_id) conversation_count = sum(1 for m in memories if m["event"].startswith("Player said:")) if conversation_count % 5 == 0: insights = reflect(npc_id) if insights: print(f"[REFLECTION] {insights}") recent = get_recent_memories(npc_id, n=8) reply = ollama.chat( model="mistral", messages=[ {"role": "system", "content": "You are a villager in a fantasy kingdom. Keep answers short. You have a memory of past conversations — use it naturally in your responses."}, {"role": "system", "content": f"YOUR RECENT MEMORIES:\n{recent}"}, {"role": "system", "content": f"WORLD_CONTEXT: {world_context}"}, {"role": "user", "content": player_text} ] ) reply_text = reply["message"]["content"] add_memory(npc_id, f"I replied: '{reply_text}'")
✅ Шаг проверен: после нескольких разговоров в PowerShell появится [REFLECTION], а в villager_01.json — запись с выводами о поведении игрока.
Шаг 10. Planning — NPC планирует действия
В оригинальной статье план — расписание на день. Мы сделаем игровую версию: NPC формирует намерения на основе накопленных инсайтов.
Добавляем в memory.py:
# memory.py — добавляем в конец файла def get_or_create_plan(npc_id: str) -> str: """ Получить текущий план NPC. Если плана нет или он устарел — сгенерировать новый. """ plan_path = f"{MEMORY_DIR}/{npc_id}_plan.txt" memories = load_memories(npc_id) # Считаем реплики игрока по строкам "Player said:". # В шаге 11 мы заменим этот подсчёт на отдельное поле interaction_count. conversation_count = sum(1 for m in memories if m["event"].startswith("Player said:")) should_replan = ( not os.path.exists(plan_path) or conversation_count % 10 == 0 ) if not should_replan: with open(plan_path, encoding="utf-8") as f: return f.read() reflections = [ m["event"] for m in memories if "[REFLECTION]" in m["event"] ] reflection_text = "\n".join(reflections[-3:]) if reflections else "No reflections yet." response = ollama.chat( model="mistral", messages=[ { "role": "system", "content": "You are a fantasy NPC planning your behavior. Be concise." }, { "role": "user", "content": ( f"You are NPC '{npc_id}', a villager.\n" f"Your recent insights:\n{reflection_text}\n\n" "Write your current intentions as 2-3 short bullet points. " "Example: '- Help lost travelers find the tavern'" ) } ] ) plan = response["message"]["content"] with open(plan_path, "w", encoding="utf-8") as f: f.write(plan) return plan
Добавляем план в main.py — обновляем импорт и передаём план в контекст LLM:
# main.py — обновляем импорт from memory import add_memory, get_recent_memories, reflect, get_or_create_plan # В /voice endpoint добавляем после рефлексии: recent = get_recent_memories(npc_id, n=8) npc_plan = get_or_create_plan(npc_id) print(f"[PLAN] {npc_plan}") # лог — виден в PowerShell на каждом запросе reply = ollama.chat( model="mistral", messages=[ {"role": "system", "content": "You are a villager in a fantasy kingdom. Keep answers short. You have a memory of past conversations — use it naturally in your responses."}, {"role": "system", "content": f"YOUR CURRENT PLAN:\n{npc_plan}"}, {"role": "system", "content": f"YOUR RECENT MEMORIES:\n{recent}"}, {"role": "system", "content": f"WORLD_CONTEXT: {world_context}"}, {"role": "user", "content": player_text} ] )
✅ Шаг проверен: файл villager_01_plan.txt создаётся уже при первом разговоре. В PowerShell текущий план выводится при каждом запросе. В шаге 11 мы заменим этот подсчёт на отдельное поле interaction_count в памяти NPC.
Шаг 11. JSON-контракт и финальные файлы
Теперь NPC имеет память и характер. Нужно оформить ответ в структуру, которую Unity умеет читать.
JSON-контракт:
{ "reply_text": "The blacksmith? Head to the Red Forge, just past the market.", "action": "point", "target": "blacksmith" }
Главное изменение этого шага — в main.py: добавляем SYSTEM_PROMPT с JSON-форматом, JSON-валидацию и передачу решения через заголовок ответа.
Примечание: здесь используется prompt-based JSON — просим модель вернуть JSON через system prompt и валидируем вручную. Это рабочий подход для прототипа. Для более надёжного решения Ollama поддерживает structured outputs — JSON schema на уровне API, без зависимости от промпта.
Вот полный финальный main.py:
# main.py — финальная версия from fastapi import FastAPI, UploadFile, Form from fastapi.responses import FileResponse from faster_whisper import WhisperModel from memory import add_memory, get_recent_memories, reflect, get_or_create_plan, get_interaction_count import edge_tts import ollama import json app = FastAPI() whisper = WhisperModel("base", device="cpu", compute_type="int8") # device="cpu" — без GPU, работает везде # compute_type="int8" — быстрее на CPU SYSTEM_PROMPT = """ You are a villager NPC in a fantasy world. You have memories of past conversations and a personal plan. Always respond ONLY in valid JSON. No extra text, no markdown. Format: { "reply_text": "Your spoken reply (1-2 sentences, in character)", "action": "none|point|give_quest|open_shop", "target": "poi_id from WORLD_CONTEXT or null" } Rules: - Stay in character, use your memories naturally - action rules: - "point": when player asks WHERE something is — use this to point at a location - "give_quest": when player asks for help with a task or problem - "open_shop": when player wants to buy something - "none": for general conversation - target: only IDs from WORLD_CONTEXT, or null - Never break the JSON format """ @app.get("/npc") def npc_reply(text: str): resp = ollama.chat( model="mistral", messages=[ {"role": "system", "content": "You are a villager in a fantasy kingdom. Keep answers short. You have a memory of past conversations — use it naturally in your responses."}, {"role": "user", "content": text} ] ) return {"reply": resp["message"]["content"]} @app.post("/voice") async def voice( file: UploadFile, world_context: str = Form(""), npc_id: str = Form("default") ): # STT path = "voice.wav" with open(path, "wb") as f: f.write(await file.read()) segments, _ = whisper.transcribe(path) player_text = " ".join(s.text for s in segments).strip() # Memory — считаем этот разговор как одно взаимодействие add_memory(npc_id, f"Player said: '{player_text}'", count_interaction=True) # Reflection каждые 5 разговоров count = get_interaction_count(npc_id) if count % 5 == 0: insights = reflect(npc_id) if insights: print(f"[REFLECTION] {insights}") recent = get_recent_memories(npc_id, n=8) npc_plan = get_or_create_plan(npc_id) # обновляется каждые 10 разговоров # LLM raw = ollama.chat( model="mistral", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": f"YOUR CURRENT PLAN:\n{npc_plan}"}, {"role": "system", "content": f"YOUR RECENT MEMORIES:\n{recent}"}, {"role": "system", "content": f"WORLD_CONTEXT: {world_context}"}, {"role": "user", "content": player_text} ] )["message"]["content"] # Валидация JSON try: decision = json.loads(raw) except json.JSONDecodeError: decision = {"reply_text": raw, "action": "none", "target": None} reply_text = decision.get("reply_text", raw) add_memory(npc_id, f"I replied: '{reply_text}'") # TTS → MP3 communicate = edge_tts.Communicate(reply_text, voice="en-GB-RyanNeural") await communicate.save("reply.mp3") # Возвращаем аудио + JSON решение в заголовке response = FileResponse("reply.mp3", media_type="audio/mpeg") response.headers["X-NPC-Decision"] = json.dumps(decision) return response
Заметь: решение NPC возвращается в HTTP-заголовке
X-NPC-Decision. Так Unity получает и аудио, и данные для действия одним запросом — удобно для демо. В более серьёзной системе стоит рассмотреть multipart-ответ или отдельный endpoint для JSON.
И финальный memory.py со всеми компонентами:
Важно: если ты проходил предыдущие шаги по порядку, удали старые файлы из папки
memories/перед переходом на эту версию. В шагах 8–10 память хранилась как список[{...}], а здесь формат изменился на объект{"interaction_count": 0, "memories": [...]}— старый файл вызовет ошибку.
# memory.py — финальная версия import json import os from datetime import datetime import ollama MEMORY_DIR = "memories" os.makedirs(MEMORY_DIR, exist_ok=True) def load_data(npc_id: str) -> dict: """Загрузить полные данные NPC (воспоминания + счётчик).""" path = f"{MEMORY_DIR}/{npc_id}.json" if not os.path.exists(path): return {"interaction_count": 0, "memories": []} with open(path, encoding="utf-8") as f: return json.load(f) def save_data(npc_id: str, data: dict): """Сохранить данные NPC.""" path = f"{MEMORY_DIR}/{npc_id}.json" with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def add_memory(npc_id: str, event: str, count_interaction: bool = False): """Добавить наблюдение в поток памяти NPC.""" data = load_data(npc_id) data["memories"].append({ "time": datetime.now().isoformat(), "event": event }) if count_interaction: data["interaction_count"] += 1 # Храним последние 50 воспоминаний if len(data["memories"]) > 50: data["memories"] = data["memories"][-50:] save_data(npc_id, data) def load_memories(npc_id: str) -> list: """Загрузить список воспоминаний NPC.""" return load_data(npc_id)["memories"] def get_interaction_count(npc_id: str) -> int: """Вернуть число разговоров с игроком.""" return load_data(npc_id)["interaction_count"] def get_recent_memories(npc_id: str, n: int = 10) -> str: """Вернуть N последних воспоминаний в виде текста.""" memories = load_memories(npc_id) recent = memories[-n:] if len(memories) >= n else memories if not recent: return "No memories yet." return "\n".join(f"- [{m['time'][:16]}] {m['event']}" for m in recent) def reflect(npc_id: str) -> str: """Синтезировать воспоминания в высокоуровневые выводы.""" memories = load_memories(npc_id) if len(memories) < 5: return "" recent = memories[-20:] memory_text = "\n".join(f"- {m['event']}" for m in recent) response = ollama.chat( model="mistral", messages=[ { "role": "system", "content": "You are analyzing an NPC's memories. Be concise." }, { "role": "user", "content": ( f"Based on these recent memories of NPC '{npc_id}':\n" f"{memory_text}\n\n" "Write 3 short insights about this player and situation. " "Format: bullet points, max 1 sentence each." ) } ] ) insights = response["message"]["content"] add_memory(npc_id, f"[REFLECTION] {insights}") return insights def get_or_create_plan(npc_id: str) -> str: """Получить текущий план NPC. Если устарел — сгенерировать новый.""" plan_path = f"{MEMORY_DIR}/{npc_id}_plan.txt" count = get_interaction_count(npc_id) should_replan = ( not os.path.exists(plan_path) or count % 10 == 0 # каждые 10 разговоров ) if not should_replan: with open(plan_path, encoding="utf-8") as f: return f.read() # Use reflections as basis for planning reflections = [ m["event"] for m in load_memories(npc_id) if "[REFLECTION]" in m["event"] ] reflection_text = "\n".join(reflections[-3:]) if reflections else "No reflections yet." response = ollama.chat( model="mistral", messages=[ { "role": "system", "content": "You are a fantasy NPC planning your behavior. Be concise." }, { "role": "user", "content": ( f"You are NPC '{npc_id}', a villager.\n" f"Your recent insights:\n{reflection_text}\n\n" "Write your current intentions as 2-3 short bullet points. " "Example: '- Help lost travelers find the tavern'" ) } ] ) plan = response["message"]["content"] with open(plan_path, "w", encoding="utf-8") as f: f.write(plan) return plan
✅ Шаг проверен: ответ сервера — MP3 аудио + JSON с action и target в заголовке.
Шаг 12. Unity читает решение и NPC действует
К этому шагу все скрипты висят на пустом GameObject в сцене — реального NPC нет. Это нормально для демо и проверки системы. Для полноценной игры NPCClient и NPCController нужно перенести на GameObject с моделью персонажа и AudioSource. Пока всё работает и так.
Добавляем два новых файла и обновляем NPCClient.cs.
give_questиopen_shop— это примеры возможных действий, заглушки.PointAtTargetработает сразу если в сцене есть нужные GameObject-ы. Остальные методы ты подключаешь к своим системам сам.
NPCResponse.cs — просто структура данных, никуда вешать не нужно. NPCController.cs — добавь на тот же GameObject что и NPCClient.cs, иначе GetComponent<NPCController>() вернёт null.
// NPCResponse.cs [System.Serializable] public class NPCResponse { public string reply_text; public string action; public string target; }
// NPCController.cs using UnityEngine; public class NPCController : MonoBehaviour { public void ApplyDecision(string json, AudioClip voiceClip) { NPCResponse response = JsonUtility.FromJson<NPCResponse>(json); // NPC говорит if (voiceClip != null) { AudioSource audioSource = GetComponent<AudioSource>(); audioSource.clip = voiceClip; audioSource.Play(); } // NPC действует switch (response.action) { case "point": PointAtTarget(response.target); break; case "give_quest": TriggerQuest(response.target); break; case "open_shop": OpenShop(response.target); break; } } private void PointAtTarget(string targetId) { GameObject target = GameObject.Find(targetId); if (target != null) { transform.LookAt(target.transform); // Animator.SetTrigger("Point"); } } private void TriggerQuest(string questId) { // Заглушка — подключи свою систему квестов // QuestManager.Instance.StartQuest(questId); Debug.Log("Quest started: " + questId); } private void OpenShop(string shopId) { // Заглушка — подключи свой UI магазина // ShopUI.Instance.Open(shopId); Debug.Log("Shop opened: " + shopId); } }
Обновляем StopAndSend() в NPCClient.cs — читаем заголовок и передаём решение в контроллер:
if (www.result == UnityWebRequest.Result.Success) { // Читаем JSON решение из заголовка string decisionJson = www.GetResponseHeader("X-NPC-Decision"); // Воспроизводим аудио и применяем действие AudioClip clip = DownloadHandlerAudioClip.GetContent(www); if (!string.IsNullOrEmpty(decisionJson)) { GetComponent<NPCController>().ApplyDecision(decisionJson, clip); } else { // Заголовок пустой — просто воспроизводим голос GetComponent<AudioSource>().PlayOneShot(clip); } Debug.Log("[Voice] NPC говорит: " + decisionJson); } else { Debug.LogError("[Voice] Ошибка: " + www.error); }
✅ Шаг проверен: спросите «Where is the blacksmith?» — NPC ответит голосом и повернётся в нужную сторону.
Важно для
PointAtTarget:GameObject.Find(targetId)ищет объект по имени в сцене. Чтобы NPC было куда поворачиваться — создай в сцене пустые GameObject-ы с именами совпадающими сidизWorldContextBuilder:
tavern, blacksmith, market, tower, crypt, alchemistРасставь их в нужных местах сцены. Без них
PointAtTargetнайдётnullи просто ничего не сделает — ошибки не будет, но и поворота тоже.
Финальная архитектура
┌─────────────────────────────────────────────────────┐ │ UNITY │ │ NPCClient → WorldContextBuilder │ │ ↓ WAV + world_context + npc_id │ └─────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────┐ │ AI-СЕРВЕР │ │ │ │ STT (Whisper) → player_text │ │ ↓ │ │ ┌──────────────────────────────────────────────┐ │ │ │ Архитектура Generative Agents │ │ │ │ │ │ │ │ Memory Stream → наблюдения + диалоги │ │ │ │ ↓ │ │ │ │ Reflection → инсайты / 5 разговоров │ │ │ │ ↓ │ │ │ │ Planning → намерения / 10 разговоров │ │ │ └──────────────────────────────────────────────┘ │ │ ↓ │ │ LLM (Mistral) + world_context → JSON-решение │ │ ↓ │ │ TTS (edge-tts) → MP3 │ └─────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────┐ │ UNITY │ │ NPCController.ApplyDecision(json, audioClip) │ │ ↙ ↘ │ │ NPC говорит NPC действует │ │ (AudioSource.Play) (point / quest / shop) │ └─────────────────────────────────────────────────────┘
Итог
Мы прошли путь от минимального теста до системы, где NPC:
Слышит игрока через микрофон
Знает мир игры через JSON-контекст
Помнит прошлые разговоры (Memory Stream)
Делает выводы из наблюдений (Reflection)
Планирует своё поведение (Planning)
Принимает решения в структурированном формате
Говорит голосом в ответ
Действует — указывает, запускает квесты, открывает магазины
Ключевая идея, которую показали авторы Generative Agents — и которую мы реализовали здесь:
Просто дать NPC язык недостаточно. Нужна память, которая накапливает опыт. Нужна рефлексия, которая превращает опыт в понимание. Нужен план, который направляет действия.
Но за этим техническим выводом стоит кое-что важнее.
Я написал эту статью, потому что хочу играть в игры, которых пока не существует. Игры, где NPC — это не декорации с репликами, а персонажи с историей. Где житель деревни помнит, что ты помог ему три недели назад. Где торговец меняет отношение к тебе в зависимости от твоей репутации. Где разговор с незнакомцем может неожиданно стать важнее любого квеста.
Мы уже умеем делать красивые миры. Физически достоверные, визуально невероятные. Но эти миры часто населены персонажами, которые живут по сценарию — говорят заготовленные фразы и не помнят тебя.
LLM меняет это. Не потому что модель «умная» — а потому что она даёт персонажу возможность реагировать на контекст, а не воспроизводить заготовленные строки. Добавь память и рефлексию — и манекен начинает напоминать кого-то живого.
Это не фантастика. Мы только что собрали такой прототип на Mistral 7B, запущенном локально на обычном компьютере. LLM и STT работают полностью локально. Для TTS в этой версии я использовал edge-tts — онлайн-компонент, выбранный ради скорости сборки прототипа. Один разработчик, несколько библиотек и несколько дней на сборку рабочего прототипа.
Представь, что будет, когда это войдёт в продакшн AAA игр с нормальными моделями и нормальными ресурсами.
Именно поэтому я считаю, что разработчикам стоит начинать экспериментировать с этим сейчас. Не ждать, пока кто-то сделает готовый инструмент. Строить, ломать, понимать, как это работает изнутри — и двигать индустрию туда, где NPC наконец станут персонажами.
Исходный код из статьи: https://github.com/merrymaker14/unity-llm-npc
