大規模言語モデル(LLM)で話し相手を(全部ローカルで)作ってみた
2023-12-22
azblob://2023/12/19/eyecatch/2023-12-22-llm-talk-000.jpg

2023年最後のブログ投稿、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: Ryzen5 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でコンテナ1つ、text to speech、speech to text、フロントを担うコンテナ1つで合計2つのコンテナ使用します。

ファイル構造は以下の通りです。

.
├─front/
│	├─whisper/
│	├─index.html
│	├─server.py
│	└─Dockerfile
├─text-generation-webui/
│├─...
│...
│└─...
└─compose.yml

index.htmlはとりあえず動きさえすればいいので、CSSも無しに最低限の機能(録音、再生、やり取りの表示)を実装しただけになっています。

HTML<!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を使いました。 

Pythonfrom 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あたりを使うのが現実的だと思います。

まだまだプロンプトにも改善の余地はありますが、ひとまず何かしらの応答を音声で返すことができました。

最後に

一人暮らしでも寂しくない話相手を作ることはできたのですが、客観的にみるとモニターに向かって話しかけたら虚空から返事が返ってくるというなかなかシュールというか、これはこれで残念なシチュエーションになってしまいました。

音声アシスタントのような形にすればよかったかもしれないとほんのちょっとだけ後悔しています。

ただレスポンスがめちゃくちゃ遅くて、正直使い物になりません。

 なので冒頭でローカルでやることのツッコミは心の中にしまってもらってなんですが、絶対に昨日のブログの方を参考にした方がいいです。