見出し画像

いまさら、llama.cppのOpenAIサーバとクライアントアプリ

とても単純なWebアプリです。OpenAI互換サーバがあれば動きます。もちろんOpenAIでも使えるはず。今回は最近のローカルllmの能力が向上したことを受け、Webアプリでllmの長い回答の表示に便利なストリーミング機能を実装し、ロール指定や記憶機能ももたせています。
① llm回答はストリーミング
② llmに対してroleを任意に指定可能
③ 記憶ターン数を指定可能
④ llmの回答字の名前を指定
⑤ FastAPIと配下のHTMLファイルのみで実現しています。

サーバ側

ますOpenAI互換サーバを動かします。llama.cppです。

最近、コマンド名が変更されました。過去の記事のllama.cppを使うコードが全て動きません。要注意です。

インストール

適当な仮想環境を作成せいて以下でインストール

git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp

GPUがある場合

make GGML_CUDA=1

又は

cmake -B build -DGGML_CUDA=ON
cmake --build build --config Release

GPUがない場合

make

又は

cmake -B build
cmake --build build --config Release

以下に色々な方法などの記述があります。

lama.cpp/docs/build.md

モデルのダウンロード

gguf形式のモデルをダウンロードします。例えば最近発表された性能の高いgemma-2は以下からダウンロードできます。

サーバを動かす

./llama-server -m ./models/gemma-2-9b-it-Q4_K_M.gguf -n 2048 --n_gpu_layers 43

port="8080" hostname="127.0.0.1"でアクセスできます。

gemma-2-9b-it-Q4_K_M.ggufだと10G強なので12GのVRAMがあるGPUで動きます。gemma-2-27b-it-Q4_K_M.ggufでは20Gになるので、24GのVRAMが必要になります。

Webアプリ

llama.cppで使えるUIはたくさんあります。redmeに記載されているだけでも、30種以上です。最近はollamaがよく使われているようです。今回はOpenAI互換性の確認と、ストリーミングを使うためのwebsocketの使い方を勉強するためと、簡単に動かすためにフレームワークを使用せずにFastAPIと配下のhtmlだけで動かします。コードが簡単なのでOpeAI互換機能やストリーミングがどのように動くのかよくわかります。

インストール

適当な仮想環境を作成し以下をインストール

pip install fastapi
pip install openai

OpenAIのコードについては以下にたくさん例が示されています。

作成したコード

FastAPI

import asyncio
from fastapi import FastAPI, WebSocket,File, Form, UploadFile
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from openai import AsyncOpenAI
import json

app = FastAPI()

client = AsyncOpenAI(
    base_url="http://localhost:8080/v1",
    api_key="YOUR_OPENAI_API_KEY", #このままでOK
)

@app.get("/", response_class=HTMLResponse)
async def read_root():
    with open('static/index.html', 'r') as f:
        return f.read()

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    response_sum = ""
    while True:
            data = await websocket.receive_text()
            data_dict = json.loads(data)  # 受信したJSONデータをPython辞書に変換
            message = data_dict.get("message")
            role = data_dict.get("role")
            print(f"Received message: {message} with role: {role}")
            stream = await client.chat.completions.create(
                model="gpt-4",
                messages=[{"role": role, "content": data}],
                stream=True
            )
            async for chunk in stream:
                if chunk.choices[0].delta.content:
                    response_sum += chunk.choices[0].delta.content or ""
                    chank_data=chunk.choices[0].delta.content
                    await websocket.send_text(chank_data)
                print("chank sum  ==>",response_sum)
            print("+++++++++++++++++++++++++++++++++++++")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.0.0.1", port=8001)

以下でhtmlを読み込みます。staticディレクトリを作成してhtmlファイルを収納しています。

@app.get("/", response_class=HTMLResponse)
async def read_root():
    with open('static/index.html', 'r') as f:
        return f.read()

ここはOpeAIのサンプル通りです

client = AsyncOpenAI(
    base_url="http://localhost:8080/v1",
    api_key="YOUR_OPENAI_API_KEY", #このままでOK
)

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):

llmとやり取りするエンドポイント
入力はroleとuserだけです。プロンプトです。

stream = await client.chat.completions.create()
で生成されてくるchankを以下でフロントに順次返送しています。
await websocket.send_text(chank_data)

HTML

HTML部分はとてもシンプルです。

