ในฐานะ Technical Lead ที่ดูแลโปรเจกต์เกม RPG แบบ Open World ขนาดใหญ่ ผมเคยเผชิญปัญหาคอขวดด้านประสิทธิภาพของ LLM API อยู่บ่อยครั้ง โดยเฉพาะเมื่อต้องรองรับ NPC หลายร้อยตัวพร้อมกัน บทความนี้จะเล่าประสบการณ์การย้ายระบบจาก API ทางการมาสู่ HolySheep AI อย่างเป็นขั้นตอน พร้อมวิธีแก้ปัญหาที่พบระหว่างทาง

ทำไมต้องย้ายระบบ LLM API สำหรับ Unity NPC

ทีมของเราเริ่มโปรเจกต์ด้วยการใช้ OpenAI API ร่วมกับ Unity โดยตรง ซึ่งในช่วงแรกทำงานได้ดี แต่เมื่อเกมขยายขนาด ปัญหาที่ตามมาคือ:

หลังจากทดสอบ API หลายราย ทีมตัดสินใจย้ายมายัง HolySheep AI เพราะอัตรา ¥1=$1 ทำให้ประหยัดค่าใช้จ่ายได้ถึง 85%+ รวมถึงรองรับ WeChat/Alipay ทำให้ชำระเงินสะดวก

การเตรียมการก่อนการย้ายระบบ

การเตรียมตัวที่ดีเป็นกุญแจสำคัญของการย้ายระบบที่ราบรื่น ก่อนเริ่มกระบวนการ ทีมควรเตรียมดังนี้:

ขั้นตอนการย้ายระบบ Unity LLM Integration ไปยัง HolySheep

1. การติดตั้งและกำหนดค่า HolySheep SDK

ขั้นตอนแรกคือการตั้งค่า HolySheep API ใน Unity Project โดยใช้ UnityWebRequest หรือ REST Client

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

namespace HolySheep.NPC
{
    public class HolySheepLLMClient : MonoBehaviour
    {
        // กำหนดค่า Base URL สำหรับ HolySheep API
        private const string BASE_URL = "https://api.holysheep.ai/v1";
        private const string API_KEY = "YOUR_HOLYSHEEP_API_KEY";
        
        [Header("Configuration")]
        [SerializeField] private string modelName = "gpt-4.1";
        [SerializeField] private float maxTokens = 500f;
        [SerializeField] private float temperature = 0.7f;
        
        [Header("NPC Settings")]
        [SerializeField] private List<NPCController> registeredNPCs = new List<NPCController>();
        
        private Dictionary<string, ConversationContext> activeConversations = new Dictionary<string, ConversationContext>();
        
        /// <summary>
        /// ส่งข้อความไปยัง LLM API พร้อม Context ของ NPC
        /// </summary>
        public IEnumerator SendMessageToNPC(string npcId, string playerMessage, System.Action<string> onResponse)
        {
            if (!activeConversations.ContainsKey(npcId))
            {
                activeConversations[npcId] = new ConversationContext();
            }
            
            var conversation = activeConversations[npcId];
            conversation.AddMessage("user", playerMessage);
            
            RequestPayload payload = new RequestPayload
            {
                model = modelName,
                messages = conversation.GetMessages(),
                max_tokens = (int)maxTokens,
                temperature = temperature
            };
            
            yield return StartCoroutine(MakeAPICall(payload, onResponse));
        }
        
        private IEnumerator MakeAPICall(RequestPayload payload, System.Action<string> callback)
        {
            string jsonPayload = JsonUtility.ToJson(payload);
            
            using (UnityWebRequest request = new UnityWebRequest(BASE_URL + "/chat/completions", "POST"))
            {
                request.SetRequestHeader("Content-Type", "application/json");
                request.SetRequestHeader("Authorization", "Bearer " + API_KEY);
                request.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(jsonPayload));
                request.downloadHandler = new DownloadHandlerBuffer();
                request.timeout = 30;
                
                float startTime = Time.time;
                yield return request.SendWebRequest();
                float elapsed = Time.time - startTime;
                
                if (request.result == UnityWebRequest.Result.Success)
                {
                    string responseJson = request.downloadHandler.text;
                    ResponseData response = JsonUtility.FromJson<ResponseData>(responseJson);
                    string content = response.choices[0].message.content;
                    
                    Debug.Log($"[HolySheep] Response received in {elapsed * 1000:F2}ms: {content}");
                    callback?.Invoke(content);
                }
                else
                {
                    Debug.LogError($"[HolySheep] API Error: {request.error}");
                    callback?.Invoke($"ขอโทษครับ มีปัญหาในการติดต่อ AI ขณะนี้ (Error: {request.responseCode})");
                }
            }
        }
        
