この記事はパソコンで動くAI同士の会話アプリを作りたい人に向けたハンズオン解説です。

関連記事:副業のネタが出ない人へ──LLM(Gemma3)同士の対話で副業アイデアを自動で量産する方法

1. どんなアプリを作るの?

キングダムで有名な秦王嬴政と楚の使者の AI が、交互にセリフを言い合います。画面には吹き出しが出て、VoiceVox でセリフが読み上げられます。AI同士で会話をさせ、その様子を見て聞くイメージです。完成した画面は以下になります。
スクリーンショット 2025-04-22 200729

2. 使う道具(技術スタック)

Streamlit:Webアプリを作るためのライブラリ。
Ollama:ローカルLLMを動かすための実行環境。
Gemma3 4B:Google が開発した 軽量で高性能なLLM。今回は40億パラメータを利用。
VoiceVox:オープンソースの音声合成エンジン。テキストを音声に変換。
sounddevice:Pythonでオーディオの 再生を行うライブラリ。

3. コードの全体像

全てのコードは以下の通りです。StreamlitからOllamaで動作するLLMにmessagesを投げて、LLMが出力したテキストをvoicevoxに渡して音声再生しています。
コード理解が不要でとりあえず動かしたい場合は、[5. 環境構築]に進んでください。
import streamlit as st
import ollama
import requests
import io
import scipy.io.wavfile as wav
import sounddevice as sd
import time
import uuid
import os
import json
from datetime import datetime
import base64

# ==== モデル名・ロール設定(必要なら別々のモデルを指定) ====
MODEL_NAME_A = "gemma3:4b"
MODEL_NAME_B = "gemma3:4b"

ROLE_PROMPT_A = "あなたはキングダムで有名な秦王の嬴政です。中華統一を望んでいます。他国から使者が来て、中華統一を諫められます。以下の会話履歴に基づいて、この会話を続けてください。会話は短めにしてください。論破されたら「負けました」と発言してください。"
ROLE_PROMPT_B = "あなたは春秋戦国時代の楚国の使者です。「なにかそういうデータがあるんですか」「嘘つくのやめて貰っていいですか」など短い言葉で相手を追い詰めることが得意です。使者として、秦王の中華統一の野望を諫めてください。以下の会話履歴に基づいて、この会話を続けてください。論破されたら「負けました」と発言してください。"

# ==== 会話履歴保持の上限 ====
MAX_HISTORY = 8

# ==== VoiceVoxスピーカーID ====
VOICEVOX_SPEAKER_ID_A = 13 #青山龍星 
VOICEVOX_SPEAKER_ID_B = 3 #ずんだもん

# ==== アイコン画像 ====
def get_base64_image(path):
    with open(path, "rb") as f:
        return base64.b64encode(f.read()).decode()

ICON_URL_A = get_base64_image("./png/嬴政.png")
ICON_URL_B = get_base64_image("./png/使者.png")

# ==== JSONファイル関連 ====
JSON_PATH = "session_data.json"

def init_json_file():
    """JSONファイルが存在しない場合は空の辞書を作る。"""
    if not os.path.exists(JSON_PATH):
        with open(JSON_PATH, "w", encoding="utf-8") as f:
            json.dump({}, f, ensure_ascii=False, indent=2)

def load_all_sessions():
    """JSONファイルから全セッションデータを読み込む。"""
    with open(JSON_PATH, "r", encoding="utf-8") as f:
        return json.load(f)

