ローカルLLMをUnityに実装してみた

こんにちは！

83effect（はちさんえふぇくと、eighty-three effect）です！

私達は現在、ローカルLLMを組み込んだUnity製のゲームを開発中です。
ローカルLLMの実行にはLlamaCppPythonを使用する形での実装を試みましたが、こちらの実装が中々に大変でした。
本記事では、実装に際して得た学びを書き残しておきたいと思います。
ローカルLLMを組み込んだUnity製のゲームを制作したいと考えている方の参考になれば幸いです。

# そもそもローカルLLMとは？

LLMと言えばChatGPTやGeminiが有名ですが、これらはインターネットを通じて使用するLLMになります。

それに対してローカルLLMは、ローカル環境で（つまりインターネットに接続しなくても）使用できるLLMになります。

以下はゲームへの組み込みにおいての簡単な比較です。

![スクリーンショット 2024-10-19 120736.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/30394f01-3c6b-31f5-7ce9-d2d9c1935e10.png)

解説動画

<iframe width="560" height="315" src="https://www.youtube.com/embed/Oz14fdka0Jo?si=3WCuslGtlcpmEiK6" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


# 環境・技術紹介

### 環境
Windows 11

### 使用技術
* Unity 2022.3.47f1
* LlamaCppPython 0.3.2
* pythonnet 3.0.4
* Python 3.12.7（Embeddable）
* Llama-3-ELYZA-JP-8B-GGUF

#### 使用技術の解説
- Unity
ゲーム開発やリアルタイム3Dコンテンツの制作を行うための総合的な開発プラットフォーム

https://unity.com/ja

- LlamaCppPython
ローカルLLMをPythonで使用するためのライブラリ

https://github.com/abetlen/llama-cpp-python

- pythonnet
C#アプリケーション内でPythonコードを実行する為に必要なライブラリ

https://pypi.org/project/pythonnet/

- Python（Embeddable）
今回使用するのは組み込み用のPython
pythonnetでサポートされているバージョンを使用する

https://www.python.org/downloads/windows/

- Llama-3-ELYZA-JP-8B-GGUF
今回使用するローカルLLM
量子化されており、CPUでも動かすことが可能
    
    - ライセンス
    月間アクティブユーザー数が7億人を超えない限り商用でも無料で利用可

https://huggingface.co/elyza/Llama-3-ELYZA-JP-8B-GGUF/tree/main

https://www.llama.com/llama3/license/


# 実装手順

## 0. Unityプロジェクトの作成
各自で、Unityのプロジェクトを作成してください。
この際のテンプレートはどれでも大丈夫です。
また、既存のプロジェクトに追加実装する形式でも構いません。

以降の内容では、「2D (Built-In Render Pipeline)」で作成した空のプロジェクトを使用して、実装手順を紹介していきます。

:::note
現在は、CPU対応版のみの紹介になります。
GPU対応版は近日追記予定です。
:::

## 1. Unity上にPythonの実行環境を作る
ローカルLLMを動かすために、LlamaCppPythonというPythonライブラリを使用します。
そのために、まずはUnity上でPythonの実行環境を構築していきます。

なお以降の手順については、こちらの記事を参考にしています。

https://zenn.dev/shiena/articles/unity-python

### 1-a. pythonnetのインストール

#### ①Project Settings

Edit > Project Settingsを開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/7a8e7759-b96f-8d0e-b11a-e4e9f7176447.png)

Package Managerを開き、以下の設定を追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/8e30b264-528d-73cd-984a-b855aa954f0b.png)

```
Name: Unity NuGet
URL: https://unitynuget-registry.azurewebsites.net
Scope(s): org.nuget
```

この時、忘れずにSaveを押しましょう。

Project Settingsでの設定項目はもう1つあります。
Playerを開き、「Configuration / Api Compatibility Level*」を「.NET Framework」に変更します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/7507b1eb-7cc8-3ba4-df34-6b9dfa17c7cb.png)

ここでは設定の変更が自動で反映されるため、そのままProject Settingsのウィンドウを閉じます。

#### ②Package Manager

Window > Package Managerを開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/4998d6ab-e5b9-b407-b462-924993d1a201.png)

左上のプルダウンを開き、「Packages」を「My Registries」に変更します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/3d9dc8b0-4517-f9c8-2bc6-f168bca943f3.png)

右上の検索窓で「pythonnet」を検索します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/d12b9dc8-1b56-440e-3d81-96e6f7314384.png)

