「AIを使って稼ぎたい。でも何をやればいいのか分からない…」
そんな悩みを持つあなたに向けて、ローカルLLMを利用した“副業アイデア製造機”を作ってみました。

ローカルLLMには、Gemma3を採用しました。Gemma3は軽量なローカルLLMでありながら、Chatbot Arenaでo1-previewモデルより上位の評価を受けています。人間の評価において、最新のクラウド型モデルと遜色ない会話ができます。また、短いトークン数での文章理解や知識蒸留で工夫されており、メモリ効率が極めて高いことが特徴です。

本アプリは、あなたのパソコン上で、Gemma3モデル2機が、Streamlit上で自動対話させることで、【ひらめき → 深掘り → 要約】をループしながら、アイデアを連続で生み出します
  • ネット接続もAPI代も不要。

  • 自分のPC上で、好きなだけ回せる。

  • “副業のタネ”を見つけたら、あとは行動するだけ。

この記事では、副業アイデア製造機の使い方を解説します。

※Gemma3のライセンスは、Gemma Terms of Useです。商用利用可能ですが、Gemma Prohibited Use Policyで禁止された用途(児童性搾取、暴力や犯罪の助長等)では利用できません。

アプリの全体像

2025年5月11日 03_03_34

役者は 3 つ

  • Creator A(LLM‑A) … 量産型の奇抜アイデアを出す発散担当(温度0.9に設定)
  • Creator B(LLM‑B) … 直前の案を深掘り・補完する収束/拡張担当
  • Evaluator C … 5 ターン10発話ごとに両者の対話を要約し Markdown で保存

使い方のイメージ

1. あなたが「副業の方向性(例:教育 × AI × 地方)」を1行だけ入力。
2. あとはAI同士が勝手に会話を始めて、毎ターン新しいアイデアが生まれていく。
3. 10発話ごとに会話を要約&保存、次のセッションへ自動で切り替え。
→ これをループしていくと、“アイデア”がどんどんストックされていきます。

環境構築の手順

1. Ollamaのインストール

Ollamaとは、ローカルLLMを動作・管理するソフトウェアです。無料のOSSでMITライセンスです。
以下の公式のURL にアクセスし、Ollamaのインストーラーをダウンロードします。


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

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

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

2. Pythonのインストール

ダウンロードサイトからpythonをインストールする。

3. 必要なライブラリのインストール

pip install streamlit==1.37.1 ollama requests

4. Gemma3モデルを Ollama に pull

Gemma3のパラメータは、1b、4b、12b、27bから選べます。
お持ちのパソコンのメモリが32GB程度ある場合、gemma3:12bをpullしてください。もし、メモリが8GB程度で少な目の場合、gemma3:1bもしくはgemma3:4bモデルをpullしてください。
ollama pull gemma3:12b

5. コードの保存

記事の最後にあるコード全体をコピーしてapp.pyのファイル名で保存する。

6. アプリを起動

streamlit run app.py
蒸気を実行すると、ブラウザが自動で開き、以下のような画面が表示されます。
自動で開かない場合は、ブラウザに http://localhost:8501 と打ち込んでアクセスしてください。
スクリーンショット 2025-05-08 191441

使い方の手順

Step 1 ― アイデアの方向性を入力して再生ボタン押下

  1. テキスト入力欄にアイデアの方向性を日本語で入力します。(例:副業×AI×教育)
  2. (空欄の場合、 Creator A が “無から” 発想を試みます)
  3. 再生を押すと LLM‑A → LLM‑B が自動で走り出します。

Step 2 ― 会話を眺める

1 ターン目:LLM‑A が 5 案を “タイトル+概要” 形式で列挙。
2 ターン目:LLM‑B がそれぞれを補完する 5 つの「種」を提案。
この流れで創発→深化が交互にループし、アイデアがどんどん多角化します。

Step 3 ― 5 ターンごとの要約をチェック

ターン数が 5 の倍数になると Evaluator C が要約を生成し、
会話履歴+要約を Markdown にまとめます。
ファイル名は 20250508_191530_f3e2d6.md のように日付+時刻+短縮 UUID。
ローカルストレージのまま Git 管理すれば、ナレッジベースとして蓄積可能です。

Step 4 ― 必要に応じて停止 / 新しい会話

  • 停止 … 現在のセッションを残したまま一時停止。再度 再生 で続行可能。
  • 新しい会話を開始 … 全カウンタと履歴がリセット。別テーマの種を入れ直せます。

個別カスタマイズしたい人向け

1. 要約するターン数を変更したい

# ===== 会話履歴 & ファイル保存 =====
TURNS_PER_SUMMARY = 3          # 5ターン (=A+B 10発話) ごとに要約

2. モデルを切り替えたい

MODEL_NAME_A = "gemma3:4b"   # Creator A
MODEL_NAME_B = "gemma3:4b"   # Creator B
SUMMARIZER_MODEL = "gemma3:4b"  # Evaluator C (要約)
処理のスピードが遅い、動かない場合、gemma3:1bもしくはgemma3:4bの小型モデルを利用ください。Gemma3は、パラメータ数を減らしても十分賢く、あまり性能劣化は起きない印象です。