<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Chat with llama.cpp</title>
    </head>
    <body>
        <h1>Chat with llama.cpp</h1>
        <div id="mainArea">
            <pre id="responses"></pre>
            <div>
                <div id="roleArea">
                    <h6>LLMへのRole</h6>
                    <textarea type="text" id="roleText" placeholder="Role to LLM..."></textarea>
                </div>
                <div id="inputArea">
                    <h6>LLMへのメッセジ・質問・会話・指示など</h6>
                    <textarea type="text" id="inputText" placeholder="Type your message..."></textarea>
                    <button onclick="sendMessage()">Send</button>
                    <h6>会話ターン記憶数</h6>
                    <input type="number" id="numberInput" min="0" max="20" placeholder="0-20">
                    <h6>LLMの名前</h6>
                    <input type="text" id="nameInput" placeholder="Enter llm name">
                </div>
            </div>
        </div>
    </body>
</html>

これで以下のデサインになります。(長いCSSがあります)

JavaScriptは少し長いです。ストリーミングの処理と出力エリアのスクロールや表示形式、色などがあるためです。説明は省略

<script>
    const ws = new WebSocket("ws://127.0.0.1:8001/ws");
    let isFirstResponse = true;  // フラグを初期化
    const messagesContainer = document.getElementById('responses');  // コンテナを取得
    let savedRoleMessage = "";  // roleMessageを保存するためのグローバル変数
    let LastChank="";
    let lastMessage="";
    let conversationLogs = [];  // 会話ログを保存する配列
    const defaultMaxLogs = 5;   // デフォルトの最大ログ数
    let singleTurn="";
    let currentUserName = "";  // ユーザー名を保存するためのグローバル変数

    document.querySelectorAll('textarea').forEach(textarea => {
            textarea.addEventListener('input', function() {
                this.style.height = 'auto';
                this.style.height = (this.scrollHeight) + 'px';
            }, false);
        });

    ws.onmessage = function(event) {
        if (isFirstResponse) {
            // 最初の応答に "Response:" を挿入、これを赤く表示(改行なし)
            const initialDiv = document.createElement('div');
            initialDiv.className = 'response-message';  // CSSクラスを適用
            initialDiv.innerHTML = `<strong style="color: red;">${currentUserName}:</strong> `;
            messagesContainer.appendChild(initialDiv);
            isFirstResponse = false;
        }
        // 既存の最後のdivに受信データを追加(改行を保持しながら)
        const lastMessageDiv = messagesContainer.lastElementChild;
        if (lastMessageDiv && lastMessageDiv.classList.contains('response-message')) {
            const span = document.createElement('span'); // 新しい span 要素を作成
            span.innerHTML = event.data;  // 受信データを追加
            lastMessageDiv.appendChild(span);  // 最後の div に span を追加
            LastChank +=event.data;
         } else {
            const newMessageDiv = document.createElement('div');
            newMessageDiv.className = 'response-message';
            newMessageDiv.innerHTML = event.data;  // 新しいデータブロックとして追加
            messagesContainer.appendChild(newMessageDiv);
            lastReceivedChunk +=event.data;
            LastChank +=event.data;
        }
        // スクロール位置を最新のメッセージに合わせる
        messagesContainer.scrollTop = messagesContainer.scrollHeight;
    };

    function sendMessage() {
        const inputElement = document.getElementById('inputText');
        const roleElement = document.getElementById('roleText');
        const numberElement = document.getElementById('numberInput');
        const nameElement = document.getElementById('nameInput');
        const name = nameElement.value.trim();
        const message = inputElement.value;
        let roleMessage = roleElement.value.trim();
    
        if (!message.trim() && !name.trim()) {
            alert("Please enter llm name and a message.");
            return; // 名前とメッセージの両方が空の場合は警告を表示
        }

        // 新しいroleMessageがあれば更新し、なければ最後に保存された値を使用
        if (roleMessage === "") {
            roleMessage = savedRoleMessage;
        } else {
            savedRoleMessage = roleMessage;  // 新しい値でグローバル変数を更新
        }
        currentUserName = name;  // ユーザー名をグローバル変数に保存
        if (message.trim() === "" && roleMessage === "") return; // メッセージが空の場合は送信しない
        if (lastMessage  !=""){
            singleTurn='user:' + lastMessage  +'response:' + LastChank;
            console.log("+++++singleTurn=",singleTurn)
            }
        LastChank="";//LastChankをクリア
        lastMessage=message//lastMessageに今回のメッセージを保存
        conv_log=addLog(singleTurn)//記憶用の過去ログを取得
        const NewPrompt=conv_log + 'user:' + message 
        //console.log("+++++NewPrompt= ",NewPrompt);
        const userDiv = document.createElement('div');
        userDiv.className = 'user-message';  // ユーザーメッセージのCSSクラスを適用
        userDiv.innerHTML = `<strong style="color: blue;">User:</strong> ${message}`;
        messagesContainer.appendChild(userDiv);

        ws.send(JSON.stringify({ message: NewPrompt, role: roleMessage }));
        inputElement.value = '';
        isFirstResponse = true;  // メッセージ送信後、次のレスポンスを初回として扱う
        // 送信後もスクロール位置を調整
        messagesContainer.scrollTop = messagesContainer.scrollHeight;
    }
    function addLog(message) {
        const maxLogCount = document.getElementById('numberInput').value || defaultMaxLogs;
        const logEntry = { message };
        conversationLogs.push(logEntry);
        if (conversationLogs.length > maxLogCount) {
            conversationLogs.shift();  // 一番古いログを削除
        }
        //console.info('+++++lOGLIST=', conversationLogs)
        // conversationLogs内のメッセージを改行で結合して返す
        return conversationLogs.map(log => log.message).join('\n');
    }
