Agent Mentor Learn
ループからグラフへ: エージェントシステムのオーケストレーション工学 · 第 6 回 / 全 6 回

レッスン6: 実践: ハーネスを小さなグラフに引き上げる

学習目標:

  • 最初の5レッスンのルーティング、ファンアウト、合流、レビュー回路、レポートを1つの orchestrate.mjs に溶接する: 計画はコードの中にあり、各ノードは依然としてコース7(エージェントハーネスの基礎: ループと制御)の stop_reason ループを走らせ、中間結果はスクリプトの変数に留まる
  • レビュー回路を実際に回し、その2通りの止まり方を両方見る——1件は gate のレポートどおりに直して完了、もう1件は2ラウンド連続で同じレポートを返し、進展なしと判定され needs_human が立つ
  • グラフ全体の実行トレースを run-state.jsonrun.jsonl に永続化し、実行サマリー表と突き合わせる: どのノードがどれだけ時間を使い、モデル呼び出しが何回で、token が何個で、gate が何ラウンド回ったか

前提: レッスン1〜5を修了し、コース7(エージェントハーネスの基礎: ループと制御)のハーネスループを走らせられること | 前: << レッスン5

まずは動かして見る

最初の5レッスンは部品を分解して扱ってきました。誰が計画を持つか(レッスン1)、チェーンとルーティング(レッスン2)、セクショニングと投票、そして上限つきの並行プール(レッスン3)、オーケストレーター・ワーカーと委任プロンプトの4要素(レッスン4)、レビュー回路と、これらのパターンをレッスン5が言うところの「グラフ」に組み上げる方法(レッスン5)。本レッスンはそれらを1つのファイルに溶接します。

タスクはわざと地味にしてあります。inbox/ にカスタマーサポートのチケットが6件あり、その1件ずつに、そのまま送れる返信を書くのが仕事です。まず、走り終わったときの姿から見てください。

text
\$ node orchestrate.mjsinbox/ からチケット 6 件を受領: T-1001, T-1002, T-1003, T-1004, T-1005, T-1006[route] T-1001=billing  T-1002=bug  T-1003=other  T-1004=billing  T-1005=bug  T-1006=other[fanout] 並行度の上限 2、初稿 6 件を産出[merge] out/ に 6 件を書き出し、下流へは参照と一行要約のみ渡す[review] gate による書き直し: 合計 2 ラウンド
=== グラフ全体の実行サマリー ===ノード    所要    モデル呼出  token    gate 回数   状態route     62ms    1           720      -           okfanout    247ms   8           8903     -           okmerge     2ms     0           0        -           okreview    127ms   2           3033     2           ok
=== チケット別の内訳 ===チケット カテゴリ  処理者            gate 回数   停止理由        状態T-1001   billing   worker:billing    0           gate_pass       passT-1002   bug       worker:bug        0           gate_pass       passT-1003   other     template          0           gate_pass       passT-1004   billing   worker:billing    1           no_progress     needs_humanT-1005   bug       worker:bug        1           gate_pass       passT-1006   other     template          0           gate_pass       pass
出力ディレクトリ out/: 6 件の返信;人手での引き継ぎが必要: 1 件  - T-1004 (no_progress): チケット T-1004: 宛名を個人から会社…トレース: run-state.json / run.jsonl (run_id=run-mta57gsx)\$ echo \$?1

本レッスンのターミナル出力はすべて、このスクリプトを実際に走らせたものを1行ずつ書き写したもので、手打ちの例は1行もありません。実行ごとに変わるのは2箇所だけです。ミリ秒の所要時間と、run_id(タイムスタンプを36進数にしたもの)です。それ以外——分類結果、呼び出し回数、token 数、gate のラウンド数、どのチケットが needs_human になるか——はすべて釘付けにされた定数です。その理由は後の「検証の仕掛け」の節で説明します。

まず目を止める価値があるのは、最後のあの 1 です。これはエラーではなく、判定です。6件のうち1件が自動で片付かなかったので、終了コードは 0 ではありません。このグラフの実行は毎回、ログの山ではなく、CI や cron ジョブが読み取れる結論を出します。

グラフはどう見えるか: 計画は main() のあの十数行

まずはスクリプトの骨格から。「グラフ」「ノード」という語を使うのはレッスン5が導入した語彙で、これは私たち独自の作図体系であって公式の概念ではなく、一次資料の錨はちょうど1つだけです。ワークフローのスクリプト自体がループ、分岐、中間結果を保持する1。以下のコードは、その一文をそのまま実装したものです。

レッスン5は先に組み合わせのグラフを描きました。このグラフはその一変種で、3つの違いがあります。レッスン5は難易度で「単純/複雑」に分けましたが、ここではトピックで billing / bug / other に分けます。レッスン5のファンアウトは「1件の複雑なチケットを3人のワーカーに配って合流させる」でしたが、ここはセクショニング——「6件のチケットにそれぞれ処理者を1人割り当てる」です。レッスン5の戻り辺は独立した [起草] ノードに戻りましたが、ここでは元のワーカーに戻ります。なぜこう変えたのかは、末尾の「突き合わせ表」の節にまとめてあります。

routeddraftsitems——この3つの const 宣言が、グラフ全体の状態です。何か型付きの状態オブジェクトではなく、ただの JavaScript の変数で、マージ戦略もありません。中間結果はスクリプトの変数に留まり1、ノード間のデータの受け渡しは関数の戻り値で行われます。全体像を見るモデルは1つもありません。ルーティングのモデルは6件のチケット本文しか見ず、billing ワーカーは割り当てられた1件しか見ず、レビューの gate は1件の返信ファイルしか見ません。

これが、ワークフローとエージェントのアーキテクチャ上の区別がコードになった姿です。LLM とツールが事前定義されたコード経路を通じてオーケストレーションされており2、モデルが自らのプロセスを自律的に指揮しているのではありません2

