Mac上で動作するオンデバイスのチャットアプリをGradioでサクッと作ってみる
はじめに
ChatGPT APIを使ってチャットアプリケーションを簡単に構築できることが話題となっています。しかし、ある程度のメモリーを備えた環境であれば、ローカルで大規模言語モデル:Large language Models(以下、LLM)を動作させて、完全にオフラインのチャットアプリケーションだって簡単に構築できます。本記事では、その方法について、ステップバイステップで紹介します。
アプリケーションの構成
ユーザーが入力した質問に、言語モデルが回答を作成し、それを出力する普通のチャットアプリケーションとしました。もちろん、日本語によるやりとりです。さらに、音声認識を利用して音声で入力、音声合成を利用して音声で出力する機能も付加しました。
プラットフォームとプログラミング言語
私の持っているMacBook Air M2チップモデル(16GBメモリ)でアプリを作成しました。メモリサイズは重要で、おそらく、デフォルトの8GBメモリでは本記事のアプリケーションをそのままで動作させることは困難と思います。
AI関連ライブラリの一部でApple M1/M2チップのMetalへの最適化が進んでいます。今回のLLMを含めAI推論[1]をMac上で高速に動作させることができるようになりました。
プログラミング言語はAI関連のライブラリが充実のPythonを使いました。
開発方針
このアプリケーションでは、大きく分けると、音声認識、LLM推論、音声合成という3種類の機能を持ちます。
それぞれ、高度に専門的な技術分野であり、アルゴリズムレベルから実装するのは無理なので、オープンに提供されているライブラリを使わせていただきました。チュートリアル、ドキュメント、技術記事など利用するための情報が豊富で、他のソフトウェアとの連携がしやすいという選定基準で、以下のライブラリを選択しました。
機能 | ライブラリ |
---|---|
音声認識 | OpenAI Whisper |
LLM推論 | llama.cpp + llama-cpp-python |
音声合成 | pyopenjtalk |
いきなり、全部を組み合わせて、正しく動作させることを期待するのは難しいです。そのため、比較的簡単に実装できるモジュールから手をつけて行きました。それぞれのモジュールをテストするときもGradioを利用しました。
環境セットアップ
まず、パッケージ管理システムであるHomebrewを使って、FFmpegとPortAudioをインストールします。これらのパッケージは、後述のWhisperで必要となります。
brew install ffmpeg portaudio
次に、開発用仮想環境をMinicondaを使って作成します。
conda create --name devchat python=3.10 --yes
作成した仮想環境をアクティベートします。
conda activate devchat
本アプリケーションが依存しているPythonライブラリをインストールします。
pip install -U pip \
&& pip install torch torchvision torchaudio pyopenjtalk openai-whisper pyaudio gradio \
&& CMAKE_ARGS="-DLLAMA_METAL=on" pip install -U llama-cpp-python --no-cache-dir \
&& pip install 'llama-cpp-python[server]'
Gradio
Gradioは、機械学習分野のデモアプリケーションをサクッと作成するのに使えるとても便利なソフトウェアです。チャットの履歴管理と表示、音声入力、音声出力など、本アプリケーションが必要とする機能のほとんどをGradioは備えています。なお、Gradioで作成するアプリケーションは、完全なスタンドアローンアプリケーションではなく、ウェブブラウザ内で動作するアプリケーションです。[2]
なお、本記事では、Gradioの使い方は説明していません。Gradioの使い方はGradio公式サイトのクイックスタートや、技術記事を参照ください。
音声合成
日本語音声合成は複雑なアルゴリズムを組み合わせた非常に高度な技術ですが、pyopenjtalkを利用すると、とても簡単に実装できます。
import pyopenjtalk
import gradio as gr
def text2speech(text):
audio, sr = pyopenjtalk.tts(text)
return sr, audio
demo = gr.Interface(
text2speech,
[
gr.Textbox(label="Text"),
],
"audio",
)
demo.launch()
上記のコードを実行してみましょう。
python gr_tts.py
上記コマンドを実行した標準出力にGradioを起動するためのURL http://127.0.0.1:7860 が表示されますので、このURLをウェブブラウザで開きます。
音声認識
日本語音声認識には、OpenAI Whisperの多言語版を利用させていただきました。OpenAI社はウェブAPIによるWhisperの有料サービスを行なっていますが、今回、利用したものはオープンソースとしてローカルで動作するもので、無料で利用できます。
WhisperはPython APIにより簡単に利用できますが、少しだけ面倒なところがあります。それは、Whisperが本記事執筆時現在、サンプリングレート16kHzの音声入力にのみ対応しているため、音声データのリサンプリングが必要なことです。今回は、このリサンプリングをTorchAudioリサンプリングAPIにより実装しました。
import gradio as gr
import whisper
import numpy as np
import torch
import torchaudio.transforms as T
model = whisper.load_model("small")
def transcribe(audio):
sr, y = audio
# 整数型から浮動小数点型へ変換
y = y.astype(np.float32)
y /= np.max(np.abs(y))
# サンプルレートをWhisperが対応する16kHzへリサンプリング
y_tensor = torch.from_numpy(y).clone()
resample_rate = whisper.audio.SAMPLE_RATE
resampler = T.Resample(sr, resample_rate, dtype=y_tensor.dtype)
y2_tensor = resampler(y_tensor)
y2_float = y2_tensor.to("cpu").detach().numpy().copy()
# 音声認識
result = model.transcribe(
y2_float,
verbose=True,
fp16=False,
language="ja"
)
return result["text"]
demo = gr.Interface(
transcribe,
gr.Audio(sources=["microphone"]),
"text",
)
demo.launch()
LLM推論
利用したモデル
LLMにはMeta Llama2をベースに、日本語追加事前学習を行なったELYZA-japanese-Llama-2-7b-instruct[3]を利用させていただきました。このモデルはただのLLMではなく、「ユーザーからの指示に従い様々なタスクを解くことを目的として、ELYZA-japanese-Llama-2-7bに対して事後学習を行ったモデルです。」とあるとおり、ユーザーからの質問に回答するチャットアプリケーションに適したLLMです。
量子化
但し、このLLMをそのまま、私のMacBook Airの上で動作させることはできません。約63億パラメーターのELYZA-japanese-Llama-2-7b-instructは比較的小規模のLLMですが、それでも私のMacBook Airの16GBメモリには載りません。そこで、量子化が役立ちます。今回は、8ビット量子化されたモデルを利用しました。これはオリジナルのモデルの半分のサイズになります。さらにサイズの小さい4ビット量子化なども利用できますが、 結果が良くないため、 8ビット量子化を採用しています。
(追記)Q4_0方式で量子化したモデルでは良い結果が得られませんでしたが、Q4_K_M方式とQ4_K_S方式で量子化されたモデルでは、納得できる結果が得られました。
量子化されたLLaMA系LLMの推論を実行するライブラリとして、llama.cppが存在します。このライブラリはApple M1/M2チップのMetalに対応しているので、Mac上で高速に動作します。量子化されたモデルの作成にもllama.cppが利用できますが、今回はHugging Face Hubで公開されている量子化済みモデル
ELYZA-japanese-Llama-2-7b-instruct-ggufを利用させていただきました。
ChatInterface
Gradioにはチャットボット用にChatInterfaceが用意されているので、チャットアプリケーションのプロトタイプを簡単に作成できます。通常、LLM自体に過去の会話を記憶する機能はありません。そのため、ChatInterfaceが会話の履歴を管理し、その履歴を利用してプロンプトを作成します。以下のコードではconstruct_prompt関数でプロンプトを作成していますが、これはLlama2形式のプロンプトを作成します。他のLLMを利用する場合、そのLLMに適した形式のプロンプトを作成する方が良い結果が出ます。
import gradio as gr
from huggingface_hub import hf_hub_download
from llama_cpp import Llama
B_INST, E_INST = "[INST]", "[/INST]"
B_SYS, E_SYS = "<<SYS>>\n", "\n<</SYS>>\n\n"
B_OS, E_OS = "<s>", "</s>"
DEFAULT_SYSTEM_PROMPT = "あなたは誠実で優秀な日本人のアシスタントです。"
MEMORY_LENGTH = 2
CONTEXT_SIZE = 2048
MAX_TOKENS = 512
LLM_REPO_ID = "mmnga/ELYZA-japanese-Llama-2-7b-instruct-gguf"
LLM_FILE = "ELYZA-japanese-Llama-2-7b-instruct-q8_0.gguf"
model_path = hf_hub_download(repo_id=LLM_REPO_ID, filename=LLM_FILE)
llm = Llama(model_path, n_gpu_layers=128, n_ctx=CONTEXT_SIZE)
def construct_prompt(message, history):
prompt = "{bos_token}{b_inst} {system}\n".format(
bos_token=B_OS,
b_inst=B_INST,
system=f"{B_SYS}{DEFAULT_SYSTEM_PROMPT}{E_SYS}"
)
if history is not None:
for item in history[-MEMORY_LENGTH:]:
prompt += "{user} {e_inst} {assistant} {eos_token}{bos_token}{b_inst} ".format(
user=item[0],
e_inst=E_INST,
assistant=item[1],
eos_token=E_OS,
bos_token=B_OS,
b_inst=B_INST
)
prompt += "{message} {e_inst}".format(
message=message,
e_inst=E_INST
)
return prompt
def predict(message, history):
# プロンプトを作成
prompt = construct_prompt(message, history)
print("---prompt begin---\n" + prompt + "\n---prompt end---")
# 推論
streamer = llm.create_completion(prompt, max_tokens=MAX_TOKENS, stream=True)
# 推論結果をストリーム表示
answer = ""
for msg in streamer:
message = msg["choices"][0]
if 'text' in message:
new_token = message["text"]
if new_token != "<":
answer += new_token
yield answer
gr.ChatInterface(predict).queue().launch()
音声認識+LLM推論+音声合成
最後に、上記3モジュールをすべて統合したコードをお見せします。入出力に音声を追加すると、GradioのChatInterfaceは使えないので、その代わりにBlocksを使いました。ChatInterfaceに比べて柔軟性は増しますが、自分で記述するコードも増えます。チャット履歴情報はChatBotコンポーネントで管理できます。
音声出力用Gradio Audioコンポーネントを生成時にautoplayパラメータをTrueにして、自動的に音声合成結果が出力されるように設定しています。しかし、これがうまく動作しないことがあり、再生ボタンのクリックが必要なときがあります。
import gradio as gr
from huggingface_hub import hf_hub_download
from llama_cpp import Llama
import pyopenjtalk
import whisper
import numpy as np
import torch
import torchaudio.transforms as T
B_INST, E_INST = "[INST]", "[/INST]"
B_SYS, E_SYS = "<<SYS>>\n", "\n<</SYS>>\n\n"
B_OS, E_OS = "<s>", "</s>"
DEFAULT_SYSTEM_PROMPT = "あなたは誠実で優秀な日本人のアシスタントです。"
MEMORY_LENGTH = 2
CONTEXT_SIZE = 2048
MAX_TOKENS = 512
LLM_REPO_ID = "mmnga/ELYZA-japanese-Llama-2-7b-instruct-gguf"
LLM_FILE = "ELYZA-japanese-Llama-2-7b-instruct-q8_0.gguf"
model_path = hf_hub_download(repo_id=LLM_REPO_ID, filename=LLM_FILE)
llm = Llama(model_path, n_gpu_layers=128, n_ctx=CONTEXT_SIZE)
asr = whisper.load_model("small")
def construct_prompt(history):
message = history[-1][0]
prompt = "{bos_token}{b_inst} {system}\n".format(
bos_token=B_OS,
b_inst=B_INST,
system=f"{B_SYS}{DEFAULT_SYSTEM_PROMPT}{E_SYS}"
)
if history is not None:
for item in history[-(MEMORY_LENGTH + 1):-1]:
prompt += "{user} {e_inst} {assistant} {eos_token}{bos_token}{b_inst} ".format(
user=item[0],
e_inst=E_INST,
assistant=item[1],
eos_token=E_OS,
bos_token=B_OS,
b_inst=B_INST
)
prompt += "{message} {e_inst}".format(
message=message,
e_inst=E_INST
)
return prompt
def text2speech(history):
text = history[-1][1]
audio, sr = pyopenjtalk.tts(text)
return sr, audio
def speech2text(audio, history):
sr, y = audio
# 整数型から浮動小数点型へ変換
y = y.astype(np.float32)
y /= np.max(np.abs(y))
# サンプルレートをWhisperが対応する16kHzへリサンプリング
y_tensor = torch.from_numpy(y).clone()
resample_rate = whisper.audio.SAMPLE_RATE
resampler = T.Resample(sr, resample_rate, dtype=y_tensor.dtype)
y2_tensor = resampler(y_tensor)
y2_float = y2_tensor.to("cpu").detach().numpy().copy()
# 音声認識
result = asr.transcribe(
y2_float,
verbose=True,
fp16=False,
language="ja"
)
text = result["text"]
history += [[text, None]]
return history
def user(user_message, history):
return "", history + [[user_message, None]]
def bot(history):
# プロンプトを作成
prompt = construct_prompt(history)
print(prompt)
# 推論
streamer = llm.create_completion(prompt, max_tokens=MAX_TOKENS, stream=True)
# 推論結果をストリーム表示
history[-1][1] = ""
for msg in streamer:
message = msg["choices"][0]
if 'text' in message:
new_token = message["text"]
if new_token != "<":
history[-1][1] += new_token
yield history
with gr.Blocks() as demo:
chatbot = gr.Chatbot(label="チャット")
msg = gr.Textbox("", label="あなたからのメッセージ")
clear = gr.Button("チャット履歴の消去")
audio_in = gr.Audio(sources=["microphone"], label="あなたからのメッセージ")
audio_out = gr.Audio(type="numpy", label="AIからのメッセージ", autoplay=True)
# テキスト入力時のイベントハンドリング
msg.submit(
user, [msg, chatbot], [msg, chatbot], queue=False
).then(
bot, chatbot, chatbot
).then(
text2speech, chatbot, audio_out
)
# 音声入力時のイベントハンドリング
audio_in.stop_recording(
speech2text, [audio_in, chatbot], chatbot, queue=False
).then(
bot, chatbot, chatbot
).then(
text2speech, chatbot, audio_out
)
# チャット履歴の消去
clear.click(lambda: None, None, chatbot, queue=False)
demo.queue().launch()
まとめ
いかがでしたでしょうか?便利なライブラリのおかげで、とても少ないコーディング量で、ローカルに動作する会話型日本語チャットアプリケーションが実現できることがご理解いただけたのではないでしょうか?コーディング量が削減できる反面、どのライブラリを使えば良いかの判断や、ライブラリの使い方の習得にはかなりの労力が必要です。それでも、Gradioのようなツールを利用することにより、ステップバイステップでアプリケーションを構築できると思います。本記事が皆様のLLMアプリケーション構築のきっかけになれば幸いです。
Discussion