0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WSL2にvLLM+StreamlitでローカルLLMチャット環境を構築する

Last updated at Posted at 2025-08-02

ローカル・LLMスタック構築手順

ゲーム用のPCにLLM環境を構築する。
はじめはwindows上にtext-generation-webuiで構築しようとしたが、AutoAWQローダーの導入回りで躓き、WSL2上に構築した。モデルは、CodeLlama-70B-Instruct-AWQ,Qwen2.5-Coder-7B-Instructあたりも試したが、*Mistral-7B-Instruct-v0.2が軽くて良かった。
使い道はあまりない。

― RTX 3090/WSL2(Ubuntu)+ vLLM+Streamlit+Web検索 ―

既存環境の構成

  • Windows 11(WSL2 Ubuntu 22.04)
  • GPU: RTX 3090(24 GB VRAM)
  • Python 3.10 – 3.12
  • CUDA 12.8 以上+対応 PyTorch
  • モデル: Mistral-7B-Instruct-v0.2(HF公開)
  • 追加機能: ストリーミング対応チャット UI+DuckDuckGo 検索+履歴管理

1. WSL2 & GPU ドライバ

wsl --install
# Ubuntu を選択しユーザー作成
# NVIDIA ドライバ (Windows) は 551.xx 以降
# WSL2 CUDA ツールキット
wsl --update

Ubuntu 内:

wget https://developer.download.nvidia.com/compute/cuda/repos/wsl-ubuntu/x86_64/cuda-wsl-ubuntu.pin
sudo mv cuda-wsl-ubuntu.pin /etc/apt/preferences.d/cuda-repository-pin-600
wget https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda-repo-wsl-ubuntu-12-8-local_12.8.0-1_amd64.deb
sudo dpkg -i cuda-repo-wsl-ubuntu-12-8-local_12.8.0-1_amd64.deb
sudo cp /var/cuda-repo-wsl-ubuntu-12-8-local/cuda-*-keyring.gpg /usr/share/keyrings/
sudo apt-get update
sudo apt-get -y install cuda-toolkit-12-8
nvidia-smi            # 動作確認

2. Python 仮想環境 & vLLM

sudo apt install python3-venv python3-pip -y
python3 -m venv vllm_env
source vllm_env/bin/activate
pip install --upgrade pip
# CUDA12.8ビルドの PyTorch
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
# vLLM 本体 + Streamlit + 検索ライブラリ
pip install vllm sseclient-py duckduckgo-search streamlit

3. モデル取得(Hugging Face)

mkdir -p ~/models
cd ~/models
huggingface-cli download mistralai/Mistral-7B-Instruct-v0.2 \
  --local-dir mistral7b --local-dir-use-symlinks False

トークン不要・公開モデル。
他モデルの場合は huggingface-cli login でトークン取得。


4. vLLM サーバー起動

vllm serve ~/models/mistral7b \
  --trust-remote-code \
  --dtype float16 \
  --gpu-memory-utilization 0.90 \
  --max-model-len 8192 \
  --host 0.0.0.0 \
  --port 8000
  • VRAM不足時--max-model-len を 4096 まで縮小
  • ストリーミング出力は OpenAI 互換 (stream=True) で利用可

5. Streamlit チャット UI (ui.py)

以下の機能を提供:

  • ストリーミングチャット: vLLM からリアルタイム応答
  • Web検索: !search コマンドで DuckDuckGo 検索
  • 履歴管理: チャット履歴の保存・読み込み・削除
  • 新規チャット: サイドバーから新規チャット開始
ui.py
# ui.py
import streamlit as st
import requests
import sseclient
import json
import os
import datetime
from duckduckgo_search import DDGS

# 履歴保存ディレクトリ
HISTORY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chat_history")
if not os.path.exists(HISTORY_DIR):
    os.makedirs(HISTORY_DIR)
    st.write(f"履歴ディレクトリを作成しました: {HISTORY_DIR}")

# 検索機能
def web_search(query, max_results=3):
    with DDGS() as ddgs:
        results = ddgs.text(query)
        summary = ""
        for i, result in enumerate(results):
            if i >= max_results:
                break
            summary += f"[{result['title']}]({result['href']}): {result['body']}\n\n"
    return summary