まとめ

このアプリはGemma 3 モデル×2だけで、「ひらめき ➜ 深掘り ➜ 要約アーカイブ」まで完結できる手軽なアイデア発散環境です。

無料で高速推論できるローカルLLMの強みは、量をたくさん生成できることです。アイデア発想などは、質より量が重要なので、利用ケースとしては有効だと考えられます。

テーマを変えながらガンガン回して、ピンと来るアイデアを見つけたら、o3と実現方法を相談してみてください。

関連記事:Ollama×Streamlit×VoiceVoxで作るローカルLLM音声対話アプリ【完全ハンズオン】

(参考)コード全体

コード全体は以下の通りです。後半のHTMLの部分で一部表示がおかしいですが、問題なく動作するはずです。
# -*- coding: utf-8 -*-
import streamlit as st
import ollama
import uuid
import os
import json
from datetime import datetime
import base64
import requests 

# ===== LLM 設定 =====
MODEL_NAME_A = "gemma3:12b"   # Creator A
MODEL_NAME_B = "gemma3:12b"   # Creator B
SUMMARIZER_MODEL = "gemma3:12b"  # Evaluator C (要約)

# ===== プロンプト =====
ROLE_PROMPT_A = """
あなたは **Creator A** 。目標は大量のアイデアを0→1発想で生み出すことです。
- 1ターン5案 (番号付き)、各案は【タイトル(15字以内) + 概要(40字以内)】の2行構成。
- 既出案と重複させず、新規性が高い順に並べる。
- ホワイトスペース・生成AIコスト0円・越境EC など多角的に検討する。
"""

ROLE_PROMPT_B = """
あなたは **Creator B** 。直前に示されたアイデアを補完・拡張する “種” を 5 つ提案してください。
- 150字以内/アイデア。既出と重複しない視点を含めること。
"""

# ===== 会話履歴 & ファイル保存 =====
TURNS_PER_SUMMARY = 5          # 5ターン (=A+B 10発話) ごとに要約
JSON_PATH         = "session_data.json"

# -- 共通 I/O ユーティリティ --------------------------------------------------
def load_sessions() -> dict:
    """セッションファイルを読み込み (存在しなければ空dict)"""
    if not os.path.exists(JSON_PATH):
        return {}
    with open(JSON_PATH, "r", encoding="utf-8") as f:
        return json.load(f)