</script>

CSSはもっと長くて同じ設定を名前を変えて定義しているなど、冗長部分もありますが、修正する可能性もあるおでそのままにしています。

<style>
    body {
        display: flex;
        flex-direction: column;
        align-items: center;
        margin: 0;
        padding: 20px;
    }
    #mainArea {
        display: flex;
        width: 100%;
        justify-content: space-between;
    }
    #inputArea {
        width: 80%;
        padding: 10px;
        margin-top: -20px;
        margin-right: 20px;  /* 右側に20pxのマージンを追加 */
        margin-left: 10px;  /* 右側に20pxのマージンを追加 */
    }
    #roleArea {
        width: 80%;
        padding: 10px;
        margin-top: -20px;
        margin-right: 20px;  /* 右側に20pxのマージンを追加 */
        margin-left: 10px;  /* 右側に20pxのマージンを追加 */
    }
    #inputText {
        width: 100%;
        padding: 10px;
        margin-bottom:10px;
        font-size: 10px; /* フォントサイズを小さく設定 */
    }
    textarea, input[type="text"], input[type="number"] {
            width: 100%;
            padding: 10px;
            box-sizing: border-box; /* パディングとボーダーを幅に含める */
            margin-bottom: 5px; /* 下のマージンを小さく設定 */
    }
    h6 {
            margin-bottom: 5px; /* 下のマージンを小さく設定 */
    }
    #roleText {
        width: 100%;
        height: 100px;
        padding: 10px;
        margin-bottom: 0px;
        font-size: 10px; /* フォントサイズを小さく設定 */
    }
    #responses {
        width: 65%;
        height: 400px;
        overflow-y: auto;
        border: 1px solid #ccc;
        padding: 10px;
        white-space: pre-wrap;
        background-color: #f9f9f9;
    }
    button {
        width: 60px;
        height: 30px;

    }
    .user-message {
        color: blue;
    }
    .response-message {
        color: black;
    }
    #numberInput {
        width: 60px;
        padding: 8px;
        margin-top: 0px;
        box-sizing: border-box; /* パディングを幅に含める */
    }
    .black-message {
        color: black;
    }
    #responses {
        width: 65%;
        height: 410px;
        overflow-y: auto;
        border: 1px solid #ccc;
        padding: 10px;
        background-color: #f9f9f9;
        white-space: pre-wrap; /* 改行と空白を保持 */
    }
    .user-message strong {
        color: #0000ff;  // ラベルの色を赤にする
    }
</style>

コードの全体

上記HTML部、JavaScript部、CSS部を順に記述してindex.html名で保存します。場所はどこでも大丈夫です。以下で変更できます。

@app.get("/", response_class=HTMLResponse)
async def read_root():
    with open('static/index.html', 'r') as f:
        return f.read()

アプリを開始する

各コードは別々のターミナルで動かしてください。
① llama.cppサーバを動かします。
② python openai_gui.py

ブラウザーから
http://127.0.0.1:8001/
にアクセスすると動きます。

まとめ

OpenAI互換とストリーミングを確かめることができました。簡単な記憶機能はJavaScript側で処理しているので、FastAPIサーバへは複数のブラウザーから接続できます。性能の高いローカルllmを用途別にプロンプトで使い分けることができて便利になりました。