5つのノードが、それぞれ1区間を担当します。

ノード何をするか誰がやるか
route安い呼び出し1回で6件を3カテゴリに分けるモデルループ1つ
fanoutカテゴリごとに専門ワーカーへ配り、並行度は上限つき2種類のモデルループ + 純コードのテンプレート1つ
merge出力をディスクに落とし、下流へは参照と一行要約だけ渡す純コード
review決定的な gate で先に濾し、落ちたものだけ「検査・修正・再検査」へ純コード + 必要に応じたモデルループ
reportサマリー表を印字し、終了コードを決める純コード

5つのノードのうち、実際にモデルを呼ぶのは2つだけです。すべてのノードがモデルである必要はありません——これは本レッスンでいちばん安上がりで、いちばん見落とされやすいルールです。mergereport は純粋関数、fanoutother カテゴリは文字列テンプレート、review の第一フィルタは数行の includes です。決定的なコードが同じ答えを出せるところで、モデル呼び出しのコストとレイテンシを払う理由はありません。

ノードの内側: 依然としてコース7のループ

いちばん内側の層を先に押さえれば、グラフの意味が通ります。各モデルノードの内部では、コース7(エージェントハーネスの基礎: ループと制御)の stop_reason ループがそのまま走っています。

ループ本体の4ステップ——assistant を push、ツールを実行、tool_result を push、response を再代入——は、コース7のレッスン6と一字一句同じで、コメントまで写してあります。バルブ1(最大ターン数)も元の位置、ループ本体の先頭、turns++ の前にあります。ループの停止条件として最大反復回数を残しておくのは、制御を保つための標準的な実践です2

コース7と比べた変更は2箇所、どちらもループ本体の外側です。clientsystem がモジュールレベルの定数から引数になりました(3つの役割が別々のスタブと別々のシステムプロンプトを必要とするので、渡さざるを得ません)。そして token と呼び出し回数の計測が、ループ本体の中からクライアントの外側のラッパー層へ移りました。ループの内側は変わっていません。

この変更にはコストがあり、それは明言しておく必要があります。コース7のバルブ2(token 予算)は元々ループ本体の中の累算値に依存していました。その累算器がもうループの中にないので、バルブ2も移ってきませんでした。このグラフでは各ノードのスタブのレスポンスキューが固定長で、キューを使い切れば直接 throw するので暴走はしません。しかしスタブを本物のクライアントに差し替えるときは、バルブ2を戻してください。metered に予算超過で throw させるか、計測をループ本体に戻してコース7の元の形を復元するかのどちらかです。バルブ3(空回り検知)とバルブ4(人間の承認)も同様に移ってきていません。理由は後の「突き合わせ表」の節に挙げます。

ツール側も写しです。1ターンのレスポンスに tool_use ブロックが複数あればその数だけ tool_result を返し、ツールが throw したらプロセス全体を落とすのではなく is_error: true に包んでモデルに返します。

ノード1: ルーティング——安い呼び出し1回、そして出力を締める

ルーティングは入力を分類し、専門化された後続タスクへ振り分けます2。これはグラフでいちばん安いモデル呼び出しです。1リクエストで6件を分類し、ツールもなし、返信も書きません。

肝心なのはモデル呼び出しではなく、真ん中の十行です。モデルは自由なテキストを返してきて、下流のすべての分岐がこの値に依存するので、下流に入る前に3つの正当なラベルのどれかに締め付けなければなりません。フォーマットに合わない行は捨て、ホワイトリストにないカテゴリは other に落とし、1行もマッチしなかったチケットは parsed.get(t.id) ?? "other" が拾います。

最後のチケットについては、スタブがわざと「苦情」を返すようにしてあります——ホワイトリストにはありません。実際の実行ログにこの締め付けが残っています。

text
{"ts":"2026-08-26T13:41:46.130Z","run_id":"run-mta57gsx","node":"route","event":"clamped","ticket":"T-1006","raw":"苦情","category":"other"}

モデルは自分で作ったラベルを返し、コードがそれを other に押し戻し、何を押し戻したかの記録を残しました。下流の分岐は、コードが検証済みの値しか認識しません——これがルーティングノードと「次にどこへ飛ぶかをモデルに直接決めさせること」の実務上の違いであり、ルーティングがユニットテストできる理由でもあります。

ノード2: ファンアウト——3人のワーカーと上限つきの並行プール

ファンアウトはセクショニングに従います。タスクを互いに独立したサブタスクに切り分け、並列に走らせるというものです2。ここでの「独立」は自然に成り立ちます。6件のチケットには互いの依存がゼロで、順序も問いません。

3カテゴリ、3つの処理者、うちモデルは2つだけです。

並行プールはレッスン3のプールです(あちらでは pool、ここでは runPool)。タスクをカーソルの後ろに並べ、limit 個の消費者を起動して取りに行かせ、尽きたら終わりです。この上限は飾りではなく、本当に効いています。1 に変えてもう一度走らせると、fanout の行の所要時間が目に見えて伸びます(呼び出し回数と token は同一、ミリ秒はいつもどおり揺れます)。

text
\$ POOL_SIZE=1 node orchestrate.mjs...=== グラフ全体の実行サマリー ===ノード    所要    モデル呼出  token    gate 回数   状態route     62ms    1           720      -           okfanout    494ms   8           8903     -           okmerge     2ms     0           0        -           okreview    131ms   2           3033     2           ok

494ms 対 247ms、呼び出し回数と token は同一です。並行度が買うのはウォールクロック時間であって、仕事量が減るわけではありません——これは本物の API に切り替えても変わらず、ただしそのときはプロバイダのレート制限も考える必要があり、上限はいっそう不可欠になります。

委任プロンプト: 4要素すべて揃っている

