GodotでローカルLLMの返答をリアルタイム表示させたい

まえがき

以前、GodotでもLLMを使う方法について話しました。

しかし、上記の方法にはいくつかの課題点がありました。具体的には

  1. LLM実行が終わるまで、ゲームが停止する
    • フリーズに近い状態なので、ゲームとして成り立たなくなる
  2. LLMの吐き出す文字数が多いと、返答まで待つことになる
    • ローカルLLMをGPUで実行しても2、3秒待つので、ユーザー体験が損なわれる

1つ目の課題点は、threadやos.create_processなどを使うことで解決できます。しかし、別プロセスとして実行すると、ノードが持つオブジェクトにはアクセスできなくなるので、実装は面倒になります。

2つ目の課題点は、Godot 4.3で実装予定のOS.execute_with_pipe()で解決できるかもしれません。しかし、自分で動かしてみたのですが、資料が少なく、期待通りの実装はできませんでした。

そこで、今回は別の方法を利用して、この2つの課題を解決しようと思います。

追記

C#をGDScriptと併用してGodotで使うことで、簡単にLLMを逐次表示できました。別プロセスで実行するよりはこちらのほうがいいかも

解決策

GodotでのLLMの実行は現時点では、別の実行ファイルに頼る形式が最も実装が楽です。

前の記事ではllama.cppを使うことを推奨し、その後、llamafileを使うことを推奨しました。

そして、llamafile(llama.cpp)には、サーバーモードが実装されているのです。

そこで今回は、godotでサーバーモードのllamafileを実行し、http通信によって返答を逐次受け取りながらgodotに表示する方法を取りたいと思います。

ディレクトリ

ディレクトリ構成は次のようになっています。godot側ではllm_connectというシーンを追加して、スクリプトをアタッチするだけです。

godot_llamafile_stream_test
│  llm_connect.gd
│  llm_connect.tscn
│  project.godot
└─models
   └─  Phi-3-mini-4k-instruct.Q4_K_M.llamafile.exe

また、Phi-3-miniのllamafileをmodelsというディレクトリを作って、入れておきます。phi-3-miniのllamafileは以下からダウンロードできます。windows環境下では、拡張子に.exeを付け足してください。

Mozilla/Phi-3-mini-4k-instruct-llamafile · Hugging Face
We’re on a journey to advance and democratize artificial intelligence through open source and open science.

※お好きなモデルのllamafileをダウンロードしてもらって構いません。

シーン構成

llm_connectのノードは次のような構成になっています。

UIの位置調整等は適当に配置してもらって構いません。例を上げときます。TextEditのtextにデフォルトで「What is best movie?」と入れていますが、テスト用なので入れなくて構いません。

Buttonのpressedシグナルはスクリプトにアタッチしてください。

プログラム

llm_connectにアタッチしたllm_connect.gdを編集します。以下、全文です。

extends Node2D

var llm_pid: int

signal recived_tokens

var http_client := HTTPClient.new()
var http_is_connected := false
var http_is_requested := false
var port := 8080
var host := 'http://127.0.0.1'
var url := '/completion'
var header := 'Content-Type: application/json'

var output_text := ''

var n_predict := 512

func _ready() -> void:
	tree_exited.connect(_exit_tree)
	
	var arguments := [
		'-ngl', '9999', '--host', '0.0.0.0', '--server', '--nobrowser'
	]
	llm_pid = OS.create_process('models/Phi-3-mini-4k-instruct.Q4_K_M.llamafile.exe', arguments)
	
	recived_tokens.connect(_on_recived_tokens)

func _on_button_pressed() -> void:
	connect_llm()

func _on_recived_tokens(tokens: Array):
	for token in tokens:
		output_text += token
	$Panel/VBoxContainer/ScrollContainer/Label.text = output_text

func connect_llm():
	$Panel/VBoxContainer/Button.disabled = true
	output_text = ''
	
	print('call connect_llm')
	var err := http_client.connect_to_host(host, port)
	print('  err :', err)
	if err == OK:
		http_is_connected = true
	else:
		http_is_connected = false

func close_connect():
	$Panel/VBoxContainer/Button.disabled = false
	http_client.close()
	http_is_connected = false
	http_is_requested = false

