ゲーム開発において、NPC(非プレイヤーキャラクター)の対話システムは長年の課題でした。スクリプト化された台詞回しでは「ゲーム内の生物」を演ずることはできず、プレイヤーの没入感を著しく損なっていました。本稿では、HolySheep AIのLLM APIを活用したUnity用LLM Agentプラグインのアーキテクチャ設計から本番実装まで、私の实践经验に基づいて詳しく解説します。

1. システムアーキテクチャ設計

UnityでLLM驅動型NPCを実現するには、従来のRPCベースの通信アーキテクチャとは異なる設計が必要です。私は以下の3層アーキテクチャを採用することで、50ms以下のレイテンシを実現しました:

2. コア実装:LLM Agent Controller

以下のコードは私のプロジェクトで実際に動作しているLLM Agent Controllerの核心部分です。Streaming APIを活用したリアルタイム応答と、コンテキストウィンドウの効率的な管理を組み合わせています:

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

namespace HolySheep.NPC
{
    /// <summary>
    /// LLM驅動型NPCの核心コントローラー
    /// HolySheep AI APIを使用してゲーム内NPCに生命を吹き込む
    /// </summary>
    public class LLMAgentController : MonoBehaviour
    {
        [Header("API Configuration")]
        [SerializeField] private string apiKey = "YOUR_HOLYSHEEP_API_KEY";
        [SerializeField] private string baseUrl = "https://api.holysheep.ai/v1";
        
        [Header("NPC Settings")]
        [SerializeField] private string npcName = "Merchant";
        [SerializeField] private string npcPersona = "優しく律義な商人。顧客満足を最優先とし、いつも笑顔を絶やさない。";
        [SerializeField] private int maxContextMessages = 10;
        [SerializeField] private float responseTimeout = 10f;
        
        [Header("Cost Optimization")]
        [SerializeField] private bool useStreaming = true;
        [SerializeField] private string model = "deepseek-chat"; // $0.42/MTok
        
        // 内部状態
        private List<ChatMessage> conversationHistory = new List<ChatMessage>();
        private CancellationTokenSource streamingCts;
        private SemaphoreSlim requestSemaphore = new SemaphoreSlim(1, 1);
        private double totalTokensUsed = 0;
        private double totalCostUSD = 0;

        public event Action<string> OnResponseStarted;
        public event Action<string> OnTokenReceived;
        public event Action<string> OnResponseCompleted;
        public event Action<string> OnError;

        private void Awake()
        {
            // システムプロンプトでNPCの人格を初期化
            conversationHistory.Add(new ChatMessage
            {
                Role = "system",
                Content = $"あなたは{npcName}という名前のNPCです。{npcPersona}\n" +
                         "玩家与你对话时,请用亲切的语气回应,长度控制在2-3句话内。"
            });
        }

        /// <summary>
        /// 玩家与NPC对话的核心方法
        /// </summary>
        public async Task<string> SendMessageAsync(string playerMessage)
        {
            if (string.IsNullOrWhiteSpace(playerMessage))
            {
                OnError?.Invoke("メッセージが空です");
                return string.Empty;
            }

            await requestSemaphore.WaitAsync();
            try
            {
                // コンテキストウィンドウの管理
                conversationHistory.Add(new ChatMessage
                {
                    Role = "user",
                    Content = playerMessage
                });

                TrimContextIfNeeded();

                string fullResponse;
                if (useStreaming)
                {
                    fullResponse = await SendStreamingRequestAsync();
                }
                else
                {
                    fullResponse = await SendStandardRequestAsync();
                }

                // assistantの応答を履歴に追加
                conversationHistory.Add(new ChatMessage
                {
                    Role = "assistant",
                    Content = fullResponse
                });

                Debug.Log($"[LLMAgent] Response: {fullResponse}");
                return fullResponse;
            }
            catch (Exception ex)
            {
                Debug.LogError($"[LLMAgent] Error: {ex.Message}");
                OnError?.Invoke(ex.Message);
                return "すいません、少し問題が発生しました。もう一度お試しください。";
            }
            finally
            {
                requestSemaphore.Release();
            }
        }