3つのモデル役割のプロンプトはいずれもレッスン4の4要素——目的、出力フォーマット、ツール指針、タスクの境界——に従っています。サブエージェントには目的、出力フォーマット、使うツールとソースの指針、明確なタスクの境界が必要で、十分な記述がなければワーカーは作業を重複させ、抜けを残し、見つけるべきものを見つけられません3。billing ワーカーはこうです。

4行がそれぞれ仕事をしています。目的が何を書くかを決め、出力フォーマットが下流の gate に検査対象を与え(「冒頭にチケットIDを書く」という要求は gate の第1ルールに直接対応します)、ツール指針が「金額はどこから来るか」を lookup_order に釘付けにしてチケットの記述から数字を捏造する道を塞ぎ、タスクの境界が越権行為を止めると同時に、その場しのぎの言葉を先回りして禁じています。

bug ワーカー版は中身を差し替えて、既知不具合DBの照合、issue 番号の引用、番号の捏造禁止になっています。ルーターの「ツール指針」は「このステップではツールを渡さないので、チケット本文だけで判断すること」と述べており、コードで渡している空のツール配列と一致します。この3つのプロンプトの差そのものが、ルーティングの見返りです。分類したあとはそれぞれが自分の分だけを書けばよく、3種類の仕事の要求を1つのプロンプトに詰め込む必要がありません——これがまさに、ルーティングが可能にする関心の分離と、より専門化されたプロンプトです2

ノード3: 合流——ペイロードではなく参照を渡す

merge は純コードで、モデル呼び出しはゼロです。やることは2つ。各初稿を out/ に書き出し、そのうえで下流向けに軽量なマニフェスト {id, category, handler, file, oneLine} を集める——ファイルパス1つと一行要約であって、6件の返信全文ではありません。(同時に run-state.json にもチケットごとの記録を作ります。フィールドは完全なコードの第9節にあります。)

これはマルチエージェントシステムのエンジニアリング上の助言を、単一プロセスのスクリプトに持ち込んだものです。専門エージェントには成果物を外部システムに保存させ、コーディネーターには軽量な参照だけを返させる3。あの振り返りでは、この助言は「すべてをリードエージェント経由で中継する」ことによるコンテキストの膨張を解いていました。ここでは同じことの小規模版を解いています。レビューノードが必要なのは「どのファイルを検査すべきか」であって、6件の全文が1つの変数に積まれて回されることではありません。

だからレビューノードの最初の動作は、ファイルから内容を読み直すことです。

このステップは冗長に見えます。どうせ同じプロセス内なのだから、文字列をそのまま渡せばよい、と。しかしこれは2つのものを買っています。out/ のファイルがそのチケットの唯一の真実の源になること——誰が編集してもレビューが見るのはそれです。そして、この辺がプロセスやマシンをまたぐ必要が出たとき、変わるのは readFileSync のこの1行だけで、ノード間の契約は動かないことです。

小テスト

ここまでで、グラフの5ノードのうち3つが揃いました。ルーティングはコードで締め付けられ、合流は純コード、次に来る gate も純コードです。この時点でいちばんよく聞かれる質問を、そのまま出しておきます。

ノード4: レビュー回路——gate で先に濾し、落ちたものは炉に戻す

レビューノードがやるのは検査・修正・再検査です。チェッカーを走らせ、失敗したものを直し、通るか、あるいは進展しなくなるまで繰り返す1。このグラフの中で「モデルの出力が書き直しに送り返される」唯一の場所です。

第一フィルタは決定的で、数行の includes で済みます。

2つのルールはどちらも、コース10(検証と品質保証: 「正しそう」を素通しさせない)が「決定的に判定できるならジャッジに訊くな」と言っていた類のものです。返信にはチケットIDが含まれていなければならず(カスタマーサポートのシステムはそれをキーにします)、「お待ちください」「今しばらくお待ちください」「早急に対応します」のような情報量のない言葉が含まれていてはなりません。どちらも意味理解を要さず、文字列の包含判定で足り、結果は毎回同じで、しかも都合よく、そのままワーカーに投げ返せるレポート文字列が得られます。

ここに LLM のジャッジを置けば「返信の語調は適切か」「事実がツールの返した範囲を超えていないか」といった、includes では本当に判定できないことを見られます。ただし gate の後ろに並ばなければなりません。gate は無料で決定的なので、まず明らかな問題を濾させ、残ったものだけがジャッジに1回分の呼び出しを払って相談する価値があります。このグラフには gate の層しか入れていません。この一群のチケットの受け入れ基準がたまたまルールとして書けるからです。受け入れ基準に「語調の適切さ」のような言葉が入るなら、コース10の階層的な判定の割り振りに従ってジャッジの層を足してください。

ループ本体はこうです。

3つの break が3通りの止まり方に対応しており、レッスン5が宣言したとおりです。通過(while の条件が自然に偽になる)、これ以上進展しない、最大ラウンド数に到達。3つ目の if は当て木です。other カテゴリの返信は純コードのテンプレート生成なので、送り返す先のワーカーがなく、テンプレート自体が壊れていたら人手への引き継ぎしか手がありません。今回の実行では踏んでいません(テンプレートは定数なので必ず gate を通ります)。それでも残してあるのは、誰かがテンプレート文字列を壊したときに、空回りするループより no_rewriter の記録が見たいからです。

差し戻しのときにワーカーへ渡すものは単純です。前の版の全文 + gate のレポート + 「レポートで名指しされた問題だけを直し、返信全体を書き直すこと」の一文(組み立ては callWorker の中)。

2通りの止まり方が、今回の実行で両方とも起きている

スタブに2つの筋書きを仕込んで、ループのそれぞれの出口を1回ずつ通るようにしてあります。