func request_llm(http_client_status):
	print('call request_llm')
	print('  http_client_status :',http_client_status)
	if http_client_status == HTTPClient.STATUS_CONNECTING or http_client_status == HTTPClient.STATUS_RESOLVING:
		return
		
	if http_client_status == HTTPClient.STATUS_CONNECTED:
		var body = get_send_body()
		var err = http_client.request(HTTPClient.METHOD_POST, url, [header], body)
		print('  err :', err)
		if err == OK:
			http_is_requested = true

func _process(delta):
	if http_is_connected == false:
		return
		
	http_client.poll()
	var http_client_status = http_client.get_status()
	if http_is_requested == false:
		request_llm(http_client_status)
		return
	if http_client.has_response() and http_client_status == HTTPClient.STATUS_BODY:
		http_client.poll()
		
		var chunk := http_client.read_response_body_chunk()
		if chunk.size() == 0:
			return
		
		var string_body  := chunk.get_string_from_utf8()
		if string_body :
			var tokens = to_token(string_body)
			emit_signal('recived_tokens', tokens)

func get_send_body():
	var input_text :String = $Panel/VBoxContainer/TextEdit.text
	var prompt := "<|user|>\n{0}<|end|>\n<|assistant|>\n".format([input_text])
	
	var body = {
		'prompt':prompt,
		'n_predict':n_predict,
		'stream':true
	}
	return JSON.stringify(body)

func to_token(string_body: String):
	#print('call to_token')
	var ret = []
	var data_list = string_body.strip_edges().split('\n\n')
	
	for data in data_list:
		var dict_data = JSON.parse_string(data.replace('data: ', ''))
		if dict_data != null:
			if dict_data.has('content'):
				ret.append(dict_data['content'])
			if dict_data.has('stop') and dict_data['stop']:
				close_connect()
	return ret

func _exit_tree():
	if http_client:
		http_client.close()
	OS.kill(llm_pid)

プログラム解説

プログラムの解説を軽くしておきます。流れとしては、

  1. サーバーの起動
  2. サーバー接続(Buttonクリック時)
  3. サーバーへのリクエスト
  4. responseの逐次受け取り
  5. 更新シグナルの送受信
  6. 接続終了

となっています。ChatGPTのapiで同じようなことをする話は、Web上にあると思いますので、そこら辺を参考にしながら実装しました。

サーバーの起動

サーバーの起動はOS.create_processで実行しました。引数についてですが、-nglはGPUの仕様割合をきめるパラメータ(9999でGPU最大限使用)で、–nobrowserはWeb UIを立ち上げないことを表しています。

また、pidを保存しておき、プログラム終了時に、サーバーを終了するようにします。

func _ready() -> void:
	tree_exited.connect(_exit_tree)
	
	var arguments := [
		'-ngl', '9999', '--host', '0.0.0.0', '--server', '--nobrowser'
	]
	llm_pid = OS.create_process('models/Phi-3-mini-4k-instruct.Q4_K_M.llamafile.exe', arguments)

	...

func _exit_tree():
	if http_client:
		http_client.close()
	OS.kill(llm_pid)

サーバー接続

Buttonが押されたら、connect_llmを呼び出します。呼び出されたら、HTTPClinetによってサーバーへの接続を確立します。接続が確立できたら、http_is_connectedがtrueになります。

また、LLMから返答が返ってくるまで、Buttonをdisableにして、送信できないようにします。

func _on_button_pressed() -> void:
	connect_llm()

func connect_llm():
	$Panel/VBoxContainer/Button.disabled = true
	output_text = ''
	
	print('call connect_llm')
	var err := http_client.connect_to_host(host, port)
	print('  err :', err)
	if err == OK:
		http_is_connected = true
	else:
		http_is_connected = false

サーバーへのリクエスト

サーバーへ接続済みかつ、リクエストを送っていない場合は、_processにてサーバーにリクエストを送ります。

リクエストとして、TextEditのtextフィールドをpromptとして書き起こして、json形式でサーバーへ送ります。promptは用途によって書き換えてもらっていいです。

リクエストを送ると、http_is_requestedがtrueになります。

func _process(delta):
	if http_is_connected == false:
		return
		
	http_client.poll()
	var http_client_status = http_client.get_status()
	if http_is_requested == false:
		request_llm(http_client_status)
		return