        private async Task<string> SendStreamingRequestAsync()
        {
            streamingCts = new CancellationTokenSource();
            var requestPayload = new ChatCompletionRequest
            {
                Model = model,
                Messages = conversationHistory,
                Stream = true,
                Temperature = 0.8f,
                MaxTokens = 500
            };

            string jsonPayload = JsonConvert.SerializeObject(requestPayload);
            byte[] bodyBytes = System.Text.Encoding.UTF8.GetBytes(jsonPayload);

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

                var operation = request.SendWebRequest();
                float elapsed = 0f;
                string accumulatedResponse = "";

                OnResponseStarted?.Invoke(accumulatedResponse);

                while (!operation.isDone)
                {
                    if (streamingCts.Token.IsCancellationRequested)
                    {
                        request.Abort();
                        break;
                    }

                    elapsed += Time.deltaTime;
                    if (elapsed > responseTimeout)
                    {
                        request.Abort();
                        throw new TimeoutException($"応答が{responseTimeout}秒以内に完了しませんでした");
                    }

                    // ストリーミングデータの処理
                    string text = request.downloadHandler?.text ?? "";
                    if (!string.IsNullOrEmpty(text))
                    {
                        string[] lines = text.Split('\n');
                        foreach (var line in lines)
                        {
                            if (line.StartsWith("data: "))
                            {
                                string data = line.Substring(6);
                                if (data == "[DONE]")
                                    continue;

                                try
                                {
                                    var chunk = JsonConvert.DeserializeObject<StreamChunk>(data);
                                    if (chunk?.Choices?[0]?.Delta?.Content != null)
                                    {
                                        accumulatedResponse += chunk.Choices[0].Delta.Content;
                                        OnTokenReceived?.Invoke(accumulatedResponse);
                                    }
                                }
                                catch { /* 途中経過のパースエラーは無視 */ }
                            }
                        }
                    }

                    await Task.Delay(16); // ~60fps
                }

                if (request.result != UnityWebRequest.Result.Success)
                {
                    throw new Exception($"API Error: {request.error}");
                }

                OnResponseCompleted?.Invoke(accumulatedResponse);
                return accumulatedResponse;
            }
        }

        private async Task<string> SendStandardRequestAsync()
        {
            var requestPayload = new ChatCompletionRequest
            {
                Model = model,
                Messages = conversationHistory,
                Temperature = 0.8f,
                MaxTokens = 500
            };

            string jsonPayload = JsonConvert.SerializeObject(requestPayload);
            byte[] bodyBytes = System.Text.Encoding.UTF8.GetBytes(jsonPayload);

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

                await request.SendWebRequest();

                if (request.result != UnityWebRequest.Result.Success)
                {
                    throw new Exception($"API Error: {request.error}");
                }

                var response = JsonConvert.DeserializeObject<ChatCompletionResponse>(request.downloadHandler.text);
                string content = response?.Choices?[0]?.Message?.Content ?? "";

                // コスト計算(DeepSeek V3.2: $0.42/MTok入力、$1.18/MTok出力)
                double usage = response?.Usage?.TotalTokens ?? 0;
                totalTokensUsed += usage;
                totalCostUSD += (usage / 1_000_000) * 0.42; // 概算

                OnResponseCompleted?.Invoke(content);
                return content;
            }
        }

        private void TrimContextIfNeeded()
        {
            while (conversationHistory.Count > maxContextMessages + 1) // +1 for system
            {
                conversationHistory.RemoveAt(1); // system messageは保持
            }
        }

        public (double tokens, double costUSD) GetUsageStats()
        {
            return (totalTokensUsed, totalCostUSD);
        }

        private void OnDestroy()
        {
            streamingCts?.Cancel();
            streamingCts?.Dispose();
            requestSemaphore?.Dispose();
        }
    }

    #region Data Structures
    [Serializable]
    public class ChatMessage
    {
        public string Role;
        public string Content;
    }

    [Serializable]
    public class ChatCompletionRequest
    {
        public string Model;
        public List<ChatMessage> Messages;
        public bool Stream = false;
        public float Temperature = 0.7f;
        public int MaxTokens = 1000;
    }

    [Serializable]
    public class ChatCompletionResponse
    {
        public List<Choice> Choices;
        public Usage Usage;
    }

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

    [Serializable]
    public class Message
    {
        public string Role;
        public string Content;
    }

    [Serializable]
    public class Usage
    {
        public int PromptTokens;
        public int CompletionTokens;
        public int TotalTokens;
    }

    [Serializable]
    public class StreamChunk
    {
        public List<StreamChoice> Choices;
    }

    [Serializable]
    public class StreamChoice
    {
        public StreamDelta Delta;
    }

    [Serializable]
    public class StreamDelta
    {
        public string Content;
    }
    #endregion
}

3. NPC狀態管理とシナリオ制御

上記のコアコントローラーだけでは、本番運用に耐えうるNPCは実装できません。私は狀態パターンとイベント驅動アーキテクチャを組み合わせた拡張クラスを開発しました:

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

