AIエージェントの中身はwhileループ ── 自作して壊れ方まで実測した

目次
  1. エージェントの本体は「tool_calls が無くなるまで回すループ」
  2. 最初の実行: エージェントは1ターンで「何もせずに」終わった
  3. フォールバックパーサを書いたら、今度は地の文と JSON が混ざった
  4. 1.5b に落とすと壊れ方が変わる: フェンス・捏造・無限ループ
  5. 実測から言えること: フレームワークが隠している仕事の正体

AIエージェントの解説を読むたびに引っかかっていたことがある。LangChain や CrewAI の使い方は分かっても、「エージェントが自律的に動く」その瞬間に何が流れているのかが見えない。中身は「LLM にツール一覧を渡し、ツール呼び出しが返ってきたら実行して結果を差し戻す while ループ」のはずで、それなら標準ライブラリだけで書けるはずだ。

実際に ollama のローカル LLM と Python 標準ライブラリだけで組んでみたら、教科書どおりの tool_calls は最後まで一度も返ってこなかった。代わりに、フレームワークが普段何を吸収してくれているのかを、モデルサイズごとに違う壊れ方として観察できた。

エージェントの本体は「tool_calls が無くなるまで回すループ」

実行環境は次のとおり。モデルは取得済みの qwen2.5-coder の 7b(4.7 GB)と 1.5b(986 MB)を使う。すべてローカルで、課金 API は使わない。

$ sh -c 'python3 -V; sw_vers; ollama --version; ollama list | head -5'
Python 3.14.6
...
ollama version is 0.31.1
...
# exit=0 (51 ms)

macOS 26.6.1 上で動かしている(バージョンは同ログの sw_vers 出力から)。

エージェントに渡すツールは2つだけにした。実体はただの Python 関数で、JSON Schema を添えて宣言する。

def list_files(path: str = ".") -> str:
    target = (SANDBOX / path).resolve()
    names = sorted(p.name for p in target.iterdir())
    return json.dumps(names, ensure_ascii=False)

def write_file(path: str, content: str) -> str:
    target = (SANDBOX / path).resolve()
    target.write_text(content, encoding="utf-8")
    return f"wrote {len(content)} chars to {path}"

IMPL = {"list_files": list_files, "write_file": write_file}

「ツールを渡す」といっても、LLM が直接ファイルシステムを触れるようになるわけではない。tools として渡す JSON Schema は「こういう関数が呼べる」という宣言をプロンプトに埋め込むための情報にすぎず、実行するのは常にこちら側の Python だ。LLM は「どの関数をどの引数で呼びたいか」というテキストを返すだけで、それを実際の関数呼び出しに変換する責任はループを書く側にある。エージェントの安全性の議論がツール実装側の話になるのは、この構造のためだ。

LLM 呼び出しは ollama の /api/chat に HTTP POST を1本投げるだけだ。requests すら使わず urllib で書ける。