T-1005: 正しく直って完了。 bug ワーカーの初版はチケットIDを書き忘れており(第1ルール不通過)、gate は missing_ticket_id を返し、ワーカーはレポートどおり冒頭行を足し、第2版が通過します。

text
{"ts":"2026-08-26T13:41:46.444Z","run_id":"run-mta57gsx","node":"review","event":"gate","ticket":"T-1005","round":0,"pass":false,"report":"missing_ticket_id"}{"ts":"2026-08-26T13:41:46.505Z","run_id":"run-mta57gsx","node":"review","event":"worker_done","ticket":"T-1005","round":2,"calls":1,"tokens":1638}{"ts":"2026-08-26T13:41:46.506Z","run_id":"run-mta57gsx","node":"review","event":"gate","ticket":"T-1005","round":1,"pass":true,"report":""}

T-1004: 直したが、直っておらず、ループが自分で止まった。 billing ワーカーの初版は「お待ちください」と書き、gate は filler_word:お待ちください を返します。ワーカーは書き直し、文はまるごと別物になり、長くなり、説明も増えましたが、その言葉は残ったままです。2ラウンド目のレポートは1ラウンド目と同一です。

text
{"ts":"2026-08-26T13:41:46.381Z","run_id":"run-mta57gsx","node":"review","event":"gate","ticket":"T-1004","round":0,"pass":false,"report":"filler_word:お待ちください"}{"ts":"2026-08-26T13:41:46.443Z","run_id":"run-mta57gsx","node":"review","event":"worker_done","ticket":"T-1004","round":2,"calls":1,"tokens":1395}{"ts":"2026-08-26T13:41:46.443Z","run_id":"run-mta57gsx","node":"review","event":"gate","ticket":"T-1004","round":1,"pass":false,"report":"filler_word:お待ちください"}

この瞬間 gate.report === lastReport が成立し、ループは進展なしと判定して停止し、このチケットに needs_human を立てます。本当はまだ2ラウンド分の予算が残っていましたが(MAX_REVIEW_ROUNDS は 3)、使っても無駄になります。同じレポートを投げ返せば、返ってくるのはたいてい同じ返信だからです。「これ以上進展しない」という出口の価値はここにあります。最大ラウンド数より早く損切りでき、しかも情報量のある結論を返します。「3回試してもだめだった」ではなく「このフィードバックを理解していない」——これこそが人間にエスカレーションすべきだというシグナルです。

2つの出口の違いは、データを見れば一目で分かります。

2件とも gate_rounds は 1 で、ラウンド数だけでは成功と失敗を見分けられません。分かれ目は gate_reports の長さです。これは失敗したレポートを毎回記録しており、停止の原因になった最後の1件も含みます。T-1005 は1件しか残していません(第2版が通過したので、2件目のレポートが出ていない)。T-1004 は内容の同じものを2件残しており、stop フィールドが結論をそのまま no_progress と書いています。

ノード5: レポートとトレース

最後のノードも純コードです。state.nodes とチケット別の内訳を2つの表として印字し、needs_human を数え、終了コードを決めます。すべて通過なら 0、1件でも人手が必要なら 1 です。

トレースは2つのファイルに分かれ、それぞれ役割があります。run.jsonl はコース11(可観測性とデバッグ: エージェントの一歩一歩を見る)の構造化ログで、1行1 JSON イベント、各行が tsrun_id を持ち、あとから grep できます——今回の実行は合計 39 行で、前の節の抜粋はすべてそこから逐語で grep したものです。

run-state.json は実行のトレースを記録します(「グラフの状態 = あの数個のスクリプト変数」とは別物です)。書き方はコース9(状態管理と永続化: 長いタスクを中断から生き延びさせる)の流儀です。まず .tmp に書き、それから rename で原子的に差し替えるので、どの瞬間に殺されてもディスク上にあるのは直前の完全な状態か、新しい完全な状態のどちらかで、半分の JSON になることはありません。

書き込みのタイミングは「小さなステップごとに永続化」です。各ノードが完了するたびに1回、レビューノードの内側ではチケット1件を判定するたびにもう1回。レッスン5が引いた理由——各エージェントの結果を逐次追跡することが、まさに同じセッション内で実行を再開できる前提です1。多数の小さなエージェントに作業を扇状に広げるワークフローは、1体の長時間エージェントよりも多くの進捗を保全します1。このグラフはマルチエージェントのランタイムではありませんが、同じ言明がここでも成り立ちます。6件のチケットは6つの独立した進捗単位であり、レビューの途中で死んだとき、すでに永続化されたものが一緒に消えてはいけません(ファンアウトの段階はまだこれを達成できていません——突き合わせ表の項目3を参照)。

この言明の効果を実際に見るには、STOP_AFTER=merge でファンアウトの後、レビューの前にプロセスを止めます。

text
\$ STOP_AFTER=merge node orchestrate.mjsinbox/ からチケット 6 件を受領: T-1001, T-1002, T-1003, T-1004, T-1005, T-1006[route] T-1001=billing  T-1002=bug  T-1003=other  T-1004=billing  T-1005=bug  T-1006=other[fanout] 並行度の上限 2、初稿 6 件を産出[merge] out/ に 6 件を書き出し、下流へは参照と一行要約のみ渡す[stop] STOP_AFTER=merge: review の前で停止、今回の実行に判定はなし\$ echo \$?2

この時点の run-state.json(抜粋)はこうです。

3つのノードの勘定は揃っており、6件すべてのカテゴリ、処理者、出力ファイルのパスも揃っており、6件の初稿ファイルはすでに out/ に永続化されています。失われたのはレビュー区間だけです。すべてのチケットが status: "drafted"stop: null で止まっています。この状態があれば再開を支えるのに十分です——out/ から初稿を読み戻し、レビューノードから直接始められます。T-1005 の one_line が、たまたま初稿の欠陥を露出させていることに注目してください。冒頭にチケットIDがありません。レビューがまだ走っていないので、この欠陥はまだ捕まっていないのです。