「Install」を押します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/2bbe902d-7f59-a8d0-02bd-5337e9bf921e.png)

インストールが完了したら、pythonnetのVersionを確認しておいてください（以下では「3.0.4」）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/de636e3d-4cd9-d6f0-c210-9b2c63cc93ac.png)

タブを閉じます。


### 1-b. Python Embeddable Packageをダウンロード

Pythonのダウンロードページを開き、<b>Windows embeddable package</b>をダウンロードします。

https://www.python.org/downloads/windows/

先程インストールしたpythonnetのドキュメントを確認し、サポートしているpythonのバージョンを確認します。

:::note warn
Unityにインストールされているバージョンのドキュメントを確認することに注意してください
:::
https://pypi.org/project/pythonnet/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/f3fd7f98-a61c-f9f0-7fc4-8cbf5abdcc61.png)

> pythonnet 3.0.4はpython 3.7 ~ 3.12をサポートしているようです。

ここではPython 3.12.7の<b>Windows embeddable package</b>をダウンロードします。

:::note warn
「Windows installer」と間違えないように注意しましょう
:::



![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/00f5e11b-f8f8-989a-3d1a-d357a2bd2d54.png)



zipをダウンロードできたら解凍して、Unityプロジェクトの
Assets\StreamingAssets配下にpython-3.12.7-embed-amd64フォルダを配置してください。
（StreamingAssetsフォルダがなければ作成してください）

### 1-c. pipの導入
python312._pth ファイルの import site のコメントアウトを削除します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/803750a1-1677-70b9-7eb5-a3927122afae.png)


次に、コマンドプロンプトを起動し、以下を順に実行していきます。
（PowerShellで実行する場合は一部コマンドが変わるので注意しましょう）

```
# python-3.12.7-embed-amd64に移動（カレントディレクトリはUnityのプロジェクトフォルダ）
# ダウンロードしたバージョンによって適切に変える
cd Assets\StreamingAssets\python-3.12.7-embed-amd64

# pipの導入
curl -L https://bootstrap.pypa.io/get-pip.py | .\python

# 以下のコマンドでインストールできているか確認
.\Scripts\pip list
```
こちらの環境では以下が出力されました。
```
Package Version
------- -------
pip     24.3.1
```

## 2. LlamaCppPythonをインストール

### 2-a. 「.whl」ファイルのダウンロード

以下のページから、上記でダウンロードしたPythonと同じバージョンのllama-cpp-pythonを探し、ダウンロードします。

https://github.com/abetlen/llama-cpp-python/releases

:::note info
インストール時にのみ使用するファイルのため、配置場所はUnityプロジェクトの外で大丈夫です。
以下ではDownloadsフォルダに配置しています。
:::

【LlamaCppPython0.3.2 / Python 3.12.7を使用する場合】

ファイル名は以下の形式になっています。

```
llama_cpp_python-0.3.2-cp312-cp312-win_amd64.whl
```

- CPU対応

    Latestのタグが付いているのを選べばOKです。

:::note warn
「-cuXXX」のラベルがついているのはGPU版なので注意してください
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/ae0718ef-aa69-7574-08ff-7fc6389aff5b.png)


### 2-b. LlamaCppPythonのインストール　

ダウンロードできたら、以下のコマンドでインストールします。

```
# カレントディレクトリは "\Assets\StreamingAssets\python-3.12.7-embed-amd64"
# Pathは適切に書き換えてください

.\Scripts\pip install <ダウンロードした.whlファイルのPath>

# （例）
# .\Scripts\pip install "C:\Users\<Username>\Downloads\llama_cpp_python-0.3.2-cp312-cp312-win_amd64.whl"
```

```
# 以下のコマンドでインストールできているか確認
.\Scripts\pip list
```

こちらの環境では以下が出力されました。
```
Package           Version
----------------- -------
diskcache         5.6.3
Jinja2            3.1.4
llama_cpp_python  0.3.2
MarkupSafe        3.0.2
numpy             2.1.3
pip               24.3.1
typing_extensions 4.12.2
```

:::note info
インストールを確認出来たら、先程ダウンロードした .whl ファイルは削除して構いません。
:::


## 3. ローカルLLMのモデルをダウンロード


今回はELYZA社の日本語大規模言語モデル「Llama-3-ELYZA-JP-8B-GGUF」を使用します。
他のモデルでも大丈夫ですが、LlamaCppPythonで動くモデルである必要があります。（基本的にGGUF形式であればOK）

https://note.com/elyza/n/n360b6084fdbd