        private void OnDestroy()
        {
            // บันทึก Conversation state ก่อนทำลาย
            SaveAllConversationStates();
        }
        
        private void SaveAllConversationStates()
        {
            foreach (var kvp in activeConversations)
            {
                PlayerPrefs.SetString($"NPC_Context_{kvp.Key}", kvp.Value.ToJson());
            }
            PlayerPrefs.Save();
        }
    }
    
    [System.Serializable]
    public class RequestPayload
    {
        public string model;
        public List<Message> messages;
        public int max_tokens;
        public float temperature;
    }
    
    [System.Serializable]
    public class Message
    {
        public string role;
        public string content;
    }
    
    [System.Serializable]
    public class ResponseData
    {
        public List<Choice> choices;
    }
    
    [System.Serializable]
    public class Choice
    {
        public ResponseMessage message;
    }
    
    [System.Serializable]
    public class ResponseMessage
    {
        public string content;
    }
    
    public class ConversationContext
    {
        private List<Message> messages = new List<Message>();
        private const int MAX_HISTORY = 10;
        
        public void AddMessage(string role, string content)
        {
            messages.Add(new Message { role = role, content = content });
            if (messages.Count > MAX_HISTORY)
            {
                messages.RemoveAt(0);
            }
        }
        
        public List<Message> GetMessages() => messages;
        public string ToJson() => JsonUtility.ToJson(messages);
    }
}

2. การสร้าง NPC Behavior System พร้อม Caching

เพื่อเพิ่มประสิทธิภาพและลดจำนวน API Call ทีมพัฒนา Smart Caching System ที่จะ Cache Response ของคำถามที่คล้ายกัน

using UnityEngine;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;

namespace HolySheep.NPC
{
    public class NPCCachingManager : MonoBehaviour
    {
        [Header("Cache Settings")]
        [SerializeField] private int maxCacheSize = 500;
        [SerializeField] private float cacheExpiryHours = 24f;
        
        private Dictionary<string, CacheEntry> responseCache = new Dictionary<string, CacheEntry>();
        
        /// <summary>
        /// สร้าง Cache Key จาก NPC ID และ Message Content
        /// 
        private string GenerateCacheKey(string npcId, string message)
        {
            string combined = $"{npcId}:{message}".ToLower().Trim();
            using (SHA256 sha256 = SHA256.Create())
            {
                byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(combined));
                StringBuilder sb = new StringBuilder();
                foreach (byte b in hashBytes)
                {
                    sb.Append(b.ToString("x2"));
                }
                return sb.ToString();
            }
        }
        
        /// 
        /// ตรวจสอบและดึงข้อมูลจาก Cache
        /// 
        public bool TryGetCachedResponse(string npcId, string message, out string cachedResponse)
        {
            string key = GenerateCacheKey(npcId, message);
            
            if (responseCache.TryGetValue(key, out CacheEntry entry))
            {
                if (entry.IsExpired(cacheExpiryHours))
                {
                    responseCache.Remove(key);
                    cachedResponse = null;
                    return false;
                }
                
                entry.IncrementHitCount();
                cachedResponse = entry.Response;
                Debug.Log($"[Cache] HIT - Key: {key.Substring(0, 8)}..., Hits: {entry.HitCount}");
                return true;
            }
            
            cachedResponse = null;
            return false;
        }
        
        /// 
        /// บันทึก Response ลง Cache
        /// 
        public void CacheResponse(string npcId, string message, string response)
        {
            string key = GenerateCacheKey(npcId, message);
            
            // ลบ Entry เก่าถ้า Cache เต็ม
            if (responseCache.Count >= maxCacheSize)
            {
                RemoveOldestEntry();
            }
            
            responseCache[key] = new CacheEntry
            {
                Response = response,
                CachedAt = System.DateTime.Now,
                HitCount = 0
            };
            
            Debug.Log($"[Cache] Stored - Key: {key.Substring(0, 8)}..., Size: {responseCache.Count}/{maxCacheSize}");
        }
        