STOP_AFTER が認識する値は merge の1つだけで、コース9の制御されたクラッシュ地点の簡略版です。終了コードは 0 が全件通過、1 が人手の要るチケットあり、2 が早期停止で判定なし、3 がスクリプト自体のクラッシュ——4つのコードは重複せず、CI は「走ったが一部は引き継ぎが要る」と「クラッシュした」を一目で区別できます。)

完全な orchestrate.mjs

以下が全文です。連続した1ブロックなので、空のディレクトリで orchestrate.mjs に貼り付けて node orchestrate.mjs を実行してください。依存ゼロ、npm i 不要、package.json も不要(.mjs という拡張子が ES モジュールであることをすでに宣言しています)、API キーも不要です——モデルのクライアントはスタブです。初回の実行で inbox/kb/out/ が作られ、あの6件のチケットが書き込まれます。

全体で 679 行、うちおよそ 190 行はスタブに与えるデータ(SCRIPTS の表、6件のチケット原文、既知不具合DB、スタブクライアント)で、実際のオーケストレーションのロジック——5つのノード、並行プール、gate、エントリポイント——がおよそ 250 行、残りの 40 行ほどが可観測性と状態トレースです。この規模は意図的なものです。1つのループといくつかのパターンは、本当に数行のコードで実装できるものだからです2

検証の仕掛け

本レッスンのターミナル出力はすべて、このスクリプトを実際に走らせて得たものです。「何度も走らせて見栄えのよいものを選ぶ」のではなく、非決定性の源を2つ、あらかじめ釘付けにしてあります。

モデルを、固定キューを再生するスタブに差し替える。 SCRIPTS は表で、キーは「チケットID + 第何版か」、値は事前に書いたレスポンスの列です。messages.create を呼ぶたびに次の1つを順に吐き出し、キューを使い切ってもなお呼べば直接 throw します。こうすることで「どのチケットが第何ラウンドでどのツールを呼ぶか、モデルがいつ書き終えるか」がすべて定数になります。スタブにはアサーションも1つ残してあります。createmodelmax_tokens を必ず持たなければならず、欠ければ throw します——本物のクライアントはこの2つのパラメータを要求するので、スタブが肩代わりしないようにしておけば、本物に差し替えた日に穴を発見することがありません。この手法はコース8の実践からここまでずっと使っています。検証対象があなたの制御ロジックであって、その日のモデルの調子ではないようにするためです(本物のモデルは非決定的で、同じ入力でも異なるレスポンスを返し得ます4)。

スタブはさらに 60ms の固定遅延を足して、本物のネットワーク往復の代わりにしています。これがないと全ノードが 0ms になり、並行プールの効果がサマリー表にまったく現れません。前掲の POOL_SIZE=1 の比較(494ms 対 247ms)はこれに依存しています。

スタブに2つのループの筋書きを仕込む。 レビュー回路が本当に回るには、本当に gate を落ちるものが要ります。そこで:

  • T-1005#1(bug ワーカーの初版)はわざとチケットIDを落として missing_ticket_id を起こし、T-1005#2 が冒頭行を足して第2版が通過します——これで「検査・修正・再検査」の正常完了の出口を見せます。
  • T-1004#1T-1004#2(billing ワーカーの2つの版)はどちらも「お待ちください」を抱えています。2つの版は文がまるごと違い、長さも違いますが、gate はその言葉があるかどうかを見るので、2ラウンドのレポート文字列は同一になり、「これ以上進展しない」を起こします——これで損切りの出口を見せます。

この2つの筋書きの書き方には工夫があります。第2版を初版の逐語の繰り返しにはしません(それでは人間が見ても無限ループだと分かってしまいます)。「直したが、正しく直っていない」にしてあります。これは実際のループで最もよく起こる失敗モードであり、まさに「2ラウンド連続で同じレポート」という基準が捕まえるものです。

制御された早期停止。 STOP_AFTER=merge はファンアウトの後、レビューの前でプロセスを止め、終了コードは 2 です。これはコース9の CRASH_AFTER の簡略版で、「どのステップで中断するか」を正確に指定できるパラメータにし、運任せで当てにいかないようにするものです。前掲の drafted 状態の run-state.json は、この実行から得たものです。

突き合わせ表: このグラフが前のレッスンに負っている借りを、1行ずつ清算する

コースが総まとめの実践に至ったときにいちばんやりがちな失敗は、前に立てたルールを黙って覆すことです。だからここで1行ずつ突き合わせ、食い違いは明示的に書き出します。

1. ループ本体はコース7と一致。 ループ本体の4ステップ——assistant を push、ツールを実行、tool_result を push、response を再代入——はコース7のレッスン6と一字一句同じで、コメントも変えていません。バルブ1も元の位置にあります。宣言する差分: runAgent のシグネチャに clientsystem の2引数が増えました(3つの役割が別々のスタブと別々のシステムプロンプトを必要とするため)。create の呼び出しに system フィールドが増えました。token の計測がループ本体から metered ラッパーへ移ったため、コース7のバルブ2(token 予算)は付いてきておらず、バルブ3(空回り検知)とバルブ4(人間の承認)も移っていません——このグラフのツールはファイル読み取りと注文照会だけで、どちらも読み取り専用の操作なので承認を要する高影響アクションがなく、スタブのキューは有限なので空回りもできないからです。本物の API につなぐ前に、この3つのバルブは必ず戻してください。

2. 委任プロンプトは4要素が揃っている(レッスン4)。 ルーター、billing ワーカー、bug ワーカーの3つのプロンプトはいずれも、目的、出力フォーマット、ツール指針、タスクの境界の4節を1行ずつ書いており、1行ずつ突き合わせられます3