ここでポイントとして、量子化されたモデルを用いることをおすすめします。
量子化についての詳細は他の記事を参照していただきたいのですが、簡単に例えるとCD音源をmp3に変換するような感じで、大体の性能を保ったまま容量を軽くできるのがメリットになっています。

### Llama-3-ELYZA-JP-8B-GGUFをダウンロード
このサイトを開き、.ggufファイルをダウンロードします。

https://huggingface.co/elyza/Llama-3-ELYZA-JP-8B-GGUF/tree/main

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/bf24313c-e1a8-8b1d-46dd-c49838982290.png)

ダウンロードできたら、Assets\StreamingAssets配下に配置してください。
（以下ではAssets\StreamingAssetsの下にGGUFフォルダを作成し、その中にモデルを配置しています）

:::note warn
Assets\StreamingAssetsの配下でないとBuildした際にモデルのファイルがプロジェクトに含まれません！
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/5b9fb9f9-ef24-9d23-aa0b-b88b14d1790e.png)

## 3. PythonでローカルLLMを動かすためのコードを記述

C#とPythonのファイルを作成していきます。

### C#のサンプルコード
Assets配下にgameManager.csとPythonLifeCycle.csを作成します。

```cs:gameManager.cs
using Python.Runtime;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class gameManager : MonoBehaviour
{
    private SemaphoreSlim _semaphoreSlim;

    void OnEnable()
    {
        _semaphoreSlim = new SemaphoreSlim(1, 1);
    }

    void Start()
    {
        LlamaReply();
    }

    void Update()
    {

    }

    public async void LlamaReply()
    {
        IntPtr? state = null;
        try
        {
            await _semaphoreSlim.WaitAsync(destroyCancellationToken);
            state = PythonEngine.BeginAllowThreads();
            string resultText = await Task.Run(() => LlamaPython());
            Debug.Log(resultText);
        }
        catch (OperationCanceledException e) when (e.CancellationToken != destroyCancellationToken)
        {
            Debug.Log("回答の生成に失敗しました");
            throw;
        }
        finally
        {
            if (state.HasValue)
            {
                PythonEngine.EndAllowThreads(state.Value);
            }
            _semaphoreSlim.Release();
        }
    }

    private string LlamaPython()
    {
        string modelPath = Application.streamingAssetsPath + "/GGUF/" + "Llama-3-ELYZA-JP-8B-q4_k_m.gguf"; // TODO: 適切に変える
        Debug.Log(modelPath);
        string systemContent = "語尾に「にゃん」をつけて回答してください。";
        string userContent = "富士山について教えてください。";
        using (Py.GIL())
        {
            using dynamic sample = Py.Import("sample"); // TODO: 適切に変える
            using dynamic result = sample.llamaCppPython(modelPath, systemContent, userContent);
            return result
        }
    }
}
```

```cs:PythonLifeCycle.cs
using Python.Runtime;
using System;
using UnityEngine;

public static class PythonLifeCycle
{
    private const string PythonFolder = "python-3.12.7-embed-amd64"; // TODO: 適切に変える
    private const string PythonDll = "python312.dll"; // TODO: 適切に変える
    private const string PythonZip = "python312.zip"; // TODO: 適切に変える
    private const string PythonProject = "Scripts"; // TODO: 適切に変える

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    private static void PythonInitialize()
    {
        Application.quitting += PythonShutdown;
        Initialize(PythonProject);
    }
    private static void PythonShutdown()
    {
        Application.quitting -= PythonShutdown;
        Shutdown();
    }

    public static void Initialize(string appendPythonPath = "")
    {
        var pythonHome = $"{Application.streamingAssetsPath}/{PythonFolder}";
        var appendPath = string.IsNullOrWhiteSpace(appendPythonPath) ? string.Empty : $"{Application.streamingAssetsPath}/{appendPythonPath}";
        var pythonPath = string.Join(
            ";",
            $"{appendPath}",
            $"{pythonHome}/Lib/site-packages",
            $"{pythonHome}/{PythonZip}",
            $"{pythonHome}"
        );
        var scripts = $"{pythonHome}/Scripts";

        var path = Environment.GetEnvironmentVariable("PATH")?.TrimEnd(';');
        path = string.IsNullOrEmpty(path) ? $"{pythonHome};{scripts}" : $"{pythonHome};{scripts};{path};";
        Environment.SetEnvironmentVariable("PATH", path, EnvironmentVariableTarget.Process);
        Environment.SetEnvironmentVariable("DYLD_LIBRARY_PATH", $"{pythonHome}/Lib", EnvironmentVariableTarget.Process);
        Environment.SetEnvironmentVariable("PYTHONNET_PYDLL", $"{pythonHome}/{PythonDll}", EnvironmentVariableTarget.Process);
#if UNITY_EDITOR
			Environment.SetEnvironmentVariable("PYTHONDONTWRITEBYTECODE", "1", EnvironmentVariableTarget.Process);
#endif

        PythonEngine.PythonHome = pythonHome;
        PythonEngine.PythonPath = pythonPath;

        PythonEngine.Initialize();
    }
    public static void Shutdown()
    {
        PythonEngine.Shutdown();
    }
}
```