# 履歴保存機能
def save_chat_history(messages, title=None):
    if not title:
        title = f"chat_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
    
    # systemメッセージを除いた履歴を保存
    chat_history = messages[1:] if messages else []
    
    history_data = {
        "title": title,
        "timestamp": datetime.datetime.now().isoformat(),
        "messages": chat_history
    }
    
    filename = f"{HISTORY_DIR}/{title}.json"
    
    # デバッグ情報を表示
    st.write(f"保存先: {filename}")
    st.write(f"メッセージ数: {len(chat_history)}")
    st.write(f"履歴ディレクトリ存在: {os.path.exists(HISTORY_DIR)}")
    
    try:
        with open(filename, 'w', encoding='utf-8') as f:
            json.dump(history_data, f, ensure_ascii=False, indent=2)
        
        # 保存確認
        if os.path.exists(filename):
            st.write(f"ファイル保存成功: {filename}")
        else:
            st.error(f"ファイル保存失敗: {filename}")
        
        return filename
    except Exception as e:
        st.error(f"保存エラー: {e}")
        raise e

# 履歴読み込み機能
def load_chat_history(filename):
    try:
        with open(filename, 'r', encoding='utf-8') as f:
            history_data = json.load(f)
        return history_data
    except Exception as e:
        st.error(f"履歴の読み込みに失敗しました: {e}")
        return None

# 履歴削除機能
def delete_chat_history(filename):
    try:
        os.remove(filename)
        return True
    except Exception as e:
        st.error(f"履歴の削除に失敗しました: {e}")
        return False

# 履歴ファイル一覧取得
def get_history_files():
    if not os.path.exists(HISTORY_DIR):
        return []
    
    files = []
    for filename in os.listdir(HISTORY_DIR):
        if filename.endswith('.json'):
            filepath = os.path.join(HISTORY_DIR, filename)
            try:
                with open(filepath, 'r', encoding='utf-8') as f:
                    history_data = json.load(f)
                    files.append({
                        'filename': filepath,
                        'title': history_data.get('title', filename),
                        'timestamp': history_data.get('timestamp', ''),
                        'message_count': len(history_data.get('messages', []))
                    })
            except:
                continue
    
    # タイムスタンプでソート(新しい順)
    files.sort(key=lambda x: x['timestamp'], reverse=True)
    return files

st.set_page_config(page_title="Mistral-7B Chat", layout="wide")
st.title("🧠 Mistral-7B-Instruct-v0.2 Chat")

# チャット履歴の初期化(最初に実行)
if "messages" not in st.session_state:
    st.session_state.messages = [
        {"role": "system", "content": "あなたは親切で有能なAIアシスタントです。回答は日本語で記述してください。"}
    ]

# サイドバーに履歴管理UIを追加
with st.sidebar:
    st.header("📚 チャット履歴")
    
    # 新規チャット作成ボタン
    if st.button("🆕 新規チャット", key="new_chat_button"):
        st.session_state.messages = [
            {"role": "system", "content": "あなたは親切で有能なAIアシスタントです。回答は日本語で記述してください。"}
        ]
        st.success("新規チャットを開始しました")
        st.rerun()
    
    # 現在のチャット情報を表示
    current_messages = len(st.session_state.messages) - 1  # systemメッセージを除く
    if current_messages > 0:
        st.info(f"現在のチャット: {current_messages}件のメッセージ")
    else:
        st.info("現在のチャット: 新規")
    
    st.divider()
    
    # 現在の履歴を保存
    st.subheader("💾 履歴を保存")
    
    # 保存する履歴があるかチェック
    if len(st.session_state.messages) > 1:  # systemメッセージ以外がある場合
        # タイトル入力
        title = st.text_input("履歴のタイトル", 
                            value=f"chat_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}",
                            key="save_title")
        
        # 保存ボタン
        if st.button("保存", key="save_button"):
            try:
                filename = save_chat_history(st.session_state.messages, title)
                st.success(f"履歴を保存しました: {filename}")
                # 入力フィールドをクリア
                st.session_state.save_title = f"chat_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}"
                st.rerun()
            except Exception as e:
                st.error(f"保存に失敗しました: {e}")
    else:
        st.warning("保存する履歴がありません")
    
    st.divider()
    
    # 保存済み履歴の一覧
    history_files = get_history_files()
    if history_files:
        st.subheader("保存済み履歴")
        for file_info in history_files:
            col1, col2 = st.columns([3, 1])
            with col1:
                if st.button(f"📖 {file_info['title']}", key=f"load_{file_info['filename']}"):
                    history_data = load_chat_history(file_info['filename'])
                    if history_data:
                        # systemメッセージを追加して履歴を復元
                        st.session_state.messages = [
                            {"role": "system", "content": "あなたは親切で有能なAIアシスタントです。回答は、Markdown形式で構造化し、日本語で記述してください。"}
                        ] + history_data['messages']
                        st.success(f"履歴を読み込みました: {file_info['title']}")
                        st.rerun()
            
            with col2:
                if st.button("🗑️", key=f"delete_{file_info['filename']}"):
                    if delete_chat_history(file_info['filename']):
                        st.success("履歴を削除しました")
                        st.rerun()
            
            st.caption(f"メッセージ数: {file_info['message_count']} | {file_info['timestamp'][:19]}")
    else:
        st.info("保存済みの履歴はありません")