func request_llm(http_client_status):
	print('call request_llm')
	print('  http_client_status :',http_client_status)
	if http_client_status == HTTPClient.STATUS_CONNECTING or http_client_status == HTTPClient.STATUS_RESOLVING:
		return
		
	if http_client_status == HTTPClient.STATUS_CONNECTED:
		var body = get_send_body()
		var err = http_client.request(HTTPClient.METHOD_POST, url, [header], body)
		print('  err :', err)
		if err == OK:
			http_is_requested = true

func get_send_body():
	var input_text :String = $Panel/VBoxContainer/TextEdit.text
	var prompt := "<|user|>\n{0}<|end|>\n<|assistant|>\n".format([input_text])
	
	var body = {
		'prompt':prompt,
		'n_predict':n_predict,
		'stream':true
	}
	return JSON.stringify(body)

responseの逐次受け取り

サーバーへリクエストを送っていた場合は、_processにてresponseを逐次受け取ります。

chunkで受け取って、それをutf8の文字列に変換します。

変換された文字列(json)から、LLMの逐次回答(tokens)を抜き出して、recived_tokensシグナルへと渡します。

func _process(delta):

	...

	if http_client.has_response() and http_client_status == HTTPClient.STATUS_BODY:
		http_client.poll()
		
		var chunk := http_client.read_response_body_chunk()
		if chunk.size() == 0:
			return
		
		var string_body  := chunk.get_string_from_utf8()
		if string_body :
			var tokens = to_token(string_body)
			emit_signal('recived_tokens', tokens)

func to_token(string_body: String):
	#print('call to_token')
	var ret = []
	var data_list = string_body.strip_edges().split('\n\n')
	
	for data in data_list:
		var dict_data = JSON.parse_string(data.replace('data: ', ''))
		if dict_data != null:
			if dict_data.has('content'):
				ret.append(dict_data['content'])
			if dict_data.has('stop') and dict_data['stop']:
				close_connect()
	return ret

更新シグナルの送受信

_process中の

emit_signal('recived_tokens', tokens)

にて、recived_tokensシグナルへ逐次受け取ったtokensを渡します。

そして、recived_tokensシグナルは、_readyにて_on_recived_tokens関数へと繋げます。

func _ready() -> void:
	...
	recived_tokens.connect(_on_recived_tokens)

func _on_recived_tokens(tokens: Array):
	for token in tokens:
		output_text += token
	$Panel/VBoxContainer/ScrollContainer/Label.text = output_text

_on_recived_tokensでは、受け取ったtokenをoutput_textへとつかして、それをLabelのtextフィールドへセットします。これで、ノードのLabelのtextへ、LLMから受け取ったトークンが逐次表示されます。

接続終了

トークンを逐次受け取る際に、dict_dataのstopがtrueになった場合は、LLMの出力が止まったことを意味します。その場合は、httpの接続を切ります。

送信ボタンのdisabledを解除し、http_is_connectedとhttp_is_requestedをfalseに直します。

func to_token(string_body: String):
	#print('call to_token')
	var ret = []
	var data_list = string_body.strip_edges().split('\n\n')
	
	for data in data_list:
		var dict_data = JSON.parse_string(data.replace('data: ', ''))
		if dict_data != null:
			if dict_data.has('content'):
				ret.append(dict_data['content'])
			if dict_data.has('stop') and dict_data['stop']:
				close_connect()
	return ret

func close_connect():
	$Panel/VBoxContainer/Button.disabled = false
	http_client.close()
	http_is_connected = false
	http_is_requested = false

実行

実行します。llamafileのサーバーを実行するのにネットワークの接続に許可を求められるかもしれません。

※llamafileサーバーの起動まで数秒かかるときがあります。その場合は、数秒待ってから、sendを押してください

※「プロジェクトの実行を停止(F8)」ボタンでゲームを停止すると、別プロセスで起動したサーバーが終了されません。そうすると、次回実行時に正常にLLMとのHTTP通信ができなくなります。ゲームのウィンドウのバツボタン、もしくはタスクバーのウィンドウを終了して、ゲームを閉じてください。もしも、サーバープロセスが残ってしまった場合は、タスクマネージャーで終了してください。

phi-3-miniは日本語も出力できるので、試してみます。

いい感じですね!

まとめ

Godotでローカルで逐次表示できるLLM環境を作りました。

いい感じに表示できるようになったと思います。これを利用すれば、ゲーム作成の幅が結構広がる気がします。

※追記 : エラー処理が甘いので、使うときは適切に編集することを推奨します

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