0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unity AndroidのローカルLLMでFunction Calling風の構造化レスポンスを扱う方法

0
Posted at

Unity AndroidのローカルLLMでFunction Calling風の構造化レスポンスを扱う方法

Unity AndroidアプリにローカルLLMを組み込む場合、単に自然文を返すだけではなく、アプリ側の処理を安全に実行できる形式でレスポンスを受け取りたいことがあります。

例えば、ゲーム内NPCとの会話でLLMが次のように判断できると便利です。

ユーザー: 道具を買いたい
LLM: open_shop を呼ぶ
Unity: 道具屋UIを開く

これはOpenAI APIでいう Function calling / Tool calling に近い考え方です。

この記事では、Function calling相当の機能を持つローカルLLMランタイムを使う前提で、Unity Androidアプリからtool callを扱う設計を紹介します。


前提

この記事では、以下のようなローカルLLM実行環境を想定します。

Unity Android App
        │
        ▼
Local LLM Runtime
        │
        ├── Ollama tool calling
        ├── llama.cpp server tool calling
        ├── llama-cpp-python function calling
        └── その他OpenAI互換tool calling対応ランタイム

重要なのは、ローカルLLM側が以下のような OpenAI互換のtools / tool_calls に近いインターフェースを提供していることです。

{
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "open_shop",
        "description": "指定したショップUIを開く",
        "parameters": {
          "type": "object",
          "properties": {
            "shopId": {
              "type": "string"
            }
          },
          "required": ["shopId"]
        }
      }
    }
  ]
}

レスポンス側では、LLMが次のような tool_calls を返します。

{
  "tool_calls": [
    {
      "type": "function",
      "function": {
        "name": "open_shop",
        "arguments": "{\"shopId\":\"item_shop\"}"
      }
    }
  ]
}

Unity側は、この tool_calls を受け取り、検証して、実際の関数を実行します。


Function callingで重要な考え方

Function callingでは、LLMが直接Unityの関数を実行するわけではありません。

LLMが行うのは、あくまで 「どの関数を、どの引数で呼ぶべきか」を提案すること です。

実際に関数を実行するのはUnity側です。

ユーザー入力
  ↓
LLM
  ↓
tool_calls
  ↓
Unity側で検証
  ↓
Unity側で関数実行

この分離が重要です。

LLMの出力をそのまま信用してはいけません。
Unity側で必ず以下を確認します。

- 存在するtool名か
- 引数JSONが正しいか
- 必須項目があるか
- 値が許可範囲内か
- 実行してよい状態か
- セキュリティ上問題がないか

Unity Androidでの全体構成

Unity Androidで実装する場合、構成は大きく2つあります。

構成A:ローカルHTTP APIとして呼ぶ

Unity C#
  ↓ HTTP
Local LLM API
  ↓
tools / tool_calls

開発中はこの形が分かりやすいです。

例えば、PC上のOllamaやllama.cpp serverを起動し、Unity EditorからHTTPで呼び出します。

構成B:Android Native Plugin内で呼ぶ

Unity C#
  ↓ AndroidJavaObject
Android Native Plugin
  ↓
Local LLM Runtime
  ↓
tool_calls相当の構造体 / JSON

本番のAndroidアプリ内にローカルLLMを組み込む場合は、この形になります。

この記事では、Unity側の設計を共通化するため、LLMランタイムから返ってきた結果を最終的に次の形に正規化します。

public class ToolCall
{
    public string id;
    public string name;
    public string argumentsJson;
}

HTTP経由でも、Native Plugin経由でも、最終的に ToolCall に変換してから処理します。


例:ゲーム用tool定義

ゲーム内で使うtoolを定義します。

例として、以下の3つを用意します。

show_dialogue  NPCのセリフを表示する
open_shop      ショップUIを開く
start_quest    クエストを開始する

OpenAI互換のtools形式では、次のように定義します。