3. 並行プールに上限があり、合流はペイロードではなく参照を渡す(レッスン3)。 runPoollimit は硬い上限で、POOL_SIZE=1POOL_SIZE=2 の所要時間の差ですでに検証しました。merge 以降は下流に {id, category, handler, file, oneLine} を渡し、全文は out/ に残り、レビューノードは自分でファイルから読み戻します3宣言する差分: レッスン3のプールは「同じ一群のサブタスクを並列に走らせる」ものでしたが、ここではプールが3種類の処理者にまたがります——2つのモデルワーカーと1つの純コードテンプレートで、テンプレートがプールに入ってもほとんど時間を使いません。プールの意味論は変わっていません(実行中のタスク数が上限を超えない)。タスク自体が異種混合になっただけです。もう1つ、レッスン3が立てたのにここで省いたものがあります。スクリプトを短くするため、レッスン3が要求していた「レーンごとに別々の try/catch を持たせ、1レーンの失敗が一括処理全体を道連れにしないようにする」を runPool は備えていません——その代償は、ファンアウトの段階でどれか1レーンが throw すると初稿がまとめて永続化されないことです。本物の API につなぐ前には必ず足してください。本物のネットワークでは1レーンのタイムアウトは日常です。

4. gate をジャッジより前に、ループの停止条件はレッスン5と一致。 第一フィルタはモデルではなく決定的なコードです。本レッスンは LLM ジャッジの層を入れていません。この一群のチケットの受け入れ基準がたまたまルールとして書けるので、入れれば金の無駄になるからです——コース10の階層的な判定はこの順序です。決定的に判定できるものを先に判定し、残ったものをジャッジに相談する。ループの停止条件は3種類、通過、これ以上進展しない、最大ラウンド数に到達1 2で、概念としてはレッスン5と1対1に対応します。ただしフィールド名と値名は変わりました: レッスン5は reason フィールドに着地し、値は passed/no-progress/max-rounds でしたが、ここでは stop フィールドに着地し、値は gate_pass/no_progress/max_rounds です(判定者がジャッジから gate に替わり、ハイフンも本レッスンの snake_case の慣習に合わせてアンダースコアに変えました)。さらにレッスン5の rounds は生成回数を数えており初稿が第1ラウンドでしたが、本レッスンの gate_rounds は書き直し回数を数えており初稿は第0ラウンドです。したがって同じチケットでも、2つのレッスンではラウンド数の起点が1つずれます。宣言する差分: コードには4つ目の出口 no_rewriter があります(純コードのテンプレートには送り返す先のワーカーがない)。これはレッスン5が取りこぼしたパターンではなく、このグラフに固有の事情です——レッスン5のループは「生産者はモデルである」と前提していましたが、ここでは一部の生産者がテンプレートです。今回の実行ではこの分岐を踏んでいません。

5. 「グラフ」という言い回しはレッスン5の宣言と一致。 全文の「グラフ」「ノード」はどちらも本レッスン独自のエンジニアリング上のメタファーであり、レッスン5がこの作図体系を導入するときにすでに明言したとおり、どの一次資料の公式概念でもありません。立てる錨はあの1つだけです。ワークフローのスクリプト自体がループ、分岐、中間結果を保持する1。本レッスンは新しい用語を1つも足していません——「状態機械」「ノード間を受け渡される状態オブジェクト」はどちらも使っていません。レッスン5が定義した「エッジ」(誰の出力が誰に流れるか)は merge → review のデータの流れを説明するときに1度使っただけで、新しい語彙ではありません。routed / drafts / items はただのローカル変数3つです。

6. run-state.json の原子的書き込みはコース9と一致。 まず .tmp に書き、それから renameSync で差し替える。1ステップも欠けていません。書き込みのタイミングもあのコースの基準どおりで、小さなステップが完了するたびに1回永続化し、実行全体が終わってから1回ではありません。

7. 可観測性の基準はコース11と同じ形だが、粒度は粗い。 1行1 JSON イベントで、各行が tsrun_id を持ち、あとから grep できます。4つの差分: (a) コース11のロガーは内容の要約(形、長さ、先頭数文字)を記録しますが、本レッスンは ID、カテゴリ、ファイル名、レポート文字列と各種カウントしか記録せず、返信の全文は記録しません——全文はすでに out/ にあります。(b) 関連付けのフィールドをコース11は trace_id と呼びますが、ここでは run_id と呼んでいます。(c) あのコースの核心は span_id/parent_id でトレースのツリーをつなぐことですが、このグラフはノード→ワーカー→ツールの3層入れ子であるにもかかわらず親子の紐付けを実装しておらず、トレースのツリーがありません。(d) initLog() は実行のたびに run.jsonl をクリアし、最新の1回分しか残しません。コース11の実行間の比較(v-goodv-bug)をやるには、run_id ごとに別ファイルへ追記する方式に変える必要があります。このグラフを本物のトレースシステムにつなぐには、コース11の span フィールドをあのパターンに従って足す必要があります。

8. オーケストレーター・ワーカーのパターンは、本レッスンが意図的に実装していない(レッスン4)。 レッスン4のオーケストレーター・ワーカーの肝は「何件配るか、それぞれ何をするか」をモデルが入力を見てその場で決めることです。このグラフはそうではありません。6件のチケットをどう分類するか、各カテゴリがどのワーカーへ行くかは、コードの1行目を書く前に CATEGORIES と3つの定数プロンプトに固定されています。これはまさにレッスン4の「事前定義できるなら動的にしない」の直接の適用です。この一群の仕事の形は既知なので、決定権をモデルに戻すべきではありません。したがって厳密に言えば、このファイルに溶接されているのは4つのパターン(チェーン、ルーティング、並列化のセクショニング、レビュー回路)で、投票はレベル2の演習で5つ目として補い、オーケストレーター・ワーカーはこの一群のタスクの性質によって外された1つです。

