第4章 エージェントループ — 使ったら結果を返して、また考えさせる
📖 この章のゴール:前章で作った 「道具を1回だけ使う1往復」 を、
for(またはwhile)のループに育てる。stop_reason(ストップ・リーズン=止まった理由)が"end_turn"(えんど・たーん=もう話すことはない)になるまで、「考える→道具→結果→また考える」をぐるぐる回す。これこそがエージェントの心臓。あわせて、暴走しないための回数の上限もつける。 ← 目次・はじめにへもどる
📱 Claude Code ではこう見える
第1章でも見た、あの動き。もう一度ゆっくり眺めてみます。Claude Code に「このテスト直して」と頼むと——
- ファイルを読む(道具を1回使った)
- 結果を見て「ここが原因だな」と考え、コードを直す(また道具を使った)
npm testを走らせる(また道具)- 結果を読むと……まだ失敗している。「じゃあ別のところを直そう」とまた直す
- もう一度走らせる → 今度は成功
- 「直りました」と報告して止まる
注目してほしいのは、1回道具を使って終わりではないこと。読む→直す→走らせる→まだ失敗→また直す→また走らせる…… と、何度も何度も往復しています。そして「もう直った(=やることが無くなった)」と判断するまで、勝手には止まりません。
前章まででは、AIに「道具メニュー」を渡し、AIが道具を1つ選び、それを実行して結果を返す——という たった1往復 を作りました。でも本物のエージェントは、その結果を見てまた考え、必要ならまた道具を使う。この「結果を見て、また考える」のくり返しが、今日の主役です。
🤔 なぜ「1往復」では足りないのか(🟢 基礎)
前章の1往復を、もう一度図にしてみます。
あなたの入力 → LLMに相談 → LLMが道具を指示 → 道具を実行 → 結果をLLMに返す → おわり
最後が「おわり」で終わっているのが問題です。結果をLLMに返したのに、そこで会話を打ち切ってしまっている。これでは、せっかく道具の結果をLLMに見せても、LLMがそれを使って次の一手を打つ前に終わってしまうのです。
人間にたとえるなら、料理人に「まず冷蔵庫の中を見て」と頼んで、見た瞬間に電話を切るようなもの。中を見たうえで「じゃあ次に何をするか」を聞かなければ、料理は一向に進みません。
だから必要なのは、たった一つの発想の転換です。
道具の結果を返したら、終わりにせず「で、次はどうする?」ともう一度LLMに聞く。 そしてまた道具を指示されたら、また実行して、また結果を返して、また聞く。 これを LLMが「もう道具はいらない、これで完成」と言うまでくり返す。
この 「結果を見て、また考える」のくり返し を エージェントループ と呼びます。これが、ただのチャットをエージェントに変える、たった一つの仕掛けです。
「まだ道具を使いたい」か「もう完成」かを、どう見分ける?(🟢 基礎)
ループを回すには、いつ止めるかを知る必要があります。その合図が、API の返事に入っている stop_reason(止まった理由) です。前章でも少し出てきましたね。大事なのは次の2つです。
stop_reason の値 |
意味 | エージェントがすべきこと |
|---|---|---|
"tool_use"(つーる・ゆーず) |
LLMが「まだ道具を使いたい」と言っている | 道具を実行して、結果を返し、もう一度LLMに聞く(ループ続行) |
"end_turn"(えんど・たーん) |
LLMが「もう話すことはない=完成」と言っている | 最終回答を表示して、ループを終わる |
つまりループの判断はとてもシンプルです。
stop_reasonが"tool_use"のあいだは回し続ける。"end_turn"(など"tool_use"以外)になったら止める。
「考える → 道具 → 結果 → また考える → 道具 → 結果 → …… → もう道具いらない(end_turn)→ おわり」。これだけ。
🛡 でも、止まらなかったら?(🟢 基礎・背骨)
ここで背骨の 🛡 安全 が顔を出します。「end_turn になるまで回す」と聞いて、こう思いませんでしたか——もし end_turn がずっと来なかったら?
実際に起こります。AIが同じ道具を延々と呼び続けたり、堂々めぐりに陥ったりすることがあるのです。そうなると、ループは永遠に止まりません。これが 無限ループ。
そして、エージェントの無限ループはただ固まるだけでは済みません。ループが1回まわるたびに LLMのAPIを1回呼ぶ=お金がかかるからです。止まらないループは、気づかぬうちに料金をどんどん積み上げます(コストの話は付録F)。
🔑 だから鉄則:ループには必ず「最大何回まで」の上限をつける。 たとえば「最大10回」と決めておけば、たとえAIが暴走しても10往復で必ず止まる。安全弁です。これは毎章のチェックリストにも入る、エージェントの基本作法です。
「end_turn で正常終了。上限回数で強制終了」——この2つの出口を用意するのが、安全なループの形です。
🛠 こう作る(🟢 基礎)
では、前章までの部品(anthropic・MODEL・messages・tools)を使って、エージェントの心臓——runAgent 関数を組み立てます。これが本書で以降ずっと使う形です。
① エージェント本体:runAgent
async function runAgent(userInput: string) {
messages.push({ role: "user", content: userInput });
for (let step = 0; step < 10; step++) { // 暴走防止に上限10回
const res = await anthropic.messages.create({
model: MODEL, max_tokens: 1024, system: SYSTEM_PROMPT, messages, tools,
});
messages.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") {
printText(res); // 最終回答を表示して終了
return;
}
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of res.content) {
if (block.type !== "tool_use") continue;
const result = await executeTool(block.name, block.input);
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result });
}
messages.push({ role: "user", content: toolResults });
}
}
1行ずつ読むと:
async function runAgent(userInput: string) {… エージェント本体。あなたが打った1つの入力(例:「このファイルを読んで」)を受け取って、完了するまで面倒を見る関数です。messages.push({ role: "user", content: userInput });… まず、あなたの発言を会話履歴(messages)に追加します。LLMはこの履歴を丸ごと読んで考えるので、何を入れるかが大事でした。for (let step = 0; step < 10; step++) {… ここがループ。stepが 0,1,2,… と増えながら、最大10回までくり返します。この< 10が、さっき話した暴走防止の上限(安全弁)です。const res = await anthropic.messages.create({ … });… LLMに「いまの履歴を見て、どうする?」と相談します。messages(履歴)とtools(道具メニュー)を毎回いっしょに渡しているのがポイント。messages.push({ role: "assistant", content: res.content });… LLMの返事を、まるごと履歴に追加します。res.contentは文字どおり全部(テキストも、道具を使いたいという指示も)。ここが超重要なので、後で「ハマりどころ」でもう一度だけ強調します。if (res.stop_reason !== "tool_use") {… 「LLMはもう道具を使いたいわけじゃない」=ふつうは"end_turn"(完成)です。printText(res);… 完成したので、最終回答を画面に表示します(中身は前章までの「テキストブロックを取り出して表示」する関数です)。return;… そして関数を抜けて終わり。これが「end_turnで正常終了」の出口です。const toolResults: Anthropic.ToolResultBlockParam[] = [];… ここから下は「まだ道具を使いたい」=stop_reasonが"tool_use"のとき。結果をためる空の箱を用意します。複数の結果を入れられるよう、配列にしているのがミソ。for (const block of res.content) {… LLMの返事(複数のブロックの集まり)を1つずつ見ていきます。テキストのブロックも、道具を使いたいブロックも混じっています。if (block.type !== "tool_use") continue;… 道具の指示以外(ただのテキストなど)は読み飛ばします。const result = await executeTool(block.name, block.input);… 道具を実際に実行します。block.name(道具の名前)とblock.input(引数。前章で見たとおりもうオブジェクトになっている=JSON.parse不要)を、次に作るexecuteToolに渡します。結果は文字列で返ってきます。toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result });… 実行結果を「どの道具呼び出しへの返事か」(tool_use_id)とセットで箱に入れます。このidをそろえないと、LLMはどの結果がどの依頼への返事か分からなくなります。messages.push({ role: "user", content: toolResults });… 集めた結果を、ユーザーの発言として履歴に追加します(道具の結果はrole: "user"側で返すのが Anthropic の流儀)。}(forの閉じ) … これでループの先頭(create)に戻り、結果を踏まえてLLMにもう一度相談します。これが「結果を見て、また考える」の正体です。
💡 図にすると、たったこれだけ。
┌──────────────────────────────┐ ▼ │ ① LLMに相談 (create) │ │ │ stop_reason は? │ ├─ "end_turn" → 表示して return(おわり)│ └─ "tool_use" → ② 道具を実行 ───────────┘ ③ 結果を履歴に追加して①へ (ただし最大10回まで。超えたら強制終了)
② 道具を実行する係:executeTool
runAgent が「read_file を実行して」と言われたとき、実際に手を動かすのがこの関数です。前章までの形をそのまま使い、ここでは安全な道具を2つだけ用意します(本物のファイル操作は次章から)。
async function executeTool(name: string, input: any): Promise<string> {
switch (name) {
case "get_current_time":
return new Date().toString();
case "add":
return String(input.a + input.b);
default:
return `不明な道具: ${name}`;
}
}
1行ずつ読むと:
async function executeTool(name: string, input: any): Promise<string> {… 道具の実行ディスパッチャ(振り分け係)。道具の名前nameと引数inputを受け取り、結果を文字列で返します。「結果は必ず文字列」がコツ——LLMにそのまま渡せるからです。switch (name) {… 名前を見て、どの道具を動かすか振り分けます。道具が増えてもcaseを足すだけ。case "get_current_time":… 「今の日時を返す」道具(前章で定義した安全な道具)。return new Date().toString();… いまの日時を文字列にして返すだけ。何も壊さない=安全な道具です。case "add":… 「2つの数を足す」道具。引数でaとbをもらう想定です。return String(input.a + input.b);… 足し算の結果を文字列にして返します(String(...)で数値を文字列へ)。これも読むだけで何も壊しません。default:… メニューに無い名前を言われたとき。return \不明な道具: ${name}`;` … エラーで落とさず、「そんな道具は無い」と文字列で返します。落とさないのが大事——LLMはこの返事を読んで「あ、その道具は無いのか」と方針を変えられます。
🔧 応用(読み飛ばし可):道具の中で失敗しそうな処理(ファイル読み込み・通信など)は、
try { … } catch (e) { returnエラー: ${…}; }で囲み、例外を投げる代わりに文字列で返すのが定石です。そうすればループは止まらず、LLMがエラー内容を読んで自分でリカバリしようとします。次章から本物の道具を作るとき、この形が効いてきます。
③ 動かしてみる
第2章で作った入力ループから runAgent を呼べば完成です(再掲)。
import * as readline from "node:readline/promises";
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
while (true) {
const userInput = await rl.question("> ");
if (userInput === "exit") break;
await runAgent(userInput);
}
rl.close();
1行ずつ読むと:
import * as readline …… ターミナルで1行ずつ入力を読むための、Node.js標準の道具を読み込みます。const rl = readline.createInterface({ … });… キーボード入力(stdin)と画面出力(stdout)をつないだ「入力受付係」を作ります。while (true) {… 「exitと打つまで、ずっと入力を受け付ける」外側のループ(これは会話そのもののループ。エージェントループとは別物です)。const userInput = await rl.question("> ");…>と表示して、あなたの入力を1行待ちます。if (userInput === "exit") break;…exitなら会話を終了。await runAgent(userInput);… 入力をエージェント本体に渡します。ここで初めて、さっきの「考える→道具→結果→また考える」ループが回ります。1回の入力につき、内側で何往復もするわけです。rl.close();… 会話を終えたら受付係を閉じます。
例えば「今日の日付を教えて、それに 7 を足した日は何日?」のように頼むと、エージェントは内側で get_current_time を呼ぶ → 結果を見る → add を呼ぶ → 結果を見る → 文章で答える と、自分で何往復もしてから答えてくれます。あなたが書いたのは「ループと道具」だけ。考える順番はLLMが決めています。これが心臓の動く瞬間です。
⚠️ ハマりどころ
-
ループの上限を付け忘れる(無限ループ=コスト爆発) →
for (let step = 0; step < 10; step++)の< 10を消したりwhile (true)にしたりすると、AIが堂々めぐりしたとき永遠に止まりません。しかも1往復ごとにAPIを呼ぶので、止まらない=料金が積み上がり続ける。上限は気休めではなく必須の安全弁です(コストの詳細は付録F)。 -
assistantの返事を「テキストだけ」履歴に入れてしまう → ここが一番やりがちな罠。messages.push({ role: "assistant", content: res.content });のres.contentを、「テキストの部分だけ取り出して」入れてしまうと、LLMが「道具を使いたい」と言ったtool_useブロックが消えてしまいます。すると次に返すtool_resultが「どの依頼への返事か」分からなくなり、API がエラーになります。res.contentは丸ごと履歴に積むのが鉄則。テキスト表示用に中身を読むのは別の話で、履歴に入れるのは加工せず全部です。 -
tool_useを1つしか処理しない → LLMは1回の返事で、道具を同時に複数呼ぶことがあります(例:2つのファイルを並行して読みたい)。なのでfor (const block of res.content)で全部のtool_useブロックを処理し、結果も全部toolResultsに入れて返さないといけません。1つだけ返すと、残りの依頼が宙ぶらりんになってエラーになります。「最初の1個だけ」で済ませないこと。 -
毎ターン履歴を丸ごと再送している、と知らずにいる → このループは、回るたびに
messages(=これまでの全履歴)をまるごとLLMに送り直しています。つまり往復が増えるほど、送るトークン(=入力コスト)が雪だるま式に増えます。動作上は正しいのですが、長い作業ではここがコストの中心になります。対策(要約・上限・プロンプトキャッシュ)は第14章と付録Fで扱います。 -
stop_reasonを"end_turn"だけで判定してしまう → 止める条件は「"tool_use"以外なら止める」(!== "tool_use")と書くのが安全です。"end_turn"以外にも終了に近い理由(max_tokensで途中で切れた等)があり得るため、「道具を使いたいとき以外は止める」という発想にしておくと、思わぬ取りこぼしを防げます。
🤖 AIに頼むなら(Vibe codingのコツ)
エージェントループは、Claude Code などに頼んで書いてもらえます。ただ「ループ作って」だけだと、上限の無い危ないループや、tool_use を1個しか処理しないコードが出がちです。止め方と上限をはっきり指定して頼みましょう。
🗣 プロンプト例: 「TypeScript と公式
@anthropic-ai/sdkで、エージェントループの関数runAgent(userInput)を書いて。要件は4つ: ①forループで回し、暴走防止に最大10回の上限をつける。 ② 毎回anthropic.messages.createを呼び、返事(res.content)は加工せず丸ごとmessagesにrole: "assistant"で push する。 ③res.stop_reasonが"tool_use"以外なら、最終回答を表示してreturn(ループを抜ける)。 ④"tool_use"のときはres.contentの中の すべてのtool_useブロックをexecuteTool(name, input)で実行し、結果をtool_result(tool_use_idを一致させて)にまとめてrole: "user"で push してから次の回へ。executeToolは switch で振り分け、いまはget_current_timeとaddの安全な2つだけでいい。」
出てきたコードを見るときの確認ポイント:
- ループに回数の上限がある(
while (true)のまま放置していない)か? stop_reasonが"end_turn"(="tool_use"以外)のとき、ちゃんと止まるか?res.contentの中の すべてのtool_useを処理しているか?(最初の1個だけになっていないか)assistantの返事をres.content丸ごと push しているか?(テキストだけにしてtool_useを捨てていないか)
📝 ことばメモ
- エージェントループ:「LLMに相談 → 道具を実行 → 結果を履歴に戻す → また相談」を、完了するまでくり返す往復。エージェントの心臓。これがチャットとエージェントを分ける一点
stop_reason(ストップ・リーズン):APIの返事に入っている「なぜ止まったか」の合図。ループを続けるか止めるかの判断に使う"end_turn"(えんど・たーん):stop_reasonの値の一つ。「もう話すことはない=完成」。これが来たらループを抜ける"tool_use"(つーる・ゆーず):stop_reasonの値の一つ。「まだ道具を使いたい」。これが来たら道具を実行して結果を返し、ループを続ける- イテレーション上限(じょうげん):ループを回す最大回数(例:10回)。
end_turnがいつまでも来ない無限ループ=コスト爆発を防ぐための安全弁。回数を1つ足すたびにAPIを1回呼ぶ=お金がかかる、という事実とセットで覚える
➡️ 次の章へ
これで心臓ができました。「考える→道具→結果→また考える」をループで回し、end_turn で止まり、上限で暴走を防ぐ——エージェントの土台はもう動いています。
ただし、いま持たせた道具は「日時を返す」「足し算する」だけ。まだ自分のパソコンには手を出していません。次の第5章では、いよいよ本物の道具を持たせます——ファイルを読む・一覧する。読み取りは何も壊さない安全な道具なので、最初の一歩にぴったりです。心臓に、ようやく“手”が生えます。