        private void RemoveOldestEntry()
        {
            string oldestKey = null;
            System.DateTime oldestTime = System.DateTime.MaxValue;
            
            foreach (var kvp in responseCache)
            {
                if (kvp.Value.CachedAt < oldestTime)
                {
                    oldestTime = kvp.Value.CachedAt;
                    oldestKey = kvp.Key;
                }
            }
            
            if (oldestKey != null)
            {
                responseCache.Remove(oldestKey);
                Debug.Log($"[Cache] Evicted oldest entry: {oldestKey.Substring(0, 8)}...");
            }
        }
        
        public void ClearCache()
        {
            responseCache.Clear();
            Debug.Log("[Cache] Cleared all entries");
        }
        
        public int GetCacheSize() => responseCache.Count;
        
        public float GetCacheHitRate()
        {
            if (responseCache.Count == 0) return 0f;
            
            int totalHits = 0;
            foreach (var entry in responseCache.Values)
            {
                totalHits += entry.HitCount;
            }
            return (float)totalHits / responseCache.Count;
        }
    }
    
    public class CacheEntry
    {
        public string Response { get; set; }
        public System.DateTime CachedAt { get; set; }
        public int HitCount { get; set; }
        
        public bool IsExpired(float expiryHours)
        {
            return (System.DateTime.Now - CachedAt).TotalHours > expiryHours;
        }
        
        public void IncrementHitCount()
        {
            HitCount++;
        }
    }
}

3. การสร้าง Smart NPC Controller พร้อม Batch Processing

สำหรับการรองรับ NPC จำนวนมาก ทีมพัฒนา Batch Processing System ที่จะรวม Request หลายรายการเข้าด้วยกันเพื่อลด API Overhead

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

namespace HolySheep.NPC
{
    public class NPCCoordinator : MonoBehaviour
    {
        [Header("References")]
        [SerializeField] private HolySheepLLMClient llmClient;
        [SerializeField] private NPCCachingManager cacheManager;
        
        [Header("Batch Processing Settings")]
        [SerializeField] private int batchSize = 5;
        [SerializeField] private float batchIntervalSeconds = 0.5f;
        [SerializeField] private int maxConcurrentRequests = 3;
        
        [Header("Performance Monitoring")]
        [SerializeField] private bool enableDebugLog = true;
        
        private Queue<NPCRequest> requestQueue = new Queue<NPCRequest>();
        private List<NPCRequest> activeRequests = new List<NPCRequest>();
        private int totalRequestsProcessed = 0;
        private float totalLatency = 0f;
        
        private Coroutine batchProcessor;
        
        void Start()
        {
            if (llmClient == null)
            {
                llmClient = FindObjectOfType<HolySheepLLMClient>();
            }
            if (cacheManager == null)
            {
                cacheManager = FindObjectOfType<NPCCachingManager>();
            }
            
            batchProcessor = StartCoroutine(ProcessBatchQueue());
        }
        
        /// 
        /// ส่งคำขอไปยัง NPC ผ่านระบบ Queue และ Cache
        /// 
        public void EnqueueNPCRequest(string npcId, string message, System.Action<string> onResponse)
        {
            // ตรวจสอบ Cache ก่อน
            if (cacheManager.TryGetCachedResponse(npcId, message, out string cachedResponse))
            {
                onResponse?.Invoke(cachedResponse);
                LogPerformance(npcId, true, 0);
                return;
            }
            
            NPCRequest request = new NPCRequest
            {
                NpcId = npcId,
                Message = message,
                Callback = onResponse,
                QueuedAt = Time.time
            };
            
            requestQueue.Enqueue(request);
        }
        
        private IEnumerator ProcessBatchQueue()
        {
            while (true)
            {
                // รอจนกว่าจะมีที่ว่างสำหรับ Request ใหม่
                while (activeRequests.Count >= maxConcurrentRequests)
                {
                    yield return new WaitUntil(() => activeRequests.Count < maxConcurrentRequests);
                }
                
                // รวบรวม Request สำหรับ Batch
                List<NPCRequest> batch = new List<NPCRequest>();
                float batchStartTime = Time.time;
                
                while (batch.Count < batchSize && requestQueue.Count > 0)
                {
                    if (Time.time - batchStartTime > batchIntervalSeconds)
                        break;
                    
                    batch.Add(requestQueue.Dequeue());
                }
                
                if (batch.Count > 0)
                {
                    StartCoroutine(ProcessBatch(batch));
                }
                else
                {
                    yield return new WaitForSeconds(batchIntervalSeconds);
                }
            }
        }
        