def save_all_sessions(data):
    """全セッションデータをJSONファイルに書き込む。"""
    with open(JSON_PATH, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

def fetch_conversation_history(session_id: str):
    """指定セッションIDの会話履歴を返す。なければ空リスト。"""
    data = load_all_sessions()
    return data.get(session_id, [])

def add_message_to_file(session_id: str, role: str, content: str):
    """会話履歴にメッセージを追加し、古い履歴が多ければ削除。"""
    data = load_all_sessions()
    if session_id not in data:
        data[session_id] = []
    data[session_id].append({"role": role, "content": content})
    while len(data[session_id]) > MAX_HISTORY:
        data[session_id].pop(0)
    save_all_sessions(data)

# ==== VoiceVox音声合成 ====
def synthesize_and_play_voice(text: str, speaker: int):
    if not text.strip():
        return
    # 1. 合成クエリ
    try:
        query_resp = requests.post(
            "http://localhost:50021/audio_query",
            params={"text": text, "speaker": speaker},
            timeout=5
        )
        query_resp.raise_for_status()
        query = query_resp.json()
    except requests.exceptions.RequestException as e:
        st.error(f"VoiceVoxとの通信エラー: {e}")
        return

    # 2. 音声データ生成
    try:
        synth_resp = requests.post(
            "http://localhost:50021/synthesis",
            params={"speaker": speaker},
            json=query,
            timeout=13
        )
        synth_resp.raise_for_status()
    except requests.exceptions.RequestException as e:
        st.error(f"音声合成エラー: {e}")
        return

    # 3. 再生
    with io.BytesIO(synth_resp.content) as f:
        fs, data = wav.read(f)
        sd.play(data, fs)
        sd.wait()

# ==== Ollama応答生成 ====
def generate_llm_response(session_id: str, assistant_role: str) -> str:
    history = fetch_conversation_history(session_id)
    if assistant_role == "assistant_A":
        system_prompt = ROLE_PROMPT_A
        model_name = MODEL_NAME_A
        my_role = "assistant_A"
    else:
        system_prompt = ROLE_PROMPT_B
        model_name = MODEL_NAME_B
        my_role = "assistant_B"

    messages = [{"role": "system", "content": system_prompt}]
    # 履歴をChat形式に変換(自分: assistant / 相手: user)
    for turn in history:
        if turn["role"] == my_role:
            messages.append({"role": "assistant", "content": turn["content"]})
        else:
            messages.append({"role": "user", "content": turn["content"]})

    try:
        st.session_state.api_status = f"Generating response for {assistant_role}..."
        response = ollama.chat(model=model_name, messages=messages)
        response_text = (
            response["message"].get("content", "").strip()
            if response and "message" in response
            else ""
        )
        add_message_to_file(session_id, assistant_role, response_text)
        st.session_state.api_status = "Ready"
    except Exception as e:
        st.error(f"LLMエラー: {e}")
        st.session_state.api_status = f"Error: {e}"
        return ""
    return response_text

# ==== 会話表示 ====
def show_conversation(session_id: str):
    history = fetch_conversation_history(session_id)
    if not history:
        st.write("まだ会話はありません。")
        return
    for turn in history:
        if turn["role"] == "assistant_A":
            icon_url = ICON_URL_A
            speaker_name = "嬴政"
            bg_color = "#F7F7F7"
        else:
            icon_url = ICON_URL_B
            speaker_name = "使者"
            bg_color = "#E8E8E8"
        st.markdown(
            f"""
            <div style="display: flex; align-items: flex-start; margin-bottom: 1rem;">
              <img src="data:image/jpg;base64,{icon_url}" style="width: 40px; height: 40px; margin-right: 0.5rem; border-radius: 50%;"/>
              <div style="background-color: {bg_color}; padding: 0.75rem; border-radius: 0.5rem; flex: 1;">
                <strong>{speaker_name}</strong>
                <div style="margin-top: 0.5rem;">{turn['content']}</div>
              </div>
            </div>
""", unsafe_allow_html=True ) # ==== メイン ==== def main(): st.title("LLM同士の自動会話デモ") init_json_file() if "session_id" not in st.session_state: st.session_state.session_id = str(uuid.uuid4()) if "is_running" not in st.session_state: st.session_state.is_running = False if "voicevox_enabled" not in st.session_state: st.session_state.voicevox_enabled = False if "last_update_time" not in st.session_state: st.session_state.last_update_time = datetime.now() if "conversation_step" not in st.session_state: st.session_state.conversation_step = 0 if "api_status" not in st.session_state: st.session_state.api_status = "Ready" if "needs_rerun" not in st.session_state: st.session_state.needs_rerun = False # サイドバー with st.sidebar: st.header("設定") st.session_state.voicevox_enabled = st.checkbox( "VoiceVox で音声を再生する", value=st.session_state.voicevox_enabled ) update_interval = st.slider("会話の更新間隔(秒)", 1, 30, 5, 1) col1, col2 = st.columns(2) with col1: if st.button("再生", use_container_width=True): st.session_state.is_running = True st.session_state.conversation_step = 0 st.session_state.needs_rerun = True with col2: if st.button("停止", use_container_width=True): st.session_state.is_running = False if st.button("新しい会話を開始", use_container_width=True): st.session_state.session_id = str(uuid.uuid4()) st.session_state.conversation_step = 0 st.session_state.needs_rerun = True status_text = "再生中" if st.session_state.is_running else "停止中" st.info(f"現在の状態: {status_text}") st.info(f"API状態: {st.session_state.api_status}") # 会話表示 st.subheader("会話") conversation_container = st.container() with conversation_container: show_conversation(st.session_state.session_id) # 自動会話 if st.session_state.is_running: current_time = datetime.now() if (current_time - st.session_state.last_update_time).total_seconds() >= update_interval: with st.spinner("会話を生成中..."): if st.session_state.conversation_step == 0: resp_A = generate_llm_response(st.session_state.session_id, "assistant_A") if st.session_state.voicevox_enabled and resp_A: synthesize_and_play_voice(resp_A, VOICEVOX_SPEAKER_ID_A) st.session_state.conversation_step = 1 else: resp_B = generate_llm_response(st.session_state.session_id, "assistant_B") if st.session_state.voicevox_enabled and resp_B: synthesize_and_play_voice(resp_B, VOICEVOX_SPEAKER_ID_B) st.session_state.conversation_step = 0 st.session_state.last_update_time = current_time st.session_state.needs_rerun = True # 自動更新 if st.session_state.is_running or st.session_state.needs_rerun: st.session_state.needs_rerun = False time.sleep(0.1) st.rerun() if __name__ == "__main__": main()

4. コードを7つのパーツで読み解く

以下からコードを解説していきます。

4.1 LLMの役割を決める(プロンプト)

MODEL_NAME_A = "gemma3:4b"
MODEL_NAME_B = "gemma3:4b"

ROLE_PROMPT_A = "あなたはキングダムで有名な秦王の嬴政です。中華統一を望んでいます。他国から使者が来て、中華統一を諫められます。以下の会話履歴に基づいて、短めに会話を続けてください。会話は短めにしてください。論破されたら「負けました」と発言してください。"
ROLE_PROMPT_B = "あなたは春秋戦国時代の楚国の使者です。「なにかそういうデータがあるんですか」「嘘つくのやめて貰っていいですか」など短い言葉で相手を追い詰めることが得意です。使者として、秦王の中華統一の野望を諫めてください。以下の会話履歴に基づいて、この会話を続けてください。論破されたら「負けました」と発言してください。"

# ==== VoiceVoxスピーカーID ==== VOICEVOX_SPEAKER_ID_A = 13 #青山龍星 VOICEVOX_SPEAKER_ID_B = 3 #ずんだもん
MODEL_NAME_A/Bで、Gemma 3‑4B を 2 つ用意し、嬴政(A)と楚の使者(B)を担当させています。
ROLE_PROMPT_A/Bに、キャラクター設定と挙動ルールを定義し、最初にLLMの system プロンプトとして渡します。

また、VOICEVOX_SPEAKER_IDで、VOICEVOXのキャラクターを指定しています。今回利用した音声合成のキャラクターは以下の通りです。
  - VOICEVOX:青山龍星 
  - VOICEVOX:ずんだもん

4.2 会話履歴の保持ロジック(JSON)

def init_json_file():
    """JSONファイルが存在しない場合は空の辞書を作る。"""
    if not os.path.exists(JSON_PATH):
        with open(JSON_PATH, "w", encoding="utf-8") as f:
            json.dump({}, f, ensure_ascii=False, indent=2)

def fetch_conversation_history(session_id: str):
    """指定セッションIDの会話履歴を返す。なければ空リスト。"""
    data = load_all_sessions()
    return data.get(session_id, [])

def add_message_to_file(session_id: str, role: str, content: str):
    """会話履歴にメッセージを追加し、古い履歴が多ければ削除。"""
    data = load_all_sessions()
    if session_id not in data:
        data[session_id] = []
    data[session_id].append({"role": role, "content": content})
    while len(data[session_id]) > MAX_HISTORY:
        data[session_id].pop(0)
    save_all_sessions(data)
init_json_file関数は、初回起動時に空のjsonファイルを生成します。
fetch_conversation_history関数は、これまでの会話履歴を読み込みます。
add_message_to_file関数は、新しい会話履歴を追加して 8 ターンを超えた場合、古い会話履歴を削除します。これによりプロンプトの肥大化を防止します。

4.3 音声合成(VoiceVox)

# ==== VoiceVox音声合成 ====
def synthesize_and_play_voice(text: str, speaker: int):
    if not text.strip():
        return
    # 1. 合成クエリ
    try:
        query_resp = requests.post(
            "http://localhost:50021/audio_query",
            params={"text": text, "speaker": speaker},
            timeout=5
        )
        query_resp.raise_for_status()
        query = query_resp.json()
    except requests.exceptions.RequestException as e:
        st.error(f"VoiceVoxとの通信エラー: {e}")
        return

    # 2. 音声データ生成
    try:
        synth_resp = requests.post(
            "http://localhost:50021/synthesis",
            params={"speaker": speaker},
            json=query,
            timeout=13
        )
        synth_resp.raise_for_status()
    except requests.exceptions.RequestException as e:
        st.error(f"音声合成エラー: {e}")
        return

    # 3. 再生
    with io.BytesIO(synth_resp.content) as f:
        fs, data = wav.read(f)
        sd.play(data, fs)
        sd.wait()
ローカルで起動しているVoiceVox エンジンに、テキストと話者IDをリクエストして音声合成を行い、即時に再生しています。

1. 音声合成クエリの作成(/audio_query)
/audio_queryエンドポイントは、指定したテキストをどう発音するかの情報(ピッチや速度など)を生成する処理です。
返ってくるのは、音声の生成に必要な「プロソディ情報(音の調子)」が詰まったJSONです。これを次のステップで json=query として使います。

2. 音声データの合成(/synthesis)
/synthesisエンドポイントは、先ほどのプロソディ情報のjsonをリクエストすると、バイナリ形式の音声データ(WAVファイル)のレスポンスを受け取ることができます。

3. 音声の再生
Pythonの標準ライブラリのBytesIO を使うことで、物理的なWAVファイルを作らずに、読み書きを メモリ上で行えます。

scipy.io.wavfile.readで、サンプリング周波数(fs)と音声データ(data)を取得し、sounddevice.play() で音声再生を開始します。sd.wait() では、再生が終わるまで次の処理に進まないように待機します。

4.4 LLM 応答生成

def generate_llm_response(session_id: str, assistant_role: str) -> str:
    history = fetch_conversation_history(session_id)
    if assistant_role == "assistant_A":
        system_prompt = ROLE_PROMPT_A
        model_name = MODEL_NAME_A
        my_role = "assistant_A"
    else:
        system_prompt = ROLE_PROMPT_B
        model_name = MODEL_NAME_B
        my_role = "assistant_B"

    messages = [{"role": "system", "content": system_prompt}]
    # 履歴をChat形式に変換(自分: assistant / 相手: user)
    for turn in history:
        if turn["role"] == my_role:
            messages.append({"role": "assistant", "content": turn["content"]})
        else:
            messages.append({"role": "user", "content": turn["content"]})

    try:
        st.session_state.api_status = f"Generating response for {assistant_role}..."
        response = ollama.chat(model=model_name, messages=messages)
        response_text = (
            response["message"].get("content", "").strip()
            if response and "message" in response
            else ""
        )
        add_message_to_file(session_id, assistant_role, response_text)
        st.session_state.api_status = "Ready"
会話履歴について、過去発話のラベル付けを「自分=assistant / 相手=user」に動的に書き換えています。理由は、assistantが続くとLLMが回答しなくなることと、モデル側からは “自分以外はユーザー” に見えるので人格が混同しにくいからです。

次にollama.chatでローカルLLMにリクエストしています。messagesで会話履歴を丸ごと渡しています。
LLMの出力結果を、add_message_to_file関数で会話履歴へ追加し、UI 用の状態 api_status を更新します。

4.5 会話表示

def show_conversation(session_id: str):
    history = fetch_conversation_history(session_id)
    if not history:
        st.write("まだ会話はありません。")
        return
    for turn in history:
        if turn["role"] == "assistant_A":
            icon_url = ICON_URL_A
            speaker_name = "嬴政"
            bg_color = "#F7F7F7"
        else:
            icon_url = ICON_URL_B
            speaker_name = "使者"
            bg_color = "#E8E8E8"
        st.markdown(
            f"""
            <div style="display: flex; align-items: flex-start; margin-bottom: 1rem;">
              <img src="data:image/jpg;base64,{icon_url}" style="width: 40px; height: 40px; margin-right: 0.5rem; border-radius: 50%;"/>
              <div style="background-color: {bg_color}; padding: 0.75rem; border-radius: 0.5rem; flex: 1;">
                <strong>{speaker_name}</strong>
                <div style="margin-top: 0.5rem;">{turn['content']}</div>
              </div>
            </div>
            """,
            unsafe_allow_html=True
        )
履歴を上から時系列で描画しています。LLMで作成したアイコン画像を元に、キャラごとにアイコン/背景色を切り替え、吹き出し風 UI を実現しています。unsafe_allow_html=True でカスタム HTML を直接埋め込んでいます。

4.6 Streamlit サイドバー

    # サイドバー
    with st.sidebar:
        st.header("設定")
        st.session_state.voicevox_enabled = st.checkbox(
            "VoiceVox で音声を再生する",
            value=st.session_state.voicevox_enabled
        )
        update_interval = st.slider("会話の更新間隔(秒)", 1, 30, 5, 1)

        col1, col2 = st.columns(2)
        with col1:
            if st.button("再生", use_container_width=True):
                st.session_state.is_running = True
                st.session_state.conversation_step = 0
                st.session_state.needs_rerun = True
        with col2:
            if st.button("停止", use_container_width=True):
                st.session_state.is_running = False

        if st.button("新しい会話を開始", use_container_width=True):
            st.session_state.session_id = str(uuid.uuid4())
            st.session_state.conversation_step = 0
            st.session_state.needs_rerun = True

        status_text = "再生中" if st.session_state.is_running else "停止中"
        st.info(f"現在の状態: {status_text}")
        st.info(f"API状態: {st.session_state.api_status}")
Streamlitのサイドバーを以下で作成しています。
checkbox     VoiceVox 再生の ON/OFF。
slider     新しいターン生成までの待機秒数。会話が速いと人間の目が追い付かないため。
再生 / 停止ボタン is_running フラグで自動進行を制御。
新しい会話   新 UUID を発行して履歴をクリア。
info       再生状態と API 呼び出し状態を表示。

4.7 自動会話ループ

if st.session_state.is_running:
    if 経過時間 >= update_interval:
        ① assistant_A を生成 → Voice → step=1
        ② assistant_B を生成 → Voice → step=0
conversation_step を 0/1 で交互に切り替え、人間が操作しなくても LL​M が会話を続行します。
needs_rerun フラグと st.rerun() でページを自動更新し、リアルタイムに新しい発話を反映します。

5. 環境構築

1. Ollamaのインストール

公式のURL にアクセスし、インストーラーをダウンロードします。


ダウンロードしたOllamaSetup.exeをクリックし、インストールしてください。

インストールが完了したら、「すべてのアプリ」をクリックしてOllamaを起動しましょう。Ollamaは画面を持たないコマンドラインツールです。右下のシステムトレイにollamaが常駐するので、それで起動しているかどうかを確認できます。

※Ollamaの詳細なインストール手順と利用方法は、以下の記事の[2. Ollamaのインストールと利用方法]をご参考ください。
参考記事:OllamaをPythonから操作:WindowsでローカルLLM入門


2. モデルをダウンロード
コマンドプロンプトから以下を実行し、OllamaからGemma3モデルをダウンロードします。
ollama pull gemma3:4b

3. VOICEVOXのインストール
1. 公式サイトにアクセスして、「ダウンロード」をクリックします。
2. 利用している環境のOS、GPU/CPUを指定し、「ダウンロード」をクリックして保存します。
3. インストーラーをダブルクリックします。

3. VOICEVOXの起動
インストールが完了したら、アイコンをクリックし、VoiceVox エンジンを起動します。

4. ライブラリのインストール
コマンドプロンプトから以下を実行し、動作に必要なライブラリをインストールします。
pip install streamlit ollama sounddevice scipy requests

5. アプリを起動
上述コードをsample_app.pyで保存し、コマンドプロンプトから以下を実行します。
streamlit run sample_app.py
サイドバーの「再生」を押すと AI が話し始めます。

おわりに

Streamlit(画面)+ Ollama(AI)+ VoiceVox(声)の 3 点セットで、“ローカルだけで完結する LLM会話メーカー”が完成しました。まずはコードを動かし、アイコンやセリフを好きなキャラに変えて遊んでみましょう。

利用ソフトウェアとライセンス

本コードでは、以下のソフトウェアを利用しています。
・Ollama

・Gemma 3 4B — Google Gemma License  

・VOICEVOX Core / VOICEVOX Engine —MIT License
  <https://github.com/VOICEVOX/voicevox_core/blob/main/LICENSE>

・音声合成に使用したキャラクター音源
   ・VOICEVOX:青山龍星 
   ・VOICEVOX:ずんだもん
  <https://voicevox.hiroshiba.jp/term/>