[
  {
    "type": "function",
    "function": {
      "name": "show_dialogue",
      "description": "NPCのセリフを画面に表示する",
      "parameters": {
        "type": "object",
        "properties": {
          "speaker": {
            "type": "string",
            "description": "話者名"
          },
          "emotion": {
            "type": "string",
            "enum": ["happy", "sad", "angry", "neutral"],
            "description": "表情"
          },
          "text": {
            "type": "string",
            "description": "30文字以内の日本語セリフ"
          }
        },
        "required": ["speaker", "emotion", "text"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "open_shop",
      "description": "指定したショップUIを開く",
      "parameters": {
        "type": "object",
        "properties": {
          "shopId": {
            "type": "string",
            "enum": ["item_shop", "weapon_shop", "inn"]
          }
        },
        "required": ["shopId"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "start_quest",
      "description": "指定したクエストを開始する",
      "parameters": {
        "type": "object",
        "properties": {
          "questId": {
            "type": "string"
          }
        },
        "required": ["questId"]
      }
    }
  }
]

ここで重要なのは、description を丁寧に書くことです。

LLMはtool名だけでなく、descriptionやparametersを見て、どのtoolを呼ぶべきか判断します。


Unity側のデータクラス

Unityでtool callを扱うためのクラスを用意します。

[System.Serializable]
public class ToolCall
{
    public string id;
    public string name;
    public string argumentsJson;
}

各toolのargumentsもクラス化しておきます。

[System.Serializable]
public class ShowDialogueArgs
{
    public string speaker;
    public string emotion;
    public string text;
}

[System.Serializable]
public class OpenShopArgs
{
    public string shopId;
}

[System.Serializable]
public class StartQuestArgs
{
    public string questId;
}

Tool Dispatcherを作る

LLMが返したtool callをUnityの実処理に接続するクラスを作ります。

using System;
using UnityEngine;

public class ToolDispatcher
{
    public void Dispatch(ToolCall toolCall)
    {
        if (toolCall == null)
        {
            Debug.LogWarning("ToolCall is null.");
            return;
        }

        switch (toolCall.name)
        {
            case "show_dialogue":
                HandleShowDialogue(toolCall.argumentsJson);
                break;

            case "open_shop":
                HandleOpenShop(toolCall.argumentsJson);
                break;

            case "start_quest":
                HandleStartQuest(toolCall.argumentsJson);
                break;

            default:
                Debug.LogWarning($"Unknown tool: {toolCall.name}");
                break;
        }
    }

    private void HandleShowDialogue(string argumentsJson)
    {
        var args = JsonUtility.FromJson<ShowDialogueArgs>(argumentsJson);

        if (args == null)
        {
            Debug.LogWarning("Invalid show_dialogue args.");
            return;
        }

        if (!IsAllowedEmotion(args.emotion))
        {
            Debug.LogWarning($"Invalid emotion: {args.emotion}");
            return;
        }

        if (string.IsNullOrWhiteSpace(args.text))
        {
            Debug.LogWarning("Dialogue text is empty.");
            return;
        }

        if (args.text.Length > 30)
        {
            args.text = args.text.Substring(0, 30);
        }

        ShowDialogue(args.speaker, args.emotion, args.text);
    }

    private void HandleOpenShop(string argumentsJson)
    {
        var args = JsonUtility.FromJson<OpenShopArgs>(argumentsJson);

        if (args == null || string.IsNullOrWhiteSpace(args.shopId))
        {
            Debug.LogWarning("Invalid open_shop args.");
            return;
        }

        if (!IsAllowedShop(args.shopId))
        {
            Debug.LogWarning($"Invalid shopId: {args.shopId}");
            return;
        }

        OpenShop(args.shopId);
    }

    private void HandleStartQuest(string argumentsJson)
    {
        var args = JsonUtility.FromJson<StartQuestArgs>(argumentsJson);

        if (args == null || string.IsNullOrWhiteSpace(args.questId))
        {
            Debug.LogWarning("Invalid start_quest args.");
            return;
        }

        StartQuest(args.questId);
    }

    private bool IsAllowedEmotion(string emotion)
    {
        return emotion == "happy"
            || emotion == "sad"
            || emotion == "angry"
            || emotion == "neutral";
    }

    private bool IsAllowedShop(string shopId)
    {
        return shopId == "item_shop"
            || shopId == "weapon_shop"
            || shopId == "inn";
    }

    private void ShowDialogue(string speaker, string emotion, string text)
    {
        Debug.Log($"[{emotion}] {speaker}: {text}");
        // 実際にはUIや表情制御へ接続する
    }

    private void OpenShop(string shopId)
    {
        Debug.Log($"Open shop: {shopId}");
        // 実際にはショップUIを開く
    }

    private void StartQuest(string questId)
    {
        Debug.Log($"Start quest: {questId}");
        // 実際にはクエスト開始処理を呼ぶ
    }
}

ポイントは、LLMのargumentsをそのまま信じないことです。

必ずUnity側で検証してから実行します。


OpenAI互換APIへtoolsを送る例

開発中にPC上のローカルLLM APIへHTTPで投げる場合、リクエストは以下のようになります。

{
  "model": "local-tool-model",
  "messages": [
    {
      "role": "system",
      "content": "あなたはゲーム内NPC制御AIです。必要に応じてtoolを呼び出してください。"
    },
    {
      "role": "user",
      "content": "道具を買いたい"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "open_shop",
        "description": "指定したショップUIを開く",
        "parameters": {
          "type": "object",
          "properties": {
            "shopId": {
              "type": "string",
              "enum": ["item_shop", "weapon_shop", "inn"]
            }
          },
          "required": ["shopId"]
        }
      }
    }
  ],
  "tool_choice": "auto"
}

期待するレスポンスは次のような形です。

{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "tool_calls": [
          {
            "id": "call_001",
            "type": "function",
            "function": {
              "name": "open_shop",
              "arguments": "{\"shopId\":\"item_shop\"}"
            }
          }
        ]
      }
    }
  ]
}

