PythonでAIエージェントを作る方法【Groq API + 天気 + Web検索】初心者向け完全ガイド Windows対応

 

この記事のURLをコピーする

AIチャットボット作成の記事では、無料のGeminAPIを使った簡易的なAIボットを作成しました。

しかし、GeminiAPIはモデルエラーが多く、無料枠のリクエスト制限もシビアです。

本記事では、比較的リクエスト制限が少なく、完全無料で使えるGroq APIを使って、天気情報取得・Web検索ができるAIエージェントをPythonで構築する手順をゼロから解説します。

目次





AIエージェントとは?この記事で作るものの概要

AIエージェントとは、ユーザーの質問に応じてAIが自律的にツール(APIなど)を選択・実行し、回答を返す仕組みです。

今回は「天気を聞かれたら天気APIを呼ぶ」「ニュースを聞かれたらWeb検索をする」という2つのツールを持つCLIエージェントを作ります。

コードの安全性について

本記事で紹介しているコードは、すべて基本的に安全な処理のみで構成されていますが、実行環境や設定によっては意図しない動作が発生する可能性があります。

安心して使用するために、以下のポイントを確認してください。

外部APIの使用について

本記事ではGroq APIやWeb検索APIなどの外部サービスを利用しています。
APIキーは第三者に公開しないよう注意してください。

実行前のコード確認

コードをそのまま実行する前に、内容を一度確認することを推奨します。
特に以下の処理には注意してください:

* 外部通信(APIリクエスト)
* ファイルの読み書き処理
* 環境変数の使用

仮想環境での実行を推奨

Pythonの仮想環境(venvなど)で実行することで、PC本体への影響を最小限に抑えることができます。

自己責任での利用

本記事のコードは学習目的で提供しています。実行・応用はご自身の責任で行ってください。

💡 不安な方へ

まずはテスト環境やサブPCでの実行から始めるのがおすすめです。



全体の流れ

  1. 必要なソフト・ライブラリの確認とインストール
  2. 3つのAPIキーを取得する(Groq・OpenWeatherMap・Tavily)
  3. フォルダとファイルを作成する
  4. .envにAPIキーを設定する
  5. python main.pyで起動して動作確認する
  6. エラーが出たら「よくあるエラーと対処法」を参照する

必要なもの

  • OS: Windows 10 / 11
  • Python 3.8以上(推奨: 3.11以上)
  • API(各用途に3つ)
  • ターミナル: PowerShell または Windows Terminal(推奨)
  • Pythonライブラリ

ステップ① 事前準備(APIキーの取得手順)

今回使用するAPIはすべて無料プランがあります。また、1日あたりのリクエスト制限も比較的余裕のあるものを選定しています。

Groq・OpenWeatherMap・Tavilyの3つのAPIキーを事前に取得しておきましょう。それぞれの登録方法と取得手順を順番に説明します。

Groq APIの取得

公式サイトにアクセスしてGoogleアカウントでサインアップしてください。

公式サイト: https://console.groq.com/home

上部メニューにある「API Key」をクリック。

Create API Key」をクリック。

Display Nameに分かりやすい名前を入力して「Submit」をクリック。

しばらく待つとAPI Keyが作成され、コピーできる画面になるのでコピーをしてメモ帳などに保管します。

OpenWeatherMap APIの取得

公式サイトにアクセスして右上の「Sign up」をクリック。

公式サイト: https://openweathermap.org/

ユーザー名とメールアドレス、パスワードを入力して各項目にチェックを入れて「Create Account」をクリック。※メールアドレスやパスワードは忘れないように保管してください。

メール受信箱に届いたメールから「Verify your email」をクリック。

上部メニューにある「API Key」をクリック。

分かりやすいAPI Keyの名前を入力して「Genarate]をクリック。

Keyが表示されるのでコピーしてメモ帳などに保管します。

Tavily APIの取得

公式サイトにアクセスします。

公式サイト: https://www.tavily.com/

右上にある「Sign up」をクリック。

Googleアカウントでサインアップします。

でAPI Keyの名前を入力して「Create」をクリック。

API Keyが表示されるのでコピーしてメモ帳などに保管します。

フォルダ構成と各ファイルの役割

コードを1ファイルにまとめることもできますが、ツールを後から追加しやすくするために最初からファイルを分けておくことをおすすめします。

今回は以下の構成で作成します。

📁 ai_agent/
├── 📄main.py ← CLIループ(起動はここ)
├── 📄agent.py ← Groqとのやり取りのコアロジック
├── 📄config.py ← APIキー管理(.envから読み込み)
├── 📁 tools/
│ ├── 📄__init__.py ← ツールの登録・実行を一元管理
│ ├── 📄weather.py ← 天気ツール
│ └── 📄search.py ← Web検索ツール
├── 📄.env ← APIキーをここに記入
├── 📄requirements.txt
└── 📄README.md

ステップ② フォルダとファイルを一括で作成する

PowerShellを起動して、フォルダを作成する場所に移動します。

