本記事はFIXERが提供する「cloud.config Tech Blog」に掲載された「大規模言語モデル(LLM)で話し相手を(全部ローカルで)作ってみた」を再編集したものです。
GPU有効活用シリーズの第3弾です。第1弾、第2弾はこちらからどうぞ→前編 後編 第2弾
今年から一人暮らしを始めたのですが、家にいると思っている以上に口から何かを発することが無いことに気づきました。Discordなりで家にいても人と会話することができないわけではないのですが、わざわざ人を呼び出すのも気が引けます。
なので最近流行りのLLMに話し相手になってもらうことにしました。
前提条件
すべてローカルで完結すること。
会話文の生成はChatGPTやAzure Open AI、最近はAmazon bedrockなど、Speech to TextやText to SpeechもAzure、AWS、GCPと大抵のクラウドにAPIはありますが、せっかくLLMをローカルで動かすのだからどうせなら全部ローカルにしようとなりました。
なので“どこどこのAPIの方が性能いいぞ”とかのツッコミは心の内にしまっておいてください。
また、細かいプロンプト調整とかには手が回らなかったので、そのあたりの調整は試すときに試行錯誤してみてください。
環境
CPU:Ryzen 5 3600
メモリー:DDR4 32GB
GPU:GeForce RTX 4070(VRAM 12GB)
OS:Windows 11
Docker Desktop(Docker compose)
実行環境準備
ローカルでLLMを動かすには text-generation-webuiのリポジトリを使います。第1弾の後編と同じく、Dockerで実行しましょう。
元々は拡張機能として作りたかったのですがどうもうまく動かなかったので、APIサーバーとしてのtext-generation-web-uiでコンテナひとつ、text to speech、speech to text、フロントを担うコンテナひとつで合計2つのコンテナ使用します。
ファイル構造は以下の通りです。
. ├─front/ │ ├─whisper/ │ ├─index.html │ ├─server.py │ └─Dockerfile ├─text-generation-webui/ │├─... │... │└─... └─compose.yml
index.htmlはとりあえず動きさえすればいいので、CSSも無しに最低限の機能(録音、再生、やり取りの表示)を実装しただけになっています。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div>
<div>
<ol id="chat">
</ol>
</div>
<div class="flex gap-8 py-6">
<form>
<input type="button" , value="録音開始" , id="start">
</form>
</div>
<div class="flex gap-8 py-6">
<form>
<input type="button" , value="録音終了" , id="end">
</form>
</div>
<div>
<select name="models" id="models">
<option value="medium">--Please choose an option--</option>
{% for model in models %}
<option value={{ model }} id="model">{{ model }}</option>
{% endfor %}
</select>
</div>
</div>
<script>
(() => {
let recorder = null;
let chunks = [];
let startButton = null;
let endButton = null;
function startup() {
navigator.mediaDevices.getUserMedia({
audio: true
}).then((stream) => {
recorder = new MediaRecorder(stream)
recorder.onstop = async (_) => {
const blob = new Blob(chunks, { "type": "audio/webm" });
const uploadFormData = new FormData();
uploadFormData.append("file", blob);
uploadFormData.append("name", document.getElementById("models").value)
const headers = new Headers();
const resp = await fetch(new URL(`${location.href}v1/audio/transcriptions`), {
headers: headers,
body: uploadFormData,
method: "POST",
});
resp.text().then((text) => {
console.log(text)
const obj = JSON.parse(text)
const li = document.getElementById("chat")
const input = document.createElement("li")
input.innerText = `You: ${obj.input}`
const output = document.createElement("li")
output.innerText = `Chara: ${obj.output}`
const bgm1 = new Audio(`/v1/audio/play?text=${obj.output}`);
bgm1.type = "audio/wav"
bgm1.addEventListener("canplay", () => {
li.appendChild(input)
li.appendChild(output)
bgm1.play()
})
});
chunks = [];
};
recorder.ondataavailable = (e) => {
chunks.push(e.data)
};
})
startButton = document.getElementById("start");
endButton = document.getElementById("end");
startButton.addEventListener("click", () => {
console.log("start", !!recorder)
if (recorder != null) {
recorder.start(1000)
}
});
endButton.addEventListener("click", () => {
console.log("end")
recorder.stop();
});
}
window.addEventListener("load", startup, false);
})();
</script>
</body>
</html>
server.pyでは送られてきた音声をテキストに変換、text-generation-webuiのAPIにリクエストを送って返信をまた音声にして返しています。
Speech to TextにはWhisperを、Text to SpeechにはVALL-E-Xを使いました。
Python from fastapi import FastAPI, File, UploadFile from fastapi.responses import FileResponse, HTMLResponse from fastapi.requests import Request from fastapi.templating import Jinja2Templates import whisper from whisper import available_models import numpy as np from ffmpeg import input, Error import uvicorn import requests import json from utils.generation import SAMPLE_RATE, generate_audio, preload_models from scipy.io.wavfile import write as write_wav download_root = './whisper' app = FastAPI() templates = Jinja2Templates(directory='.') preload_models() @app.get("/",response_class=HTMLResponse) async def home(request: Request): return templates.TemplateResponse( "index.html", { "request": request, "models": available_models() } ) @app.post("/v1/audio/transcriptions") async def create_file(file:UploadFile = File(...), name:str="medium"): contents = await file.read() text = transcribe(name,contents) headers = { "Content-Type": "application/json" } data = { "messages": [ { "role": "system", "content": "You must answer in Japanese and short sentences." }, { "role": "user", "content": text } ], "mode": "instruct", "instruction_template": "Alpaca" } response = requests.post( url="http://text-generation-webui:5000/v1/chat/completions", headers=headers, data=json.dumps(data), verify=False ) assistant_message = response.json()['choices'][0]['message']['content'] resp = { "input": text, "output": assistant_message } return resp @app.get("/v1/audio/play", response_class=FileResponse) async def play(text:str=""): audio_array = generate_audio(text) write_wav("audio.wav", SAMPLE_RATE, audio_array) return FileResponse("audio.wav") def transcribe(name:str,data:bytes): ndarray = load_audio(data) model = whisper.load_model(name, download_root=download_root,device='cuda') result = model.transcribe(audio=ndarray, verbose=True, language='ja') return result["text"] def load_audio(file_bytes: bytes, sr: int = 16_000) -> np.ndarray: try: out, _ = ( input('pipe:', threads=0) .output("pipe:", format="s16le", acodec="pcm_s16le", ac=1, ar=sr) .run_async(pipe_stdin=True, pipe_stdout=True) ).communicate(input=file_bytes) except Error as e: raise RuntimeError(f"Failed to load audio: {e.stderr.decode()}") from e return np.frombuffer(out, np.int16).flatten().astype(np.float32) / 32768.0 uvicorn.run(app, host="0.0.0.0",port=8000)
そしてDockerfileはこれを使います
FROM nvidia/cuda:12.1.1-devel-ubuntu22.04 ENV DEBIAN_FRONTEND noninteractive RUN apt-get update && \ apt-get --no-install-recommends -y install curl git ffmpeg python3 python3-dev python3-pip libcudnn8 libcudnn8-dev && \ pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu121 && \ pip install git+https://github.com/openai/whisper.git faster_whisper fastapi python-multipart ffmpeg-python "uvicorn[standard]" RUN git clone https://github.com/Plachtaa/VALL-E-X.git /app && \ pip install -r /app/requirements.txt ENV LD_LIBRARY_PATH /usr/local/cuda/lib64/:$LD_LIBRARY_PATH ENV PYTHONPATH /app:$PYTHONPATH COPY . /app WORKDIR /app CMD [ "python3", "server.py" ]
実行の事前準備として、compose.ymlの用意をするのですが、text-generation-web-uiのdockerディレクトリにあるものを参考に書き加えたうえで、text-generation-webuiディレクトリやfrontendディレクトリと同じ階層に配置します。
version: '3' services: front: build: context: front dockerfile: Dockerfile container_name: front tty: true ports: - "8000:8000" volumes: - ./front/whisper:/app/whisper deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [ gpu ] text-generation-webui: build: context: ./text-generation-webui dockerfile: docker/nvidia/Dockerfile args: # specify which cuda version your card supports: https://developer.nvidia.com/cuda-gpus TORCH_CUDA_ARCH_LIST: ${TORCH_CUDA_ARCH_LIST:-7.5} BUILD_EXTENSIONS: ${BUILD_EXTENSIONS:-openai} APP_GID: ${APP_GID:-6972} APP_UID: ${APP_UID-6972} env_file: ./text-generation-webui/.env user: "${APP_RUNTIME_UID:-6972}:${APP_RUNTIME_GID:-6972}" ports: - "${HOST_PORT:-7860}:${CONTAINER_PORT:-7860}" - "${HOST_API_PORT:-5000}:${CONTAINER_API_PORT:-5000}" stdin_open: true tty: true volumes: - ./text-generation-webui/characters:/home/app/text-generation-webui/characters - ./text-generation-webui/extensions:/home/app/text-generation-webui/extensions - ./text-generation-webui/loras:/home/app/text-generation-webui/loras - ./text-generation-webui/models:/home/app/text-generation-webui/models - ./text-generation-webui/presets:/home/app/text-generation-webui/presets - ./text-generation-webui/prompts:/home/app/text-generation-webui/prompts - ./text-generation-webui/softprompts:/home/app/text-generation-webui/softprompts - ./text-generation-webui/training:/home/app/text-generation-webui/training - ./text-generation-webui/cloudflared:/etc/cloudflared deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [ gpu ]
後はDocker用のドキュメントを見ながら
text-generation-webui/modelsにお好きなモデルを配置し、text-generation-web-ui/docker/.env.sampleを.envにリネームしたうえで書き換えます。
今回はモデルにTheBloke_StableBeluga-7B-GPTQを使用し、TORCH_CUDA_ARCH_LISTは8.9としました。
# by default the Dockerfile specifies these versions: 3.5;5.0;6.0;6.1;7.0;7.5;8.0;8.6+PTX # however for me to work i had to specify the exact version for my card ( 2060 ) it was 7.5 # https://developer.nvidia.com/cuda-gpus you can find the version for your card here TORCH_CUDA_ARCH_LIST=8.9 # your command-line flags go here: CLI_ARGS=--model TheBloke_StableBeluga-7B-GPTQ --listen --api # the port the webui binds to on the host HOST_PORT=7860 # the port the webui binds to inside the container CONTAINER_PORT=7860 # the port the api binds to on the host HOST_API_PORT=5000 # the port the api binds to inside the container CONTAINER_API_PORT=5000 # Comma separated extensions to build BUILD_EXTENSIONS="openai" # Set APP_RUNTIME_GID to an appropriate host system group to enable access to mounted volumes # You can find your current host user group id with the command `id -g` APP_RUNTIME_GID=6972 # override default app build permissions (handy for deploying to cloud) #APP_GID=6972 #APP_UID=6972
編集が終わったら、
docker compose up -d
でコンテナを起動します。
ビルドが終わればコンテナの起動自体はすぐに終わりますが、コンテナをたてた直後はモデルをロードしないとAPIが反応を返してくれないので数分待ちます。
実行の様子
録音開始のボタンを押して話しかけ停止を押すと、かなり時間がかかりますが返事が返ってきました。
画像生成とは違ってGeforce RTX 4070であろうと、精度と時間どちらで見てもかなりキツイですね。
またVALL-E-Xは、“声が生成する度に変わってしまう”や“文章が長くなると合成音声が崩壊する”、といったことが起こるのでvoicevoxあたりを使うのが現実的だと思います。
まだまだプロンプトにも改善の余地はありますが、ひとまず何かしらの応答を音声で返すことができました。
最後に
一人暮らしでも寂しくない話相手を作ることはできたのですが、客観的にみるとディスプレーに向かって話しかけたら虚空から返事が返ってくるというなかなかシュールというか、これはこれで残念なシチュエーションになってしまいました。
音声アシスタントのような形にすればよかったかもしれないとほんのちょっとだけ後悔しています。
ただレスポンスがめちゃくちゃ遅くて、正直使い物になりません。
なので冒頭でローカルでやることのツッコミは心の中にしまってもらってなんですが、絶対にこちらの記事の方を参考にした方がいいです。
小野亮太朗/FIXER
(おの りょうたろう)
23卒エンジニア。SBCでNAS、VPN、マイクラとかのサーバを建てて遊んでる人。
動的型付けよりは静的型付けが好き。
この連載の記事
-
TECH
生成AIに感謝を伝えると回答精度が向上する? GaiXerで検証した -
TECH
生成AIアシスタントのAmazon QにS3のデータソースを連携する方法 -
TECH
インスタグラムのエフェクトを「Meta Spark Studio」で自作してみた -
TECH
インスタエフェクト自作第二弾!“小顔デカ目効果”を作る -
TECH
RAGの基礎知識を得て“ゼロ円RAGシステム”を構築してみた -
TECH
Microsoft Fabricを触ってデータサイエンスに超入門してみた! -
TECH
LLM活用はチャットだけじゃない、自由記述文を共通フォーマットに落とし込む方法を学んだ -
TECH
Gemini 1.5 Proの特徴とは? Gemini API経由で試す -
TECH
Azure OpenAIの便利な「jsonモード」の使い方&制限事項 -
TECH
生成AIのClaude 3に本格的なコーディングをさせるプロンプトを作った - この連載の一覧へ