🤖 AIエージェントって結局なに?

皆さんこんにちは。今日は、AIエージェントについてわかりやすく共有したいと思います!

「エージェント」という言葉、最近よく見ますよね。LangChain、CrewAI、LangGraph、OpenAI Agents SDK……名前が多すぎて、どこから入ればいいのかわからない人も多いと思います。でも本質はかなりシンプルです。AIエージェントは、LLM(頭脳)とツール(手足)、そしてループ(考えて、動いて、また考える)の組み合わせで動きます。

この記事では、この中身をフレームワークなしで一緒に組み立てていきます。
つまり、「ライブラリの使い方」を覚えるのではなく、「エージェントの正体」を理解するのが目的です。


誰のための記事か?

  • AIエージェントの仕組みをちゃんと理解したい人
  • API課金なしで試したい人
  • LangChain系を触る前に基礎を固めたい人
  • Pythonでまず小さく動くものを作りたい人

環境を整える!?

まずは動かすところまで一気にやりましょう! 必要なのは以下です。

  • macOS
  • Python 3.9以上
  • Homebrew

Step 1:Ollamaをインストール

brew install --cask ollama

⚠️ 重要: Cask版をインストールするとmacOS アプリが自動起動し、ポート11434が占有される場合があります。その場合、Step 2の `ollama serve` は実行 不要です。CLI のみで管理したい場合は `brew install ollama`(cask なし)を使い、その後 `ollama serve` を実行してください。


Step 2:Ollamaサーバーを起動

このターミナルは起動したままにしておいてください。

ollama serve

Step 3:モデルを取得

ollama pull qwen2.5

モデル選択の目安
このチュートリアルでは、軽量で動作確認しやすい qwen2.5:1.5b(約1GB)をデフォルトとしています。ツール呼び出しや推論の安定性を高めたい場合は、より大型のモデルに切り替えてください。

ollama pull qwen2.5      # より安定(約4.7GB)
ollama pull qwen3.5         # 最新・高性能(約6.6GB、デフォルトは9Bモデル)


Step 4:Pythonファイルを作る

後半にあるフルコードを ai_agent_from_scratch.py として保存します。

Step 5:実行

python3 ai_agent_from_scratch.py

ここまでで準備完了です。では本題に入ります。

まずは完成形のイメージを見てみましょう ↑

📌 コンポーネント1:LLMと指示

最初に作るのは、エージェントの頭脳です。
ここではOllamaのAPIを叩くクラスと、エージェントに守らせたいルールを書きます。

LLMと指示のコードを開く
import re
import urllib.request
import urllib.error
from typing import Any, Dict, List

OLLAMA_URL = "http://localhost:11434/api/chat"
OLLAMA_MODEL = "qwen3.5"

class Agent:
    def __init__(self, model: str = OLLAMA_MODEL):
        self.model = model
        self.system_message = (
            "You are a helpful assistant. Always answer in Japanese. "
            "Break problems into steps and solve systematically. "
            "If asked what YOU own or possess, answer naturally that as an AI "
            "you have no physical possessions. "
            "For any arithmetic, age calculation, or numeric reasoning, "
            "you must use the calculator tool before giving a final answer."
        )

ここで覚えておいてほしいのは、system_messageがエージェントの性格とルールを決めるということです。

  • 常に日本語で答える
  • 数値問題はツールを使う
  • AIなのに物を持っているふりをしない

この指示が曖昧だと、出力もぶれます。「モデルが賢ければ何とかなる」は、実際にはかなり危険です。


📌 コンポーネント2:メモリ

次に記憶です。
会話履歴を持たないと、エージェントは毎回その場限りで受け答えします。

これでは会話になりません。なので、履歴を全部 messages に入れて毎回送ります。