例:Dドライブのルートに作成する場合

D:\に移動する。

cd D:\

移動したら下のコマンドを実行。

# ai_agent プロジェクト構造を一括作成
$base = "ai_agent"

# フォルダ作成
New-Item -ItemType Directory -Force -Path "$base/tools" | Out-Null

# ファイル作成
$files = @(
    "$base/main.py",
    "$base/agent.py",
    "$base/config.py",
    "$base/tools/__init__.py",
    "$base/tools/weather.py",
    "$base/tools/search.py",
    "$base/.env",
    "$base/requirements.txt",
    "$base/README.md"
)

foreach ($file in $files) {
    New-Item -ItemType File -Force -Path $file | Out-Null
}

Write-Host "✅ ai_agent の構造を作成しました!" -ForegroundColor Green
tree $base

Dドライブにai_agentフォルダが作成されているのを確認します。

これでフォルダとファイルの準備は完了です。

ソースコードの解説

作業前にソースコードの役割を理解しておくと「今何を編集しているか?」が分かるので作業が捗ります。

config.py|APIキーの管理

APIキーはコードに直接書かず、.envファイルで管理します。python-dotenvを使うことで、環境変数として安全に読み込めます。

weather.py|天気ツール

OpenWeatherMap APIを呼び出し、気温・湿度・天気・風速などを取得します。都市名は英語で渡す必要があります(例: Tokyo)。

search.py|Web検索ツール

Tavily APIを使ってWeb検索を行います。検索結果の上位5件とAIによる要約を取得できます。

__init__.py|ツールの登録

ALL_TOOLSにツール定義を、TOOL_MAPに関数の対応を登録します。新しいツールを追加するときはここだけ修正すればOKです。

agent.py|エージェントのコアロジック

Groq APIにメッセージとツール定義を渡し、ツール呼び出しが必要な場合は実行して結果を返すループを実装します。日本語のクエリを英語に変換してからツールに渡す点がポイントです。

main.py|CLIインターフェース
ユーザーの入力を受け取り、agent.pyに渡してレスポンスを表示するシンプルなループです。resetで会話履歴のリセット、exitで終了できます。
.env|APIキーの保存先

3つのAPIキーを記入するファイルです。このファイルはあなたのAPIキーを記述しているため、絶対に公開してはいけません。

requirements.txt|ライブラリ一覧

プロジェクトに必要なPythonライブラリをまとめたファイルです。pip install -r requirements.txtを実行するだけで必要なライブラリをまとめてインストールできます。

README.md|プロジェクトの説明書

プロジェクトの概要・セットアップ手順・ツールの追加方法をまとめたドキュメントです。自分用のメモとしても、他者と共有するときの説明書としても役立ちます。

ステップ③ 必要ライブラリを記述する

まずはrequirements.txtをメモ帳で開いて次のように記述します。

groq
requests
python-dotenv
📁ai_agent/
├── 📄main.py
├── 📄agent.py
├── 📄config.py
├── 📁tools/
│ ├── 📄 __init__.py
│ ├── 📄weather.py
│ └── 📄search.py
├── 📄.env
├── 📄requirements.txt ←このファイルをメモ帳で開く
└── 📄README.md

記述できたらメモ帳の「ファイル」→「上書き保存」で保存します。

ステップ④ .envにAPIを設定する

.envをメモ帳で開いて次のように記述します。

GROQ_API_KEY=ここにあなたのAPIキーを入力
OPENWEATHER_API_KEY=ここにあなたのAPIキーを入力
TAVILY_API_KEY=ここにあなたのAPIキーを入力
📁ai_agent/
├── 📄main.py
├── 📄agent.py
├── 📄config.py
├── 📁tools/
│ ├── 📄 __init__.py
│ ├── 📄weather.py
│ └── 📄search.py
├── 📄 .env ←このファイルをメモ帳で開く
├── 📄 requirements.txt 
└── 📄 README.md

記述できたら「ファイル」→「上書き保存」で保存します。

※このファイルはAPIキーが含まれる為、誰にも公開せず厳重に管理してください。

ステップ⑤ 各.pyファイルを編集する

ここから各.pyファイルをメモ帳で開いて編集していきます。コピペでOKです。

すべてのファイルで記述できたらメモ帳の「ファイル」→「上書き保存」で保存してください。

main.py

"""
AIエージェント エントリーポイント
Usage: python main.py
"""

from agent import run_agent


def main():
    print("=" * 60)
    print("🤖 AIエージェント (Groq + 天気 + Web検索)")
    print("=" * 60)
    print("終了するには 'quit' または 'exit' と入力してください。")
    print("会話をリセットするには 'reset' と入力してください。")
    print("-" * 60)

    conversation_history = []

    while True:
        try:
            user_input = input("\n👤 あなた: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\n👋 終了します。")
            break

        if not user_input:
            continue

        if user_input.lower() in ("quit", "exit"):
            print("👋 終了します。")
            break

        if user_input.lower() == "reset":
            conversation_history = []
            print("🔄 会話履歴をリセットしました。")
            continue

        print("\n🤖 エージェント: 考え中...")
        try:
            response, conversation_history = run_agent(user_input, conversation_history)
            print(f"\n🤖 エージェント:\n{response}")
        except Exception as e:
            print(f"❌ エラーが発生しました: {str(e)}")