        private IEnumerator ProcessBatch(List<NPCRequest> batch)
        {
            activeRequests.AddRange(batch);
            
            List<Coroutine> coroutines = new List<Coroutine>();
            foreach (NPCRequest request in batch)
            {
                coroutines.Add(StartCoroutine(ProcessSingleRequest(request)));
            }
            
            foreach (Coroutine coroutine in coroutines)
            {
                yield return coroutine;
            }
            
            foreach (NPCRequest request in batch)
            {
                activeRequests.Remove(request);
            }
        }
        
        private IEnumerator ProcessSingleRequest(NPCRequest request)
        {
            float startTime = Time.time;
            
            yield return StartCoroutine(llmClient.SendMessageToNPC(
                request.NpcId,
                request.Message,
                (response) =>
                {
                    // Cache ผลลัพธ์
                    cacheManager.CacheResponse(request.NpcId, request.Message, response);
                    request.Callback?.Invoke(response);
                }
            ));
            
            float latency = Time.time - startTime;
            totalRequestsProcessed++;
            totalLatency += latency;
            
            LogPerformance(request.NpcId, false, latency);
        }
        
        private void LogPerformance(string npcId, bool cacheHit, float latency)
        {
            if (!enableDebugLog) return;
            
            if (cacheHit)
            {
                Debug.Log($"[NPC] {npcId} - Cache HIT (Latency: 0ms)");
            }
            else
            {
                float avgLatency = totalLatency / totalRequestsProcessed;
                Debug.Log($"[NPC] {npcId} - API Response (Latency: {latency * 1000:F2}ms, Avg: {avgLatency * 1000:F2}ms)");
            }
        }
        
        public PerformanceStats GetPerformanceStats()
        {
            return new PerformanceStats
            {
                TotalRequests = totalRequestsProcessed,
                AverageLatencyMs = totalRequestsProcessed > 0 ? (totalLatency / totalRequestsProcessed) * 1000 : 0,
                CacheSize = cacheManager.GetCacheSize(),
                CacheHitRate = cacheManager.GetCacheHitRate(),
                QueuedRequests = requestQueue.Count,
                ActiveRequests = activeRequests.Count
            };
        }
        
        void OnDestroy()
        {
            if (batchProcessor != null)
            {
                StopCoroutine(batchProcessor);
            }
        }
    }
    
    public class NPCRequest
    {
        public string NpcId { get; set; }
        public string Message { get; set; }
        public System.Action<string> Callback { get; set; }
        public float QueuedAt { get; set; }
    }
    
    public struct PerformanceStats
    {
        public int TotalRequests { get; set; }
        public float AverageLatencyMs { get; set; }
        public int CacheSize { get; set; }
        public float CacheHitRate { get; set; }
        public int QueuedRequests { get; set; }
        public int ActiveRequests { get; set; }
    }
}

4. การตั้งค่า Prompt Template สำหรับ NPC

การปรับแต่ง Prompt Template ให้เหมาะกับบทบาท NPC แต่ละประเภทช่วยเพิ่มคุณภาพ Response และลด Token Usage

using UnityEngine;
using System.Collections.Generic;

namespace HolySheep.NPC
{
    public class NPCTemplateManager : MonoBehaviour
    {
        [Header("NPC Role Templates")]
        [SerializeField] private List<NPCTemplate> templates = new List<NPCTemplate>();
        
        private Dictionary<string, NPCTemplate> templateLookup = new Dictionary<string, NPCTemplate>();
        
        void Awake()
        {
            InitializeTemplates();
        }
        
