
🤖 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 が起動していない可能性が高いです。別ターミナルで起動すること。
次のステップ
- Web検索ツールを追加する
- 会話履歴を要約する仕組みを入れる
- LangGraphやCrewAIに移って比較する
- 複数エージェントに役割分担させる
関連リンク
まとめ
今日やったことは、次の4つです。
- LLMと指示で、エージェントの基本ルールを作った
- メモリで、会話履歴を持てるようにした
- ツールで、計算を確実にした
- ループで、考える→使う→返すをつないだ
フレームワークを使う前にここまで理解しておくと、後から何を触っても強いです。
次は、あなた専用のツールを1つ足してみてください。そこから一気に面白くなります。