if __name__ == "__main__":
    main()

agent.py

"""
エージェントのコアロジック(Groq版)
ツールを呼び出しながらGroqと会話するループを管理する
"""

import re
import json
from datetime import datetime
from groq import Groq

from config import GROQ_API_KEY
from tools import ALL_TOOLS, execute_tool


SYSTEM_PROMPT = """You are a helpful AI assistant. Respond to the user in Japanese.
Current datetime: {datetime}

Use the available tools to answer user questions accurately.
- get_weather: retrieve weather info for a city
- web_search: search the web for latest information

Important rules:
- Always use English for tool arguments (city names, search queries).
- ALWAYS invoke tools using the tool_calls API. NEVER output tool calls as plain text or XML tags.
- Do NOT output , ,  or any similar tags.
- Provide final answers in Japanese."""


def _translate_to_english(client: Groq, text: str) -> str:
    """日本語クエリを英語に翻訳する"""
    response = client.chat.completions.create(
        model="moonshotai/kimi-k2-instruct-0905",
        messages=[{
            "role": "user",
            "content": (
                "Translate the following text to English. "
                "Output only the translation, nothing else:\n"
                f"{text}"
            )
        }],
        max_tokens=256,
    )
    return response.choices[0].message.content.strip()


def _contains_raw_tool_call(text: str) -> bool:
    """テキストにツール呼び出しタグが含まれているか確認する"""
    if text is None:
        return False
    patterns = [
        r"",
        r" tuple[str, list]:
    """
    ユーザーのメッセージを処理し、必要に応じてツールを呼び出す。
    Returns: (最終的な返答テキスト, 更新された会話履歴)
    """
    client = Groq(api_key=GROQ_API_KEY)

    # システムプロンプトが履歴にない場合は先頭に追加
    if not conversation_history:
        conversation_history.append({
            "role": "system",
            "content": SYSTEM_PROMPT.format(
                datetime=datetime.now().strftime("%Y-%m-%d %H:%M")
            )
        })

    # 日本語メッセージを英語に翻訳してからエージェントに渡す
    english_message = _translate_to_english(client, user_message)
    print(f"  🌐 翻訳: {user_message} → {english_message}")

    conversation_history.append({
        "role": "user",
        "content": english_message
    })

    retry_count = 0
    MAX_RETRIES = 3

    # エージェントループ
    while True:
        response = client.chat.completions.create(
            model="moonshotai/kimi-k2-instruct-0905",
            messages=conversation_history,
            tools=ALL_TOOLS,
            tool_choice="auto",
            max_tokens=4096,
        )

        message = response.choices[0].message

        # モデルがツール呼び出しをテキストとして出力してしまった場合の対処
        if _contains_raw_tool_call(message.content) and not message.tool_calls:
            retry_count += 1
            if retry_count >= MAX_RETRIES:
                conversation_history.append({
                    "role": "user",
                    "content": "Please answer the question directly in Japanese without using any tools or tags."
                })
            else:
                conversation_history.append({
                    "role": "user",
                    "content": "Do NOT output tool calls as text or XML. Use the tool_calls API properly."
                })
            continue

        # アシスタントの返答を履歴に追加(tool_callsがNoneの場合は含めない)
        assistant_message = {"role": "assistant", "content": message.content}
        if message.tool_calls:
            assistant_message["tool_calls"] = message.tool_calls
        conversation_history.append(assistant_message)

        # ツール呼び出しがない場合 → 最終回答を返す
        if not message.tool_calls:
            return message.content, conversation_history

        # ツール呼び出しがある場合 → 実行して結果を返す
        for tool_call in message.tool_calls:
            tool_name = tool_call.function.name
            tool_input = json.loads(tool_call.function.arguments)

            print(f"  🔧 ツール使用中: {tool_name}({json.dumps(tool_input, ensure_ascii=False)})")
            result_str = execute_tool(tool_name, tool_input)

            # ツール結果を履歴に追加
            conversation_history.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result_str,
            })

        # ループ継続(ツール結果をもとに再度Groqに問い合わせ)

config.py

"""
設定・APIキー管理
環境変数から読み込む(python-dotenvが必要)
"""

import os
from dotenv import load_dotenv

load_dotenv()

GROQ_API_KEY = os.getenv("GROQ_API_KEY", "your-groq-api-key")
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "your-openweather-api-key")
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "your-tavily-api-key")

__init__.py

"""
ツールの一元管理(Groq / OpenAI互換形式)
"""

import json
from tools.weather import get_weather
from tools.search import web_search

