【Python】LLM + Gradio でローカルチャットアプリを作ってみた

# LLM + Gradio でローカルチャットアプリを作ってみた

## はじめに
最近 LLM で色々と遊んでみています．
今回は LLM と Gradio を用いて，**ローカル + CPU で簡易的なチャットアプリ**を作成してみました．
使用するモデルやセットアップに関しては，以下の過去記事で紹介しているので，未読の方は先に目を通していただくとスムーズかと思います．

👉 [ローカル+CPU+Python で Large-Language Model（LLM）を動かしてみた](https://qiita.com/k2-gc/items/137b6986afb00a5c9ed3)


## アプリ構想
### 使用したモデル・ライブラリ
| モデル・ライブラリ | 説明 |
|:-:|:-:|
|  `llama-cpp-python` | .gguf 形式のモデルを Python で動かすための bindings  |
|  `Phi-3-mini-4k-instruct-q4.gguf` |  Microsoft の開発した軽量言語モデル <br> 量子化（q4）なら **CPU でも十分動作可能** |
| `Gradio` | 機械学習モデルの **Web アプリやデモ UI を簡単に作れる Python ライブラリ** | 

### 構成
#### バックエンド（モデル実行部分）
* `llama-cpp-python` を利用し，.gguf 形式の LLM モデルを Python 上で実行
* 入力されたプロンプトと履歴を元に LLM が応答を生成
* モデルには `Phi-3-mini-4k-instruct-q4.gguf` を使用（軽量でCPUでも動作）

#### フロントエンド（UI表示部分）
* `Gradio` を用いてチャット UI を構築
* `Textbox()` による入力，`Chatbot()` による吹き出し形式の応答表示
* save ボタンで会話を .csv 形式で保存可能

#### 処理の流れ
```plaintext
Textbox() にユーザー入力 → 送信 → Python 関数（バックエンド）コール → llama-cpp-python で推論 → Chatbot() に応答表示
```
* `Gradio` UI 上で，`Textbox()` にメッセージを入力
* 送信後，バックエンドで履歴を考慮したプロンプトを組み立て，`llm()` を呼び出して推論
* 応答が生成された後，再び `Gradio` 上の `Chatbot()` に返して画面に表示

このようなシンプルな構成でアプリを作成しました．

## コード
### コード全体像
```python
import argparse

import gradio as gr
from llama_cpp import Llama
import pandas as pd


class LLMChatModel:
    def __init__(self, model_path, n_ctx, n_threads, n_keep = 5):
        self.model_path = model_path
        self.llama = Llama(
            model_path=self.model_path,
            n_ctx=n_ctx,
            n_threads=n_threads,
        )
        self.n_keep = n_keep
        self.raw_conversation_list = []
        self.current_conversation_list = []
        self.summary = ""
        self.concept = (
            "あなたは日本語で答えるアシスタントです。親切で簡潔に答えます。\n"
            "以下は回答時の制約です。\n"
            "- 日本語のみ（英語や記号表現なし）\n"
            "- 「Instruction:」、「Step:」、「指示1:」などの表現は**使わない**\n"
            "- 本文から自然に始める\n"
            "- 128文字以内で簡潔に\n"
            )
    
    def summarize_old_conversation(self):
        summary_input = self.summary + "\n\n"
        for m, r in self.current_conversation_list[:self.n_keep]:
            summary_input += f"ユーザ：{m}\nアシスタント：{r}\n"
        summary_prompt = "以下の内容を簡潔にまとめてください：\n" + summary_input + "\n要約："
        result = self.llama(summary_prompt, max_tokens=256)
        self.summary = "これまでの会話の要約：" + result["choices"][0]["text"].strip() + "\n"
        self.current_conversation_list = self.current_conversation_list[self.n_keep:]
    
    def create_conversation(self):
        prompt = ""
        if len(self.current_conversation_list) > 2*self.n_keep:
            self.summarize_old_conversation()
        for m, r in self.current_conversation_list:
            prompt += self.make_prompt(m, r)
        prompt = self.summary + prompt
        return prompt

    def make_prompt(self, query, reply = "", is_brief = False):
        return (
            "
\n"
            "制約を守って質問に答えてください。\n\n"
            f"質問：{query}\n"
            "
\n"
            "
\n"
            f"{reply + '\n
\n' if reply != '' else ''}"
        )
    
    def chat(self, message, history):
        whole_prompt = self.create_conversation()
        prompt = self.make_prompt(message, is_brief=True)
        prompt = self.concept + whole_prompt + prompt
        # tokens = self.llama.tokenize(prompt.encode("utf-8"))
        # print("トークン数:", len(tokens))
        reply = self.llama(
            prompt, 
            max_tokens=256, 
            stop=["
", "
", "
", "\n\n", "Instruction", "Step"]
            )
        reply = reply["choices"][0]["text"].strip()
        self.raw_conversation_list.append((message, reply))
        self.current_conversation_list.append((message, reply))
        history.append((message, reply))
        return "", history
    
    def dump_history(self):
        df = pd.DataFrame(self.raw_conversation_list, columns=["User", "Assistant"])
        df.to_csv("chat_history.csv", index=False, encoding="utf-8-sig")

def app_run(model_path, n_ctx, n_threads):
    llm_chat_model = LLMChatModel(
        model_path=model_path,
        n_ctx=n_ctx,
        n_threads=n_threads,
    )
    with gr.Blocks() as demo:
        with gr.Row():
            with gr.Column():
                msg = gr.Textbox(
                    placeholder="メッセージを入力...", 
                    lines=10,
                    )
                send_btn = gr.Button("Send")
                save_btn = gr.Button("Save")
            chatbot = gr.Chatbot()
        send_btn.click(
            fn=llm_chat_model.chat,
            inputs=[msg, chatbot],
            outputs=[msg, chatbot],
        )
        msg.submit(
            fn=llm_chat_model.chat,
            inputs=[msg, chatbot],
            outputs=[msg, chatbot],
        )
        save_btn.click(
            fn=llm_chat_model.dump_history,
            inputs=None,
            outputs=None,
        )
    demo.launch()

def get_args():
    args = argparse.ArgumentParser()

    args.add_argument('--model-path', default="./models/Phi-3-mini-4k-instruct-q4.gguf",
                        type=str, help='path to model')
    args.add_argument('--n-ctx', default=2048,
                        type=int, help='n_ctx')
    args.add_argument('--n-threads', default=4,
                        type=int, help='n_threads')
    return args.parse_args()

if __name__ == "__main__":
    args = get_args()
    app_run(
        args.model_path,
        args.n_ctx,
        args.n_threads,
    )
```
| 関数・クラス | 説明 |
|:-:|:-:|
| `LLMChatModel()`  |  LLM の推論・会話履歴管理・要約等を行うクラス |
| `app_run()`  |  Gradio での UI 定義と `LLMChatModel` 初期化 |

### 使い方
1. 環境
    1. Python：3.13.1
    1. pandas：2.2.3
    1. gradio：5.31.0
    1. llama-cpp-python：0.3.9
1. ライブラリインストール
    ```bash
    $ pip install pandas gradio llama-cpp-python
    ```
1. モデル配置
    1. [こちら](https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/tree/main)からモデルをダウンロード
    1. 下記のフォルダ構成でモデルを配置
        ```plaintext
        ./playground/
            ┣━ models/           # モデル格納用フォルダ
            ┃    ┗━ Phi-3-mini-4k-instruct-q4.gguf
            ┗━ chat_app.py         # 実行スクリプト
        ```


### 軽量 LLM で自然なチャットを実現するための工夫点
* 会話履歴管理
    * 1 つ前，2 つ前，... のやりとりを返答に反映させるため，会話のやり取りを記録
    * プロンプトにそれらを含めて回答を得る  


* 1 回のやり取りを端的に
    * モデルの英語での出力や勝手な会話を抑制するため，1 回のやり取りを端的にするように制約を追加
        ```python
            self.concept = (
                "あなたは日本語で答えるアシスタントです。親切で簡潔に答えます。\n"
                "以下は回答時の制約です。\n"
                "- 日本語のみ（英語や記号表現なし）\n"
                "- 「Instruction:」、「Step:」、「指示1:」などの表現は**使わない**\n"
                "- 本文から自然に始める\n"
                "- 128文字以内で簡潔に\n"
                )
                
            "制約を守って質問に答えてください。\n\n"
        ```

    * 無駄な出力を抑制するため `stop_token` の強化
        ```python
        stop=["
", "
", "
", "\n\n", "Instruction", "Step"]
        ```

* 定期的な過去の会話の要約
    * 過去の会話を必要とするため，やりとりが長くなるとプロンプトのトークン数が増大
    * 抑制のため一定のやり取りをした場合，最新 5 回のやり取り以外を要約
    * 要約した内容をプロンプトに追加して推論を実行


## デモ
### アプリ実行時
![img1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4078542/fdc4428e-070d-4907-a200-9e7ebb1039c8.png)
* 左側に**テキストボックス**と**送信ボタン**，会話の**保存ボタン**を配置
* 右側に**チャット画面**を配置

グラフィカルな UI で構築されており，ユーザーの入力とモデルの応答が左右で明確に見える構成

### チャット時の画面
![img2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4078542/1bc382be-94ff-4407-8c5d-40080e333020.png)
* ユーザ入力（例）：「プログラミングを勉強したい！」
　　　　　　　　　「おすすめのプログラミング言語はある？」
* モデルの応答（例）：「Python人気」

### 実際の使用映像
![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4078542/7c14d783-2cdc-4e12-9f5c-dc6e80ce5854.gif)

* **実際にチャットっぽくやりとりができている**
* 質問にも答えてくれている
* 一回の推論で 30 秒から 90 秒ぐらい
* 割と制約を強くしたので淡白な会話に
* 時々おかしな日本語も

この辺りはもっと大きいマシン・サーバにすると精度の高いモデルも動かせるようになるので改善の余地あり

## おわりに
今回は **LLM + CPU + gradio でローカルでチャットアプリ**を作ってみました．
LLM のライブラリやアプリ開発のライブラリを使用すると簡単に実装することが可能です．

LLM に関してはあとは RAG をチャットアプリに取り入れたりしたいと思っているので，また完成したら記事にします．

また，過去に LLM，RAG についての記事も書いているので，興味がある方はぜひご覧ください．
👉 [Retrieval-Augmented Generation（RAG） を使って "ブラック企業の社内規則チャットボット" を作ってみた](https://qiita.com/k2-gc/items/2ce5ee092aadee9432ca)
👉 [ローカル+CPU+Python で Large-Language Model（LLM）を動かしてみた](https://qiita.com/k2-gc/items/137b6986afb00a5c9ed3)