境界

このグラフが面倒を見ているものは小さいです。1プロセス、1バッチのチケット、走って終わる。作る価値があるのは、「チケットが届く → 分類する → カテゴリごとに処理する → 検査する → 報告する」という5ステップが、コードの1行目を書く前に確定していたからです。もしタスクが「この顧客が過去半年に実際に何に遭遇したのかを突き止めよ、何ステップ必要かは自分で判断せよ」になったら、このグラフは間違ったアーキテクチャです。事前にステップを予測できず、固定した経路をハードコードできない、その種のオープンエンドな問題は、本質的に自律的なループに属します2

いくつかの境界を、明示しておきます。

ファンアウトは同期で、規模が大きくなると痛みます。 fanoutNode のプールは、一括処理全体の完了を待ってから merge に入らなければなりません。これはまさに、あの実運用システムが認めていたボトルネックです。同期実行は調整を単純にしますが、情報の流れにボトルネックを作ります——1体のサブエージェントが長引けば、システム全体がそれを待って止まります3。6件で各件たかだか2回の呼び出しなら、このボトルネックはまったく痛みません。600 件で各件 10 回になれば、「最も遅い1件がバッチ全体のウォールクロック時間を決める」に変わります。非同期に変えるかどうかは、代償を計算しなければなりません。非同期はエージェントを同時に働かせ、必要に応じて新しいものを起動させますが、結果の調整、状態の一貫性、サブエージェントをまたぐエラーの伝播という難しさを追加します3——この3つは同期版には存在しません。順序がコードで決まっているからです。

レビュー回路の2つのルールは浅く、脆いです。 includes("お待ちください") は「お待ちくださいと言う必要はなく、すでに処理済みです」のような文もその場しのぎと誤判定します。これはコース10が昔から警告していた問題です。厳しすぎる決定的な検証器は、正しいものを誤りと判定します。本番で使うなら、この2つのルールは実際の返信の小さな一群に対して較正するか、あるいは「ジャッジの再確認に回すフラグ」に格下げして、直接書き直しに送り返さないようにする必要があります。

本物の API に差し替えても、動かすのはスタブだけで、構造は動きません。 makeStubClient(queue)new Anthropic() に替え、SCRIPTS の表をまるごと削除すれば、残りは1行も変わりません——runAgent は最初から本物の API の stop_reason / tool_use / tool_result の形に合わせて書かれており、modelmax_tokens も常に持たせてあります。差し替えたあと3つのことが変わります。分類結果が揺れる(同じチケットでも2回の実行で別のカテゴリに落ちうる)、gate のラウンド数が揺れる、token 数が揺れる。1回走らせるのに金と時間がかかる。そしてコース7の移っていない3つのバルブを必ず戻すこと。

複雑さを1層足すたびに「測定可能な改善」というゲートを通すこと。 このグラフのどのパターンも、個別に外せます。ルーティングをやらず、汎用のプロンプト1つでチケットに返信することもできます。ファンアウトをやらず、6件を直列に走らせても終わります。レビュー回路をやらず、人手の抜き取り検査という手もあります。外したときに指標が下がるのか、どれだけ下がるのかは、試さなければ分かりません。複雑さが本当に結果を改善するときにだけ、それを足す価値があります2

💻 演習

まとめ

  • 4つのパターンを1つのファイルに溶接しました(投票は演習で5つ目として補い、オーケストレーター・ワーカーは振り分けが事前定義できるため意図的に不在)。「計画がコードにある」という言明には具体的な形があります。main() の十数行がすべて制御フローで、routed / drafts / items という3つのただの変数がすべて状態です。LLM とツールは事前定義されたコード経路を通じてオーケストレーションされ2、スクリプト自体がループ、分岐、中間結果を保持し、モデルのコンテキストはそのステップに必要なものだけを保持します1
  • すべてのノードがモデルである必要はありません。5つのノードのうちモデルを呼ぶのは2つで、mergereport、gate の第一フィルタはすべて純コード、other カテゴリは文字列テンプレートを通ります。決定的なコードが同じ答えを出せるところで、呼び出し1回分の金とレイテンシを払う理由はありません
  • ルーティングの価値はあの呼び出しにあるのではなく、呼び出しのあとの十行の締め付けコードにあります。モデルの自由なテキストが3つの正当なラベルのどれかに押し込まれ、下流の分岐はコードが検証済みの値しか認識しません。専門化されたプロンプトは、分類が買った配当です2
  • ファンアウトの並行度には上限が要り、合流はペイロードではなく参照を渡さなければなりません——出力はディスクに落とし、下流には軽量な参照だけを渡し3、レビューノードは自分でファイルから読み戻します。同期のファンアウトはこの規模なら痛みませんが、規模が大きくなればボトルネックになります3。非同期に変えるなら3つの代償を払わなければなりません。結果の調整、状態の一貫性、サブエージェントをまたぐエラーの伝播です3
  • レビュー回路は検査・修正・再検査で、通過するか、これ以上進展しなくなるまで回り1、そこに最大ラウンド数の安全網が加わります2。決定的な gate はジャッジより前に並びます。「2ラウンド連続で同じレポート」という基準は最大ラウンド数より早く損切りでき、しかも返す結論の情報量が多い——「3回試してもだめだった」ではなく、「このフィードバックを理解していない」です
  • 逐次のトレースが復旧可能性をもたらします。各ノードの完了ごとに1回永続化することが、まさに同じセッション内で実行を継続できる前提です1(プロセスやマシンをまたぐ継続は、状態をディスクに永続化した本レッスンが自前で足した一段の格上げです)。これを .tmp に書いてから rename する原子的な差し替えと組み合わせれば、どの瞬間に殺されてもディスクには読み戻せる完全な状態が1つあります
  • このグラフが面倒を見るのは、1プロセス、1バッチのチケット、ステップが確定した仕事です。ステップを予測できないオープンエンドな問題は自律的なループに戻すべきです2。複雑さを1層足すたびに「測定可能な改善」というゲートを通さなければなりません2