# Groqに渡すツール定義(OpenAI互換のJSONスキーマ形式)
ALL_TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "指定した都市の現在の天気情報(気温・湿度・天気・風速など)を取得します。",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "天気を調べたい都市名(例: Tokyo, Osaka, London)"
                    }
                },
                "required": ["city"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "web_search",
            "description": "インターネットで最新情報を検索します。ニュース・調査・知識の確認に使用します。",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "検索クエリ"
                    },
                    "max_results": {
                        "type": "integer",
                        "description": "取得する検索結果の最大数(デフォルト: 5)",
                        "default": 5
                    }
                },
                "required": ["query"]
            }
        }
    },
]

# ツール名 → 関数のマッピング
TOOL_MAP = {
    "get_weather": get_weather,
    "web_search": web_search,
}


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """ツール名と入力に応じて対応する関数を呼び出す"""
    func = TOOL_MAP.get(tool_name)
    if func is None:
        result = {"error": f"未知のツール: {tool_name}"}
    else:
        result = func(**tool_input)
    return json.dumps(result, ensure_ascii=False, indent=2)

search.py

"""
Web検索ツール: Tavily APIを使って最新情報を検索する
"""

import requests
from config import TAVILY_API_KEY


def web_search(query: str, max_results: int = 5) -> dict:
    """インターネットで最新情報を検索します。ニュース・調査・知識の確認に使用します。

    Args:
        query: 検索クエリ
        max_results: 取得する検索結果の最大数(デフォルト: 5)
    """
    url = "https://api.tavily.com/search"
    payload = {
        "api_key": TAVILY_API_KEY,
        "query": query,
        "max_results": max_results,
        "search_depth": "basic",
        "include_answer": True,
    }
    try:
        response = requests.post(url, json=payload, timeout=15)
        response.raise_for_status()
        data = response.json()
        results = []
        for r in data.get("results", []):
            results.append({
                "title": r.get("title", ""),
                "url": r.get("url", ""),
                "content": r.get("content", "")[:500],
            })
        return {
            "answer": data.get("answer", ""),
            "results": results,
        }
    except requests.exceptions.HTTPError as e:
        return {"error": f"Tavily APIエラー: {str(e)}"}
    except Exception as e:
        return {"error": f"Web検索エラー: {str(e)}"}

weather.py

"""
天気ツール: OpenWeatherMap APIを使って現在の天気を取得する
"""

import requests
from config import OPENWEATHER_API_KEY


def get_weather(city: str) -> dict:
    """指定した都市の現在の天気情報(気温・湿度・天気・風速など)を取得します。

    Args:
        city: 天気を調べたい都市名(例: Tokyo, Osaka, London)
    """
    url = "https://api.openweathermap.org/data/2.5/weather"
    params = {
        "q": city,
        "appid": OPENWEATHER_API_KEY,
        "units": "metric",
        "lang": "ja",
    }
    try:
        response = requests.get(url, params=params, timeout=10)
        response.raise_for_status()
        data = response.json()
        return {
            "city": data["name"],
            "country": data["sys"]["country"],
            "temperature": data["main"]["temp"],
            "feels_like": data["main"]["feels_like"],
            "humidity": data["main"]["humidity"],
            "description": data["weather"][0]["description"],
            "wind_speed": data["wind"]["speed"],
        }
    except requests.exceptions.HTTPError:
        if response.status_code == 404:
            return {"error": f"都市 '{city}' が見つかりませんでした。"}
        return {"error": f"天気APIエラー: HTTPステータス {response.status_code}"}
    except Exception as e:
        return {"error": f"天気取得エラー: {str(e)}"}

ステップ⑥ Pythonで仮想環境を作成する

ai_agentフォルダをクリックして何もないところで右クリック→「PowerShell ウィンドウをここで開く」でPowerShellを起動します。この手順で起動すると必ず作業するディレクトリ(カレントディレクトリ)でPowerShellが起動するのでおすすめです。

PowerShellで次のコマンドを実行します。

python -m venv venv

ai_agentフォルダ内にvenvフォルダが作成されていればOKです。

ステップ⑦ 仮想環境を実行する

続けて、PowerShellで次のコマンドを実行します。

.\venv\Scripts\Activate.ps1

(venv)と表示されればOKです。

ステップ⑧ 必要ライブラリをインストールする

続けてPowerShellで下のコマンドを実行します。

pip install -r requirements.txt

これで必要ライブラリのインストールは完了です。

ステップ⑨ main.pyを実行する

続けてPowerShellで次のコマンドを実行します。

python main.py

下画像のように「あなた」と表示されればOKです。

任意の質問を入力してキーボードのEnterを押すとAIが返答します。

これでAIエージェントの作成は完了です。



AIエージェントの終了の仕方

基本的にPowerShellで何か作業中(例:Pythonスクリプトを走らせている場合)やライブラリなどのダウンロード中でなければ、PowerShellウィンドウの❌閉じるボタンから終了してもOKです。

