Bối Cảnh Dự Án

Chào mọi người, tôi là một tech lead với 8 năm kinh nghiệm phát triển game tại studio indie của Việt Nam. Hôm nay tôi muốn chia sẻ hành trình chúng tôi triển khai hệ thống NPC thông minh trong game RPG sử dụng LLM Agent, từ quyết định chọn HolySheep AI cho đến quá trình implementation thực tế.

Dự án bắt đầu khi chúng tôi nhận ra rằng các NPC truyền thống với script cứng không còn đáp ứng được kỳ vọng của người chơi. Một câu chuyện game RPG hấp dẫn đòi hỏi NPC có khả năng giao tiếp tự nhiên, phản ứng theo ngữ cảnh, và tạo ra trải nghiệm độc đáo cho từng người chơi.

Vì Sao Chúng Tôi Cần LLM Agent Cho Game NPC

Trước khi đi vào chi tiết kỹ thuật, tôi muốn giải thích tại sao giải pháp LLM Agent lại quan trọng cho game NPC hiện đại:

Chọn HolySheep AI — Quyết Định Quan Trọng

Ban đầu, đội ngũ của tôi thử nghiệm với API chính thức của OpenAI. Kết quả rất ấn tượng về mặt chất lượng, nhưng chi phí vận hành là ác mộng. Với 50.000 người chơi active hàng ngày, mỗi người tương tác với NPC khoảng 20 lần/session, chi phí API đã vượt xa ngân sách của một studio indie.

Sau khi thử nghiệm nhiều giải pháp relay API, chúng tôi tìm thấy HolySheep AI — một nền tảng API relay với:

Kiến Trúc Hệ Thống NPC Thông Minh

Chúng tôi xây dựng hệ thống gồm 4 module chính:

Code Implementation — Bước 1: Cài Đặt Base Agent

Đầu tiên, chúng tôi tạo một Unity MonoBehaviour để wrap LLM Agent. Điều quan trọng: luôn sử dụng https://api.holysheep.ai/v1 làm base_url.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
using System.Text;
using System.Json;

namespace GameLLM
{
    [Serializable]
    public class ChatMessage
    {
        public string role; // "system", "user", "assistant"
        public string content;
    }

    [Serializable]
    public class ChatRequest
    {
        public string model;
        public List messages;
        public float temperature = 0.7f;
        public int max_tokens = 500;
    }

    [Serializable]
    public class ChatResponse
    {
        public List choices;
    }

    [Serializable]
    public class Choice
    {
        public Message message;
    }

    [Serializable]
    public class Message
    {
        public string content;
    }

    public class LLMNpcAgent : MonoBehaviour
    {
        // ⚠️ QUAN TRỌNG: Không bao giờ dùng api.openai.com
        private const string BASE_URL = "https://api.holysheep.ai/v1";
        private const string API_KEY = "YOUR_HOLYSHEEP_API_KEY";

        [Header("NPC Configuration")]
        [SerializeField] private string npcName = "VillageElder";
        [SerializeField] private string npcSystemPrompt = "Bạn là một cụ già thông thái trong làng. Bạn biết nhiều về lịch sử và có thể đưa ra lời khuyên cho người chơi.";

        private List conversationHistory = new List();
        private readonly int maxHistoryLength = 20;

        void Start()
        {
            // Khởi tạo system prompt
            conversationHistory.Add(new ChatMessage
            {
                role = "system",
                content = npcSystemPrompt
            });
        }

        public async Task SendMessageAsync(string playerMessage)
        {
            // Thêm tin nhắn của người chơi vào lịch sử
            conversationHistory.Add(new ChatMessage
            {
                role = "user",
                content = playerMessage
            });

            // Trim history nếu quá dài
            TrimConversationHistory();

            // Gọi API
            string response = await CallHolySheepAPIAsync();

            // Thêm response vào lịch sử
            conversationHistory.Add(new ChatMessage
            {
                role = "assistant",
                content = response
            });

            return response;
        }

        private async Task CallHolySheepAPIAsync()
        {
            // Sử dụng DeepSeek V3.2 để tiết kiệm chi phí - chỉ $0.42/MTok
            var request = new ChatRequest
            {
                model = "deepseek-chat",
                messages = conversationHistory,
                temperature = 0.7f,
                max_tokens = 300
            };

            string jsonBody = JsonUtility.ToJson(request);
            byte[] bodyBytes = Encoding.UTF8.GetBytes(jsonBody);

            using (UnityWebRequest request = new UnityWebRequest(BASE_URL + "/chat/completions", "POST"))
            {
                request.uploadHandler = new UploadHandlerRaw(bodyBytes);
                request.downloadHandler = new DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");
                request.SetRequestHeader("Authorization", $"Bearer {API_KEY}");

                request.timeout = 10; // Timeout 10 giây cho game

                await request.SendWebRequest();

                if (request.result != UnityWebRequest.Result.Success)
                {
                    Debug.LogError($"API Error: {request.error}");
                    return "Xin lỗi, có lỗi xảy ra. Vui lòng thử lại sau.";
                }

                ChatResponse response = JsonUtility.FromJson(request.downloadHandler.text);
                return response.choices[0].message.content;
            }
        }