def save_sessions(data: dict) -> None:
    """セッションデータをファイルへ書き込み"""
    with open(JSON_PATH, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

# -- 会話履歴操作 -------------------------------------------------------------
def fetch_history(session_id: str):
    return load_sessions().get(session_id, [])

def add_message(session_id: str, role: str, content: str):
    data = load_sessions()
    data.setdefault(session_id, []).append({"role": role, "content": content})

    # ---- ここだけ変更 ----
    keep = TURNS_PER_SUMMARY * 2           # 例: 5ターン×2発話 = 10
    data[session_id] = data[session_id][-keep:]
    save_sessions(data)   

# -- 要約生成 ------------------------------------------------------------------
def summarize_and_save(session_id: str, history_block):
    """履歴を Evaluator C で要約し、Markdown を out/ に保存"""
    plain_history = "\n".join(f"{h['role']}: {h['content']}" for h in history_block)
    messages = [
        {"role": "system", "content": "あなたは Evaluator C。以下の会話を統合評価し、日本語で Markdown で出力してください。"},
        {"role": "user",   "content": plain_history},
    ]

    response = ollama.chat(model=SUMMARIZER_MODEL, messages=messages)
    summary_md = response["message"]["content"].strip()

    # ---- Markdown 生成 ----
    history_md = "\n".join(f"- **{h['role']}**: {h['content']}" for h in history_block)
    combined_md = f"## 要約\n\n{summary_md}\n\n---\n\n## 会話履歴\n\n{history_md}\n"

    # ---- ファイル保存 (重複しないファイル名) ----
    os.makedirs("out", exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    fname = f"out/{ts}_{session_id.split('-')[0]}.md"
    suffix = 1
    while os.path.exists(fname):
        fname = f"out/{ts}_{session_id.split('-')[0]}_{suffix}.md"; suffix += 1
    with open(fname, "w", encoding="utf-8") as f:
        f.write(combined_md)

    return combined_md

# -- 画像 (アイコン) -----------------------------------------------------------
def get_base64_remote_image(url: str) -> str:
    """Unsplash など外部 URL 画像 → Base64 文字列へ変換"""
    resp = requests.get(url, timeout=15)
    resp.raise_for_status()
    return base64.b64encode(resp.content).decode()

ICON_URL_A = get_base64_remote_image(
    "https://images.unsplash.com/photo-1502685104226-ee32379fefbe"  
    "?w=100&h=100&crop=faces&fit=crop"                              
)

ICON_URL_B = get_base64_remote_image(
    "https://images.unsplash.com/photo-1529626455594-4ff0802cfb7e"  
    "?w=100&h=100&crop=faces&fit=crop"
)

# ===== LLM 応答生成 ===========================================================
def generate_response(session_id: str, assistant_role: str):
    """Creator A / B の応答を生成し履歴へ追加"""
    history     = fetch_history(session_id)
    system_p    = ROLE_PROMPT_A if assistant_role == "assistant_A" else ROLE_PROMPT_B
    model_name  = MODEL_NAME_A if assistant_role == "assistant_A" else MODEL_NAME_B

    # システム + これまでの会話
    messages = [{"role": "system", "content": system_p}]
    for turn in history:
        role = "assistant" if turn["role"] == assistant_role else "user"
        messages.append({"role": role, "content": turn["content"]})

    # Creator A は温度高めで発散
    options = {"temperature": 0.9} if assistant_role == "assistant_A" else {}
    response = ollama.chat(model=model_name, messages=messages, options=options)
    content  = response["message"]["content"].strip()

    add_message(session_id, assistant_role, content)
    st.session_state.total_turns += 1

# ===== UI ヘルパー ===========================================================
def render_history(history):
    """履歴をチャット形式で表示"""
    for turn in history:
        if turn["role"] == "user_seed":          
            continue                             

        icon = ICON_URL_A if turn["role"] == "assistant_A" else ICON_URL_B
        speaker = {"assistant_A": "LLM‑A", "assistant_B": "LLM‑B"}.get(
            turn["role"], "📝 User"
        )
        bg = "#F7F7F7" if "assistant_A" in turn["role"] else "#E8E8E8"
        st.markdown(
            f"""
            <div style='display:flex;align-items:flex-start;margin-bottom:1rem;'>
              <img src='data:image/png;base64,{icon if "assistant" in turn["role"] else ""}'
                   style='width:40px;height:40px;margin-right:0.5rem;border-radius:50%;'/>
              <div style='background-color:{bg if "assistant" in turn["role"] else "#FFF"};padding:0.75rem;
                          border-radius:0.5rem;flex:1;'>
                <strong>{speaker}</strong>
                <div style='margin-top:0.5rem;'>{turn['content']}</div>
              </div>
            </div>
            """,
            unsafe_allow_html=True,
        )

# ===== メインアプリ ===========================================================
def main():
    st.title("Gemma3でアイデア創出")

    # -- Session state 初期化 --
    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 "last_time"   not in st.session_state: st.session_state.last_time = datetime.now()
    if "step"        not in st.session_state: st.session_state.step = 0   # 0:A ➜ 1:B
    if "total_turns" not in st.session_state: st.session_state.total_turns = 0
    if "summaries"   not in st.session_state: st.session_state.summaries = []

    # -- アイデア種入力 (初回の Creator A に渡す) --
    idea_seed = st.text_input("アイデアの方向性・種を入力してください (例: 教育×AI×副業)")

    # -- サイドバー (操作) --
    with st.sidebar:
        st.header("操作パネル")
        interval = st.slider("会話の更新間隔 (秒)", 0, 3, 1)

        col1, col2 = st.columns(2)
        if col1.button("再生", use_container_width=True):
            st.session_state.is_running = True
            st.session_state.step = 0
        if col2.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.step        = 0
            st.session_state.total_turns = 0
            st.session_state.summaries   = []
            st.session_state.is_running  = False

        st.markdown(f"**現在の会話数:** {st.session_state.total_turns}")


    # -- 会話表示 --
    st.subheader("現在の会話")
    render_history(fetch_history(st.session_state.session_id))

    # -- SUMMARY_THRESHOLD ごとに要約 --
    completed_turns = st.session_state.total_turns // 2

    if (
        st.session_state.step == 0
        and completed_turns > 0
        and completed_turns % TURNS_PER_SUMMARY == 0
    ):
        history_block = fetch_history(st.session_state.session_id)

        # A+B 10 発話そろっているかを確認
        if len(history_block) >= TURNS_PER_SUMMARY * 2:
            md = summarize_and_save(st.session_state.session_id, history_block)
            st.session_state.summaries.append(md)

            # ----- 新セッションへリセット -----
            st.session_state.session_id  = str(uuid.uuid4())
            st.session_state.step        = 0
            st.session_state.total_turns = 0

    # -- 過去の要約 --
    if st.session_state.summaries:
        st.subheader("過去の要約")
        for idx, sm in enumerate(reversed(st.session_state.summaries), 1):
            with st.expander(f"要約 #{idx}"):
                st.markdown(sm, unsafe_allow_html=True)

    # -- 自動対話ループ --
    if st.session_state.is_running:
        now = datetime.now()
        if interval == 0 or (now - st.session_state.last_time).total_seconds() >= interval:
            if st.session_state.step == 0:  # Creator A に種を渡すのは最初の 1 回だけ
                if idea_seed:
                    add_message(st.session_state.session_id, "user_seed", idea_seed)
            role = "assistant_A" if st.session_state.step == 0 else "assistant_B"
            generate_response(st.session_state.session_id, role)
            st.session_state.step = 1 - st.session_state.step  # A ↔ B 切替え
            st.session_state.last_time = now
            st.rerun()  # 画面更新

if __name__ == "__main__":
    main()