おすすめの確実な終了方法は、PowerShell内でキーボードのCtrl + Cですべての処理を中断させてから、deactivateで仮想環境から離脱後にexitでPowerShellを終了します。

Pythonにおけるdeactivateは、「仮想環境(Virtual Environment)から抜け出す」ためのコマンドです。

Pythonで開発をしていると、プロジェクトごとにライブラリを使い分けるために仮想環境(venvなど)を作成しますが、その「隔離された空間」での作業を終了して、通常の(システム全体で使う)Python環境に戻る際に使用します。

次にAIエージェントを起動する時は、まず仮想環境に入ってから実行しましょう。



AIエージェントのデスクトップアプリ化について

本記事で作成したAIエージェントもデスクトップアプリ化が可能です。

PowerShellで次のコマンドを実行します。仮想環境から実行するのを忘れずに。

pip install pyinstaller
pyinstaller --onefile main.py

実行するとai_agentフォルダにdistフォルダが作成されて、その中にmain.exeが生成されます。それをクリックすると起動します。

GUI版AIエージェントを作成する方法

コマンドラインからではなく、ChatGPTのような画面で操作したい場合は、customtkinterと言うライブラリを使えば作ることができます。

ai_agentフォルダの中に新しくapp.pyと言うファイルを作成します。

PowerShellで次のコマンドを実行。(仮想環境で実行)

New-Item app.py

ai_agentフォルダにapp.pyが作成されます。app.pyをメモ帳で開いて次のコードをコピペして上書き保存します。

"""
AIエージェント - CustomTkinter GUI版
Usage: python app.py
"""

import threading
import customtkinter as ctk
from agent import run_agent

# ============================================================
# カラーパレット(ダーク / ライト)
# ============================================================
PALETTES = {
    "dark": {
        "bg_main":      "#0f1117",
        "bg_sidebar":   "#1a1d27",
        "bg_chat":      "#13161f",
        "bg_input":     "#1e2130",
        "bubble_ai":    "#1e2a3a",
        "bubble_user":  "#1a3a5c",
        "accent":       "#4a9eff",
        "accent_hover": "#6ab4ff",
        "text_main":    "#e8eaf0",
        "text_sub":     "#7a8099",
        "text_tool":    "#f0a050",
        "border":       "#2a2d3e",
    },
    "light": {
        "bg_main":      "#f0f2f8",
        "bg_sidebar":   "#e2e6f0",
        "bg_chat":      "#f7f8fc",
        "bg_input":     "#ffffff",
        "bubble_ai":    "#dde8f5",
        "bubble_user":  "#c5daf7",
        "accent":       "#1a6fd4",
        "accent_hover": "#1558b0",
        "text_main":    "#1a1d2e",
        "text_sub":     "#6b7280",
        "text_tool":    "#b45a00",
        "border":       "#c8cfe0",
    }
}

# 現在のパレット(グローバル参照)
COLORS = dict(PALETTES["dark"])


def switch_palette(mode: str):
    """COLORSをモードに合わせて更新する"""
    COLORS.update(PALETTES[mode])


# ============================================================
# メッセージバブルウィジェット
# ============================================================
class MessageBubble(ctk.CTkFrame):
    def __init__(self, parent, role: str, text: str, **kwargs):
        super().__init__(parent, fg_color="transparent", **kwargs)

        is_user = role == "user"
        bubble_color = COLORS["bubble_user"] if is_user else COLORS["bubble_ai"]
        label_text = "あなた" if is_user else "AI"
        label_color = COLORS["accent"] if is_user else COLORS["text_tool"]

        outer = ctk.CTkFrame(self, fg_color="transparent")
        outer.pack(fill="x", padx=8, pady=3)

        name_label = ctk.CTkLabel(
            outer,
            text=label_text,
            font=ctk.CTkFont(family="Consolas", size=10, weight="bold"),
            text_color=label_color,
        )
        name_label.pack(anchor="e" if is_user else "w", padx=16)

        bubble = ctk.CTkFrame(
            outer,
            fg_color=bubble_color,
            corner_radius=14,
            border_width=1,
            border_color=COLORS["border"]
        )
        bubble.pack(anchor="e" if is_user else "w", padx=12, pady=(2, 6))

        ctk.CTkLabel(
            bubble,
            text=text,
            font=ctk.CTkFont(family="Yu Gothic UI", size=13),
            text_color=COLORS["text_main"],
            wraplength=480,
            justify="left",
            anchor="w"
        ).pack(padx=16, pady=10)


# ============================================================
# ツール使用インジケーター
# ============================================================
class ToolIndicator(ctk.CTkFrame):
    def __init__(self, parent, tool_text: str, **kwargs):
        super().__init__(parent, fg_color="transparent", **kwargs)
        frame = ctk.CTkFrame(
            self,
            fg_color=COLORS["bg_input"],
            corner_radius=8,
            border_width=1,
            border_color=COLORS["border"]
        )
        frame.pack(anchor="w", padx=20, pady=2)
        ctk.CTkLabel(
            frame,
            text=f"  🔧 {tool_text}  ",
            font=ctk.CTkFont(family="Consolas", size=11),
            text_color=COLORS["text_tool"]
        ).pack(padx=4, pady=4)


