この記事について
昨今「AIエージェント」という言葉が流行し、さまざまな場面で見聞きするようになりました。AIエージェントの作り方を解説した記事も数多くありますが、その多くはフレームワークを用いた実装方法の紹介に寄っています。フレームワークを使えば少ないコードで手早く形にできる一方で、内部で何が起きているのか、どのような仕組みで動いているのかを掴みにくいという課題があります。
そこで本記事では、フレームワークを使わず最小構成でAIエージェントを実装し、動作の「手触り感」を持って仕組みを理解することを目指します。
記事内で紹介している、実装したコードは下記に置いています。
https://github.com/raucha/simple-ai-agent
※ 0からと書いていますが、LLM本体は実装しません(一応)。APIを利用します。
AIエージェントの定義
何をAIエージェントと呼ぶかは色々な説明があり、まだ各人のイメージも揺れているように思いますが、概ね下記のようなものをAIエージェントと呼ぶようです。
与えられた目標を達成するために、状況判断や外部環境とのインタラクションを自律的に行うAIシステム。
最近のAIエージェントという単語が出てくる文脈では、一般的には内部でLLMが利用されます。
LLMとAIエージェントの間にあるギャップ
一方、LLMは突き詰めると 「文字列を入力として受け取り、文字列を出力するステートレスな関数」 というモデルと解釈できます。
LLM単体では外部環境とのインタラクション手段を持たないため自律的に情報を取りに行ったり何かを実行したりすることはできません。また、ステートレスで情報を保持することができないので、例えば「調査して結果をレポートにまとめる」といった複数段に分けた処理ステップを実行することができません。
LLMをAIエージェントとして利用するには、このあたりの課題を解決する必要があります。
文字列しか入出力できないLLMで、どうやって外部環境側とインタラクションするのか?
下記のようにして解決します。
- LLMに「実行可能な関数名・利用すべきシーン・入力形式・出力形式」の説明をあらかじめ文字列として渡す。
- LLMが状況に応じて「関数呼び出し用の文字列」を出力する。
- LLMの外側で実際に関数を実行する。
この仕組みで、LLMが一般のPython関数などを呼び出せるようにします。
一般的にtool callingなどと呼ばれる方法です。
ステートレスなのに、どうやって複数段ステップで実行して結果を得るのか?
LLMの実行ごとの結果を、LLMの外側で履歴として積み上げて管理します。
そのうえで、ユーザー入力と履歴をまとめて入力の文字列としてLLMに渡すことで、どんな情報が得られていて次に何をしなければならないかがLLMに判断できるようにします。
具体的には、下記のような流れで処理を実施します。
- ユーザーが入力を与える
- LLMが「
tool呼び出し」か「最終回答」を判断する -
tool呼び出しだった場合にはLLMが出力した引数で該当の関数を実行し、実行結果を履歴に溜めるて再度LLMを呼び出す。 -
最終回答が判断された場合はその結果をユーザーに提示する。
このLLM自身に判断させるループ構造は色々な所で出てくるのですが、はっきりとした呼び名は分かりませんでした・・・。
agent loop / tool call loopなどの呼び名で出てくることが多いように思います。
参考: https://github.com/humanlayer/12-factor-agents?tab=readme-ov-file#agents-as-loops
実装編
上記を踏まえて実装します。
また実行可能な全体のコードはgithubに配置しています。
AIエージェントに任意コード実行の権限を渡しています。
上記リポジトリの様に、devcontainer等を利用してコンテナ内で実行することを推奨します。
ステートレスなLLMの関数の定義
import os
import httpx
def call_llm(prompt: str) -> str:
"""
Stateless LLM call: input string -> output string.
Uses an OpenAI-compatible endpoint for simplicity.
"""
api_key = os.getenv("LLM_API_KEY")
base_url = "https://api.openai.com"
model = "gpt-4.1"
if not api_key:
raise RuntimeError("Set LLM_API_KEY in your environment.")
payload = {
"model": model,
"messages": [
{"role": "user", "content": prompt},
],
"temperature": 0.0,
}
headers = {"Authorization": f"Bearer {api_key}"}
url = base_url.rstrip("/") + "/v1/chat/completions"
with httpx.Client(timeout=60) as client:
resp = client.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
- 余分な考慮事項が入ってこないように、文字入力->文字出力の関数でラップする。
- ChatGPTの4.1のモデルを利用。
利用するツールの定義
今回は2つのツールを定義します。
- pythonを実行するツール
from io import StringIO
import sys
TOOL_NAME = "python_repl"
class PythonREPL:
"""Simulates a standalone Python REPL with persistent globals."""
def __init__(self) -> None:
self._globals: dict = {}
def run(self, command: str) -> str:
old_stdout = sys.stdout
sys.stdout = mystdout = StringIO()
try:
exec(command, self._globals)
sys.stdout = old_stdout
output = mystdout.getvalue()
except Exception as exc:
sys.stdout = old_stdout
output = str(exc)
if output.strip() == "":
return "[no output] Python実行完了。実行結果を取得した場合は、printを利用してください。"
return output
def python_repl(command: str) -> str:
return _PYTHON_REPL.run(command)
_PYTHON_REPL = PythonREPL()
- Web検索を行うツール
import os
import httpx
TOOL_NAME = "tavily_search"
def tavily_search(query: str) -> str:
api_key = os.getenv("TAVILY_API_KEY")
if not api_key:
raise RuntimeError("Set TAVILY_API_KEY in your environment.")
payload = {
"api_key": api_key,
"query": query,
"search_depth": "basic",
"max_results": 5,
}
with httpx.Client(timeout=30) as client:
resp = client.post("https://api.tavily.com/search", json=payload)
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
lines = []
for item in results:
title = item.get("title", "")
url = item.get("url", "")
content = item.get("content", "")
lines.append(f"- {title}\n {url}\n {content}")
return "\n".join(lines).strip()
ループ処理の定義/システムプロンプトの定義
import json
import sys
from dotenv import load_dotenv
from .llm import call_llm
from .tools_python import TOOL_NAME as PYTHON_REPL_NAME
from .tools_python import python_repl
from .tools_tavily import TOOL_NAME as TAVILY_NAME
from .tools_tavily import tavily_search
TOOLS = {
TAVILY_NAME: tavily_search,
PYTHON_REPL_NAME: python_repl,
}
from datetime import datetime
SYSTEM_PROMPT = """あなたは親切なAIアシスタントです。
必要に応じてtoolを呼び出し、十分な情報が揃ったら最終回答してください。
以下にユーザー入力と前回までのツール実行結果が記載されたscratchpadが与えられます。
使用可能なtool:
- tavily_search:
- 概要: web検索を実行します。
- 利用するシーン: WEBから情報を収集する必要がある場合に利用してください。
- 引数: 検索キーワードを文字列で指定します。
- 戻り値: 検索結果の文字列が返されます。
- python_repl:
- 概要: pythonコードを実行します。
- 利用するシーン: 数値計算を行う場合に必ず利用してください。
- 引数: pythonスクリプト。実行結果を取得するにはprintで値を出力してください。
- 戻り値: 実行結果の文字列が返されます。
ルール:
- 新しい情報が必要ならtoolを使う。
- actionは1つのみ。
- 出力は必ずJSON 1つだけで、余計なテキストは出力しない。
- actionは必ず "tool" または "final" のどちらかです。
今日の日時:
{datetime}
出力JSONスキーマ:
下記のいずれか1つ
- {{"action":"tool","tool":"tavily_search","input":"..."}}
- {{"action":"final","output":"..."}}
User Input: {user_query}
Scratchpad :
{scratchpad}
"""
def run_agent(user_query: str) -> str:
scratchpad: list[dict] = []
max_steps = 10
for step in range(1, max_steps + 1):
print(f"\n==== Start STEP {step} ====")
prompt = SYSTEM_PROMPT.format(
datetime=datetime.now(),
user_query=user_query,
scratchpad=json.dumps(scratchpad, ensure_ascii=False, indent=2),
)
raw = call_llm(prompt)
action = json.loads(raw)
if action.get("action") == "tool":
print(f"Processing tool action.")
tool_name = action.get("tool")
tool_input = action.get("input", "")
tool_func = TOOLS.get(tool_name)
print(f"tool call: {tool_name}({tool_input})")
tool_output = tool_func(tool_input)
print(f"tool output (preview): {tool_output[:30]}")
scratchpad.append(
{
"action": "tool",
"tool": tool_name,
"input": tool_input,
"observation": tool_output,
}
)
continue
if action.get("action") == "final":
print(f"Processing final action.")
return str(action.get("output", "")).strip()
# print(f"[step {step}] invalid action: {action}")
scratchpad.append({"error": f"Unknown action: {action.get('action')}"})
return "Max steps reached without a final answer."
def main() -> int:
load_dotenv()
if len(sys.argv) < 2:
print("Usage: python -m src.agent \"your question\"")
return 1
user_query = " ".join(sys.argv[1:])
answer = run_agent(user_query)
print(answer)
return 0
if __name__ == "__main__":
raise SystemExit(main())
- ツール説明や出力フォーマットを含めたプロンプトの定義、及びLLMの出力を用いたループ処理の定義を行っています。
- LLMには、次に行う行動を判定させます。判定の結果は
action属性のtoolorfinalとして出力されます。 -
actionの値に応じて、下記の処理を実施する-
toolだった場合: 該当のツールを実行し、呼び出した引数や実行結果をscratchpadに追加する。ループを継続 -
finalだった場合: LLMの出力を最終結果として出力
-
※ プロンプトは英語で記載するのが一般的ですが、今回は分かりやすさのために日本語しています。
実行結果
web検索
- ニュースを調べてみます。
- 3ステップ(検索2回+最終結果作成1回)で処理を完了して、最終的な結果の文章が出力されます。
$ python -m src.agent "2つのニュースサイトを訪問して、それぞれのトップニュースを取得して。またその結果を200文字のレポートにまとめて"
==== Start STEP 1 ====
Processing tool action.
TOOL CALL: tavily_search(主要ニュースサイトのトップニュース 2025年12月24日)
TOOL OUTPUT (preview): - 2025年12月24日の記事一覧
https://w
==== Start STEP 2 ====
Processing tool action.
TOOL CALL: tavily_search(読売新聞 トップニュース 2025年12月24日)
TOOL OUTPUT (preview): - 読売新聞 - 12月 24, 2025
https:
==== Start STEP 3 ====
Processing final action.
2025年12月24日の主要ニュースサイト(朝日新聞・読売新聞)のトップニュースは以下の通りです。
朝日新聞では、トルコでのジェット機墜落事故やウクライナ情勢、国内では出生数の減少や高額療養費の見直し、外国人労働者政策などが大きく報じられています。
読売新聞では、国内金価格の史上最高値更新、電通過労死事件から10年の母親の訴え、高市内閣の高支持率、能登半島地震の孤立集落問題などが注目されています。
両紙とも国際情勢と国内の社会・経済問題を幅広く取り上げており、年末らしい総括や回顧記事も目立ちました。
フィボナッチ数の計算
- pythonを呼び出して処理計算しています。
- フィボナッチ数計算用の関数を定義後に実行して、結果を取得しています。
$ python -m src.agent "フィボナッチ数の30番目の数字を計算して"
==== Start STEP 1 ====
Processing tool action.
TOOL CALL: python_repl(def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
print(fibonacci(30)))
TOOL OUTPUT (preview): 832040
==== Start STEP 2 ====
Processing final action.
フィボナッチ数列の30番目の数字は832040です。
まとめ
AIエージェントの中身が理解できるよう、フレームワークを使わずに、シンプルなAIエージェントを実装してみました。
誰かの理解の助けになれば幸いです。

