【実装】エージェントループを自作する — ツール呼び出しの最小形
「AIエージェント」の中心はwhile文ひとつです。JSON関数呼び出しの形、ReActが残した設計、そして事故のほとんどが集まる停止条件までを、フレームワークを使わない40行のコードとして1から組み上げます。
要点
- エージェントの中核は「モデルに聞く → 道具を実行する → 結果を履歴に積む」という while 文である。
- モデルは毎回まっさらな状態で履歴を読み直し次の1手だけを出力し、連続性を持つのは messages 配列だけである。
- 道具の description と返り値は、人間ではなくモデルに向けたAPIドキュメントとして書く。
- 停止条件は自然終了・手数の上限・トークン予算・無進捗の検出の4本である。
- 毎ターン履歴を全部送り直すため、コストは手数の2乗で伸び、10手が20手になるとおよそ4倍になる。
ReAct: Synergizing Reasoning and Acting in Language Models
一次資料 — この記事の根拠
論文の発表 2022-10-06→この解説の公開 2026-08-273年11か月後
ReAct: Synergizing Reasoning and Acting in Language ModelsShunyu Yao, Jeffrey Zhao, Dian Yu ほか · 2022-10-06 · v3arXiv:2210.03629論文ページ·PDF原文の要旨(Abstract)を読む
While large language models (LLMs) have demonstrated impressive capabilities across tasks in language understanding and interactive decision making, their abilities for reasoning (e.g. chain-of-thought prompting) and acting (e.g. action plan generation) have primarily been studied as separate topics. In this paper, we explore the use of LLMs to generate both reasoning traces and task-specific actions in an interleaved manner, allowing for greater synergy between the two: reasoning traces help the model induce, track, and update action plans as well as handle exceptions, while actions allow it to interface with external sources, such as knowledge bases or environments, to gather additional information. We apply our approach, named ReAct, to a diverse set of language and decision making tasks and demonstrate its effectiveness over state-of-the-art baselines, as well as improved human interpretability and trustworthiness over methods without reasoning or acting components. Concretely, on question answering (HotpotQA) and fact verification (Fever), ReAct overcomes issues of hallucination and error propagation prevalent in chain-of-thought reasoning by interacting with a simple Wikipedia API, and generates human-like task-solving trajectories that are more interpretable than baselines without reasoning traces. On two interactive decision making benchmarks (ALFWorld and WebShop), ReAct outperforms imitation and reinforcement learning methods by an absolute success rate of 34% and 10% respectively, while being prompted with only one or two in-context examples. Project site with code: https://react-lm.github.io
電話越しの助手
とても物知りだが、目の前にいない助手に電話で仕事を頼む場面を想像してください。助手には手も足もなく、あなたの声しか届きません。だから助手は「そのフォルダに何が入っているか読み上げてください」と頼み、あなたが読み上げ、それを聞いた助手が次の指示を出します。この往復を何度か繰り返して、ようやく仕事が片づく。
いま「AIエージェント」と呼ばれているものの正体は、これです。言語モデルは文字列を受け取って文字列を返す関数でしかなく、ファイルを開くこともメールを送ることもできません。手を動かすのは常に外側のプログラムです。エージェントフレームワークの中心的な価値は、この電話の往復を自動化する十数行のループにすぎません。
この記事ではそのループをフレームワークなしで組みます。ゴールは動くコードを手に入れることではなく、どの行を消すと何が壊れるかを説明できるようになることです。
正体は while 文
先に結論を出します。エージェントの中核はこれだけです。
messages = [{"role": "user", "content": task}]
while True:
reply = model(messages, tools=TOOLS) # 1. 考えさせる
messages.append(assistant_turn(reply)) # 2. 発言を履歴に積む
calls = tool_calls_in(reply)
if not calls: # 3. 道具を使わなかった = 終わり
return text_of(reply)
results = [run(c) for c in calls] # 4. こちらが手を動かす
messages.append(user_turn(results)) # 5. 結果を履歴に積む
「エージェント」という言葉の魔法は、ここで一度解けておいたほうがいい。モデルは意志を持って動き続けているのではありません。毎回まっさらな状態で会話履歴を頭から読み直し、次の1手だけを出力して終わっています。 連続性を持つのは messages 配列のほうで、モデル側に前回の記憶は残りません。
この非対称性が、あとの設計判断のほとんどを決めます。履歴が唯一の記憶だから、書かなかったことはモデルにとって存在せず、ステップを重ねるほど毎回の入力が膨らみ、失敗の原因も書いてやらないと同じ失敗を繰り返します。
部品は4つだけです。道具の一覧表、モデル呼び出し、ディスパッチャ(道具の名前を実際の関数に結びつける対応表)、そして停止条件。
道具をどう説明するか
モデルに道具を渡すというのは、実質的には関数のシグネチャをJSONで書いて渡すことです。
{
"name": "read_file",
"description": "リポジトリ内のテキストファイルを1つ読む。パスはリポジトリルートからの相対。バイナリには使えない。存在を確認したいだけなら list_dir を使うこと。",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "例: src/main.py" }
},
"required": ["path"]
}
}
ここで初学者がほぼ必ず一度はやる失敗があります。description を人間向けに書いてしまうことです。この文章の読者は同僚ではなくモデルです。気の利いた要約より、「使ってはいけない場面」「引数の書式の実例」「失敗したときに何が返るか」を書いたほうが効きます。説明文を1行足しただけで挙動が変わることは珍しくありません。
なお、返ってくるJSONが構文として壊れないことは、制約デコーディング(文法的に許されないトークンを出力前に潰す仕組み)でかなり保証できます。ただし構文が正しいことと引数の中身が正しいことは別で、実在しないパスを型どおりの文字列で渡してくることは平気で起きます。この境界は構造化出力と制約デコーディングで扱っています。
ループを回すときの温度(サンプリングのばらつきを決めるパラメータ)は、通常の文章生成より低めに置きます。道具の名前の綴りが揺れれば存在しない関数を呼びますし、同じ状況で毎回違う判断をされるとデバッグが成立しないからです。
ただし温度を0にすれば安全、という話でもありません。履歴が同じなら出力も同じになるので、一度失敗ループに入ると自力で抜けられなくなります。低め(おおむね0〜0.3)に置いたうえで、抜け出せない状況は停止条件のほうで扱う、というのが実装上の分担です。
動くコードにする
擬似コードを実際のAPIの形に落とします。以下はClaudeのMessages APIの形ですが、名前が違うだけで他社のfunction callingでも構造は同じです。
import json
TOOLS = [READ_FILE_SCHEMA, LIST_DIR_SCHEMA] # 上で書いたJSON定義
IMPL = {"read_file": read_file, "list_dir": list_dir} # 名前 → 実際の関数
def run_tool(name, args):
try:
return {"ok": True, "out": IMPL[name](**args)}
except KeyError:
return {"ok": False, "err": f"unknown tool: {name}. available: {list(IMPL)}"}
except Exception as e:
return {"ok": False, "err": f"{type(e).__name__}: {e}"}
def agent(task, max_steps=12):
messages = [{"role": "user", "content": task}]
for step in range(max_steps):
reply = client.messages.create(
model=MODEL, tools=TOOLS, messages=messages,
max_tokens=1024, temperature=0,
)
messages.append({"role": "assistant", "content": reply.content})
calls = [b for b in reply.content if b.type == "tool_use"]
if not calls: # 自然終了
return "".join(b.text for b in reply.content if b.type == "text")
results = []
for c in calls: # 呼ばれた分は全部返す
r = run_tool(c.name, c.input)
results.append({
"type": "tool_result",
"tool_use_id": c.id, # 対応付けのID
"content": json.dumps(r, ensure_ascii=False)[:4000],
"is_error": not r["ok"],
})
messages.append({"role": "user", "content": results})
return "打ち切り: 手数の上限に達しました"
40行足らずですが、「ファイルを読んで要約する」程度の仕事は本当に回ります。外すと壊れる箇所が3つ。
IDの対応を崩さない。 モデルは1ターンで複数の道具を同時に呼べます。直前のアシスタントターンに tool_use が3つあれば、次のユーザーターンには tool_result が3つ必要で、1つでも欠けると検証で弾かれます。
返り値は必ず切り詰める。 上の [:4000] がそれです。list_dir が10万行返せば、それが次のターンの入力に丸ごと乗り、以降のターンにも乗り続けます。ループ内で結果を垂れ流すのはコスト事故の最多原因です。
例外を握りつぶさず、モデルに読ませる。 is_error を立てているのは失敗させないためではなく、失敗を次の一手の材料にするためです。
ReAct が残したもの
このループの原型は2022年のReAct(Yao et al.)です。当時は関数呼び出しのAPIがなく、Thought: Action: Observation: という書式をプロンプトで教え込み、出力を正規表現でパースしていました。その泥臭い部分はいまAPIが吸収し、残ったのは行動の前に理由を言わせるという一点です。
推論だけでも行動だけでもなく交互にやる、というのがReActの主張です。構造化されたtool useになった今でも「道具を呼ぶ前に、なぜそれを選ぶのかを1文で書け」と入れるだけで無関係な道具の選択が減ります。論文本文の読み解きはLLMエージェントを1から解説にあります。
停止条件を4本立てる
エージェントの事故は、ほぼすべて「止まらない」ことに由来します。停止条件は1本では足りません。
- 自然終了 — 道具を呼ばずにテキストだけ返してきた。これが正常系
- 手数の上限 —
max_steps。最初に書く。あとで書くのでは遅い - トークン予算 — 累積の入出力トークンが閾値を超えたら打ち切る
- 無進捗の検出 — 同じ
(name, args)が連続したら、あるいはエラーがk回続いたら止める
4番目が要ります。同じディレクトリを延々と読み直すのはよくある挙動で、手数の上限だけでは上限いっぱいまで課金されてから止まるからです。直近数件の呼び出しのハッシュを覚えておく、10行程度で防げます。
そして手数だけを見ていると見落とすのが、コストは手数に比例しないという点です。毎ターン履歴を全部送り直すので、送信トークンの総量は手数の2乗で効いてきます。
要するに「最初のターンで送るトークン数を 、1往復ごとに履歴へ積み上がる平均を とすると、N手回したときに課金される入力トークンの合計 は N の2乗で伸びる」と言っています。10手が20手になれば費用は2倍ではなくおよそ4倍。プロンプトキャッシュは先頭の再送を安くするだけで、2乗の項は消しません。
長いループを回すなら、古い tool_result を要約に畳むか末尾N件だけ残す圧縮が要ります。そこまで来ると、ループではなく記憶の設計の話です。
失敗はモデルに返す
エージェントの賢さは、思っているよりモデルではなくツールの返り値の設計で決まります。
- 悪い返し方:
Error - 良い返し方:
path 'src/mian.py' が見つかりません。近い名前: src/main.py
後者ならモデルは次のターンで綴りを直して自力で回復します。前者だと同じ呼び出しを繰り返すか、諦めて「ファイルが存在しないようです」と嘘の結論を出します。次の一手を選べる情報を1行で返す — これが道具を書くときの基準です。スタックトレースの丸投げは、長いわりに有用な情報が最後の1行しかないので逆効果です。
現場ではこう使う
このループを自分で書くのは、社内ツールにAI機能を足すアプリケーションエンジニア、一次対応を補助したいSRE、分析作業を自動化したいデータ担当あたりです。実務上いちばん効くのは、フレームワークを入れる前に一度この40行を書いておくこと。詰まったときにどのレイヤを見ればいいかが分かります。
触るパラメータは、名前が違うだけでどの実装にもあります。max_steps / max_iterations / recursion_limit(同じもの)、temperature、tool_choice(auto・強制・禁止の切り替え。最初の1手だけ特定の道具を強制したいときに使う)、並列呼び出しの可否、ツール実行のタイムアウトと出力の切り詰め長。
知らないと事故になる落とし穴を挙げます。
- 上限なしで一晩回す。
max_stepsを外して放置すると請求が跳ねます。上限とトークン予算は機能より先に書く tool_resultの取りこぼし。 並列で呼ばれたうちの1つを返し忘れてエラーになる。並列呼び出しを有効にした直後に必ず踏みます- 権限の境界をプロンプトで守ろうとする。 モデルは「呼んでよいか」を判断できません。削除・送信・課金のような不可逆な操作は、ループの外側で人間の承認を挟みます。システムプロンプトの禁止事項は防御機構ではありません。信頼境界の考え方はMCPとツールプロトコルで扱っています
- 道具を増やしすぎる。 選択肢が増えるほど選択の精度は落ちます。平坦に20個並べるより、5個にまとめてサブコマンドで分岐させたほうが動きます
- 成功だけをログに残す。 何手で終わったか、どの道具が何回失敗したか、打ち切りは何%か。この3つがないと後から改善できません
レビューや面接で問われるのは、たいてい「そのエージェントはどうやって止まるか」「1タスクあたりの最悪コストはいくらか」「失敗したツール呼び出しはモデルに何を返すか」の3つです。動くデモと運用できるシステムの境目は、ここに即答できるかどうかにあります。
まとめ
- 中核は「モデルに聞く → 道具を実行する → 結果を履歴に積む」while文。フレームワークはその周辺装備
- モデルに記憶はなく、
messages配列だけが連続性を持つ。書かなかったことは存在しない - 道具の
descriptionと返り値の文面は、モデル向けのAPIドキュメント - 停止条件は自然終了・手数・トークン予算・無進捗の4本。コストは手数の2乗で伸びる
次は、この履歴をどう畳んで長時間動かすかという話になります。
コメント
コメントにはログインが必要です