# ============================================================
# メインアプリ
# ============================================================
class App(ctk.CTk):
    def __init__(self):
        super().__init__()

        self.title("AI Agent")
        self.geometry("780x620")
        self.minsize(600, 480)
        self._mode = "dark"
        ctk.set_appearance_mode("dark")
        ctk.set_default_color_theme("blue")
        self.configure(fg_color=COLORS["bg_main"])

        self.conversation_history = []
        self.is_processing = False

        self._build_ui()

    # ----------------------------------------------------------
    # UI構築
    # ----------------------------------------------------------
    def _build_ui(self):
        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(1, weight=1)
        self._build_header()
        self._build_chat_area()
        self._build_input_area()

    def _build_header(self):
        self.header = ctk.CTkFrame(
            self, fg_color=COLORS["bg_sidebar"],
            corner_radius=0, height=56, border_width=0
        )
        self.header.grid(row=0, column=0, sticky="ew")
        self.header.grid_propagate(False)
        self.header.grid_columnconfigure(1, weight=1)

        ctk.CTkLabel(
            self.header,
            text="⬡  AI Agent",
            font=ctk.CTkFont(family="Consolas", size=16, weight="bold"),
            text_color=COLORS["accent"]
        ).grid(row=0, column=0, padx=20, pady=16, sticky="w")

        btn_frame = ctk.CTkFrame(self.header, fg_color="transparent")
        btn_frame.grid(row=0, column=2, padx=12, pady=10, sticky="e")

        self.theme_btn = ctk.CTkButton(
            btn_frame, text="☀", width=36, height=32,
            font=ctk.CTkFont(size=14),
            fg_color=COLORS["bg_input"],
            hover_color=COLORS["border"],
            corner_radius=8,
            command=self._toggle_theme
        )
        self.theme_btn.pack(side="left", padx=4)

        self.reset_btn = ctk.CTkButton(
            btn_frame, text="リセット", width=72, height=32,
            font=ctk.CTkFont(family="Yu Gothic UI", size=12),
            fg_color=COLORS["bg_input"],
            hover_color=COLORS["border"],
            text_color=COLORS["text_sub"],
            corner_radius=8,
            command=self._reset_chat
        )
        self.reset_btn.pack(side="left", padx=4)

    def _build_chat_area(self):
        self.chat_scroll = ctk.CTkScrollableFrame(
            self, fg_color=COLORS["bg_chat"],
            corner_radius=0,
            scrollbar_button_color=COLORS["border"],
            scrollbar_button_hover_color=COLORS["accent"]
        )
        self.chat_scroll.grid(row=1, column=0, sticky="nsew")
        self.chat_scroll.grid_columnconfigure(0, weight=1)

        self.welcome_label = ctk.CTkLabel(
            self.chat_scroll,
            text="天気・Web検索について何でも聞いてください",
            font=ctk.CTkFont(family="Yu Gothic UI", size=12),
            text_color=COLORS["text_sub"]
        )
        self.welcome_label.pack(pady=(24, 8))

    def _build_input_area(self):
        self.input_frame = ctk.CTkFrame(
            self, fg_color=COLORS["bg_sidebar"],
            corner_radius=0, height=76
        )
        self.input_frame.grid(row=2, column=0, sticky="ew")
        self.input_frame.grid_propagate(False)
        self.input_frame.grid_columnconfigure(0, weight=1)

        inner = ctk.CTkFrame(self.input_frame, fg_color="transparent")
        inner.pack(fill="x", padx=16, pady=14)
        inner.grid_columnconfigure(0, weight=1)

        self.input_box = ctk.CTkEntry(
            inner,
            placeholder_text="メッセージを入力...  (Enterで送信)",
            font=ctk.CTkFont(family="Yu Gothic UI", size=13),
            fg_color=COLORS["bg_input"],
            border_color=COLORS["border"],
            border_width=1,
            text_color=COLORS["text_main"],
            placeholder_text_color=COLORS["text_sub"],
            corner_radius=10, height=40
        )
        self.input_box.grid(row=0, column=0, sticky="ew", padx=(0, 10))
        self.input_box.bind("", lambda e: self._send_message())

        self.send_btn = ctk.CTkButton(
            inner, text="送信", width=72, height=40,
            font=ctk.CTkFont(family="Yu Gothic UI", size=13, weight="bold"),
            fg_color=COLORS["accent"],
            hover_color=COLORS["accent_hover"],
            text_color="#ffffff",
            corner_radius=10,
            command=self._send_message
        )
        self.send_btn.grid(row=0, column=1)

    # ----------------------------------------------------------
    # テーマ切替
    # ----------------------------------------------------------
    def _toggle_theme(self):
        self._mode = "light" if self._mode == "dark" else "dark"
        switch_palette(self._mode)
        ctk.set_appearance_mode(self._mode)

        # アイコン更新
        self.theme_btn.configure(text="🌙" if self._mode == "light" else "☀")

        # 主要フレームの背景色を更新
        self.configure(fg_color=COLORS["bg_main"])
        self.header.configure(fg_color=COLORS["bg_sidebar"])
        self.chat_scroll.configure(fg_color=COLORS["bg_chat"])
        self.input_frame.configure(fg_color=COLORS["bg_sidebar"])

        # ヘッダーボタン
        self.theme_btn.configure(fg_color=COLORS["bg_input"], hover_color=COLORS["border"])
        self.reset_btn.configure(fg_color=COLORS["bg_input"], hover_color=COLORS["border"], text_color=COLORS["text_sub"])

        # 入力欄・送信ボタン
        self.input_box.configure(
            fg_color=COLORS["bg_input"],
            border_color=COLORS["border"],
            text_color=COLORS["text_main"],
            placeholder_text_color=COLORS["text_sub"]
        )
        self.send_btn.configure(fg_color=COLORS["accent"], hover_color=COLORS["accent_hover"])

        # ウェルカムラベル
        if hasattr(self, "welcome_label"):
            self.welcome_label.configure(text_color=COLORS["text_sub"])

    # ----------------------------------------------------------
    # イベント処理
    # ----------------------------------------------------------
    def _send_message(self):
        if self.is_processing:
            return
        text = self.input_box.get().strip()
        if not text:
            return
        self.input_box.delete(0, "end")
        self._add_bubble("user", text)
        self._set_processing(True)
        threading.Thread(target=self._call_agent, args=(text,), daemon=True).start()

    def _call_agent(self, text: str):
        import builtins
        original_print = builtins.print

        def intercepted_print(*args, **kwargs):
            msg = " ".join(str(a) for a in args)
            if "ツール使用中:" in msg:
                tool_info = msg.replace("🔧 ツール使用中:", "").strip()
                self.after(0, lambda t=tool_info: self._add_tool_indicator(t))
            original_print(*args, **kwargs)

        builtins.print = intercepted_print
        try:
            response, self.conversation_history = run_agent(text, self.conversation_history)
            self.after(0, lambda: self._add_bubble("ai", response))
        except Exception as e:
            err_msg = str(e)
            self.after(0, lambda m=err_msg: self._add_bubble("ai", f"❌ エラー: {m}"))
        finally:
            builtins.print = original_print
            self.after(0, lambda: self._set_processing(False))

    def _reset_chat(self):
        self.conversation_history = []
        for widget in self.chat_scroll.winfo_children():
            widget.destroy()
        self.welcome_label = ctk.CTkLabel(
            self.chat_scroll,
            text="会話をリセットしました",
            font=ctk.CTkFont(family="Yu Gothic UI", size=12),
            text_color=COLORS["text_sub"]
        )
        self.welcome_label.pack(pady=(24, 8))

    # ----------------------------------------------------------
    # UI更新ヘルパー
    # ----------------------------------------------------------
    def _add_bubble(self, role: str, text: str):
        bubble = MessageBubble(self.chat_scroll, role=role, text=text)
        bubble.pack(fill="x", pady=2)
        self._scroll_to_bottom()

    def _add_tool_indicator(self, tool_text: str):
        indicator = ToolIndicator(self.chat_scroll, tool_text=tool_text)
        indicator.pack(fill="x", pady=1)
        self._scroll_to_bottom()

    def _set_processing(self, state: bool):
        self.is_processing = state
        if state:
            self.send_btn.configure(text="...", state="disabled", fg_color=COLORS["border"])
            self.input_box.configure(state="disabled")
        else:
            self.send_btn.configure(text="送信", state="normal", fg_color=COLORS["accent"])
            self.input_box.configure(state="normal")
            self.input_box.focus()

    def _scroll_to_bottom(self):
        self.after(50, lambda: self.chat_scroll._parent_canvas.yview_moveto(1.0))