### Pythonのサンプルコード
Assets\StreamingAssets\Scripts配下にsample.pyを作成します。

:::note warn
Pythonファイルを編集した場合はUnityを再起動する必要があります。
（どこかで適切に設定すれば大丈夫なのかもしれませんが…）
:::


```python:sample.py
import llama_cpp
from llama_cpp import Llama


def llamaCppPython(modelPath, systemContent, userContent):
    model = Llama(
        model_path=modelPath,
        chat_format="llama-3",  # TODO: 適切に変える
        n_ctx=1024,
    )

    response = model.create_chat_completion(
        messages=[
            {"role": "system", "content": systemContent},
            {"role": "user", "content": userContent},
        ],
        max_tokens=1024,
    )

    resultText = response["choices"][0]["message"]["content"]
    return resultText

```

ここまでやった結果のフォルダ構成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/465634dd-2d8c-7609-c1c8-82a21d7b2667.png)


## 4. 【実行】Unity(C#)でPythonの関数を呼び出す
いよいよ実行です。

### 実行準備
Hierarchyタブの中で右クリックし、「Create Empty」を選択。
「GameObject」を作成。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/689ec9d9-c328-688a-10a9-cef0da645d1f.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/3e454fa4-126e-b81f-921b-daf118154323.png)

Project > Assets\gameManager.csを、上記で作成した「GameObject」にドラッグ＆ドロップ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/2ab93d53-ae98-919e-f34e-fb43aa717e3b.png)

「GameObject」の「Inspector」を確認し、「Game Manager」が含まれていればOK

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/184ab5af-867a-87a1-9eb7-6c292c0b6148.png)

### 実行
実行ボタンを押します。

すると、gameManager.cs :56行目の Debug.Log(modelPath);が呼び出され、使用モデルのPathがConsoleに出力されるはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/17af6f6e-7b53-2ce0-97f1-ed8e1c8f0ebf.png)

この状態でしばらく待つと、gameManager.cs :56行目の Debug.Log(modelPath);が呼び出され、返答がConsoleに出力されるはずです。

環境によってはかなり時間がかかりますが、タスクマネージャーを開いてCPUの使用率が上がっていれば返答生成中なので待ちましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3909466/d6543340-54ae-1270-569f-21695d6853ad.png)

<br>

- 返答
    >富士山にゃん！日本で一番高い山で、標高は3,776メートルにゃん。三重県と山梨県の県境に位置していて、世界文化遺産に登録されているにゃん。富士山は、火山で、約25万年前に噴火してできた山にゃん。山体は、約20万年前に噴火した溶岩ドームが固まったものにゃん。富士山は、登山が人気で、多くの人が登頂を目指すにゃん。でも、気候が厳しく、登山道が整備されていない部分もあるので、注意が必要にゃん。

    ```cs:gamaManager.cs
    string systemContent = "語尾に「にゃん」をつけて回答してください。";
    string userContent = "富士山について教えてください。";
    ```
    gameManager.cs :57行目で指定したsystemContentの内容に基づいて、userContentの指示に回答してくれました。
    （三重県…？）


# 最後に
83effectはローカルLLMを組み込んだゲームを販売しています！！
体験版もご用意しておりますので、是非遊んでみてください！！

「Help me, ROBOPET!!」実況動画
<iframe width="560" height="315" src="https://www.youtube.com/embed/n7cr273C920?si=ToIFHRoY9d5VicG6" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

ストアページ:

https://83effect.itch.io/help-me-robopet

X: https://x.com/83effect
YouTube: https://www.youtube.com/@83effect-official
niconico: https://www.nicovideo.jp/user/136492465?rf=nvpc&rp=watch&ra=video_information


## 参考記事

https://zenn.dev/shiena/articles/unity-python