このページの本文へ

FIXER Tech Blog - AI/Machine Learning

LLMをローカルPCで動かし“話し相手”を作ってみた結果……

2024年02月26日 10時00分更新

文● 小野亮太朗/FIXER

  • この記事をはてなブックマークに追加
  • 本文印刷

 本記事は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、マイクラとかのサーバを建てて遊んでる人。
動的型付けよりは静的型付けが好き。

カテゴリートップへ

この連載の記事