# ============================================================
# 起動
# ============================================================
if __name__ == "__main__":
    app = App()
    app.mainloop()

PoweShellで次のコマンドを実行します。(仮想環境で実行)

pip install customtkinter

GUI版AIエージェントを起動。

python app.py

下画像のようにGUI版のAIエージェントが起動します。

GUI版AIエージェントをアプリ化したい場合は、再度デスクトップアプリ化の手順を実行する必要があります。

その場合はmain.pyの部分をapp.pyに変更して実行してください。


ワンクリックで仮想環境からGUI版AIエージェントを起動する方法

GUI版AIエージェントをアプリ化すればapp.exeからすぐに起動できますがデメリットもあります。

  • ファイルサイズが大きくなる
  • デバッグしにくい
  • アップデートやライブラリ差し替えが面倒

あとで管理がしやすいのはBatファイルを作成する方法です。この方法で起動すると仮想環境でapp.pyが一発起動します。

  • ✅ 柔軟性が高い
  • ✅ 軽量
  • ✅ デバッグしやすい

Batファイルの作成ですが、ai_agentフォルダの中にlaunch.batを追加するコマンドをPowerShellで実行するだけです。ディレクトリはどこから実行してもOKです。

@"
@echo off
cd /d %~dp0
call venv\Scripts\activate
python app.py
"@ | Out-File -FilePath launch.bat -Encoding oem