12レッスンはここで完結です。

振り返れば、いま手元にあるものは1つずつ積み上げてきたものです。コース1(Claude Code Skills: 自分専用の AI ワークフローを作る)で最初のプロンプトを書き、要求をはっきり述べることを学びました。それからツール呼び出し、ワークフロー、スキル、マルチエージェントコラボレーションと来て、コース7に至ります——あのコースは自分の手でループを書かせました。while (response.stop_reason === "tool_use")。その日からエージェントはあなたにとってブラックボックスではなく、読めるコードの一片になりました。コース8(コンテキストエンジニアリング: 有限のアテンションを効くところに使う)はそのコンテキストの管理を教え、ウィンドウが破裂するまでループを回させないようにしました。コース9は中断を生き延びさせることを教え、殺されても最後に止まった場所から続けられるようにしました。コース10は出力を検証すること、「終わったように見える」と「終わった」を分けることを教えました。コース11はその過程を見ること、壊れたときに調べられるログとトレースを持つことを教えました。そして本コースは、複数のループを、それ自身が計画を保持するグラフに組み上げることを教えました。

この6つは1つのことの6つの面です。自分が書いたコードの中で、非決定的なものを制御している、ということです。ループはあなたが書いたもの、コンテキストはあなたが管理するもの、チェックポイントはあなたのセーブ、受け入れ基準はあなたの定義、ログはあなたの print、計画はあなたの段取りです。モデルは非常に強力ですが、あなたが組んだこの制御コードの中で働いています。

最後の一歩は具体的な行動に着地します。orchestrate.mjsmakeStubClient(queue)new Anthropic() に差し替え、SCRIPTS の表を削除し、コース7の移っていない3つのバルブを戻し、そして自分の仕事で実際に積み上がっている一群のタスク——本物のチケット、本物のログ、本物の todo——を inbox/ に放り込んで、初回を走らせてください。おそらく何件かが needs_human に落ちるはずです。それこそが、このグラフのあるべき姿です。

Footnotes

  1. Orchestrate subagents at scale with dynamic workflows — Claude Code official documentation — https://code.claude.com/docs/en/workflows 2 3 4 5 6 7 8 9 10

  2. Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents 2 3 4 5 6 7 8 9 10 11 12 13 14 15

  3. How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system 2 3 4 5 6 7 8 9

  4. Writing effective tools for agents — with agents — Anthropic Engineering — https://www.anthropic.com/engineering/writing-tools-for-agents

練習

01

以下はこのグラフを1回完全に走らせたときのサマリー表と、run-state.json から取った2件のチケットの記録です(実際の実行結果で、ミリ秒と run_id は毎回変わります)。

レベル1: 図を読む——ループの中で実際に何が起きたか
text
=== グラフ全体の実行サマリー ===ノード    所要    モデル呼出  token    gate 回数   状態route     62ms    1           720      -           okfanout    247ms   8           8903     -           okmerge     2ms     0           0        -           okreview    127ms   2           3033     2           ok
=== チケット別の内訳 ===チケット カテゴリ  処理者            gate 回数   停止理由        状態T-1001   billing   worker:billing    0           gate_pass       passT-1002   bug       worker:bug        0           gate_pass       passT-1003   other     template          0           gate_pass       passT-1004   billing   worker:billing    1           no_progress     needs_humanT-1005   bug       worker:bug        1           gate_pass       passT-1006   other     template          0           gate_pass       pass

コードを書かずに、3つ答えてください。(1) 6件のうちレビュー回路に入ったのはどれで、それぞれ何ラウンド回りましたか。どのフィールドから読み取りましたか。(2) T-1004 と T-1005 の gate_rounds はどちらも 1 なのに、なぜ一方が pass で他方が needs_human なのですか。証拠はどのフィールドにあり、どう読みますか。(3) ファンアウトが完了した直後、レビューが始まる前にプロセスが殺されたと仮定します。run-state.json は何を保全でき、何が失われますか。再起動後はどのステップから再開できますか。

完了基準 · ローカルでチェック
02

other カテゴリに、語調の加減が難しいチケットが1件あります——T-1006:「3か月使っていますが、何度問題を報告しても音沙汰がありません。この製品はまだ誰か保守しているのでしょうか」。固定のテンプレート1つでこれに返信するのは、たいてい不適切です。冷たすぎればあしらったように見え、温かすぎれば過剰な約束のリスクがあります。

レベル2: グラフに投票ノードを足す

このグラフに投票ノードを足してください。同じチケット、同じタスクを、2つの角度から1回ずつ走らせ[^S1]、それから純コードで両方の版を比較し、優れたほうを合流に入れます。比較のルールは2つだけで、どちらもモデルに訊いてはいけません。まず gate の決定的なルールで足切りし(禁止語があるかチケットIDがなければ即失格)、生き残ったほうから短いほうを選びます(カスタマーサポートの返信は冗長にしない)。

要求: 2つの角度のプロンプトはどちらも4要素を備えること。2回の呼び出しはどちらも正直に runAgent を通ること(つまり完全なループを通ること)で、スタブはそれぞれにレスポンスキューを1つ用意すること。選抜の過程はターミナルと run.jsonl に痕跡を残し、なぜこの版が選ばれたのかが分かるようにすること。書き終えたら実際に1回走らせ、出力を貼ること。あわせて1つ答えてください。ここで純コードの比較を使い、モデルにどちらの版が良いか判定させないのはなぜですか。

完了基準 · ローカルでチェック