def chat(messages: list) -> dict:
    body = json.dumps({
        "model": MODEL, "messages": messages, "tools": TOOLS,
        "stream": False, "options": {"temperature": 0},
    }).encode()
    req = urllib.request.Request(
        OLLAMA_CHAT, data=body, headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as res:
        return json.loads(res.read())["message"]

会話の状態は messages 配列がすべて持つ。system・user・assistant の3種に加えて、ツールの実行結果を role: "tool" のメッセージとして積むのが唯一の拡張で、毎ターンこの配列を丸ごと送り直す。サーバ側には何の状態も残らない。「エージェントが文脈を覚えている」ように見える正体は、この配列が伸びていくことだ。

そして本体。応答に tool_calls があれば実行して role: "tool" のメッセージとして結果を積み、無ければ最終回答とみなして終了する。これがエージェントの全部である。

def main() -> None:
    messages = [{"role": "system", "content": SYSTEM},
                {"role": "user", "content": TASK}]
    for turn in range(1, MAX_TURNS + 1):
        msg = chat(messages)
        messages.append(msg)
        print(f"===== turn {turn}: assistant =====")
        print(json.dumps(msg, ensure_ascii=False, indent=2))
        tool_calls = msg.get("tool_calls")
        if not tool_calls and msg.get("content"):
            tool_calls = parse_content_tool_calls(msg["content"])
            if tool_calls:
                print(f"----- fallback: parsed {len(tool_calls)} tool call(s) from content")
        if not tool_calls:
            print(f"===== finished in {turn} turns =====")
            return
        for call in tool_calls:
            name = call["function"]["name"]
            args = call["function"].get("arguments") or {}
            try:
                result = IMPL[name](**args)
            except Exception as exc:
                result = f"ERROR: {exc!r}"
            print(f"----- tool: {name}({json.dumps(args, ensure_ascii=False)})")
            print(f"----- result: {result}")
            messages.append({"role": "tool", "content": str(result)})
    print(f"===== reached MAX_TURNS={MAX_TURNS}, aborting =====")

parse_content_tool_calls というフォールバックが既に入っているが、これは後から足したものだ。なぜ必要になったかがこの記事の本題になる。ファイル全体で 120 行。与えるタスクは「作業ディレクトリのファイル一覧を取得し、ファイル数と一覧を report.txt に書け」という、ツールを最低2回呼ばないと完了しないものにした。

もうひとつ、このループの終了条件が「ツール呼び出しが無いこと」しかない点は覚えておいてほしい。教科書的にはこれで正しいのだが、後で見るように、この判定はモデルの出力形式が崩れると簡単に誤作動する。

最初の実行: エージェントは1ターンで「何もせずに」終わった

フォールバックが無い初版を 7b で動かした結果がこれだ。

$ python3 /Users/user/Desktop/dev/ai-article-maker/pipeline/data/articles/20260817-004-ba585815/verify/workspace/agent.py qwen2.5-coder:7b
===== turn 1: assistant =====
{
  "role": "assistant",
  "content": "{\"name\": \"list_files\", \"arguments\": {\"path\": \".\"}}\n{\"name\": \"write_file\", \"arguments\": {\"path\": \"report.txt\", \"content\": \"ファイル一覧:\\n\" + \"\\n\".join(list_files_result[\"files\"]) + \"\\n\\nファイル数: \" + str(len(list_files_result[\"files\"]))}}"
}
===== finished in 1 turns =====
# exit=0 (9943 ms)

tool_calls フィールドが無い。ツール呼び出しの JSON が content に生テキストとして入っていて、ループは「ツール呼び出しなし=完了」と誤認して1ターンで終了した。ファイルは何も作られていない。

よく見ると2行目の write_file はさらにひどい。"ファイル一覧:\n" + "\n".join(...) という Python の文字列連結式が JSON の値の位置にそのまま書かれている。まだ実行していない list_files の結果を先取りして使おうとして、擬似コードを出力している。

モデルがツール呼び出しに対応していないのかと疑って、チャットテンプレートを確認した。

$ sh -c 'ollama show qwen2.5-coder:7b --template | head -40'
...
For each function call, return a json object with function name and arguments within <tool_call></tool_call> with NO other text. Do not include any backticks or ```json.
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call>
...
# exit=0 (48 ms)

テンプレートは tools に対応していて、モデルに「<tool_call> タグで囲め」と指示している。しかし先ほどの出力にタグは無い。タグなしの生 JSON では、ollama 側が tool_calls フィールドに変換してくれないのも筋が通る(ollama 内部のパーサ実装までは確認していない)。

タスクが複雑だから崩れたのかも切り分けたくて、ツール1個の最小ケースでも叩いてみた。

$ python3 /Users/user/Desktop/dev/ai-article-maker/pipeline/data/articles/20260817-004-ba585815/verify/workspace/probe_tools.py qwen2.5-coder:7b
{
  "role": "assistant",
  "content": "{\"name\": \"get_current_weather\", \"arguments\": {\"city\": \"Tokyo\"}}"
}
# exit=0 (2027 ms)

天気ツール1個・英語プロンプトの最小構成でも content に生 JSON が返った。つまり qwen2.5-coder:7b は、私の環境ではタスクの複雑さに関係なくタグ形式を守らない。「tools 対応モデル」であることと「構造化された tool_calls が実際に返ってくること」は別の話だった。

切り分けの順序はこうだった。まず「エージェントが1ターンで終わる」という症状から、終了判定に使っている tool_calls の不在を疑う。次にテンプレートを見て、ツール対応自体は存在することを確認する。最後に最小ケースで再現させて、タスク起因ではなくモデルの応答形式起因だと確定する。フレームワーク経由だとこの症状は「エージェントが何もせず終わる」としか見えないはずで、素の HTTP で組んでいたからこそ応答の生の形を直接見られた。

フォールバックパーサを書いたら、今度は地の文と JSON が混ざった

構造化フィールドが返らないなら、content から自力で拾うしかない。まず「content の全行が JSON としてパースできたらツール呼び出しとみなす」という厳格なパーサを書いた。system プロンプトに「ツールは1回に1つずつ呼べ」とも足した。これで擬似コード連結問題は消えたが、今度は別の形で沈黙した。

$ python3 /Users/user/Desktop/dev/ai-article-maker/pipeline/data/articles/20260817-004-ba585815/verify/workspace/agent.py qwen2.5-coder:7b
...
----- tool: list_files({"path": "."})
----- result: ["config.json", "hello.txt", "memo.md"]
===== turn 2: assistant =====
{
  "role": "assistant",
  "content": "ファイル数は3つです。report.txt を作成します。\n\n{\"name\": \"write_file\", \"arguments\": {\"path\": \"report.txt\", \"content\": \"作業ディレクトリのファイル一覧:\\n- config.json\\n- hello.txt\\n- memo.md\"}}"
}
===== finished in 2 turns =====
# exit=0 (4242 ms)

turn 1 はフォールバックで拾えた。しかし turn 2 では「ファイル数は3つです。report.txt を作成します。」という地の文の後に JSON が続いている。全行 JSON を要求する厳格パーサはこれを弾き、ループはまた「完了」と誤認して、report.txt を作らないまま終わった。

厳格に書いたのには理由があって、最終回答の中に JSON の例が含まれていた場合にそれをツール呼び出しと誤認したくなかった。しかし実際のモデルは「説明しながら呼ぶ」出力を平気で返してくる。誤検知の危険を承知で、「JSON として読める行だけ拾い、地の文はスキップする」方式に緩めた。

def parse_content_tool_calls(content: str) -> list:
    text = content.replace("<tool_call>", "").replace("</tool_call>", "")
    text = text.replace("```json", "").replace("```", "")  # コードフェンス除去
    calls = []
    for line in text.strip().splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            obj = json.loads(line)
        except json.JSONDecodeError:
            continue  # 地の文はスキップし、JSONとして読める行だけ拾う
        if isinstance(obj, dict) and "name" in obj and "arguments" in obj:
            calls.append({"function": {"name": obj["name"],
                                       "arguments": obj["arguments"]}})
    if not calls:  # 複数行に整形されたJSONを全文で試す
        try:
            obj = json.loads(text.strip())
            if isinstance(obj, dict) and "name" in obj and "arguments" in obj:
                calls.append({"function": {"name": obj["name"],
                                           "arguments": obj["arguments"]}})
        except json.JSONDecodeError:
            pass
    return calls

これでようやく最後まで走った。

$ python3 /Users/user/Desktop/dev/ai-article-maker/pipeline/data/articles/20260817-004-ba585815/verify/workspace/agent.py qwen2.5-coder:7b
...
----- tool: write_file({"path": "report.txt", "content": "作業ディレクトリのファイル一覧:\n- config.json\n- hello.txt\n- memo.md"})
----- result: wrote 52 chars to report.txt
===== turn 3: assistant =====
{
  "role": "assistant",
  "content": "report.txt を作成しました。内容は以下の通りです:\n\n作業ディレクトリのファイル一覧:\n- config.json\n- hello.txt\n- memo.md\n\nこの報告書を作成しました。"
}
===== finished in 3 turns =====
# exit=0 (6720 ms)

3ターンで完走した。成果物が本当にできているかも確認した。

$ sh -c 'ls -la /Users/user/Desktop/dev/ai-article-maker/pipeline/data/articles/20260817-004-ba585815/verify/workspace/sandbox/ && cat /Users/user/Desktop/dev/ai-article-maker/pipeline/data/articles/20260817-004-ba585815/verify/workspace/sandbox/report.txt'
...
作業ディレクトリのファイル一覧:
- config.json
- hello.txt
- memo.md
# exit=0 (18 ms)

report.txt は実在した。ただし完璧ではない。タスクは「ファイル数と一覧」を要求したのに、ファイルには一覧しか書かれていない。ファイル数はチャット上の地の文(「ファイル数は3つです」)に出ただけで、成果物には反映されなかった。完走と、指示を全部満たすことの間にはまだ距離がある。

1.5b に落とすと壊れ方が変わる: フェンス・捏造・無限ループ

同じループを qwen2.5-coder:1.5b に切り替えると、また違う壊れ方を見せた。まずフェンス除去を入れる前のパーサでの実行。

$ python3 /Users/user/Desktop/dev/ai-article-maker/pipeline/data/articles/20260817-004-ba585815/verify/workspace/agent.py qwen2.5-coder:1.5b
===== turn 1: assistant =====
{
  "role": "assistant",
  "content": "```json\n{\n  \"name\": \"list_files\",\n  \"arguments\": {\n    \"path\": \".\"\n  }\n}\n```"
}
===== finished in 1 turns =====
# exit=0 (3445 ms)

テンプレートが「backticks や ```json を含めるな」と明示しているにもかかわらず、1.5b はコードフェンス付き・複数行整形の JSON を返した。7b とは逸脱の形が違う。パーサにフェンス除去と複数行 JSON の全文パースを足して再実行すると、今度は最後まで止まらなくなった。

$ python3 /Users/user/Desktop/dev/ai-article-maker/pipeline/data/articles/20260817-004-ba585815/verify/workspace/agent.py qwen2.5-coder:1.5b
===== turn 1: assistant =====
{
  "role": "assistant",
  "content": "```json\n{\n  \"name\": \"list_files\",\n  \"arguments\": {\n    \"path\": \".\"\n  }\n}\n```"
}
----- fallback: parsed 1 tool call(s) from content
----- tool: list_files({"path": "."})
----- result: ["config.json", "hello.txt", "memo.md"]
===== turn 2: assistant =====
{
  "role": "assistant",
  "content": "```json\n{\n  \"name\": \"write_file\",\n  \"arguments\": {\n    \"content\": \"ファイル一覧:\\n\\nconfig.json\\necho.txt\\nmemo.md\",\n    \"path\": \"report.txt\"\n  }\n}\n```"
}
----- fallback: parsed 1 tool call(s) from content
----- tool: write_file({"content": "ファイル一覧:\n\nconfig.json\necho.txt\nmemo.md", "path": "report.txt"})
----- result: wrote 37 chars to report.txt
...
===== reached MAX_TURNS=8, aborting =====
# exit=0 (6684 ms)

このログには3つの問題が同居している。

1つ目はフォーマット逸脱(コードフェンス)で、これはパーサ側で吸収できた。2つ目は捏造だ。turn 1 で list_files が返した一覧は hello.txt なのに、turn 2 で書き込んだ内容では echo.txt に化けている。ツールの実行結果がそのままコンテキストにあるのに、書き写す段階で壊れた。3つ目は停止判定の欠如で、write_file は turn 2 で成功しているのに、1.5b は同一の呼び出しを turn 8 まで繰り返し、MAX_TURNS の強制停止でようやく止まった。ループに上限を入れておかなければ、これは文字どおりの無限ループだった。

7b と 1.5b の違いをまとめると、7b は「フォーマットは崩すがタスクは進む」、1.5b は「フォーマットも崩すし、結果を読み間違え、終わりも判断できない」となる。なお、これは qwen2.5-coder の2サイズ・このタスク・temperature 0 での観測であって、モデルサイズ一般の法則として確かめたわけではない。

実測から言えること: フレームワークが隠している仕事の正体

最終形のパーサで 7b を流し直すと、安定して3ターンで完走する。

$ python3 /Users/user/Desktop/dev/ai-article-maker/pipeline/data/articles/20260817-004-ba585815/verify/workspace/agent.py qwen2.5-coder:7b
...
===== finished in 3 turns =====
# exit=0 (12079 ms)

エージェントループ自体は本当に単純で、120 行あれば動く。この記事の実測で分かったのは、その周りにある「地味な仕事」の重さのほうだ。

  • フォーマット逸脱の吸収。 私の環境では、構造化された tool_calls は 7b でも 1.5b でも一度も返らなかった。生 JSON、地の文との混在、コードフェンス付きと、逸脱のパターンはモデルとターンごとに違い、パーサはそのたびに緩める羽目になった
  • 暴走の停止。 MAX_TURNS のガードは保険のつもりで書いたが、1.5b で実際に発火した
  • 結果の検証。 ループが「完走した」ことと、成果物が指示を満たしていることは別だった(ファイル数の書き漏れ、ファイル名の捏造)

LangChain などのフレームワークがこの種の処理を担っていること自体は、ここでの実測ではなく一般論なので深入りしない。ただ、自作ループでこれだけの吸収コードが要るという事実は、フレームワークの中で何が起きているかを推測する手がかりになる。

この構成が向かないケースも書いておく。ローカル小型モデル + 自作ループは、仕組みを理解するための教材としては最適だが、出力の正しさが問われる実タスクにそのまま使うのは勧めない。ツール結果の書き写しすら壊れる(hello.txtecho.txt)レベルの捏造が、たった3ファイルのタスクで出るからだ。実タスクに使うなら、ツール引数のスキーマ検証、成果物の事後検証、同一呼び出しの繰り返し検知を足す必要があり、そこまで書くならフレームワークとの分量差は縮んでいく。

それでも一度は素の HTTP で組んでみる価値はある。「AIエージェント」という言葉の実体が、messages 配列に role: "tool" を積んでは投げ直すだけのループだと分かれば、フレームワークのドキュメントもエラーも読み方が変わるはずだ。

関連書籍