UnityからHTTPでtool calling APIを呼ぶ

Unity Editorや開発用ビルドでは、PCやMac上のローカルLLM APIにHTTPで接続すると実装確認が楽です。

using System;
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;

public class LocalToolCallingHttpClient : MonoBehaviour
{
    [SerializeField] private string endpoint =
        "http://127.0.0.1:11434/v1/chat/completions";

    public IEnumerator Send(
        string userMessage,
        Action<string> onResponse,
        Action<string> onError
    )
    {
        string json = BuildRequestJson(userMessage);

        using var request = new UnityWebRequest(endpoint, "POST");
        byte[] bodyRaw = Encoding.UTF8.GetBytes(json);

        request.uploadHandler = new UploadHandlerRaw(bodyRaw);
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");

        yield return request.SendWebRequest();

        if (request.result != UnityWebRequest.Result.Success)
        {
            onError?.Invoke(request.error);
            yield break;
        }

        onResponse?.Invoke(request.downloadHandler.text);
    }

    private string BuildRequestJson(string userMessage)
    {
        string escaped = EscapeJson(userMessage);

        return $@"
{{
  ""model"": ""local-tool-model"",
  ""messages"": [
    {{
      ""role"": ""system"",
      ""content"": ""あなたはゲム内NPC制御AIです。必要に応じてtoolを呼び出してください。""
    }},
    {{
      ""role"": ""user"",
      ""content"": ""{escaped}""
    }}
  ],
  ""tools"": [
    {{
      ""type"": ""function"",
      ""function"": {{
        ""name"": ""open_shop"",
        ""description"": ""指定したショップUIを開く"",
        ""parameters"": {{
          ""type"": ""object"",
          ""properties"": {{
            ""shopId"": {{
              ""type"": ""string"",
              ""enum"": [""item_shop"", ""weapon_shop"", ""inn""]
            }}
          }},
          ""required"": [""shopId""]
        }}
      }}
    }},
    {{
      ""type"": ""function"",
      ""function"": {{
        ""name"": ""show_dialogue"",
        ""description"": ""NPCのセリフを表示する"",
        ""parameters"": {{
          ""type"": ""object"",
          ""properties"": {{
            ""speaker"": {{ ""type"": ""string"" }},
            ""emotion"": {{
              ""type"": ""string"",
              ""enum"": [""happy"", ""sad"", ""angry"", ""neutral""]
            }},
            ""text"": {{ ""type"": ""string"" }}
          }},
          ""required"": [""speaker"", ""emotion"", ""text""]
        }}
      }}
    }}
  ],
  ""tool_choice"": ""auto""
}}";
    }

    private string EscapeJson(string value)
    {
        return value
            .Replace("\\", "\\\\")
            .Replace("\"", "\\\"")
            .Replace("\n", "\\n")
            .Replace("\r", "\\r");
    }
}