        private void TrimConversationHistory()
        {
            // Giữ lại system prompt + maxHistoryLength messages gần nhất
            while (conversationHistory.Count > maxHistoryLength + 1)
            {
                conversationHistory.RemoveAt(1); // Luôn giữ system prompt ở index 0
            }
        }

        public void ResetConversation()
        {
            string systemPrompt = conversationHistory[0].content;
            conversationHistory.Clear();
            conversationHistory.Add(new ChatMessage
            {
                role = "system",
                content = systemPrompt
            });
        }
    }
}

Code Implementation — Bước 2: NPC Behavior Controller

Module này xử lý response từ LLM và chuyển đổi thành action cho NPC. Chúng tôi sử dụng JSON parsing để extract commands từ response.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;

namespace GameLLM
{
    public enum NPCActionType
    {
        Dialogue,
        GiveItem,
        StartQuest,
        CompleteQuest,
        Emotion,
        Teleport,
        Combat
    }

    [Serializable]
    public class NPCTask
    {
        public NPCActionType actionType;
        public string target;
        public string value;
        public Dictionary parameters;
    }

    public class NpcBehaviorController : MonoBehaviour
    {
        [Header("References")]
        [SerializeField] private LLMNpcAgent llmAgent;
        [SerializeField] private Animator npcAnimator;
        [SerializeField] private DialogueUI dialogueUI;

        [Header("Settings")]
        [SerializeField] private float typingSpeed = 0.05f;
        [SerializeField] private bool enableDebug = true;

        private Queue taskQueue = new Queue();
        private bool isProcessingTasks = false;

        public async Task<string> ProcessPlayerInput(string input)
        {
            if (enableDebug)
                Debug.Log($"[NPC] Nhận input: {input}");

            // Gửi đến LLM và nhận response
            string response = await llmAgent.SendMessageAsync(input);

            if (enableDebug)
                Debug.Log($"[NPC] Response: {response}");

            // Parse response để tìm tasks
            List<NPCTask> tasks = ParseResponseForTasks(response);
            
            foreach (var task in tasks)
            {
                taskQueue.Enqueue(task);
            }

            // Xử lý tasks
            if (!isProcessingTasks)
            {
                ProcessTaskQueue();
            }

            // Trả về text dialogue cho UI
            return ExtractDialogueText(response);
        }

        private List<NPCTask> ParseResponseForTasks(string response)
        {
            List<NPCTask> tasks = new List<NPCTask>();

            // Format: [ACTION:type|target|value]...[/ACTION]
            // Ví dụ: [ACTION:GiveItem|player|HealthPotion][/ACTION]
            int startIndex = 0;
            
            while ((startIndex = response.IndexOf("[ACTION:", startIndex)) != -1)
            {
                int endIndex = response.IndexOf("[/ACTION]", startIndex);
                if (endIndex == -1) break;

                string actionString = response.Substring(startIndex + 8, endIndex - startIndex - 8);
                string[] parts = actionString.Split('|');

                if (parts.Length >= 1)
                {
                    NPCTask task = new NPCTask();
                    
                    if (Enum.TryParse<NPCTask>(parts[0], out NPCTaskType type))
                    {
                        task.actionType = type;
                    }

                    if (parts.Length > 1) task.target = parts[1];
                    if (parts.Length > 2) task.value = parts[2];

                    tasks.Add(task);
                }

                startIndex = endIndex + 9;
            }

            return tasks;
        }

        private string ExtractDialogueText(string response)
        {
            // Loại bỏ các action tags để lấy text thuần
            string text = System.Text.RegularExpressions.Regex.Replace(
                response, 
                @"\[ACTION:.*?\[/ACTION\]", 
                ""
            );
            
            return text.Trim();
        }

        private async void ProcessTaskQueue()
        {
            isProcessingTasks = true;

            while (taskQueue.Count > 0)
            {
                NPCTask task = taskQueue.Dequeue();
                await ExecuteTask(task);
            }

            isProcessingTasks = false;
        }

        private async Task ExecuteTask(NPCTask task)
        {
            switch (task.actionType)
            {
                case NPCTaskType.GiveItem:
                    await ExecuteGiveItem(task);
                    break;
                case NPCTaskType.StartQuest:
                    await ExecuteStartQuest(task);
                    break;
                case NPCTaskType.Emotion:
                    ExecuteEmotion(task);
                    break;
                case NPCTaskType.CompleteQuest:
                    ExecuteCompleteQuest(task);
                    break;
            }

            // Delay giữa các tasks
            await Task.Delay(500);
        }