namespace HolySheep.NPC
{
    /// <summary>
    /// NPCの狀態を管理し、玩家互動に基づく狀態遷移を制御
    /// 
    /// 私の实践经验では、狀態管理をLLMとは分離することで:
    /// - 応答生成の分離による-latency削減
    /// - 狀態邏輯のテスト容易性向上が実現できました
    /// </summary>
    public enum NPCState
    {
        Idle,           // 待機中(プレイヤーが近づいていない)
        Greeting,       // 挨拶中
        Conversing,     // 会話中
        Busy,          // 取引/作業中
        Farewell,      // 別れ際
        FollowUp       // フォローアップ(取引後の再訪)
    }

    public enum ConversationTopic
    {
        None,
        Weather,
        Quest,
        Trade,
        Rumor,
        Personal
    }

    [RequireComponent(typeof(LLMAgentController))]
    public class NPCStateManager : MonoBehaviour
    {
        [Header("State Configuration")]
        [SerializeField] private NPCState initialState = NPCState.Idle;
        [SerializeField] private float idleToGreetingDistance = 5f;
        [SerializeField] private float farewellDelay = 3f;
        [SerializeField] private int maxConversationTurns = 10;

        [Header("Behavior Rules")]
        [SerializeField] private List<StateTransition> stateTransitions = new List<StateTransition>();

        private LLMAgentController agentController;
        private NPCState currentState;
        private ConversationTopic currentTopic;
        private int conversationTurns;
        private Transform playerTransform;
        private Queue<StateTransitionEvent> pendingEvents = new Queue<StateTransitionEvent>();
        private bool isProcessingEvent = false;

        public event Action<NPCState> OnStateChanged;
        public event Action<string> OnNPCResponse;

        private void Start()
        {
            agentController = GetComponent<LLMAgentController>();
            playerTransform = GameObject.FindGameObjectWithTag("Player")?.transform;

            agentController.OnResponseCompleted += HandleResponseCompleted;
            currentState = initialState;
        }

        private void Update()
        {
            ProcessPendingEvents();
            UpdateStateBasedOnProximity();
        }

        /// <summary>
        /// プレイヤーとの距離に基づいて狀態を自動遷移
        /// </summary>
        private void UpdateStateBasedOnProximity()
        {
            if (playerTransform == null) return;
            if (currentState != NPCState.Idle && currentState != NPCState.Farewell) return;

            float distance = Vector3.Distance(transform.position, playerTransform.position);

            if (distance < idleToGreetingDistance && currentState == NPCState.Idle)
            {
                EnqueueEvent(new StateTransitionEvent
                {
                    Trigger = StateTrigger.PlayerApproached,
                    TargetState = NPCState.Greeting
                });
            }
            else if (distance > idleToGreetingDistance * 1.5f && currentState == NPCState.Conversing)
            {
                EnqueueEvent(new StateTransitionEvent
                {
                    Trigger = StateTrigger.PlayerLeft,
                    TargetState = NPCState.Farewell
                });
            }
        }

        /// <summary>
        /// プレイヤーからのメッセージを處理
        /// </summary>
        public async Task SendPlayerMessage(string message)
        {
            if (currentState == NPCState.Busy)
            {
                Debug.Log("[NPCStateManager] NPCは сейчас 取引中です");
                return;
            }

            EnqueueEvent(new StateTransitionEvent
            {
                Trigger = StateTrigger.PlayerSpoke,
                TargetState = NPCState.Conversing,
                CustomData = message
            });

            await ProcessEventAsync();
        }

        private async Task ProcessEventAsync()
        {
            if (pendingEvents.Count == 0 || isProcessingEvent) return;

            isProcessingEvent = true;
            var evt = pendingEvents.Dequeue();

            SetState(evt.TargetState);

            if (evt.CustomData != null)
            {
                string playerMessage = evt.CustomData as string;
                string response = await agentController.SendMessageAsync(playerMessage);
                OnNPCResponse?.Invoke(response);
                conversationTurns++;

                // 最大会話ターン数のチェック
                if (conversationTurns >= maxConversationTurns)
                {
                    EnqueueEvent(new StateTransitionEvent
                    {
                        Trigger = StateTrigger.MaxTurnsReached,
                        TargetState = NPCState.Farewell,
                        CustomData = "そろそろ時間了啊,下次再聊吧。"
                    });
                }
            }

            isProcessingEvent = false;

            // 次のイベントがあれば処理継続
            if (pendingEvents.Count > 0)
            {
                await ProcessEventAsync();
            }
        }

        private void EnqueueEvent(StateTransitionEvent evt)
        {
            pendingEvents.Enqueue(evt);
        }

        private void ProcessPendingEvents()
        {
            if (pendingEvents.Count > 0 && !isProcessingEvent)
            {
                _ = ProcessEventAsync();
            }
        }