ai_agentフォルダの中に作成されたlaunch.batをダブルクリックするとGUI版アプリが起動します。

リクエスト制限を回避する使い方

今回使用しているAPIですが、GroqとOpenWeatherMapの二つは比較的余裕があります。しかし、Web検索を処理するTavilyAPIがネックとなる可能性があります。

リクエスト制限を回避する使い方としては、

  • Grok(思考)
  • OpenWeather(天気)
  • 検索は必要な時だけ

と言うように、なるべくWeb検索を少なくするのがポイントです。

キャッシュ(同じ質問は再検索しない)したり、天気はAPIだけで返す(検索しない)と言った使い方がベストです。

例えば、「東京の天気は?」「今日の大阪の気温は?」「今日の名古屋の湿度は?」と天気に関する質問をすると、Groqが自動で天気に関する質問としてget_weatherを選びます。

「今日のドル円は?」「Pythonの最新バージョンは?」などの質問をするとweb_searchが選択されてTavilyのリクエストがカウントされます。

この仕組みを覚えておけば、リクエスト制限をコントロールしながらAIエージェントを使えます。


会話履歴の保持について

本記事で作成したAIエージェントを起動中は会話履歴をメモリに保持しています。

ただし以下の場合はリセットされます。

操作 履歴
「リセット」ボタンを押す 消える
ウィンドウを閉じて再起動 消える
resetとチャットで入力 消える

リセットしても会話履歴を保持する方法もありますが、また別記事で書くことにします。



よくあるエラーと対処法

私が実際に遭遇したエラーと解決策をまとめました。429・503・400エラーの原因と対処法を順番に解説します。

429 RESOURCE_EXHAUSTED|無料枠の上限超過

AIエージェントを動かしていると最初に遭遇しやすいのがこのエラーです。無料枠の1日あたりのリクエスト数を超えた場合に発生します。エラーメッセージにretryDelayが含まれている場合は記載された秒数待つと回復します。根本的な解決策はモデルの変更か有料プランへの移行です。

503 UNAVAILABLE|サーバー混雑

モデルのサーバーが一時的に高負荷状態のときに発生します。コードに問題はないため、数分待ってから再試行するだけで解決するケースがほとんどです。頻繁に発生する場合は軽量モデルに切り替えると安定します。

404 NOT_FOUND|モデルが廃止されている

指定したモデル名が廃止・変更されている場合に発生します。AIのモデルは頻繁に更新・廃止されるため、エラーが出たら公式ドキュメントで現在利用可能なモデル名を確認してください。

400 tool_use_failed|ツール呼び出しのJSON生成失敗

モデルがツール呼び出しのフォーマットを正しく生成できなかった場合に発生します。failed_generationの中身を見ると崩れたJSONが含まれています。原因はモデルの不安定さか、日本語のクエリをそのままツールに渡していることです。システムプロンプトで「ツール引数は英語で渡すこと」と明示することで改善できます。

400 messages.tool_calls is not nullable|tool_callsにNullが入っている

ツールを使わない返答のときもtool_calls: Noneを会話履歴に含めてしまうと発生します。tool_callsが存在するときだけ履歴に追加するよう条件分岐を追加することで解決できます。

エラーではないが文字化け|絵文字がクエスチョンマークになる

コードの問題ではなく、Windowsのデフォルトターミナルが絵文字に未対応なだけです。Windows Terminalをインストールするか、コード内の絵文字をテキスト(例: [AI])に置き換えることで解決します。

本記事のコードは上記のエラーを修正していますので、そのままコピペで動作するはずです。


まとめ

本記事では、3つのAPIを使った無料のPython AIエージェントの作り方を解説しました。

本記事で作成したAIエージェントはGorqAPIを使用しているため、GeminiAPIのようにリクエスト制限エラーやモデルエラーが発生する確率は低くなっています。

GeminiAPIで安定しない場合や、無料で使えるエージェントを探している方はぜひ試してみてください。

PowerShellやPythonをまったく触ったことのない人でも、本記事のコードやコマンドはコピペ形式なので、ステップをゆっくり焦らず進めていけばAIエージェントを作成できるはずです。

本記事でご紹介したAIエージェントの作り方を実践すれば、自然にPowerShellの使い方やPythonの使い方も学ぶことができます。

私自身、プログラマーでもなければ、普段から頻繁にPowerShellやPythonを使っているわけではありません。

今はAIがありますので、AIにコードを修正してもらいながら学ぶことも可能になりました。

上手くAIを活用することで趣味や学びも捗ります。

この記事をシェアする

カテゴリー