        private async Task ExecuteGiveItem(NPCTask task)
        {
            if (enableDebug)
                Debug.Log($"[NPC] Tặng item: {task.target} cho {task.value}");

            // Gọi GameManager để xử lý
            // InventoryManager.Instance.AddItem(task.target, 1);
            
            await Task.CompletedTask;
        }

        private async Task ExecuteStartQuest(NPCTask task)
        {
            if (enableDebug)
                Debug.Log($"[NPC] Bắt đầu quest: {task.target}");

            // QuestManager.Instance.StartQuest(task.target);
            
            await Task.CompletedTask;
        }

        private void ExecuteEmotion(NPCTask task)
        {
            if (npcAnimator != null && !string.IsNullOrEmpty(task.target))
            {
                npcAnimator.SetTrigger(task.target); // "Happy", "Sad", "Angry", etc.
            }
        }

        private void ExecuteCompleteQuest(NPCTask task)
        {
            if (enableDebug)
                Debug.Log($"[NPC] Hoàn thành quest: {task.target}");

            // QuestManager.Instance.CompleteQuest(task.target);
        }
    }
}

Code Implementation — Bước 3: Memory System

Để NPC có "ký ức" dài hạn, chúng tôi xây dựng hệ thống memory đơn giản nhưng hiệu quả. Dữ liệu được lưu local và đưa vào context khi cần.

using System;
using System.Collections.Generic;
using UnityEngine;

namespace GameLLM
{
    [Serializable]
    public class NpcMemoryEntry
    {
        public string key;
        public string value;
        public long timestamp;
        public int importance; // 1-10

        public NpcMemoryEntry(string key, string value, int importance = 5)
        {
            this.key = key;
            this.value = value;
            this.timestamp = DateTimeOffset.Now.ToUnixTimeSeconds();
            this.importance = importance;
        }
    }

    public class NpcMemorySystem : MonoBehaviour
    {
        [Header("Settings")]
        [SerializeField] private string npcId;
        [SerializeField] private int maxMemoryEntries = 100;
        [SerializeField] private int importanceThreshold = 3;

        private List<NpcMemoryEntry> memories = new List<NpcMemoryEntry>();
        private string savePath;

        void Awake()
        {
            savePath = Application.persistentDataPath + $"/npc_memory_{npcId}.json";
            LoadMemories();
        }

        public void AddMemory(string key, string value, int importance = 5)
        {
            // Kiểm tra trùng lặp
            var existing = memories.Find(m => m.key == key);
            if (existing != null)
            {
                existing.value = value;
                existing.timestamp = DateTimeOffset.Now.ToUnixTimeSeconds();
                existing.importance = Math.Max(existing.importance, importance);
            }
            else
            {
                memories.Add(new NpcMemoryEntry(key, value, importance));
            }

            // Trim nếu quá nhiều
            TrimMemories();
            SaveMemories();
        }

        public string GetContextualMemory()
        {
            // Lấy memories quan trọng gần đây
            var relevantMemories = memories
                .FindAll(m => m.importance >= importanceThreshold)
                .ConvertAll(m => $"[{m.key}]: {m.value}");

            if (relevantMemories.Count == 0)
                return "Không có ký ức đặc biệt.";

            return string.Join("\n", relevantMemories);
        }

        public string GetRelationshipStatus(string playerId)
        {
            var relation = memories.Find(m => m.key == $"relation_{playerId}");
            return relation?.value ?? "Người lạ";
        }

        public void UpdateRelationship(string playerId, string change)
        {
            AddMemory($"relation_{playerId}", change, importance: 8);
        }

        public void AddQuestMemory(string playerId, string questId, string status)
        {
            AddMemory($"quest_{questId}_for_{playerId}", status, importance: 7);
        }

        private void TrimMemories()
        {
            if (memories.Count > maxMemoryEntries)
            {
                // Sắp xếp theo importance và timestamp
                memories.Sort((a, b) => 
                {
                    int cmp = b.importance.CompareTo(a.importance);
                    if (cmp != 0) return cmp;
                    return b.timestamp.CompareTo(a.timestamp);
                });

                memories.RemoveRange(maxMemoryEntries, memories.Count - maxMemoryEntries);
            }
        }

        public void SaveMemories()
        {
            string json = JsonUtility.ToJson(memories);
            System.IO.File.WriteAllText(savePath, json);
        }

        private void LoadMemories()
        {
            if (System.IO.File.Exists(savePath))
            {
                string json = System.IO.File.ReadAllText(savePath);
                memories = new List<NpcMemoryEntry>(
                    JsonUtility.FromJson<List<NpcMemoryEntry>>(json) ?? new List<NpcMemoryEntry>()
                );
            }
        }
    }
}

Code Implementation — Bước 4: Optimized NPC với Streaming

Để cải thiện trải nghiệm người chơi, chúng tôi implement streaming response. NPC sẽ "nói" từng từ thay vì đợi toàn bộ response. Điều này giảm perceived latency từ 2-3s xuống còn dưới 500ms.

using System;