本番ではJSON文字列を手書きするより、Newtonsoft.Json で組み立てる方が安全です。


tool_callsをUnityでパースする

OpenAI互換のtool_callsレスポンスをパースするためのクラスです。

[System.Serializable]
public class ChatCompletionResponse
{
    public Choice[] choices;
}

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

[System.Serializable]
public class Message
{
    public string role;
    public ToolCallResponse[] tool_calls;
    public string content;
}

[System.Serializable]
public class ToolCallResponse
{
    public string id;
    public string type;
    public FunctionCall function;
}

[System.Serializable]
public class FunctionCall
{
    public string name;
    public string arguments;
}

変換処理です。

using System.Collections.Generic;
using UnityEngine;

public static class ToolCallParser
{
    public static List<ToolCall> ParseFromOpenAICompatibleResponse(string json)
    {
        var result = new List<ToolCall>();

        var response = JsonUtility.FromJson<ChatCompletionResponse>(json);

        if (response?.choices == null || response.choices.Length == 0)
        {
            return result;
        }

        var message = response.choices[0].message;

        if (message?.tool_calls == null)
        {
            return result;
        }

        foreach (var tc in message.tool_calls)
        {
            if (tc?.function == null)
            {
                continue;
            }

            result.Add(new ToolCall
            {
                id = tc.id,
                name = tc.function.name,
                argumentsJson = tc.function.arguments
            });
        }

        return result;
    }
}

取得したtool callを実行する

public class ToolCallingController : MonoBehaviour
{
    [SerializeField] private LocalToolCallingHttpClient client;

    private readonly ToolDispatcher dispatcher = new ToolDispatcher();

    public void OnUserInput(string text)
    {
        StartCoroutine(client.Send(
            userMessage: text,
            onResponse: HandleResponse,
            onError: error => Debug.LogError(error)
        ));
    }

    private void HandleResponse(string json)
    {
        var toolCalls = ToolCallParser.ParseFromOpenAICompatibleResponse(json);

        if (toolCalls.Count == 0)
        {
            Debug.Log("No tool call.");
            return;
        }

        foreach (var toolCall in toolCalls)
        {
            dispatcher.Dispatch(toolCall);
        }
    }
}

これで、LLMが返した tool_calls をUnity側の実処理に接続できます。


Native Plugin方式で使う場合

Androidアプリ内にローカルLLMを組み込む場合、HTTPではなくNative Plugin経由にすることもあります。

この場合でも考え方は同じです。

Native Plugin
  ↓
ローカルLLMランタイム
  ↓
tool_calls相当のJSON
  ↓
Unity C#
  ↓
ToolCallParser
  ↓
ToolDispatcher

Native Plugin側が返す文字列を、OpenAI互換レスポンスに寄せておくと、Unity側のParserを共通化できます。

例えば、Native Plugin側から次のようなJSONを返します。

{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "tool_calls": [
          {
            "id": "call_001",
            "type": "function",
            "function": {
              "name": "show_dialogue",
              "arguments": "{\"speaker\":\"村人\",\"emotion\":\"happy\",\"text\":\"ようこそ。\"}"
            }
          }
        ]
      }
    }
  ]
}

こうしておけば、HTTP方式でもNative方式でも、Unity側の処理は同じです。


LangChainのJSON Parserとの違い

LangChainのOutput ParserやStructured Output Parserは、ざっくり言うと次のような役割を持ちます。

1. 出力スキーマを定義する
2. LLMにフォーマット指示を渡す
3. 返ってきた文字列をパースする
4. 型や必須項目を検証する
5. 必要なら修正・再試行する

Unity Androidでは、LangChainをそのまま使うより、この考え方をC#で実装する方が現実的です。

Function calling対応ローカルLLMを使う場合は、LangChain風のParserよりもさらに一歩進めて、次のようにできます。

tools schema
  ↓
LLM
  ↓
tool_calls
  ↓
Parser
  ↓
Validator
  ↓
Dispatcher
  ↓
Unity function

つまり、Unity側に小さな Tool Calling Runtime を作るイメージです。


tool callの検証を必ず行う

Function calling対応モデルを使っていても、検証は必須です。

例えば、LLMが以下のようなtool callを返す可能性があります。

{
  "name": "open_shop",
  "arguments": "{\"shopId\":\"admin_shop\"}"
}