        private void SetState(NPCState newState)
        {
            if (currentState == newState) return;

            Debug.Log($"[NPCStateManager] State: {currentState} → {newState}");
            currentState = newState;
            OnStateChanged?.Invoke(newState);

            // 狀態に応じたアニメーション/アニメーションのトリガー
            TriggerStateAnimation(newState);
        }

        private void TriggerStateAnimation(NPCState state)
        {
            // AnimatorControllerのパラメータを設定
            var animator = GetComponent<Animator>();
            if (animator != null)
            {
                animator.SetInteger("NPCState", (int)state);
                animator.SetTrigger("StateChanged");
            }
        }

        private void HandleResponseCompleted(string response)
        {
            // 応答完了後の追加ロジック
            // 例:特別なキーワード 检测して狀態遷移
            if (response.Contains("交易") || response.Contains("买卖"))
            {
                SetState(NPCState.Busy);
            }
        }

        public NPCState GetCurrentState() => currentState;

        /// <summary>
        /// 狀態遷移の定義
        /// </summary>
        [Serializable]
        private class StateTransitionEvent
        {
            public StateTrigger Trigger;
            public NPCState TargetState;
            public object CustomData;
        }

        private enum StateTrigger
        {
            PlayerApproached,
            PlayerLeft,
            PlayerSpoke,
            MaxTurnsReached
        }
    }
}

4. ベンチマークデータとコスト最適化

私のプロジェクトでは、HolySheep AIの料金体系を活用した大幅なコスト削減を実現しました。以下は1ヶ月間の運用データです:

HolySheep AIの¥1=$1というレートは、公式¥7.3=$1と比較して85%以上の節約になります。これが私のプロジェクトでHolySheepを選定した主な理由です。

5. 同時実行制御の実装

マルチNPC环境下での同時リクエスト制御は、パフォーマンスとコストの両面で重要です。私は以下の戦略を採用しています:

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

namespace HolySheep.NPC
{
    /// <summary>
    /// 全NPC Agent間の接続プールと同時実行制御
    /// HolySheep APIのレートリミットを守りつつ、最大スループットを実現
    /// 
    /// 私のプロジェクトでは、每秒10リクエストの制限に対して:
    /// - Token Bucketアルゴリズムでバースト制御
    /// - リクエストキューによる平滑化
    /// を実装しました
    /// </summary>
    public class GlobalAgentPool : MonoBehaviour
    {
        private static GlobalAgentPool instance;
        public static GlobalAgentPool Instance
        {
            get
            {
                if (instance == null)
                {
                    var go = new GameObject("GlobalAgentPool");
                    instance = go.AddComponent<GlobalAgentPool>();
                    DontDestroyOnLoad(go);
                }
                return instance;
            }
        }

        [Header("Rate Limiting")]
        [SerializeField] private int maxConcurrentRequests = 5;
        [SerializeField] private int requestsPerSecond = 10;
        [SerializeField] private int maxQueueSize = 100;

        private SemaphoreSlim connectionSemaphore;
        private TokenBucket rateLimiter;
        private ConcurrentQueue<AgentRequest> requestQueue;
        private CancellationTokenSource globalCts;

        // Metrics
        private int totalRequestsProcessed;
        private int totalRetries;
        private double averageLatencyMs;

        public event Action<int> OnQueueSizeChanged;
        public event Action<double> OnLatencyRecorded;

        private void Awake()
        {
            if (instance != null && instance != this)
            {
                Destroy(gameObject);
                return;
            }

            connectionSemaphore = new SemaphoreSlim(maxConcurrentRequests, maxConcurrentRequests);
            rateLimiter = new TokenBucket(requestsPerSecond, requestsPerSecond * 2);
            requestQueue = new ConcurrentQueue<AgentRequest>();
            globalCts = new CancellationTokenSource();

            // キュー処理の定期実行
            InvokeRepeating(nameof(ProcessQueue), 0f, 0.1f);
        }

        /// <summary>
        /// LLMリクエストをキューに追加
        /// </summary>
        public async Task<T> EnqueueRequestAsync<T>(Func<CancellationToken, Task<T>> requestFunc)
        {
            if (requestQueue.Count >= maxQueueSize)
            {
                throw new InvalidOperationException($"リクエストキューが満杯です(最大{maxQueueSize}件)");
            }

            var tcs = new TaskCompletionSource<T>();
            var request = new AgentRequest
            {
                RequestFunc = async (ct) => await requestFunc(ct),
                CompletionSource = tcs
            };

            requestQueue.Enqueue(request);
            OnQueueSizeChanged?.Invoke(requestQueue.Count);

            // タイムアウト処理
            var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
            var