        private void InitializeTemplates()
        {
            // Merchant Template
            templates.Add(new NPCTemplate
            {
                Id = "merchant",
                Role = "พ่อค้าขายของ",
                SystemPrompt = @"คุณคือ {npc_name} พ่อค้าผู้เชี่ยวชาญในเมือง {location}
คุณมีความรู้ลึกซึ้งเกี่ยวกับสินค้าทุกชนิด ราคา และวิธีการต่อรอง
ให้ข้อมูลที่เป็นประโยชน์แก่ผู้เล่น แต่พยายามขายของให้ได้กำไร
รักษาบุคลิกภาพของพ่อค้าที่ฉลาด เจ้าเล่ห์ แต่น่าเชื่อถือ",
                ExampleResponses = new List<string>
                {
                    "ยินดีต้อนรับนักเดินทาง! วันนี้มีสินค้าดีๆ มากมายรอคุณอยู่",
                    "สินค้านี้หาดียากนะ ราคาของผมก็สมเหตุสมผลแล้ว"
                }
            });
            
            // Guard Template
            templates.Add(new NPCTemplate
            {
                Id = "guard",
                Role = "ยามเฝ้าประตู",
                SystemPrompt = @"คุณคือ {npc_name} ยามผู้พิทักษ์เมือง {location}
คุณต้องตรวจสอบผู้เข้าออกเมืองอย่างเข้มงวด
ให้ข้อมูลเกี่ยวกับกฎหมายและความปลอดภัยของเมือง
เป็นมิตรแต่เฉียบขาดในเรื่องการรักษาความสงบ",
                ExampleResponses = new List<string>
                {
                    "หยุด! แสดงเอกสารผ่านด่าน",
                    "ยินดีต้อนรับสู่เมือง กรุณาปฏิบัติตามกฎหมาย"
                }
            });
            
            // Quest Giver Template
            templates.Add(new NPCTemplate
            {
                Id = "quest_giver",
                Role = "ผู้มอบภารกิจ",
                SystemPrompt = @"คุณคือ {npc_name} ผู้มีภารกิจสำคัญให้ผู้เล่น
คุณเล่าเรื่องราวอย่างละเอียด มีอารมณ์ และให้แรงจูงใจ
ระบุเป้าหมาย รางวัล และคำใบ้ที่เป็นประโยชน์
อาจมีเงื่อนไขพิเศษหรือ Easter Egg ซ่อนอยู่",
                ExampleResponses = new List<string>
                {
                    "มีเรื่องด่วน! ต้องการคนกล้าหาญอย่างคุณช่วย",
                    "ภารกิจนี้มีความเสี่ยง แต่รางวัลคุ้มค่าแน่นอน"
                }
            });
            
            // Build lookup dictionary
            foreach (var template in templates)
            {
                templateLookup[template.Id] = template;
            }
        }
        
        /// 
        /// สร้าง Prompt ที่กำหนดเองสำหรับ NPC
        /// 
        public string GetPromptForNPC(string templateId, string npcName, string location)
        {
            if (!templateLookup.TryGetValue(templateId, out NPCTemplate template))
            {
                Debug.LogWarning($"[Template] Not found: {templateId}, using default");
                return GetDefaultPrompt(npcName, location);
            }
            
            string prompt = template.SystemPrompt
                .Replace("{npc_name}", npcName)
                .Replace("{location}", location);
            
            return prompt;
        }
        
        private string GetDefaultPrompt(string npcName, string location)
        {
            return $"คุณคือ {npcName} ชาวเมือง {location} คุณเป็นคนธรรมดาที่มีเรื่องราวและบุคลิกเป็นของตัวเอง ให้ข้อมูลที่เป็นประโยชน์และมีส่วนร่วมในการสนทนาอย่างเป็นธรรมชาติ";
        }
        
        public NPCTemplate GetTemplate(string templateId)
        {
            return templateLookup.TryGetValue(templateId, out NPCTemplate template) ? template : null;
        }
        
        public List<string> GetAllTemplateIds()
        {
            return new List<string>(templateLookup.Keys);
        }
    }
    
    [System.Serializable]
    public class NPCTemplate
    {
        public string Id;
        public string Role;
        [TextArea(3, 10)]
        public string SystemPrompt;
        public List<string> ExampleResponses;
    }
}

การทดสอบระบบหลังการย้าย

หลังจากติดตั้งระบบเสร็จ การทดสอบอย่างเข้มงวดเป็นสิ่งจำเป็น ทีมควรทดสอบในหลายระดับ:

using UnityEngine;
using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;
using System.Collections.Generic;

namespace HolySheep.NPC.Tests
{
    public class HolySheepIntegrationTests
    {
        private NPCCoordinator coordinator;
        private HolySheepLLMClient llmClient;
        private NPCCachingManager cacheManager;
        
        [SetUp]
        public void Setup()
        {
            GameObject testObj = new GameObject("TestEnvironment");
            
            llmClient = testObj.AddComponent<HolySheepLLMClient>();
            cacheManager = testObj.AddComponent<NPCCachingManager>();
            coordinator = testObj.AddComponent<NPCCoordinator>();
            
            // Inject dependencies via reflection for testing
            SetPrivateField(coordinator, "llmClient", llmClient);
            SetPrivateField(coordinator, "cacheManager", cacheManager);
        }
        
        [TearDown]
        public void TearDown()
        {
            Object.DestroyImmediate(Object.FindObjectOfType<GameObject>());
        }
        
        [UnityTest]
        public IEnumerator TestCacheHitRate_WithRepeatedQueries()
        {
            // Arrange