「AIエージェントとは」を定義でなく実測で確かめた

目次
「AIエージェントとは」で検索すると、「ユーザーの代わりにタスクを自律的に実行するソフトウェア」という定義がずらりと並ぶ。定義は分かる。分からないのは、その「自律的に」の瞬間に裏で何が流れていて、普通の生成AI呼び出しと何が違うのかだ。裏が見えないままだと、エージェントと呼ばれる機能を選ぶときも作るときも、看板の言葉で判断するしかない。
以前このサイトでも、ollama のローカルLLMでエージェントループを自作して壊れ方を観察したことがある。あの記事はループの中身が主題だった。今回は違いを測る。同じモデル・同じタスクを「ツールを渡さない1回呼び出し」「ツールを渡してループを回すエージェント」「答えに必要なデータを最初からプロンプトに埋め込んだ1回呼び出し」の3条件で動かし、APIが何往復して、何トークン流れて、答えは合っているかを数えた。
測り方: 同じタスクを3条件で流す
実行環境は次のとおり。モデルはローカルの huihui_ai/qwen3-abliterated:14b を使う。後で見る実行ログのとおり、構造化された tool_calls を返すモデルを選んだ。課金APIは使わない。
$ sh -c 'python3 -V; sw_vers; ollama --version; ollama list | head -8'
Python 3.14.7
...
ProductVersion: 26.6.1
...
ollama version is 0.31.1
...
# exit=0 (85 ms)
タスクは「sandbox/ ディレクトリにある全ファイルを読み、各ファイルに書かれている在庫数の合計を答えてください」。中身はこうなっていて、正解は 58 + 23 + 47 = 128 だ(readme.txt には数値が無い)。
$ sh -c 'ls -la sandbox; for f in sandbox/*; do echo "== $f"; cat "$f"; done'
...
== sandbox/budo.txt
山梨産ぶどう 在庫: 58個
== sandbox/mikan.txt
愛媛産みかん 在庫: 23個
== sandbox/readme.txt
このディレクトリの在庫データは2026年9月時点のもの。
棚卸しは毎週月曜に実施する。
== sandbox/ringo.txt
青森産りんご 在庫: 47個
# exit=0 (12 ms)
この数字はモデルの学習データにもプロンプトにも無いので、ファイルを実際に読まなければ 128 には届かない。エージェント側に渡すツールは list_files と read_file の2つだけで、実体はただの Python 関数だ。sandbox 外を読ませないため、引数のパスは SANDBOX 直下の相対パスとして解決している。この仕様が後で1回エラーを起こす。
SANDBOX = pathlib.Path("sandbox").resolve()
def list_files(path: str = ".") -> str:
target = (SANDBOX / path).resolve()
if target != SANDBOX and SANDBOX not in target.parents:
return "ERROR: path outside sandbox"
if not target.is_dir():
return f"ERROR: {path} is not a directory"
return json.dumps(sorted(p.name for p in target.iterdir()),
ensure_ascii=False)
def read_file(path: str) -> str:
target = (SANDBOX / path).resolve()
if SANDBOX not in target.parents:
return "ERROR: path outside sandbox"
return target.read_text(encoding="utf-8")
モデルに渡すのは実装ではなく宣言のほうで、関数名・説明文・引数の JSON Schema を tools として添える。
TOOLS = [
{"type": "function", "function": {
"name": "list_files",
"description": "sandbox ディレクトリ内のファイル名の一覧を返す",
"parameters": {"type": "object", "properties": {
"path": {"type": "string", "description": "対象ディレクトリ(既定 .)"}},
"required": []}}},
{"type": "function", "function": {
"name": "read_file",
"description": "sandbox ディレクトリ内の指定ファイルの内容を返す",
"parameters": {"type": "object", "properties": {
"path": {"type": "string", "description": "ファイル名"}},
"required": ["path"]}}},
]
比較を成立させるため、3条件ともモデルとタスク文と temperature 0 は共通にし、変えるのは「ツールを渡すか」「データをプロンプトに含めるか」だけにした。なお環境ログの ollama list は head -8 で打ち切っているため使用モデルは一覧に写っていないが、モデル名は各実行ログの先頭の model: 行に出ている。
LLM呼び出しは ollama の /api/chat に POST を1本投げるだけで、urllib で書ける。
def chat(messages, tools=None):
body = {"model": MODEL, "messages": messages, "stream": False,
"options": {"temperature": 0}}
if tools:
body["tools"] = tools
req = urllib.request.Request(
OLLAMA_CHAT, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req) as res:
return json.loads(res.read())
ループの本体はこれだ(表示・集計の行は省いた抜粋)。応答に tool_calls があれば実行して結果を role: "tool" のメッセージとして履歴に積み、無ければ終了する。上限は MAX_TURNS = 10 に置いた。
for turn in range(1, MAX_TURNS + 1):
res = chat(messages, tools=TOOLS)
msg = res["message"]
messages.append(msg)
calls = msg.get("tool_calls") or []
if not calls:
break
for c in calls:
fn = c["function"]["name"]
args = c["function"].get("arguments") or {}
try:
out = IMPL[fn](**args)
except Exception as exc:
out = f"ERROR: {exc!r}"
messages.append({"role": "tool", "content": str(out)})
ツールを渡さないと、手順を説明して仮の答えを出す
まず条件A。同じモデルに同じタスクを投げるだけで、ツールは渡さない。
$ python3 single_shot.py
mode: no-tools
model: huihui_ai/qwen3-abliterated:14b
task: sandbox/ ディレクトリにある全ファイルを読み、各ファイルに書かれている在庫数の合計を答えてください。
----- assistant -----
[thinking] Okay, I need to find the total inventory from all the files in the sandbox directory. Let me think about how to approach this.
...
### 例(仮のデータ)
- `file1.txt` に書かれている在庫数: `10`
- `file2.txt` に書かれている在庫数: `20`
- `file3.txt` に書かれている在庫数: `30`
この場合、合計は:
**10 + 20 + 30 = 60**
---
### 答え
**60**(例の場合)
----- stats -----
api_calls: 1
prompt_eval_count: 41
eval_count: 980
done_reason: stop
wall_time_sec: 100.3
# exit=0 (100355 ms)
100.3秒かけて980トークンを出力した末に返ってきたのは、存在しない file1.txt〜file3.txt を仮定した例の答えだった。正解の128には届かない。断るでもなく、ファイルを読むでもなく、「こういう場合はこう計算します」という手形を返してくる。
ここで prompt_eval_count は入力側、eval_count は出力側のトークン数で、ollama がレスポンスに載せてくる値だ。このモデルは答えを出せない状況で、出力トークンを入力の24倍近く使って「考えるふり」をしていた。
一応の注記をしておくと、モデル自身は「60」を確定値ではなく「例の場合」と断ってはいる。省略した思考部分を読むと、ファイルの構造を「1行に数値が1つか」「item: quantity 形式か」といくつも仮定し直していて、かなり丁寧に悩んでいる。それでもファイルが見えない以上、正解には構造上辿り着けない。
在庫の合計を知りたい人にとって、存在しないファイルの仮定で計算された数値は無価値で、しかも手順と例題の体裁が整っているぶん、流し読みでは「ちゃんと答えてくれた」に見えてしまう。ツール無しの1回呼び出しに「調べて答える」タスクを投げると、こういう答えが返る。
ツールを渡すと、APIが7往復して正解に辿り着く
条件B。同じタスクに list_files と read_file の宣言を添えて投げ、先ほどのループを回した。
$ python3 agent_run.py
model: huihui_ai/qwen3-abliterated:14b
task: sandbox/ ディレクトリにある全ファイルを読み、各ファイルに書かれている在庫数の合計を答えてください。
tools: ["list_files", "read_file"]
===== turn 1: assistant =====
{
"role": "assistant",
"content": "",
...
"tool_calls": [
{
"id": "call_zkgvesrj",
"function": {
"index": 0,
"name": "list_files",
"arguments": {
"path": "sandbox/"
}
}
}
]
}
----- turn 1 stats: prompt_eval_count=236 eval_count=297 time=30.5s
----- tool: list_files({"path": "sandbox/"})
----- result: ERROR: sandbox/ is not a directory
...
# exit=0 (170860 ms)
「自律的」の実体がこれだ。モデルが返すのは実行結果ではなく、「list_files を path: "sandbox/" で呼んでほしい」というJSONの依頼だけ。実行するのは常にこちら側の Python で、結果を履歴に積んで送り直すと、モデルはその続きから次の一手を考える。
そして1ターン目からいきなりエラーを食らっている。先ほどのコードのとおり、list_files は引数を SANDBOX 直下の相対パスとして解決するので、sandbox/ は sandbox/sandbox/ を探しに行って「not a directory」になった。ツールの説明文に「sandbox ディレクトリ内の〜」と書いたのが、モデルに sandbox/ という引数を促した格好だ。こちらのミスである。
面白いのはここからだ。次のターンでモデルは、エラーメッセージを根拠に「パスの解釈が違うのではないか」と仮説を立て、引数を . に切り替えてファイル一覧を取り直した。ログの thinking を読むと、「sandbox/ が無いならカレントディレクトリを試す」という切り替え判断が書かれている。
$ python3 agent_run.py
...
----- tool: list_files({"path": "."})
----- result: ["budo.txt", "mikan.txt", "readme.txt", "ringo.txt"]
...
----- tool: read_file({"path": "budo.txt"})
----- result: 山梨産ぶどう 在庫: 58個
...
----- tool: read_file({"path": "mikan.txt"})
----- result: 愛媛産みかん 在庫: 23個
...
----- tool: read_file({"path": "readme.txt"})
----- result: このディレクトリの在庫データは2026年9月時点のもの。
棚卸しは毎週月曜に実施する。
...
----- tool: read_file({"path": "ringo.txt"})
----- result: 青森産りんご 在庫: 47個
...
# exit=0 (170860 ms)
一覧に出た4ファイルを、1ターンにつき1つずつ read_file で読んでいく。各ターンの tool_calls には呼び出しが1つしか入っていないので、このモデルは直列に進んだ。1ターンに複数呼び出しを並べられる実装もあるはずだが、少なくともこの観測ではそうはならなかった。readme.txt に数値が無いことも、最終回答の思考(「readme.txt: 0 (since there’s no number there)」)に反映されている。そして7ターン目に最終回答を返した。
$ python3 agent_run.py
...
===== turn 7: assistant =====
{
"role": "assistant",
"content": "颗\n\nOkay, let's see. The user wants the total inventory from all the files.\n\nSo far, I have:\n\n- budo.txt: 58\n- mikan.txt: 23\n- readme.txt: 0 (since there's no number there)\n- ringo.txt: 47\n\nAdding them up: 58 + 23 is 81, plus 47 is 128. So the total should be 128.\n\nBut wait, the readme.txt didn't have a number, so I assumed 0. The user might have expected that, or maybe they want to include the note as part of the inventory. But since the problem says \"各ファイルに書かれている在庫数\", which means the inventory numbers written in each file, and readme.txt doesn't have a number, it's safe to add 0 for that file.\n\nSo the final answer is 128.\n</think>\n\n58 + 23 + 0 + 47 = 128\n\nsandbox/ ディレクトリ内の全ファイルに書かれている在庫数の合計は **128個** です。"
}
----- turn 7 stats: prompt_eval_count=1629 eval_count=250 time=28.7s
===== finished in 7 turns =====
===== totals =====
api_calls: 7
total_prompt_eval_count: 6965
total_eval_count: 1473
total_wall_time_sec: 170.8
# exit=0 (170860 ms)
128。正解だ。readme.txt を「数値が無いので0」と処理した判断も、思考がそのまま content に漏れているので読み取れる(冒頭の 颗 と末尾の </think> は、この abliterated モデルの思考タグが出力に混入した痕跡だ)。
もう1つこのログが見せているのは、往復のコスト構造だ。prompt_eval_count がターンごとに 236 → 552 → 796 → 1051 → 1242 → 1459 → 1629 と増えている。リクエストのたびに、それまでのやり取りを丸ごと入力として送り直す実装だからだ。
このrunでは生成(eval)の累計が1473トークンなのに対し、入力(prompt_eval)の累計は6965トークンだった。エージェントの入力コストは「質問文」ではなく「積み上がった履歴の再読」が支配する。トークン課金制のAPIで同じ構造を回せば、支払う入力は往復回数より速く膨らむ。
なお各ターンの所要時間は 30.5 → 24.5 → 26.8 → 20.4 → 20.5 → 19.4 → 28.7 秒と、入力トークンの増加ほど素直には伸びなかった。出力側の eval_count は 297 → 213 → 231 → 166 → 171 → 145 → 250 で、こちらの推移のほうがターン時間と似た形をしている。この観測では、時間を左右したのは入力の長さよりも各ターンで生成した量(thinking を含む)だったように見える。
止まる判断もモデル側にある。終了条件は「応答に tool_calls が無いこと」だけで、7ターン目の応答に tool_calls が無かったから終わった。読むべきファイルを全部読んだと判断したのはモデルで、「何ターンで止めるか」も「次にどのファイルを読むか」もこちらは教えていない。渡したのはツールの宣言とタスク文だけだ。
ファイル内容を最初から渡せば、1往復4.4秒で正解する
条件C。同じ1回呼び出しだが、今度はファイルの中身をプロンプトに全部貼ってから聞く。
$ python3 single_shot.py --with-data
mode: with-data
model: huihui_ai/qwen3-abliterated:14b
task: sandbox/ ディレクトリにある全ファイルを読み、各ファイルに書かれている在庫数の合計を答えてください。
...
----- assistant -----
端的に答えてください。
</think>
58 + 23 + 47 = 128個
----- stats -----
api_calls: 1
prompt_eval_count: 139
eval_count: 25
done_reason: stop
wall_time_sec: 4.4
# exit=0 (4447 ms)
1往復、入力139トークン、4.4秒、正解128。出力冒頭の「端的に答えてください。」はこちらの指示ではなく、モデルの応答に混入した断片だ。</think> の思考タグ漏れと同種の、このモデルの出力の乱れと読んでいる。計算自体は一発で合っている。
つまり条件Bのエージェントが余分に払っていたのは「考える力」ではなく、データを自分で取りに行く往復だった。データさえ渡せば、同じモデルが25トークンの出力で正解を出す。逆に言えば、取りに行く手段が無ければ、同じモデルは100.3秒考えた末に架空の例を答える。
ここから逆方向にも言えることがある。答えに必要なデータが最初からプロンプトに収まる仕事なら、エージェントの往復は純粋な無駄だ。このタスクなら、人間がファイルを開いて内容を貼るほうが速くて安い。エージェントが効くのは、データがどこにあるか・何件あるかをこちらが予め知らない、あるいは集める手間が貼る手間を上回る仕事である。
「AIエージェントとは」の違いを数値で引き直す
3条件を並べるとこうなる。
| 条件 | API往復 | 入力トークン | 出力トークン | 所要時間 | 答え |
|---|---|---|---|---|---|
| A: ツール無し | 1 | 41 | 980 | 100.3秒 | 仮の例「60」。正解不能 |
| B: エージェント | 7 | 累計6965 | 累計1473 | 170.8秒 | 正解128 |
| C: データ埋込 | 1 | 139 | 25 | 4.4秒 | 正解128 |
解説記事の言う「自律的にタスクを実行する」を、このログに引き直すとこうなる。自分に渡っていない情報を、ツール呼び出しという形で取りに行き、結果を見て次の一手を決める往復。今回の観測では、その往復にはどのツールを選ぶかの判断と、エラーからの立て直し(sandbox/ → .)、「もう読むものが無い」と判断して止まる終了判定まで含まれていた。対価は呼び出し回数と、履歴再送で膨らむ入力トークンだ。
読者の問いへの答えとしては、エージェント型の機能や製品を見るときに確かめるべきことが、この構造からそのまま出る。ツール呼び出しと結果のログを自分で見られるか。往復の上限と課金の天井があるか(自作ループでは MAX_TURNS = 10 をその上限にした)。渡しているツールは何で、何ができてしまうのか。今回 list_files に sandbox/ を渡してエラーになったように、ツールの仕様とモデルの解釈がずれるだけで1往復無駄になる。そのずれが見えるかどうかは、製品が通信の中身を見せてくれるかに掛かっている。
限界も書いておく。これはローカルの14Bモデル1つ・タスク1種類・temperature 0での観測で、商用のエージェント製品が内部で何をしているかまで確かめたわけではない。リトライやキャッシュ、計画専用のモデルを挟む設計もありうる。
それでも、この実験が測れたことははっきりしている。同じモデルは、データを渡せば4.4秒で正解し、渡さなければ100.3秒考えて架空の例を返し、ツールを渡せば7往復・累計6965トークンの入力を払って本物の答えを取りに行った。「自律的」が買っているのは知性ではなく、データを取りに行く往復で、その勘定は呼び出し回数と再送される履歴の形で請求される。「AIエージェントとは」と調べた人が本当に知りたいのはこの対価の形で、それは定義文には書かれていない。
関連書籍

