GodotでもLLMを使いたい(C#とONNX編)

はじめに

今回作成する物のデモです。

さて、前回「GodotでもLLMを使いたい(C#とLLamaSharp編)」という記事を書きました。

これは、LLamaSharpというllama.cppのC#ラッパーを使ってGodotでLLMを動かす話でした。

今回は、LLamaSharpではなく、ONNX Runtime Generative AIを使ってLLMを動かしてみたいと思います。

ONNX Runtime Generative AI(C#)のガイドはこちら

ONNX Runtime Generative AI を使用する Windows アプリで Phi3 やその他の言語モデルの使用を開始する
Phi3 モデルと ONNX Runtime Generative AI ライブラリを使用する WinUI 3 アプリを作成する方法について説明します。

このコードを前回のコードに移植して、LLamaSharpを使わないバージョンを書いていきます。

環境構築

GodotでC#を動かす話は、以前の記事を見てください。単純に言うと、C#対応版のgodotと.NET SDKを入れるだけです。

始めに、C#版のGodotでプロジェクトを作ります。

まず、適当なノード(シーン)を作って、C#スクリプトを新規アタッチします。すると、プロジェクトフォルダに{project name}.csprojというファイルを含む、C#の環境が作られます。

この.csprojファイルがC#の外部パッケージを設定しているので、生成できていることを確認しましょう。

    Directory: E:\godot_projects\llm_onnx_test

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----          2024/06/19    12:43                .godot
d----          2024/06/19    11:11                Models
-a---          2024/06/19    11:02             80 .gitattributes
-a---          2024/06/19    11:02             36 .gitignore
-a---          2024/06/19    11:55            764 chat_view.gd
-a---          2024/06/19    12:43           1185 chat_view.tscn
-a---          2024/06/19    11:02            949 icon.svg
-a---          2024/06/19    11:02            842 icon.svg.import
-a---          2024/06/19    11:08            504 llm_onnx_test.csproj
-a---          2024/06/19    11:03           1061 llm_onnx_test.sln
-a---          2024/06/19    12:43           3089 llm_server.cs
-a---          2024/06/19    12:43            198 llm_server.tscn
-a---          2024/06/19    11:34            597 project.godot

lsコマンドでファイルを確認すると、llm_onnx_test.csprojがあります。

パッケージのインストール

次に、NuGetをつかって外部パッケージをプロジェクトにインストールします。

Windowsの.NET SDKを入れると、dotnetコマンドが使えるようになっています。これにはNuGetが内包されています。

プロジェクトフォルダでターミナルを開き、次のコマンドでOnnxRuntimeGenAIを入れます。

dotnet add package Microsoft.ML.OnnxRuntimeGenAI.DirectML

.csprojの内容に<ItemGroup>タグでMicrosoft.ML.OnnxRuntimeGenAI.DirectMLが追記されていることを確認します。

<Project Sdk="Godot.NET.Sdk/4.2.2">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net7.0</TargetFramework>
    <TargetFramework Condition=" '$(GodotTargetPlatform)' == 'ios' ">net8.0</TargetFramework>
    <EnableDynamicLoading>true</EnableDynamicLoading>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.ML.OnnxRuntimeGenAI.DirectML" Version="0.2.0" />
  </ItemGroup>
</Project>

モデルのダウンロード

次にモデルをダウンロードします。ガイドによれば、huggingface-cliを使うのが良いらしいです。

huggingface-cliはpythonのpipで入ります。

pip install -U "huggingface_hub[cli]"

これを入れたら、ModelsディレクトリをGodotのプロジェクトに作ります。

Models内でターミナルを開き、次のコマンドでモデルをダウンロードします。

huggingface-cli download microsoft/Phi-3-mini-4k-instruct-onnx --include directml/* --local-dir .

おそらく、.onnxのファイルがあればPhi-3-mini以外のLLMでも使えます。ここでは、ガイドに従ってPhi-3-miniをダウンロードしました。

プログラム

C#でONNXのLLMを動かし、それをGDScriptから利用したいと思います。

C#のノード

まずは、C#で書かれたコードをアタッチしたノードを作ります。名前はllm_serverとしました。

型はNodeにして、次のコードをアタッチします。

using Godot;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using Microsoft.ML.OnnxRuntimeGenAI;

public partial class llm_server : Node
{
	private Model? model = null;
	private Tokenizer? tokenizer = null;
	private readonly string ModelDir = Path.Combine(@"E:\godot_projects\llm_onnx_test\Models\directml\directml-int4-awq-block-128");
	public string input_text = "";
	public List<string> AntiPrompts = new List<string> {"<|end|>", "<|user|>", "<|assistant|>", "\n"};
	[Signal]
	public delegate void generateStartEventHandler();
	[Signal]
	public delegate void generatingEventHandler(string text);
	[Signal]
	public delegate void generateEndEventHandler();
	public override void _Ready()
	{
		generateStart += generate;
	}
	
	public void InitializeModel()
	{
		GD.Print("Loading model...");
		var sw = Stopwatch.StartNew();
		model = new Model(ModelDir);
		tokenizer = new Tokenizer(model);
		sw.Stop();
		GD.Print($"Model loading took {sw.ElapsedMilliseconds} ms");
	}
	public void generate(){
		Thread thread = new Thread(new ThreadStart(run_llm));
		thread.Start();
	}
	public async void run_llm(){
		string output_text = "";
		if(model != null)
		{
			var systemPrompt = "あなたは優秀なAIアシスタントです";
			var prompt = $@"<|system|>{systemPrompt}<|end|>
			<|user|>{this.input_text}<|end|>
			<|assistant|>";
			
			await foreach (var part in InferStreaming(prompt))
			{
				output_text += part;
				CallDeferred("emit_signal", SignalName.generating, output_text);
			}
		}
		CallDeferred("emit_signal", SignalName.generateEnd);
	}
	public async IAsyncEnumerable<string> InferStreaming(string prompt)
	{
		if (model == null || tokenizer == null)
		{
			throw new InvalidOperationException("Model is not ready");
		}

		var generatorParams = new GeneratorParams(model);

		var sequences = tokenizer.Encode(prompt);

		generatorParams.SetSearchOption("max_length", 2048);
		generatorParams.SetSearchOption("do_sample", true);
		
		generatorParams.SetSearchOption("temperature", 0.8);
		generatorParams.SetSearchOption("top_k", 50);
		generatorParams.SetSearchOption("top_p", 0.95);
		generatorParams.SetInputSequences(sequences);
		generatorParams.TryGraphCaptureWithMaxBatchSize(1);

		using var tokenizerStream = tokenizer.CreateStream();
		using var generator = new Generator(model, generatorParams);
		StringBuilder stringBuilder = new();
		while (!generator.IsDone())
		{
			string part;
			try
			{
				await Task.Delay(10).ConfigureAwait(false);
				generator.ComputeLogits();
				generator.GenerateNextToken();
				part = tokenizerStream.Decode(generator.GetSequence(0)[^1]);
				stringBuilder.Append(part);
				bool break_flg = false;
				foreach (string token in this.AntiPrompts)
				{
					if (stringBuilder.ToString().Contains(token))
					{
						break_flg = true;
						break;
					}
				}
				if (break_flg){
					break;
				}
			}
			catch (Exception ex)
			{
				Debug.WriteLine(ex);
				break;
			}
			yield return part;
		}
	}
}

LLamaSharpよりもコード量は増えましたね。やっている処理は殆ど変わらないです。また、前回書いたコードと同じ仕様にしてあります。少し解説します。

まず、3つのシグナルを持っていますGodot for C#のシグナルは定義が少し分かりづらいですが、EventHandlerというのを使います。+=でシグナルを関数にセットするのが直感と反します

  • generateStart : 生成の開始を知らせるシグナル。このシグナルが着たらgenerate()が呼ばれる
  • generating : 新しい文字(トークン)を生成したら、生成中の文字列を知らせるシグナル。これをGDScript側で検知して、逐次表示させる
  • generateEnd : 生成が終了したことを知らせるシグナル

また、書くメソッドは次のような機能を持っています

  • InitializeModel() : モデルに与えるパラメータなどを設定する
  • generate() : シグナルが着たら生成開始スレッドを立てて開始する。スレッドを分けないと、生成中にGodotがフリーズする。
  • run_llm() : プロンプトを作成してLLMに与える。生成中はgeneratingシグナルを発し、生成が終了したらgenerateEndシグナルを発する。
  • InferStreaming() : ONNXのLLMを実行する奴。

Threadを立ててLLMを実行しないと、Godotがフリーズするので注意してください。

※注意 : ガイドのデフォルトだと実行ごとに生成されるテキストが変化しません。以下のオプションを必ず設定してください(パラメータはご自由に)。

generatorParams.SetSearchOption("do_sample", true);
generatorParams.SetSearchOption("temperature", 0.8);
generatorParams.SetSearchOption("top_k", 50);
generatorParams.SetSearchOption("top_p", 0.95);

また、AntiPromptsとして指定の文字列がトークンに含まれたら終了するようにしています。

GDScriptのノード

GDScriptのノードは前回と変わっていません。一応再掲載しておきます。

Godotに表示するUIを作っていきます。まず、新規シーンを作成します。型はNode2D、名前はChatViewにしました。

次のようなTreeのシーンにしました。

また、画面上は次のような感じです。

次のスクリプトをChatViewにアタッチします(Buttonのpressedシグナルを接続しました)。

また、モデルのロードを_ready時に行うようにしました。モデルのロード時間が3秒程度かかるみたいです。

extends Node2D

var server_class := preload("res://llm_server.tscn")
var server

func _ready():
	server = server_class.instantiate()
	server.generating.connect(_on_llm_generate_label_generating)
	server.generateEnd.connect(_on_llm_generate_label_generate_end)
	server.InitializeModel()
	add_child(server)

func _on_button_pressed():
	var input_text :String = $Panel/VBoxContainer/TextEdit.text
	$Panel/VBoxContainer/ScrollContainer/Label.text = ""
	server.input_text = input_text
	server.emit_signal('generateStart')
	$Panel/VBoxContainer/Button.disabled = true
	
func _on_llm_generate_label_generating(text):
	$Panel/VBoxContainer/ScrollContainer/Label.text = text

func _on_llm_generate_label_generate_end():
	pass
	$Panel/VBoxContainer/Button.disabled = false

先ほど作ったC#のノード(LLM_Server)をpreloadして、readにて生成します。

あとは、ボタンが押されたらserverのgenerateStartシグナルをオンにするだけです。すると、LLMが動き出し、Labelに文章が生成されます。

実行結果

最初にものせましたが、実行結果です。

デフォルトでGPUを利用するようになっているようで、GPUが稼働しています。そのおかげで、前回のLLamaSharpよりも生成速度は段違いに早いです。

まとめ

今回は、GodotでLLMを動かす話をしました。C#のONNX Runtimeを使うことで簡単にGodotでもLLMの事項ができました。また、Threadとsignalを使うことで返答の逐次表示にも成功し、現実的にLLMでゲームを作ることが可能であることが示せました。

実はphi-3のモデルたちは、guff形式ではなくONNX形式のほうがたくさん公開されています(smallやvisionも.onnxはある)。LLamaSharpよりも少しだけコード量が増えますが、関数ひとつ分程度の差です。他のモデルを使うときにはONNX Runtimeが有力な選択肢になるかもしれません。

タイトルとURLをコピーしました