第10章 エージェントの構築 — 解答例
本文: 第10章 エージェントの構築
→ 参照: 10.2.1節、第5章5.3.2節、10.2.5節
第5章5.3.2節のループで既に実装済みなのは、LLM API呼び出し・ループ本体・ツールディスパッチ・ステップ数上限のみである。10.2.1節の表のうち未実装なのは次の6つ。
| 未実装 | 現状 |
|---|---|
| 出力のパース | 構造化出力・検証・修復なし(10.2.3) |
| エラーとレート制限 | 再試行・バックオフ・部分的失敗の扱いが一切ない(10.2.4) |
| コンテキスト管理 | messages に無制限に追加している(第8章8.3節) |
| 予算と上限 | MAX_ITERATIONS のみ。コスト・時間の上限なし |
| トレースとログ | 皆無(第12章) |
| 永続化と再開 | 皆無(10.2.5) |
(a) 社内向け調査アシスタント → トレースとログ 失敗が許容される(ユーザーがやり直せる)ため、回復機構より原因を知る手段が先である。5〜10ステップなら永続化の価値は小さく、コンテキストも溢れにくい。一方、改善のサイクル(第11章・第12章)はログがなければ回らない。
(b) 夜間バッチ1万件 → エラーとレート制限の処理 1万件×3ステップ = 3万回の呼び出しであり、RPM/TPM は必ず枯渇する(10.2.4①)。無人実行なので障害時に人が介入できず、対策がなければ大半が落ちる。セマフォによる並行度制御(10.2.4④)とバックオフが最優先。次点はコスト上限。
(c) 数時間のコード移行 → 永続化と再開 実行が長いほど途中で落ちる確率は上がり、最初からやり直すコストは膨大である(10.2.4②の「再試行は巨大な入力の再送」)。10.2.5節のとおり、ツールの副作用の記録が要点で、「ファイルを書き換えた」を再開時に繰り返さないよう冪等キー(第7章7.5.3節)が効く。ここが10.4.1節でフレームワークを検討する最大の動機になる。
→ 参照: 10.2.4節③
問題① return_exceptions=True がない。
asyncio.gather は既定で、1つでも例外が上がった時点で全体を中断し、その例外を送出する。成功した他のツールの結果まで捨てられる。エージェントにとってこれは致命的で、モデルは「何が成功して何が失敗したか」を一切知らされないまま、そのステップ全体を失う。原則は「失敗したものだけをエラーとして返し、成功したものはそのまま返す」である。
問題② tool_result に is_error が付いていない。
content にエラー文字列が入るだけでは、モデルはそれを正常な結果として読んでしまう。第7章7.5.1節の「エラーは回復のための情報である」が成立するのは、失敗が失敗として伝わるときだけである。is_error: True を付けて初めて、モデルは別の手を打てる。
加えて、例外判定は isinstance(out, BaseException) で行う。 asyncio.CancelledError は Python 3.8 以降 Exception ではなく BaseException の直接の子である。Exception だけで判定すると、タイムアウトによるキャンセル時にこの分岐に入らず、正常系の out.content にアクセスして AttributeError になる。
修正版
import asyncio
async def run_tools(registry, tool_uses) -> list[dict]: outcomes = await asyncio.gather( *(registry.execute_async(tu.name, tu.input) for tu in tool_uses), return_exceptions=True, # ① 1つの失敗が全体を巻き込まない ) results = [] for tu, out in zip(tool_uses, outcomes): if isinstance(out, BaseException): # ③ Exception では取りこぼす results.append({ "type": "tool_result", "tool_use_id": tu.id, "content": f"ツールの実行に失敗しました: {type(out).__name__}: {out}", "is_error": True, # ② 失敗を失敗として伝える }) else: results.append({ "type": "tool_result", "tool_use_id": tu.id, "content": out.content, "is_error": out.is_error, }) return resultsasync function runTools( registry: AsyncToolRegistry, toolUses: Anthropic.ToolUseBlock[]): Promise<Anthropic.ToolResultBlockParam[]> { // Python の gather(return_exceptions=True) に相当するのが allSettled。 // Promise.all は最初の reject でただちに失敗し、成功した結果まで捨ててしまう const outcomes = await Promise.allSettled( toolUses.map((tu) => // ① 1つの失敗が全体を巻き込まない registry.executeAsync(tu.name, tu.input as Record<string, unknown>) ) ) const results: Anthropic.ToolResultBlockParam[] = [] // Python は zip で対応づける。allSettled は入力と同じ長さ・同じ順序で返る for (const [i, tu] of toolUses.entries()) { const out = outcomes[i] if (out === undefined) continue // noUncheckedIndexedAccess のための確認 if (out.status === 'rejected') { // ③ JavaScript に Exception / BaseException の区別はなく、 // 中断(AbortError)も同じ rejected として受け取れるため取りこぼさない const e: unknown = out.reason const detail = e instanceof Error ? `${e.name}: ${e.message}` : String(e) results.push({ type: 'tool_result', tool_use_id: tu.id, content: `ツールの実行に失敗しました: ${detail}`, is_error: true, // ② 失敗を失敗として伝える }) } else { results.push({ type: 'tool_result', tool_use_id: tu.id, content: out.value.content, is_error: out.value.isError, }) } } return results}採点の観点: ①②の両方を指摘できているか。is_error の欠落は「モデルが失敗を認識できない」というエージェント特有の問題であり、通常の非同期処理のバグとは質が違う点に触れられているとなおよい。
書き換え後
{ "type": "function", # (1) Responses API では type が必要 "name": "search_orders", "description": ( "顧客の注文履歴を検索する。" "status は絞り込むステータス。全ステータスを対象にする場合は null を指定する。" "limit は取得件数の上限。既定(10件)でよい場合は null を指定する。" ), "parameters": { # (2) input_schema → parameters "type": "object", "properties": { "customer_id": { "type": "string", "description": "顧客ID。CUS- で始まる。", }, "status": { "type": ["string", "null"], # (4) 任意項目は型に null を含める "enum": ["pending", "shipped", None], "description": "絞り込むステータス。指定しない場合は null。", }, "limit": { "type": ["integer", "null"], # (5) default は使わず null で表現 "description": "取得件数の上限。null の場合は10件として扱う。", }, }, "required": ["customer_id", "status", "limit"], # (3) 全項目を required に "additionalProperties": False, # (3) strict の必須条件 }, "strict": True,}const searchOrdersTool: OpenAI.Responses.FunctionTool = { type: 'function', // (1) Responses API では type が必要 name: 'search_orders', description: '顧客の注文履歴を検索する。' + 'status は絞り込むステータス。全ステータスを対象にする場合は null を指定する。' + 'limit は取得件数の上限。既定(10件)でよい場合は null を指定する。', parameters: { // (2) input_schema → parameters type: 'object', properties: { customer_id: { type: 'string', description: '顧客ID。CUS- で始まる。', }, status: { type: ['string', 'null'], // (4) 任意項目は型に null を含める enum: ['pending', 'shipped', null], description: '絞り込むステータス。指定しない場合は null。', }, limit: { type: ['integer', 'null'], // (5) default は使わず null で表現 description: '取得件数の上限。null の場合は10件として扱う。', }, }, required: ['customer_id', 'status', 'limit'], // (3) 全項目を required に additionalProperties: false, // (3) strict の必須条件 }, strict: true,}呼び出し側で既定値を補う。
args = json.loads(item.arguments) # OpenAI は引数が JSON 文字列(10.3.5節)result = search_orders( customer_id=args["customer_id"], status=args["status"], # None なら絞り込まない limit=args["limit"] if args["limit"] is not None else 10,)// OpenAI は引数が JSON 文字列(10.3.5節)const args = JSON.parse(item.arguments) as { customer_id: string status: string | null limit: number | null}const result = searchOrders( args.customer_id, args.status, // null なら絞り込まない // Python の `args["limit"] if args["limit"] is not None else 10` に相当。 // ?? は null と undefined だけを既定値に置き換える(0 はそのまま通る) args.limit ?? 10)変更点
type: "function"を追加。 Responses API はフラット構造だがtypeキーが必要である。Chat Completions ではfunctionキーの下に入れ子になる — この2つを取り違えるのが定番の詰まりどころ(10.3.2節)。input_schema→parameters。 JSON Schema の中身そのものは同じで、包む外側のキー名だけが違う(10.3.5節の表)。strict: Trueの代償として、additionalProperties: falseと、全プロパティのrequired化が必要になる。customer_idだけだったrequiredにstatusとlimitを加えた。- 任意項目は「省略できる」ではなく「null を渡せる」として表現する。
statusは"type": ["string", "null"]にするが、enumにもnull(Python ではNone)を加えないと矛盾する。enumに列挙された値しか許されないため、型で null を許しても enum が拒否してしまう。 default: 10は落とした。 strict モードが受け付ける JSON Schema はサブセットであり、defaultが有効に機能する保証がない。仮に受理されても「モデルが省略したとき補われる」わけではなく、strict ではモデルが必ず値を出力するためdefaultの出番自体がない。したがって既定値はアプリケーション側でNoneを 10 に読み替える責務にし、その約束をdescriptionに明記した。
設計上の判断: 任意項目を null で表現すると、モデルは毎回「不要なら null」と明示的に決める必要がある。description に「指定しない場合は null」と書いていないと、モデルが不必要に値を埋めてしまうため、説明文の記述が strict モードでは通常より重要になる。第7章7.3.1節の「説明文はプロンプトである」がここでも効く。
→ 参照: 10.4.2節、10.4.3節、10.5.2節③、10.3.3節
(a) ただちに移行する必要はない。 公式には既存ワークロードへの破壊的変更は予定されていないとされる。判断材料は次の3つ。
- 保守の担い手が Microsoft からコミュニティへ移ったこと。セキュリティ修正・新モデル対応・依存ライブラリの追従が、いつまでどの速度で行われるか保証がない。新しいモデルやAPI仕様の変更(本章で見た Responses API / Interactions API への移行など)に追従されないリスクが実質的な期限になる。
- そのシステムを今後どれだけ触るか。 凍結して運用するだけなら猶予は長い。機能追加を続ける予定なら、新機能開発が止まったフレームワークの上に投資を重ねることになる。
- 移行コストの見積り、すなわち結合の深さ。 これは(c)と直結する。
補助的に、10.3.3節の教訓 — 抽象度の高い機能ほど寿命と移行猶予が短い(Assistants API は1年、Agent Builder / Evals Platform は約6か月) — を踏まえ、猶予が短く告知される可能性を織り込む。**当面の結論は「新規開発はAutoGenで書かない。並行して移行計画と見積りだけ先に作る」**である。
(b)
- Azure中心の場合: Microsoft Agent Framework。AutoGen と Semantic Kernel を統合した公式の後継であり、2026年4月に v1.0 に到達している。概念の対応がつきやすく、企業向けガバナンス機能も備える。
- Python中心の場合: 目的による。会話するエージェント群という AutoGen のモデルに近く、役割分担を素直に移せるのは CrewAI。永続化・再開・明示的な制御が要るなら LangGraph。軽量なハンドオフで足りるなら OpenAI Agents SDK(ただし 0.x でAPIが不安定)。型と検証を重視するなら Pydantic AI。
(c) 移行コストは大きく下がる。 ツールの実装がフレームワーク非依存の純粋な関数として書かれ、接続層が薄ければ、書き換えるのは接続層とオーケストレーションの記述だけで済む。ツール群、プロンプト、そして第11章の評価データセット(ツールに依存しないJSONで保持したもの)は資産として残り、移行前後で同じ評価セットを走らせて退行を確認できるため、移行そのものが検証可能な作業になる。
逆に、業務ロジックがフレームワークのデコレータや状態オブジェクトの中に埋め込まれていると、移行は事実上の書き直しになり、しかも「以前と同じように動くか」を確かめる手段もない。
(a) 問い合わせメールの分類・タグ付け(1日5,000件)
フローチャート以前に、そもそもエージェントではない(第4章4.5節)。制御の所在はアプリケーション側にあり、1回のLLM呼び出しで完結する。10.2.3節の構造化出力(tool_choice でツールを強制)を使った単発呼び出しのワークフローが答えである。フローチャートに載せるなら「数ステップの単純なループ」の左端よりさらに手前にある。
追加で確認すべきこと: 要求される精度と、エージェントを使わない場合との比較(第11章11.10.2節) — 既存の分類器やルールで足りないか。即時性が不要ならバッチAPIで約50%の削減(第2章2.2.2節)。日次5,000件のコスト試算。
(b) 顧客ごとの月次レポート生成(約20ステップ、途中失敗あり) 「複雑・長時間」→「落ちたときの再開が必要か」→ 必要。Python中心なら LangGraph、.NET/Azure中心なら Microsoft Agent Framework。10.4.1節のとおり、ここが永続化と再開を自前で作らずに済ませる典型例である。
ただし追加確認が要る。手順が事前に決まっているなら、そもそもエージェントではなくワークフロー(第4章4.3.2節、第9章9.2.4節のDAG)で書ける。「データ収集→集計→作図」が固定なら、制御の所在をアプリケーション側に置いたほうが再開も容易である。また、失敗の性質が一時的なAPI障害なら再試行(10.2.4節)で足り、耐久実行は過剰かもしれない。副作用(レポートの配信)の冪等性も確認する。
(c) 社内技術文書のQAチャットボット(RAG中心) フローチャートの右下、「RAGが中心」→ LlamaIndex。ただしスクラッチ + 検索ツールも有力な候補である。
追加で確認すべきこと: それが「検索」なのか「探索」なのか(第3章3.6.4節)。1回検索して答えるだけならエージェントは不要で、通常のRAGパイプラインで足りる。複数回の検索を重ねて絞り込む必要があるなら、第5章のループ + 検索ツールという最小構成で始められる。再開が要らないなら、10.5.2節④のとおりフレームワークの価値は限定的である。
→ 参照: 10.4.4節
いずれも「モデルを変えずに周辺の設計を変えただけで結果が変わった」例である。
① ツールの設計(第7章、特に7.1節) 本書が最初に挙げた例そのものである。ツール設計への投資がプロンプト調整より効果的だったという観察がある。同じモデルでも、説明文が曖昧なツールと明確なツールでは選択の正しさが変わり(7.3.1節)、粒度が細かすぎるツール群は無駄なステップを生む(7.2.2節)。第11章11.3.3節の「完遂率が高い + ツール正確性が低い」という症状は、モデルではなくツールの問題として現れる。
② ループ制御と終了条件(第5章5.4節・5.5節) 上限・停滞検知・終了条件がないループは、同じモデルでも暴走し、停滞し、上限に張り付いて完遂率を落とす。5.4.2節で述べた「停滞時にモデルが破壊的操作へ向かう」傾向は、モデルを賢くしても消えない。足回りの側で打ち切るしかない。
③ コンテキスト管理(第8章8.3節、第2章2.4節) context editing と compaction の有無で、長時間タスクが完遂できるかどうかが変わる。コンテキストが溢れれば、どれほど賢いモデルでも判断材料を失う。第2章2.4節のコスト構造も同じ話で、周辺の設計が1タスクあたりのコストを桁で変える。
(別解として挙げられるもの) 第6章6.4.2節のコンテキストへの配置(system に置くか tool_result に置くかで外部データの扱われ方が変わる)、第9章9.2節のアーキテクチャ選択(ReAct / Planner-Executor / DAG)、第3章3.3.2節の effort の設定。
Built with Astro ・ Deployed on Cloudflare Pages
© 2026 watakumi — made with 💜 & ☕ ・watakumi.page