レッスン2: コアループ: 一度の往復から継続的な運転へ
学習目標:
- stop_reasonが駆動するマルチターンのループの4ステップを言え、1回のツール呼び出しの往復を回り続けるwhileループにつなぐ
- stop_reasonの値(tool_use / end_turn)を使ってループが続くか止まるかを判断し、なぜそのフィールドがループのwhile条件なのかを説明する
- この骨組みのループに欠けている境界を指摘し、なぜ毎ターン履歴が伸びるのか、なぜモデルが「終わりました」と言うことだけに頼れないのかを説明する
前提: レッスン1を読み、ハーネスがモデルの周りにある制御コードの層であることを知っていること。1回のツール呼び出しの往復のtool_use / tool_resultを読めること | 前: レッスン1 << | 次: レッスン3 >>
1回の往復では足りなくなる
レッスン1では、完全なツール呼び出しの往復をすでに分解しました: モデルが stop_reason: "tool_use" と tool_use ブロックを返し、あなたのホストコードが name と input を読み出し、実際に処理を実行し、出力を tool_result に詰めて送り返し、そこでようやくモデルが最終的な答えを返す。JSONが3つの塊、往復は1回、これで終わりです。
実際のタスクがそこまで行儀よくしていることはめったにありません。シナリオを変えましょう: あなたはオンコール対応のボットを書いていて、ユーザーがこう言います。「apiサービスを再起動して、そのあとログにまだエラーが出ていないか確認して、出ていたら貼って」。この一文には2つの仕事が詰め込まれていて、2つ目は1つ目に依存しています——再起動が終わるまで、ログを確認しても意味がありません。モデルは最初のターンで両方をこなすことはできません。できるのはこれだけです:
- 1ターン目は
tool_use を返し、restart_service を呼び出す。あなたはそれを実行し、「再起動に成功しました」を送り返す。
- 2ターン目はまた
tool_use を返し、今度は read_logs を呼び出す。あなたはそれを実行し、ログの内容を送り返す。
- 3ターン目にようやく
stop_reason: "end_turn" が返り、「再起動が完了しました。ログにタイムアウトのエラーが2件あります。以下に貼ります」といった一文が付いてきます。
ユーザーのリクエストは1つ、往復は3回。各ステップでモデルが見られるもの、そして次にやることは、前の tool_result で何が返ってきたかに依存しています——これはまさにAnthropicによるエージェントの定義そのものです: ループの中で環境からのフィードバックに基づいてツールを使うLLM1。レッスン1の1回の往復は、そのループがたまたま1回転しかしなかった特殊なケースにすぎません。このレッスンがやるのは、「1回の往復」を「往復が続いていくもの」につなぐことであり、その真ん中にあるループが何であり、何が駆動していて、どこにブレーキが要るのかをはっきり見ることです。
ループの4つのステップ
1回の往復をループに変えるのに、新しいものを発明する必要はありません。すでに知っている動きを繰り返すだけです。Claude APIのドキュメントは、このマルチターンのプロセスを決まった手順として書き出しています:2
messages とツール一覧の tools を載せたリクエストを送る(tools は毎ターン必ず同行させる必要があります)。
- モデルがレスポンスを返す。まだツールが必要なら、
stop_reason は "tool_use" になり、content に1つ以上の tool_use ブロックが載っています。
- すべての
tool_use ブロックを実行し、それぞれの出力を tool_result ブロックに変える。このステップで肝心なのはすべてという言葉です: 1回のレスポンスで tool_use ブロックがいくつ返ってきたとしても、次の user メッセージにはそれと同じ数の対応する tool_result ブロックが必要で、それぞれ tool_use_id で紐付けられ、すべてが直後のその1つの user メッセージに詰め込まれます。ドキュメントはこのルールをこう述べています: "Whichever strategy you use, return one tool_result for each tool_use block, all together in the next user message. Match each result to its call with tool_use_id, and put every tool_result block before any text content in that message."(どの戦略を採るにせよ、tool_useブロック1つにつきtool_resultを1つ、次のuserメッセージにまとめて返すこと。各結果はtool_use_idでその呼び出しに紐付け、そのメッセージ内ではすべてのtool_resultブロックをテキストコンテンツより前に置くこと)3
- そのターンのモデルの完全なレスポンス(
assistant ロール)と、あなたが組み立てた tool_result の一式(user ロール)の両方を messages に追記し、次のリクエストを送る。
そして最も重要な一文が来ます: stop_reason が "tool_use" である間、ステップ2から繰り返す2。この「である間、繰り返す」が、1回の往復をループへと伸ばす車軸です。レッスン1の例が最初の end_turn で止まったのは、そのタスクに往復が1回しか必要なかったからです。オンコールボットはステップ2から4を3回まわし、3ターン目が end_turn で返ってくるまで続きます。
覚えておく価値があります: tool_use ブロックと tool_result ブロックのフィールドは、まったく変わっていません。tool_use ブロックは id / name / input を持ち、tool_result ブロックは tool_use_id / content を持ち、呼び出しが失敗したときには任意で is_error が付きます3。ループはこれらのフィールドの意味を1つも書き換えません。同じフィールドの組が、何度も繰り返し埋められて送り返されるようになるだけです。
stop_reasonがループのwhile条件
先ほどの一行——「stop_reason がまだ tool_use である間、繰り返す」——は、コードにするとwhileループの条件式になります。そしてこのレッスンから持ち帰るべき一文はこれです: ループが続くか止まるかの判断は、stop_reason というただ1つのフィールドに帰着します。 取りうる値はいくつもありますが、ループ制御のためには、まず2つを見分けられれば十分です:
"tool_use": モデルはまだツールを使いたい。リクエストをあなたに渡し、あなたが実行して結果を送り返すのを待ってから先に進みます。ループはもう1回転します。
"end_turn": モデルはもうツールを使いたくない。言うべきことは言ったと考えています。ループは自然に終わり、あなたは最終的なテキストをユーザーに渡します。
ハーネスエンジニアリングに関するあるオープンソースのロードマップは、この制御層をきっぱりこう述べています: ハーネスを駆動するのは、モデル→ツール→モデルを回すwhileループである4。そして、そのwhileループの条件式に座っているのが stop_reason です。同じモデル、同じツールセットでも、何回転して、いつ止まるかは、ホストがそのフィールドをどう読み、その条件をどう書くかで完全に決まります。だからこそレッスン1は "Same model, different harness, completely different result."(同じモデル、違うハーネス、まったく違う結果)と言ったのです4。
シグナルを逆に読まないよう、最初にはっきりさせておくべきことがあります: stop_reason: "tool_use" は、モデルがツールを使いたがっているという意味であって、ツールがすでに使われたという意味ではありません。モデルは自分では何も実行しません。モデルは構造化されたリクエストを発行するだけで、実際にツールを走らせるのはあなたのホストコード(またはAnthropicのサーバー)であり、結果はそのあとで初めて会話に流れ込みます2。ですから、ループの中で tool_use が現れたその瞬間には、まだ何も起きていません。アクションが起きるのは、name と input を読み出して実際に処理をしにいく、あなたのコードの数行の中です。「tool_useを受け取った」を「ツールの実行が終わった」と扱ってしまうのは、1回の往復からループへ移るときに最も陥りやすい躓きです——今このループがどのステップにいるのかを見誤らせます。
書き出してみれば、たった数行
この4つのステップと stop_reason のwhile条件をJavaScriptに落とすと、骨組みは驚くほど短くなります:
4つのステップをもう一度コードと突き合わせてみましょう: while の行が「まだ tool_use である間、繰り返す」。本体の中では、assistant のレスポンスと tool_result ブロックからなる user メッセージの両方が messages に push され、そのあと response が再代入されます。最後の代入こそが、そもそも停止を可能にしているものです——これを落とすと、response.stop_reason は古い値を永遠に持ち続け、whileループは決して抜けません(その種のデッドループはレッスン4の主役です)。
このコードは動きますが、あくまで骨組みです——ループそのものが見えるくらい単純で、本番に渡せるほど安全ではまったくありません。このコードは、モデルがいつかのターンで必ず end_turn を返してくると仮定し、すべてのツールが問題なく実行されると仮定し、履歴がどれだけ長くなっても構わないと仮定しています。この3つの仮定こそ、次のいくつかのレッスンが1つずつ解体していくものです。
1ターン進むごとに履歴は長くなる
messages.push の行をもう一度見てください: ループが1回転するたびに、messages にはメッセージが2つ増えます——モデルの assistant レスポンスと、あなたが送り返した tool_result の一式です。そして次の callModel は、messages の全体をそのままもう一度送らなければなりません。ですから、このループが長く回るほど、各リクエストが運ぶ履歴は増え、それは増える一方です。
これは実装の見落としではありません。ループという構造に本来備わった性質です: ループの中で動くエージェントは、次の推論ターンに関係しうるデータをどんどん生成します5。オンコールボットの3ターンなら、再起動の結果とログの塊が積み上がるだけです。しかし、何十ターンも必要なタスクは、履歴を巨大なものへと転がしていきます。
ここには、「エージェントメモリと状態」のコースでようやく本格的に開かれる問題が潜んでいますが、今のうちに種を蒔いておく必要があります: モデルには「注意の予算」があり、新しく導入されたトークンはそれぞれ、その予算をある程度消費します5。履歴が長いほどトークンは増え、コンテキストウィンドウ内のトークン数が増えると、そのコンテキストから情報を正確に思い出すモデルの能力は低下します5。これは長さとともに緩やかに下っていく性能の勾配であって、あるしきい値を超えたら落ちる崖ではないことに注意してください5——「上限を超えたら使い物にならない」と読まないでください。ただし方向ははっきりしています: コンテキストは、限界効用が逓減する有限の資源として扱わなければなりません5。骨組みのループにある、あの無造作な messages.push は、このどれにも対処していません。履歴は永遠に伸ばせると仮定しています——そしてその請求書を清算しに戻ってくるのが「エージェントメモリと状態」のコースです。
ループだけでは足りない: 境界を足さなければならない
これで、回るループが手に入りました。しかし「回れる」と「安全に回る」は別物です。骨組みのループは、止まるか続けるかの判断を完全にモデルに委ねています: モデルが end_turn を返したターンが、ループの止まるターンです。しかしエージェントとは、モデルが自身のプロセスとツールの使い方を動的に指揮するシステムです1。その自律性こそが有用さの源であり、同時にリスクの住処でもあります: 自律性は、より高いコストと、ループのターンを重ねるごとに積み重なっていくエラーの可能性を意味します1。モデルは多数のターンにわたって動作する可能性があり、走らせる前に、その意思決定にある程度の信頼を置かなければなりません1。
やっかいなのは、信頼が無監督と同じではないことです。モデルがどこかのステップで詰まったり、ツールが返したものに引きずられて脱線したりして、そのまま end_turn を返してこなくなったら、stop_reason しか見ていないループは、いつまでも一緒に回り続けます。ですから、モデル自身の終了シグナルに加えて、通常は明示的な停止条件も足します——たとえば最大反復回数の上限を設けて、制御を自分の側に保つのです1。骨組みにある剥き出しの while (response.stop_reason === "tool_use") には、そうしたヒューズがありません: 自分の逃げ道を残さずにモデルを信頼しています。
これでこのレッスンの2つの伏線が置かれました: このループには境界が必要(モデルが end_turn と言うことに依存できないので、明示的な停止条件が必要です——レッスン3)、そしてこのループが生む履歴には管理が必要(トークンは有限の資源なので、ただ放り込み続けることはできません——「エージェントメモリと状態」のコースに預けます)。ループが実際に脱線するとどう見えるのか、そしてそれをどう捕まえるのかはレッスン4の主題です。このレッスンでは、車軸を据えるところまでで十分です: ループがどう回り、それを駆動しているのが stop_reason だということです。
まとめ
- 1回のツール呼び出しの往復をループにつなぐのに新しい仕組みは要らず、4つのステップを繰り返すだけです: リクエストを送る →
stop_reason と tool_use ブロックを読む → ツールを実行して tool_result ブロックに詰める → 履歴に追記してもう一度送る。そして stop_reason がまだ tool_use である間、繰り返します2
stop_reason がこのループのwhile条件です: tool_use はモデルがまだツールを使いたいという意味でループは続き、end_turn はモデルが締めくくるという意味でループは自然に終わります——モデル→ツール→モデルという車軸は、そのフィールドが駆動しています4
tool_use はモデルがツールを使いたいというシグナルであって、実行されたという受領証ではありません。モデルは自分では何も実行せず、アクションはホストが name と input を読み出して処理をしにいく場所で起きます2
- ループが1回転するたびに履歴は長くなり、短くなることはありません。ループの中のエージェントは、関係しうるデータを生成し続けるからです5。そして注意の予算は有限で、コンテキストが伸びるほど想起は劣化するため、トークンは限界効用が逓減する有限の資源として扱わなければなりません5
- ループだけでは足りません: 自律性はより高いコストと積み重なるエラーをもたらし、モデルは多数のターンにわたって動作しうるので1、モデル自身の
end_turn に加えて、通常は明示的な停止条件(たとえば最大反復回数)を足して制御を自分の側に保ちます1——その設定の仕方はレッスン3の主題です
>> レッスン3: 停止条件: エージェントはいつ手を引くべきか