Agentクラスのコードを開く
class Agent:
    def __init__(self, model: str = OLLAMA_MODEL):
        self.model = model
        self.system_message = "..."
        self.messages: List[Dict[str, Any]] = [
            {"role": "system", "content": self.system_message}
        ]

    def chat(self, message: Any) -> Dict[str, Any]:
        if isinstance(message, str):
            self.messages.append({"role": "user", "content": message})
        else:
            self.messages.append(message)

        response = self._call_ollama()
        assistant_message = response.get("message", {})
        self.messages.append(assistant_message)
        return response

これだけです。
「記憶機能」と聞くと大げさに見えますが、最初はただのリストです。

覚えておきたいポイント:
長い会話では履歴が肥大化するので、実運用では要約や古い履歴の圧縮が必要になります。


📌 コンポーネント3:ツール

ここが一番エージェントらしい部分です。
LLMは文章生成は得意ですが、計算は意外と不安定です。たとえば次の計算をそのまま聞くと、もっともらしい間違いを返すことがあります。

157.09 × 493.89 = ?

こういうときは、電卓ツールを使わせます。

calculator関数のコードを開く
def calculator(expression: str) -> Dict[str, Any]:
    try:
        allowed_chars = set("0123456789+-*/()., ")
        if not all(c in allowed_chars for c in expression):
            return {"error": "不正な文字が含まれています"}
        result = eval(expression, {"__builtins__": {}}, {})
        return {"result": result}
    except Exception:
        return {"error": "無効な数式です"}

⚠️ セキュリティに関する重要な注記:このコードは学習目的のローカル実行専用です。本番環境では絶対に eval() を使わないでください。予期しないコード実行やサービス拒否につながる可能性があります。代わりに以下を使用してください。

  • ast.literal_eval() — 安全なリテラル評価
  • sympy.sympify() — 数式評価用ライブラリ
  • simpleeval — より安全な評価エンジン

覚えておいてほしいこと
LLMはツールを自分で実行しているわけではありません。
「このツールを、この引数で使いたい」と宣言しているだけで、実際に動かすのはPython側です。

ツールの説明書(スキーマ)

ツールスキーマのコードを見る
TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "calculator",
            "description": "基本的な数学計算を実行します。単純な足し算にも使用してください。",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "計算する数式",
                    }
                },
                "required": ["expression"],
            },
        },
    }
]

TOOL_MAP = {
    "calculator": calculator,
}

このスキーマがないと、モデルはそのツールの存在を知りません。
つまり、ツールそのものとツールの説明書はセットです。


📌 コンポーネント4:エージェントループ

ここが最後の大事な部品です。
ツールを持っていても、ループがなければエージェントは途中で止まります。

run_agent関数のコードを開く
def run_agent(user_input: str, max_turns: int = 10) -> str:
    agent = Agent(model=OLLAMA_MODEL)
    current_input: Any = user_input

    for i in range(1, max_turns + 1):
        response = agent.chat(current_input)
        message = response.get("message", {})
        text = message.get("content", "")
        tool_calls = message.get("tool_calls", [])

        if tool_calls:
            tool_result_messages = []
            for tc in tool_calls:
                fn_info = tc.get("function", {})
                tool_name = fn_info.get("name", "")
                tool_args = fn_info.get("arguments", {})
                tool_fn = TOOL_MAP.get(tool_name)
                result = tool_fn(**tool_args) if tool_fn else {"error": "ツールが見つかりません"}
                tool_result_messages.append({
                    "role": "tool",
                    "tool_name": tool_name,
                    "content": json.dumps(result, ensure_ascii=False),
                })
            current_input = tool_result_messages[0]
        else:
            return text

    return "[警告] 最大ターン数に達しました"

max_turns を置いているのは、無限ループ防止です。これは小さい実装でも必ず入れておいた方がいいです。


フルコード

以下はそのまま保存して動かせる完全版です。ファイル名は ai_agent_from_scratch.py にしてください。

📦 フルコードを開く
import json
import re
import urllib.request
import urllib.error
from typing import Any, Dict, List

OLLAMA_URL = "http://localhost:11434/api/chat"
OLLAMA_MODEL = "qwen2.5:1.5b"