admin_shop は存在しない、または呼ばせたくないショップかもしれません。

そのため、Unity側で必ず許可リストを使います。

private bool IsAllowedShop(string shopId)
{
    return shopId == "item_shop"
        || shopId == "weapon_shop"
        || shopId == "inn";
}

LLM出力は「命令」ではなく「提案」として扱います。


実行してよい状態か確認する

引数が正しくても、ゲーム状態によっては実行してはいけない場合があります。

例:

戦闘中にopen_shopしてはいけない
イベント中にstart_questしてはいけない
すでに開始済みのクエストを再開始してはいけない

Dispatcherでは、引数だけでなく現在のゲーム状態も確認します。

private void HandleOpenShop(string argumentsJson)
{
    if (GameState.Current == GameStateType.Battle)
    {
        Debug.LogWarning("Cannot open shop during battle.");
        return;
    }

    var args = JsonUtility.FromJson<OpenShopArgs>(argumentsJson);

    if (args == null || !IsAllowedShop(args.shopId))
    {
        return;
    }

    OpenShop(args.shopId);
}

tool_choiceを使い分ける

OpenAI互換APIでは、tool_choice によってtool呼び出しを制御できる場合があります。

自動選択

"tool_choice": "auto"

LLMが必要だと判断したときだけtoolを呼びます。

toolを使わない

"tool_choice": "none"

通常の自然文応答だけを返させたい場合に使います。

特定toolを強制

{
  "type": "function",
  "function": {
    "name": "show_dialogue"
  }
}

NPCセリフ生成のように、必ず特定toolの引数として返してほしい場合に便利です。

Unityゲームでは、場面ごとにtool_choiceを切り替えるのがおすすめです。

通常会話: auto
セリフ生成: show_dialogueを強制
ショップ会話: open_shopを許可
戦闘中: tool_choice none または battle系toolのみ

Function calling対応ローカルモデルの選び方

Function callingは、ランタイムだけでなくモデル側の性能にも依存します。

選ぶときは以下を確認します。

- tool calling対応を明記しているか
- JSON出力が安定しているか
- 小型モデルでもtool選択が正確か
- 日本語入力でtool選択できるか
- GGUF形式があるか
- Androidで動かせるサイズか

PCでは7B以上のtool callingモデルを使えても、Androidでは重すぎる場合があります。

Unity Androidでは、まず小型モデルで以下をテストします。

- 正しいtoolを選ぶか
- argumentsが正しいJSONになるか
- enum値を守れるか
- 日本語プロンプトで安定するか
- max_tokensを小さくしても壊れないか

失敗時のフォールバック

tool callが失敗した場合は、すぐアプリを止めるのではなくフォールバックします。

- tool callがない → 通常会話として表示
- argumentsが壊れている → 再生成
- 許可されていないtool → 無視
- 許可されていない引数 → デフォルト値にする
- 何度も失敗 → 固定文を表示

例:

if (toolCalls.Count == 0)
{
    ShowDialogue("村人", "neutral", "すみません、よく分かりません。");
    return;
}

ゲームでは、AIが失敗しても体験が破綻しない設計が重要です。


まとめ

Function calling相当の機能を持つローカルLLMを使えば、Unity AndroidアプリでもLLM出力をゲームロジックに接続しやすくなります。

基本構成は以下です。

tools定義
  ↓
ローカルLLM
  ↓
tool_calls
  ↓
Unity Parser
  ↓
Validator
  ↓
Dispatcher
  ↓
Unityの実関数

重要なポイントは以下です。

- LLMは関数を直接実行しない
- LLMは呼ぶべきtoolとargumentsを返す
- Unity側で必ず検証する
- tool名と引数は許可リストで制限する
- 現在のゲーム状態も確認する
- 失敗時のフォールバックを用意する
- HTTP方式でもNative Plugin方式でも、Unity側のDispatcherは共通化できる

LangChainのJSON ParserやStructured Output Parserの考え方に近いですが、Unity AndroidではC#側に小さなParser / Validator / Dispatcherを作るのが現実的です。

Function calling対応ローカルLLMを使う場合は、単なるJSON出力よりも、tools schemaを渡してtool_callsとして受け取る設計にすると、ゲーム内AIを安全に制御しやすくなります。


参考リンク

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?