# メインコンテンツ
# 履歴表示
for msg in st.session_state.messages[1:]:  # systemを除く
    with st.chat_message(msg["role"]):
        st.markdown(msg["content"])

# ユーザー入力
if prompt := st.chat_input("メッセージを入力してください(先頭に !search を付けると検索)"):
    with st.chat_message("user"):
        st.markdown(prompt)

    with st.chat_message("assistant"):
        full_response = ""
        placeholder = st.empty()

        # 検索要求がある場合
        if prompt.startswith("!search"):
            query = prompt.replace("!search", "").strip()
            if query:  # 検索クエリが空でない場合
                web_info = web_search(query)
                st.markdown("🔎 検索結果を反映しています...")
                
                # 検索結果を含むメッセージを追加
                search_message = f"以下はインターネット検索結果の要約です:\n\n{web_info}\n\nユーザーの質問: {query}"
                st.session_state.messages.append({"role": "user", "content": search_message})
            else:
                # 検索クエリが空の場合
                st.session_state.messages.append({"role": "user", "content": prompt})
        else:
            # 通常のメッセージ
            st.session_state.messages.append({"role": "user", "content": prompt})

        # vLLM にストリームリクエスト送信
        try:
            res = requests.post(
                "http://localhost:8000/v1/chat/completions",
                json={
                    "model": "mistralai/Mistral-7B-Instruct-v0.2",
                    "messages": st.session_state.messages,
                    "temperature": 0.7,
                    "stream": True
                },
                stream=True,
                headers={"Content-Type": "application/json"}
            )
            client = sseclient.SSEClient(res)
            for event in client.events():
                if event.data == "[DONE]":
                    break
                data = json.loads(event.data)
                token = data["choices"][0]["delta"].get("content", "")
                full_response += token
                placeholder.markdown(full_response)
        except Exception as e:
            placeholder.error(f"エラー: {e}")
            full_response = "エラーが発生しました。"

    st.session_state.messages.append({"role": "assistant", "content": full_response})


起動:

streamlit run ui.py

主要機能

  • サイドバーで履歴管理(保存・読み込み・削除)
  • !search 検索語 でWeb検索結果を反映
  • ストリーミング応答でリアルタイム表示
  • Markdown形式での構造化回答

6. 逆引き・最適化メモ

症状 対策
遅い --max-model-len を 4096、temperature 下げ、stream 有効
OOM gpu-memory-utilization を 0.85、dtype bfloat16 (Ampere以降)
Chat Template エラー role は system(最初のみ) + user/assistant のみ
検索API制限 Serper.dev や Bing API に置換可
履歴保存エラー chat_history ディレクトリの権限確認、JSON形式チェック

7. デプロイ拡張案

  • Docker: vLLM + Streamlit をマルチステージでまとめ、GPU Container Runtime を利用
  • RAG: LangChain / LlamaIndex でベクターDB+Retriever を実装し、Streamlit 内で呼び出す
  • 認証: Streamlit の st.secrets でベーシック認証、あるいは Nginx でリバースプロキシ
  • 複数モデル: vLLM の --model-path で複数モデルを切り替え可能

8. トラブルシューティング

vLLM サーバー起動エラー

# VRAM不足の場合
vllm serve ~/models/mistral7b \
  --dtype bfloat16 \
  --gpu-memory-utilization 0.75 \
  --max-model-len 4096

# モデルパス確認
ls -la ~/models/mistral7b/

Streamlit 接続エラー

# vLLM サーバーが起動しているか確認
curl http://localhost:8000/v1/models

# ポート確認
netstat -tlnp | grep 8000

まとめ

この手順で GPU1枚・完全ローカル でも

  • 高速推論 (vLLM)
  • ストリーミングチャット UI (Streamlit)
  • Web検索連携 (DuckDuckGo)
  • 履歴管理機能

が動く"セルフホスト AI アシスタント"環境を即構築できます。

カスタマイズ・拡張はご自由に。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?