def calculator(expression: str) -> Dict[str, Any]:
    try:
        allowed_chars = set("0123456789+-*/()., ")
        if not all(c in allowed_chars for c in expression):
            return {"error": "不正な文字が含まれています"}
        result = eval(expression, {"__builtins__": {}}, {})
        return {"result": result}
    except Exception:
        return {"error": "無効な数式です"}

TOOL_MAP = {"calculator": calculator}

TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "calculator",
            "description": "基本的な数学計算を実行します。単純な足し算にも使用してください。",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "計算する数式 (例: '2+2', '10*5', '157.09 * 493.89')",
                    }
                },
                "required": ["expression"],
            },
        },
    }
]

class Agent:
    def __init__(self, model: str = OLLAMA_MODEL):
        self.model = model
        self.system_message = (
            "You are a helpful assistant. Always answer in Japanese. "
            "Break problems into steps and solve systematically. "
            "If asked what YOU own or possess, answer naturally that as an AI you have no physical possessions. "
            "For any arithmetic, age calculation, or numeric reasoning, you must use the calculator tool before giving a final answer."
        )
        self.messages: List[Dict[str, Any]] = [
            {"role": "system", "content": self.system_message}
        ]

    def _call_ollama(self) -> Dict[str, Any]:
        payload = {
            "model": self.model,
            "messages": self.messages,
            "stream": False,
            "tools": TOOL_SCHEMAS,
            "options": {"temperature": 0.1},
        }
        data = json.dumps(payload).encode("utf-8")
        req = urllib.request.Request(
            OLLAMA_URL,
            data=data,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=120) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.URLError as exc:
            raise RuntimeError("Ollama に接続できません。`ollama serve` が起動中か確認してください。") from exc

    def chat(self, message: Any) -> Dict[str, Any]:
        if isinstance(message, str):
            self.messages.append({"role": "user", "content": message})
        else:
            self.messages.append(message)
        response = self._call_ollama()
        self.messages.append(response.get("message", {}))
        return response

def _parse_age_problem(prompt: str) -> Dict[str, int]:
    self_age = re.search(r"私(?:が|は)?\s*(\d+)\s*歳", prompt)
    mother_older = re.search(r"母(?:が|は)?.*?(\d+)\s*歳年上", prompt)
    brother_younger = re.search(r"兄(?:が|は)?.*?母.*?(\d+)\s*歳若", prompt)
    if not (self_age and mother_older and brother_younger):
        return {}
    return {
        "self_age": int(self_age.group(1)),
        "mother_older": int(mother_older.group(1)),
        "brother_younger": int(brother_younger.group(1)),
    }

def _extract_first_int(text: str):
    m = re.search(r"-?\d+", text)
    return int(m.group(0)) if m else None

def _build_age_correction(age_data: Dict[str, int]) -> str:
    a, b, c = age_data["self_age"], age_data["mother_older"], age_data["brother_younger"]
    mother = calculator(f"{a}+{b}").get("result")
    brother = calculator(f"{mother}-{c}").get("result")
    return (
        f"検算します。\n"
        f"- 母の年齢: {a} + {b} = {mother}\n"
        f"- 兄の年齢: {mother} - {c} = {brother}\n"
        f"したがって、兄は {brother} 歳です。"
    )

def _is_possession_question(prompt: str) -> bool:
    return "あなたは" in prompt and "何個" in prompt and "持って" in prompt

def _build_possession_correction() -> str:
    return "私はAIなので物理的な持ち物はありません。そのため、私が持っている数は0個です。"

def run_agent(user_input: str, max_turns: int = 10) -> str:
    agent = Agent(model=OLLAMA_MODEL)
    current_input: Any = user_input
    age_data = _parse_age_problem(user_input)
    possession_question = _is_possession_question(user_input)

    for i in range(1, max_turns + 1):
        print(f"\n{'=' * 50}")
        print(f"  イテレーション {i}")
        print(f"{'=' * 50}")
        print(f"[ユーザー入力]: {current_input}")

        response = agent.chat(current_input)
        message = response.get("message", {})
        text = message.get("content", "")
        tool_calls = message.get("tool_calls", [])

        if text:
            print(f"[エージェント]: {text}")

        if tool_calls:
            tool_result_messages = []
            for tc in tool_calls:
                fn_info = tc.get("function", {})
                tool_name = fn_info.get("name", "")
                tool_args = fn_info.get("arguments", {})
                print(f"\n[ツール呼び出し] {tool_name}({tool_args})")
                tool_fn = TOOL_MAP.get(tool_name)
                result = tool_fn(**tool_args) if tool_fn else {"error": "ツールが見つかりません"}
                print(f"[ツール結果] {result}")
                tool_result_messages.append({
                    "role": "tool",
                    "tool_name": tool_name,
                    "content": json.dumps(result, ensure_ascii=False),
                })
            current_input = (
                tool_result_messages[0]
                if len(tool_result_messages) == 1
                else {"role": "user", "content": json.dumps(tool_result_messages, ensure_ascii=False)}
            )
        else:
            if possession_question:
                corrected = _build_possession_correction()
                print(f"[補正後]: {corrected}")
                return corrected
            if age_data:
                expected = age_data["self_age"] + age_data["mother_older"] - age_data["brother_younger"]
                if _extract_first_int(text) != expected:
                    corrected = _build_age_correction(age_data)
                    print(f"[検算後]: {corrected}")
                    return corrected
            return text

    return "[警告] 最大ターン数に達しました"

if __name__ == "__main__":
    print("\n" + "★" * 60)
    print("  テスト 1: 一般的な質問(ツール不使用)")
    print("★" * 60)
    result = run_agent("私はリンゴを4個持っています。あなたは何個持っていますか?")
    print(f"\n[最終回答]\n{result}")

    print("\n" + "★" * 60)
    print("  テスト 2: 計算ツールを使う")
    print("★" * 60)
    result = run_agent("157.09 × 493.89 はいくつですか?")
    print(f"\n[最終回答]\n{result}")

    print("\n" + "★" * 60)
    print("  テスト 3: 複数ステップのツール使用")
    print("★" * 60)
    result = run_agent(
        "私は20歳です。母は私より30歳年上で、兄は母より32歳若いです。兄は何歳ですか?"
    )
    print(f"\n[最終回答]\n{result}")

フルコードに含まれる補正ロジックについて

以下のフルコードには、上記コンポーネント解説では説明されていない補正ヘルパー関数が含まれています:

  • _parse_age_problem() — 年齢問題を検出して数値を抽出
  • _build_age_correction() — LLMの回答が誤っていた場合に検算結果で上書き
  • _is_possession_question() — 「あなたは何個持ってる?」パターンを検出
  • _build_possession_correction() — 所有問題の答えを返す

これらが必要な理由:小型モデル(例: `qwen2.5:1.5b`)では計算精度や推論能力が限定的です。テスト3(年齢計算)がうまく動作しているのは、エージェントが完璧に推論しているのではなく、Python側で誤答を検出して補正しているためです。`qwen3.5` など上位モデルでは補正に頼る場面を減らせます。

本番用途では、これらの補正ロジックを削除するか、より大型のモデルに切り替えることをお勧めします。

実行結果は以下のようになります


応答が返ってこない場合の対処方法

ollama ps
ollama list

接続エラー(Connection refused)

ollama serve が起動していない可能性が高いです。別ターミナルで起動すること。


次のステップ

  1. Web検索ツールを追加する
  2. 会話履歴を要約する仕組みを入れる
  3. LangGraphやCrewAIに移って比較する
  4. 複数エージェントに役割分担させる

関連リンク

まとめ

今日やったことは、次の4つです。

  • LLMと指示で、エージェントの基本ルールを作った
  • メモリで、会話履歴を持てるようにした
  • ツールで、計算を確実にした
  • ループで、考える→使う→返すをつないだ

フレームワークを使う前にここまで理解しておくと、後から何を触っても強いです。
次は、あなた専用のツールを1つ足してみてください。そこから一気に面白くなります。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる