公式 Exam Guide 演習 — 実装ガイド
公式 Exam Guide が指定する 4 つの演習です。これを実際にコードで実装することが合格への最短経路です。
参照実装の使い方
各演習には設計判断軸と参照実装が用意されています。参照実装は折り畳まれており、まず自分で実装してから開く ことを強く推奨します。試験で評価されるのは「コードを書ける」ではなく「設計判断ができる」かどうか。参照実装を先に見ると、判断軸を体得する機会を失います。
モデル選択の判断軸
参照実装で使うモデルは演習ごとに使い分けています。これ自体が試験 D5: Context Management の「コスト最適化」判断と直結します。
- Sonnet で十分なケース(演習 1・3): 単一エージェント、定型的な抽出・ツール呼び出し、判断の分岐が少ない。Sonnet 4.6 でほぼ Opus と同等の精度が出るうえコストは約 1/5
- Opus を要するケース(演習 4): マルチエージェント coordinator のような「複数の不完全な情報から合成判断する」役割。判断ミスがパイプライン全体に波及するため、coordinator のみ Opus を当て、サブエージェントは Sonnet/Haiku に下げて費用対効果を最大化
- Haiku を選ぶケース(演習に未掲載): Skill 内のフォーマット整形、ログサマリ、定型的な分類など「決まった型に押し込むだけ」のタスク
本番運用では日付固定版を使う(例: claude-sonnet-4-6-20251022)。エイリアス claude-sonnet-4-6 は新しいスナップショットが出ると挙動が変わる可能性があるため、再現性が必要な抽出パイプラインや CI ではバージョン固定が必須。
演習 1: Build a Multi-Tool Agent with Escalation Logic
目標: stop_reason 制御・構造化エラー・フックパターンを手で書いて体得する
カバー: D1 / D2 / D5
- MCP tools を 3〜4 本定義。少なくとも 2 本は類似機能を持つツールを作り、description で明確に区別(ここが試験で問われる)
- stop_reason を検査してループを継続 / 終了するエージェンティックループを実装。
"tool_use"→ 継続、"end_turn"→ 終了 - ツールに構造化エラーレスポンスを追加:
errorCategory(transient / validation / permission)+isRetryableブーリアン + 説明。各エラータイプでエージェントが適切に対応することをテスト - ビジネスルール強制フックを実装。例:$500 超の返金をブロックして人間エスカレーションへリダイレクト
- 複数課題のリクエスト(例:「返金と住所変更を同時に」)でテスト。エージェントが課題を分解して並列処理し、統合レスポンスを返すことを確認
設計上のポイントと詰まりやすい箇所
判断軸:
- ツール description が選択の唯一の根拠。
get_customerとlookup_orderのように似た形式の identifier を扱うツールでは、「アカウント確認 → get_customer、注文照会 → lookup_order」のような 境界線を 1 文添える。これを書かないと 4〜5 個目から誤選択が増える - errorCategory ごとに retry 判断を切り替える。
transientのみisRetryable: true、validation/permission/businessは LLM 判断ではなく即エスカレーションか別アクションへ。リトライ可否を LLM に委ねない - ビジネスルールは prompt ではなくフックで強制。$500 超ブロックを CLAUDE.md やシステムプロンプトに書くのは罠(非ゼロの失敗率が残る)。
PostToolUseフックで決定論的に介入し、{ blocked: true, redirect: 'escalate_to_human' }のような構造化レスポンスを返してエージェントに次のアクションを提示する - 複数課題は 1 レスポンス内の並列 tool_use。「返金 + 住所変更」を別ターンに分けるとレイテンシが線形に増加し、後段で context が欠ける。1 つのアシスタントレスポンス内に複数
tool_useブロックを含めれば並列実行される
詰まりやすい箇所:
stop_reason === "end_turn"で終了させず、テキストに「完了しました」が含まれるかで判定してしまう(試験頻出 anti-pattern)- ツール結果の role 間違い —
userロールのtool_resultブロックとして返す(assistant ロールでもなく独立メッセージでもない) - フック介入時に
tool_resultを返さずtool_useだけ追加し、エージェントが「結果が返ってない」と再呼出ループに入る - 空配列を成功で返してしまう(access failure と valid empty result の混同、D2.2 参照)
完了基準(Definition of Done)
時間目安: 90〜120 分
動作確認チェックリスト:
-
stop_reason === "end_turn"で確実にループ終了することを確認 -
stop_reason === "tool_use"で 1 レスポンス内の複数ツールが並列実行される - 構造化エラー(4 カテゴリ)がツール戻り値に含まれ、
isRetryableで retry 判断が分岐する - $500 超の返金が PostToolUse フックでブロックされ、
escalate_to_humanにリダイレクトされる - 「返金 + 住所変更」の複数課題リクエストで両方を分解・並列処理して統合レスポンスを返す
- tool_result が
role: 'user'として履歴に追加されている(assistant ではない) - permission エラーがリトライされず、即座にエスカレーションされる
関連する試験シナリオ: Scenario 1: Customer Support Resolution Agent
参照実装を見る(自分で書いてから開くことを推奨)
構成の概要: 4 ツール + 構造化エラー型 + PostToolUse フック + stop_reason 駆動ループの最小一式。
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic()
// 1. ツール定義(境界を明示した description が肝)
const tools = [
{
name: 'get_customer',
description:
'顧客 ID またはメールでアカウントを検索・確認。返金や情報変更の前に必ず呼ぶ。注文照会には lookup_order を使うこと',
input_schema: {
type: 'object',
properties: {
identifier: { type: 'string', description: '顧客 ID またはメールアドレス' }
},
required: ['identifier']
}
},
{
name: 'lookup_order',
description:
'注文 ID で注文を照会。get_customer で本人確認したあとに使う。process_refund の前提条件',
input_schema: {
type: 'object',
properties: {
order_id: { type: 'string' }
},
required: ['order_id']
}
},
{
name: 'process_refund',
description:
'返金処理。$500 を超える額は PostToolUse フックで自動的にエスカレーション扱いになる(プロンプト側で制御しない)',
input_schema: {
type: 'object',
properties: {
customer_id: { type: 'string' },
order_id: { type: 'string' },
amount: { type: 'number' }
},
required: ['customer_id', 'order_id', 'amount']
}
},
{
name: 'escalate_to_human',
description:
'人間オペレーターへ引き継ぐ。customer_id・root_cause・recommended_action を必ず含めること',
input_schema: {
type: 'object',
properties: {
customer_id: { type: 'string' },
root_cause: { type: 'string' },
recommended_action: { type: 'string' }
},
required: ['customer_id', 'root_cause', 'recommended_action']
}
}
]
// 2. 構造化エラー型 — LLM が「リトライしていいか」を機械的に判断できる形にする
type ErrorCategory = 'transient' | 'validation' | 'permission' | 'business'
type ToolErrorResult = {
isError: true
errorCategory: ErrorCategory
isRetryable: boolean
message: string
retryAfterMs?: number
}
const RETRY_POLICY: Record<ErrorCategory, boolean> = {
transient: true,
validation: false,
permission: false,
business: false
}
function makeError(category: ErrorCategory, message: string): ToolErrorResult {
return {
isError: true,
errorCategory: category,
isRetryable: RETRY_POLICY[category],
message
}
}
// 3. PostToolUse フック — ビジネスルールを「決定論的に」強制
type ToolInput = Record<string, unknown>
function postToolUseHook(toolName: string, input: ToolInput, result: unknown) {
if (toolName === 'process_refund' && typeof input.amount === 'number' && input.amount > 500) {
return {
blocked: true,
redirect: 'escalate_to_human',
reason: '$500 超の返金は人間承認が必要',
input_for_redirect: {
customer_id: input.customer_id,
root_cause: 'refund_amount_over_threshold',
recommended_action: `Approve $${input.amount} refund for customer ${input.customer_id}`
}
}
}
return result
}
// 4. ツール実行ディスパッチャ — 各ツールのエラーカテゴリを返す例
async function dispatchTool(name: string, input: ToolInput): Promise<unknown> {
switch (name) {
case 'get_customer': {
// 検索結果なし = valid empty result(成功として返す)
// タイムアウト = transient access failure(エラーとして返す)
try {
return await fakeDb.findCustomer(input.identifier as string)
} catch (e: unknown) {
if (e instanceof TimeoutError) return makeError('transient', String(e))
throw e
}
}
case 'process_refund': {
const amount = input.amount as number
if (amount <= 0) return makeError('validation', 'amount must be positive')
return await fakeDb.refund(input as { customer_id: string; order_id: string; amount: number })
}
// ... 他のツールも同様
default:
return makeError('validation', `unknown tool: ${name}`)
}
}
// 5. エージェンティックループ — stop_reason 駆動・並列 tool_use 対応
async function runAgent(userMessage: string) {
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: userMessage }]
while (true) {
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 4096,
tools,
messages
})
messages.push({ role: 'assistant', content: response.content })
// ❌ Anti-pattern: テキストに「完了」が入っているかで判定
// ✅ stop_reason で判定
if (response.stop_reason === 'end_turn') return response
if (response.stop_reason === 'tool_use') {
// 1 レスポンス内の全 tool_use を「並列に」処理
const toolUses = response.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === 'tool_use'
)
const toolResults = await Promise.all(
toolUses.map(async tu => {
const raw = await dispatchTool(tu.name, tu.input as ToolInput)
const hooked = postToolUseHook(tu.name, tu.input as ToolInput, raw)
return {
type: 'tool_result' as const,
tool_use_id: tu.id,
content: JSON.stringify(hooked)
}
})
)
// tool_result は user ロールに含めて返す(ここを assistant にすると正しく動作しない)
messages.push({ role: 'user', content: toolResults })
continue
}
return response
}
}設計上のポイント
RETRY_POLICYを別テーブルにしているのは、新しいエラーカテゴリを足しても retry 判断が散らばらないようにするため。マジックナンバーを 1 箇所に集約するエージェントハーネス(agent harness)postToolUseHookの戻り値がblocked: true, redirect: ..., input_for_redirect: ...を含むのは、LLM が「次に何のツールを呼ぶべきか」を推論なしで判断できるようにするため。ヒントを構造化して渡すdispatchTool内でTimeoutErrorのみtransientとして捕捉している。それ以外の例外はそのまま投げて上層で捕捉 — 「全例外を transient 扱いする」と permission や validation のリトライループに陥る
関連: ここで設計した
errorCategory+RETRY_POLICYの構造は、演習 4 のSubagentFailure型でほぼそのまま再利用される。マルチエージェントでもサブエージェントの失敗をtransient / validation / permission / businessで分類し、コーディネーターが retry すべきか別経路を試すか判定する。
演習 2: Configure Claude Code for a Team Development Workflow
目標: CLAUDE.md 階層・Skills・MCP 設定を実際のプロジェクトで構築する
カバー: D3 / D2
- プロジェクトレベルの
.claude/CLAUDE.mdを作成。コーディング基準・テストコンベンションを記述。これが全チームメンバーに適用されることを確認(/memoryコマンドで検証) .claude/rules/に YAML フロントマターの glob パターンを持つルールファイルを作成。例:paths: ["src/api/**/*"](API 専用)、paths: ["**/*.test.*"](テスト全体).claude/skills/にcontext: forkとallowed-tools制限付きの Skill を作成。Skill が独立したコンテキストで実行されることを確認(メインコンテキストを汚染しない).mcp.jsonに${GITHUB_TOKEN}環境変数展開で MCP サーバーを設定。個人用実験 MCP は~/.claude.jsonに設定。両方が同時に利用できることを確認- Plan mode と direct execution を意図的に使い分け:単一ファイルバグ修正(direct)・マルチファイルリファクタリング(plan)・ライブラリ移行(plan)
設計上のポイントと詰まりやすい箇所
判断軸:
- 共有可否でファイル配置を分ける。チーム共有 →
.claude/CLAUDE.md(git 管理下)、個人 override →CLAUDE.local.md(gitignore 対象)、全プロジェクト共通 →~/.claude/CLAUDE.md(git 外)。混同すると「新メンバーが設定を受け取れない」「個人設定がチームを汚染する」が発生 paths:とglobs:の関係。試験回答ではpaths:(公式仕様)を選ぶ。一方、実装上はpaths:のクォート有無や YAML リスト記法が環境依存で動かない既知ケースが報告されており、未ドキュメントのglobs:の方が安定動作するケースがある。試験ではpaths:、本番実装で動かなければglobs:も試す が現実解user-invocable: false≠disable-model-invocation: true。前者は/メニューに 表示しない(UI 制御)、後者はモデルが 自動で呼び出さない(実行制御)。デプロイ系・破壊的副作用のある Skill は 両方設定するのが安全allowed-toolsは事前承認。ブロック機能ではない。実際にツールを禁止したい場合は.claude/settings.jsonのpermissions.denyを使う- Plan mode を使う閾値。「複数ファイル + 複数の有効なアプローチ」の両方が揃ったら plan、片方だけなら direct で十分
詰まりやすい箇所:
- 「ディレクトリ横断ルール」をサブディレクトリの
CLAUDE.mdで実現しようとする — それは実現できない。.claude/rules/の glob パターンで条件付きロードを使う ~/.claude/commands/にチーム共有のスラッシュコマンドを置いてしまい、git cloneした新メンバーに届かない- Skill の
allowed-toolsを読んで「他ツールがブロックされる」と誤解する — 試験の誤答選択肢に出るパターン .mcp.jsonに生のトークン ("GITHUB_TOKEN": "ghp_...") を書いてリポジトリに commit する — 必ず${GITHUB_TOKEN}の形で環境変数展開user-invocable: falseで自動呼出を止められる と思い込む。これは/スラッシュメニューに表示しないだけの UI 制御で、モデル側からの自動起動は止まらない。実行抑止にはdisable-model-invocation: trueを併用disable-model-invocation: trueで context token を節約できる という誤解。フラグの効果は「自動呼出抑止」だけで、Skill の name と description は system-reminder に注入され続ける。トークン節約目的の手段ではない(試験の罠候補)- path-rule は Read 時のみ注入され、Write 時には注入されない(実装上の落とし穴)。「テストファイル編集時に強制したい規約」は Read 系操作の文脈にしか効かないことに注意。試験範囲ではないが本番運用で踏むので一度知っておく価値あり
完了基準(Definition of Done)
時間目安: 60〜90 分(コードを書くより設定ファイルを書く時間が支配的)
動作確認チェックリスト:
-
.claude/CLAUDE.mdの内容が/memoryコマンドで確認できる -
.claude/rules/api.mdがsrc/api/**配下のファイルを Read した時のみ会話に注入される -
.claude/skills/release-note/SKILL.mdが/release-note明示呼び出しでのみ実行される(自動起動しない) -
.mcp.jsonの${GITHUB_TOKEN}が環境変数から展開される(生のトークンが commit されていない) -
.claude/settings.jsonのpermissions.denyでBash(rm -rf:*)等が物理的にブロックされる -
CLAUDE.local.mdが.gitignore対象になっており、git statusに出ない - 単一ファイル修正は direct execution、マルチファイル変更は plan mode を意図的に使い分けられる
関連する試験シナリオ: Scenario 2: Code Generation with Claude Code, Scenario 4: Developer Productivity with Claude
参照実装を見る(自分で書いてから開くことを推奨)
構成の概要: チーム標準を .claude/ に集約し、個人 override を CLAUDE.local.md に分離、API 専用ルールを .claude/rules/ で条件付きロード、/release-note Skill を context: fork で隔離。
<!-- .claude/CLAUDE.md(チーム共有・git 管理下) -->
# チーム標準
## コーディング規約
- TypeScript: strict mode 必須、`any` 型禁止、関数の戻り値型は明示
- React: Server Components 優先、'use client' は必要時のみ
- テスト: 新規モジュールは追加時にテストを書く(カバレッジ ratchet)
## Pull Request の流れ
1. `feat/...` ブランチで実装
2. `npm run typecheck && npm run test && npm run build` がパスすること
3. PR は draft で起票し、CI 緑になったら ready 化
4. squash merge
外部参照:
@./docs/architecture.md
@./docs/migration-rules.md<!-- .claude/rules/api.md(API 専用ルール、API ファイル編集時にだけロード) -->
---
paths:
- "src/api/**/*.ts"
- "src/api/**/*.tsx"
---
API ハンドラの規約:
- 入力 schema は zod で定義し、`parse` ではなく `safeParse` を使う
- すべてのエンドポイントに rate limit middleware を通す
- ログには PII(メール・電話番号)を含めない<!-- .claude/rules/typescript.md -->
---
paths:
- "**/*.ts"
- "**/*.tsx"
---
- import は `import type` で型のみインポートする箇所と値インポートを区別する
- exhaustive switch は `assertNever` ヘルパでガードする<!-- .claude/skills/release-note/SKILL.md -->
---
name: release-note
description: 直近のリリースタグから現在までのコミットを集約し、CHANGELOG エントリを下書きする。`/release-note <tag>` で実行
context: fork
allowed-tools:
- Bash
- Read
argument-hint: "[from-tag]"
disable-model-invocation: true
model: sonnet
---
# Release note generator
`git log <from-tag>..HEAD --oneline` を解析し、Conventional Commits の prefix ごとに分類した changelog エントリをドラフトする。
副作用なし、実際の CHANGELOG.md への書き込みは人間レビュー後。// .mcp.json — stdio + Streamable HTTP の混在例
// stdio(ローカル subprocess)と Streamable HTTP(hosted MCP)は同じ .mcp.json で混在 OK
{
"mcpServers": {
// stdio: ローカル subprocess を npx で起動
"github": {
"command": "npx",
"args": ["@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
}
},
// Streamable HTTP: hosted MCP(2025-11-25 spec の remote 公式形式)
"atlassian": {
"url": "https://mcp.atlassian.com/v1",
"transport": "http",
"headers": {
"Authorization": "Bearer ${ATLASSIAN_TOKEN}"
}
}
}
}Transport の選び方: ローカルツール(git, FS, DB の dev)は stdio、SaaS 連携は Streamable HTTP が原則。詳しくは D2 §2.4 Transport の選択 を参照。「SSE 単独 transport」は deprecated なので新規実装では選ばない(試験頻出区別ポイント)。
<!-- CLAUDE.local.md(個人 override・gitignore 対象) -->
# 個人設定
ローカル開発で使うパス・ポート:
- DB: `postgres://localhost:5433/myapp_dev`
- 個人検証用ブランチは `wip/yokoto/...` プレフィックス// .claude/settings.json — 実際のツール制限はここで
{
"permissions": {
"deny": [
"Bash(rm -rf:*)",
"Bash(git push --force:*)"
],
"allow": [
"Bash(npm run build)",
"Bash(npm run typecheck)"
]
}
}設計上のポイント
.claude/CLAUDE.mdは 常時ロード、.claude/rules/*.mdは glob 一致時のみロード。常時ロードを肥大化させない(注意分散 (attention dilution) の予防)- Skill の
disable-model-invocation: trueは副作用のある Skill(リリースノート生成・デプロイ・コミット)に必ず付ける。明示的に/release-noteを呼んだときだけ実行されるようになり、誤発火を防げる permissions.denyでBash(rm -rf:*)のような危険コマンドを物理的にブロック。これがフックやallowed-toolsではなくpermissions.denyでしか実現できない点を体感する
関連: ここで構築した
.claude/settings.jsonのpermissions.deny・演習 1 の Agent SDKPostToolUseフックは「決定論的にツール挙動を強制する」 という同じ原則の異なる実装。試験では両者の違い(Claude Code lifecycle hooks vs Agent SDK hooks)を問う問題が出る — D3.3 と D1.5 の対比を再確認しておく。
演習 3: Build a Structured Data Extraction Pipeline
目標: JSON schema 設計・tool_use・バリデーション・バッチ処理を実装する
カバー: D4 / D5
- required / optional / nullable 混在の JSON schema を持つ抽出ツールを定義。情報が存在しないかもしれないフィールドは nullable 化。ドキュメントに情報がない場合にモデルが null を返すことを確認(ハルシネーション (hallucination) しないこと)
- バリデーション失敗時の retry-with-error-feedback ループを実装。元ドキュメント・失敗した抽出・具体的なバリデーションエラーを含めて再送。フォーマットエラー(リトライ有効)vs 情報不在(リトライ無効)を区別
- 多様な文書構造(inline citation vs bibliography、narrative vs structured table)を処理する few-shot examples を追加
- Message Batches API で 100 件処理。
custom_idで失敗文書を特定し、修正(大きすぎる文書はチャンク分割)して再送。処理時間と SLA 制約を計算 - フィールド別 confidence score を出力させ、低信頼抽出を人間レビューにルーティング。ドキュメントタイプ・フィールド別に精度を分析
設計上のポイントと詰まりやすい箇所
判断軸:
- 「存在しないかもしれないフィールド」は nullable、required にしない。required にするとモデルが値を捏造する(ハルシネーション)。
type: ["string", "null"]で明示し、システムプロンプトで「不明な場合は null」を促す - enum +
"other"パターン。事前に列挙できない値は"other"を enum に含め、category_detailのような自由記述フィールドを併設する。"unclear"値も同様で、判定不能ケースを LLM に正直に表明させる - リトライしていいエラーの見分け。
tool_useで構文は守られるが意味エラー(合計値の不一致など)は残る。バリデータがvalidationエラーを返す → リトライ可。情報がそもそもドキュメントに無い → リトライしても解決しない(無限リトライループ) - Batch API はエージェンティックループ非対応。1 リクエスト = 1 往復。
tool_useを含む抽出は OK だが、ループは展開できない。SLA 必須・ブロッキング処理には同期 API を使う
詰まりやすい箇所:
- 「全フィールド required にして安全側に倒す」 → モデルが捏造する(ハルシネーションの主要原因)
- pre-merge ブロッキングチェックに Batch API を使う(SLA 保証なし、最大 24 時間)
- 情報不在ケースを「frame を変えれば見つかるかも」と何度もリトライ
custom_idを付けず、Batch のレスポンスをドキュメントに紐付けられない(失敗時に再送できない)- 信頼スコアを LLM に「自己報告」させて閾値ルーティングに使う — 自己報告型は校正不良。アンサンブル(複数モデル合議)や rule-based の補助スコアが必要
完了基準(Definition of Done)
時間目安: 90〜120 分
動作確認チェックリスト:
- 情報が存在しないドキュメントで nullable フィールドが
nullを返す(モデルが値を捏造しない) -
tool_choice: { type: 'tool', name: '...' }で抽出ツール呼び出しが強制される - バリデーション失敗時、retry-with-error-feedback ループで具体的なエラーを context に含めて修正される
- 「フォーマットエラー(リトライ有効)」と「情報不在(リトライ無効)」が区別され、無限リトライループに陥らない
- Few-shot examples で inline citation / bibliography 等の異なる文書構造をハンドリングできる
- Batch API で 100 件投入し、
custom_idで失敗ドキュメントを特定して再送できる - line_item ごとの confidence で低信頼項目が人間レビューにルーティングされる
関連する試験シナリオ: Scenario 6: Structured Data Extraction
参照実装を見る(自分で書いてから開くことを推奨)
構成の概要: nullable 対応スキーマ + retry-with-error-feedback + few-shot + Batch 投入の最小一式。
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic()
// 1. nullable と enum + "other" を活用したスキーマ
const extractInvoice = {
name: 'extract_invoice',
description: '請求書ドキュメントから構造化データを抽出する',
input_schema: {
type: 'object',
properties: {
invoice_number: { type: 'string' }, // required
total_amount: { type: 'number' }, // required
currency: { type: 'string', enum: ['USD', 'JPY', 'EUR', 'other'] },
currency_other: { type: ['string', 'null'] }, // currency=other の詳細
due_date: { type: ['string', 'null'], description: 'ISO 8601 (YYYY-MM-DD) または不明なら null' },
tax_rate: { type: ['number', 'null'] },
line_items: {
type: 'array',
items: {
type: 'object',
properties: {
description: { type: 'string' },
amount: { type: 'number' },
confidence: { type: 'number', description: '0..1、低い場合は人間レビューへ' }
},
required: ['description', 'amount', 'confidence']
}
}
},
required: ['invoice_number', 'total_amount', 'currency', 'line_items']
}
} as const
// 2. バリデータ — 構文ではなく意味エラーを検出する
type ValidationIssue = { path: string; reason: string }
function validateInvoice(data: any): ValidationIssue[] {
const issues: ValidationIssue[] = []
const sumOfLines = data.line_items.reduce(
(s: number, li: any) => s + li.amount,
0
)
if (Math.abs(sumOfLines - data.total_amount) > 0.01) {
issues.push({
path: '$',
reason: `total_amount (${data.total_amount}) does not match sum of line_items (${sumOfLines})`
})
}
if (data.currency === 'other' && !data.currency_other) {
issues.push({ path: '$.currency_other', reason: 'must be set when currency=other' })
}
return issues
}
// 3. Few-shot プロンプトで「情報不在 → null」を強化
const SYSTEM_PROMPT = `あなたは請求書データを抽出するアシスタントです。
ルール:
- ドキュメントに記載がないフィールドは null を返す(推測しない)
- enum で網羅できない値は "other" を選び、currency_other に詳細を書く
- 各 line_item に confidence (0..1) を付ける。文字が掠れている・OCR ノイズが多い箇所は低めに
例 1(明確な請求書):
入力: "Invoice #INV-001 Total: $100 Tax: 10% Items: Widget x1 $90, Tax $10"
出力: {
invoice_number: "INV-001",
total_amount: 100,
currency: "USD",
currency_other: null,
due_date: null, // 期日の記載なし → null
tax_rate: 0.10,
line_items: [
{ description: "Widget x1", amount: 90, confidence: 0.95 },
{ description: "Tax", amount: 10, confidence: 0.95 }
]
}
例 2(OCR ノイズあり):
入力: "Inv#A-22 T0tal: $48.20 Wdg3t: $43.20" (一部判読不能)
出力: {
invoice_number: "A-22",
total_amount: 48.20,
currency: "USD",
currency_other: null,
due_date: null,
tax_rate: null, // 不明 → null
line_items: [
{ description: "Widget", amount: 43.20, confidence: 0.6 }, // OCR ノイズで low confidence
{ description: "(不明)", amount: 5.00, confidence: 0.3 }
]
}`
// 4. retry-with-error-feedback ループ
async function extractWithRetry(documentText: string, maxAttempts = 2) {
let lastResult: any = null
let lastIssues: ValidationIssue[] = []
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: `# Document\n\n${documentText}` }
]
if (lastResult && lastIssues.length > 0) {
// 失敗した抽出と具体的なエラーを context に含めて再送
messages.push({ role: 'assistant', content: JSON.stringify(lastResult) })
messages.push({
role: 'user',
content: `バリデーションが失敗しました。以下の問題を修正してください。
${lastIssues.map(i => `- ${i.path}: ${i.reason}`).join('\n')}
修正版を返してください。`
})
}
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 4096,
system: SYSTEM_PROMPT,
tools: [extractInvoice],
tool_choice: { type: 'tool', name: 'extract_invoice' }, // 強制
messages
})
const block = response.content.find(b => b.type === 'tool_use')
if (!block || block.type !== 'tool_use') throw new Error('expected tool_use')
const issues = validateInvoice(block.input)
if (issues.length === 0) return { ok: true as const, data: block.input }
lastResult = block.input
lastIssues = issues
}
// ❌ Anti-pattern: ここで「情報不在」だった場合に無限リトライループ
// ✅ 上限到達 → 人間レビューへ
return { ok: false as const, partial: lastResult, issues: lastIssues }
}
// 5. Batch API 投入(100 件・SLA 不要なケースのみ)
async function submitBatch(documents: { id: string; text: string }[]) {
return await client.messages.batches.create({
requests: documents.map(d => ({
custom_id: d.id, // 紐付けに必須
params: {
model: 'claude-sonnet-4-6',
max_tokens: 4096,
system: SYSTEM_PROMPT,
tools: [extractInvoice],
tool_choice: { type: 'tool', name: 'extract_invoice' },
messages: [{ role: 'user', content: `# Document\n\n${d.text}` }]
}
}))
})
}設計上のポイント
tool_choice: { type: 'tool', name: '...' }で抽出ツールの呼び出しを 強制。autoだとモデルがテキストで返してくる失敗モードがあるvalidateInvoiceで意味エラー(合計値の不一致)を検出して context に流すのが retry-with-error-feedback の本質。「同じ入力で再試行」は無限リトライループ- バッチ送信時の
custom_idは 絶対に欠かせない。失敗時の再送・部分的成功の合成・コスト分析のすべてで必要 - confidence は line_item ごとに出させて、合計や請求書全体の confidence は 後段で別途計算する(自己報告のキャリブレーション不良を緩和)
関連:
validateInvoiceの意味バリデーションと「失敗を context に流すリトライ」の構造は、演習 4 の合成エージェントが受け取るcoverage_gapsと同じ思想。「失敗を黙らせず、構造化して下流へ流す」が両演習の共通原則。
演習 4: Design and Debug a Multi-Agent Research Pipeline
目標: マルチエージェントの完全実装・エラー伝播・情報出所を体得する
カバー: D1 / D2 / D5
allowedTools: ["Task"]付きコーディネーターを実装。各サブエージェントには直前の結果をプロンプトに直接含める(自動継承はない)- コーディネーターが 1 レスポンスで複数の Task 呼び出しを発行して並列実行。順次実行と比較してレイテンシ改善を測定
- サブエージェントの出力に claim-source mapping(source URL・document 名・pub 日付・関連抜粋)を含めさせる。合成エージェントが帰属情報を保持することを確認
- サブエージェントタイムアウトをシミュレート。コーディネーターが構造化エラーコンテキスト(失敗タイプ・試みたクエリ・部分結果)を受け取り、部分結果で処理を継続してカバレッジギャップを注釈付きで出力することを確認
- 2 つの信頼できるソースが競合する統計を示すシナリオでテスト。合成出力が一方を選択せずに両方を帰属付きで含め、conflict を明示的に注釈することを確認
設計上のポイントと詰まりやすい箇所
判断軸:
- サブエージェント description が委譲先を選ぶ唯一の根拠。
search_agent: "Searches the web"のような最小限ではなく、入力フォーマット・出力スキーマ・類似エージェントとの境界を入れる - コンテキストは自動継承されない。ユーザーの元入力・前段の結果・制約条件 — すべて コーディネーターがプロンプトに明示的に詰める。試験頻出の最重要原則
- 独立タスクは 1 レスポンス内の並列 Task 発行。順次実行はレイテンシが線形に増加する。後段が前段の結果に依存する場合のみ複数ターン化
- 部分的成功を採用、失敗を注釈。1 つのサブエージェントが失敗 → 全体終了は anti-pattern。
partial_results+coverage_gapsで帰属を明示 - 競合する情報源は両方残す。一方を任意選択するのは出所情報(provenance)の消失。
conflict_detected: trueで両方を帰属付きで含める
詰まりやすい箇所:
- サブエージェント同士を直接通信させようとする(コーディネーター経由が原則)
- タイムアウトを空配列で「成功」として返してしまい、コーディネーターが「結果なし」と誤解
- Web 検索ツールを Synthesis Agent にも与える(4〜5 ツールを超えると選択精度が低下する罠 9)
- 合成出力で「数値が違う 2 ソース」のうち片方だけ採用してしまい、出所情報が消える
- 並列実行のつもりで
for ... awaitしてしまい順次実行になる(D1.3)
完了基準(Definition of Done)
時間目安: 120〜180 分(最大の演習・3 演習の総合演習)
動作確認チェックリスト:
- コーディネーターが 1 アシスタントレスポンス内で複数 Task を発行し、並列実行される
- サブエージェントプロンプトに「元のユーザー要求」「前段の構造化出力」「制約」「出力スキーマ」がすべて明示的に詰められている
- サブエージェント出力に
source_url・published_date・関連抜粋が含まれる - サブエージェントタイムアウトを
Promise.allSettledで受け、partial_resultsが保たれる - 失敗したサブエージェントが
coverage_gapsとして明示的に注釈される - 競合する数値(同じ metric で異なる値)が
conflict_detected: trueで両方保持される(一方を任意選択しない) - 順次実行と比較して並列実行のレイテンシ改善を実測できる
関連する試験シナリオ: Scenario 3: Multi-Agent Research System
参照実装を見る(自分で書いてから開くことを推奨)
注意: この演習の参照実装は 設計骨格(疑似コード混在) です。callSubagent・TaskTool・fakeDb といったシンボルは未定義のまま残しており、そのままコピペでは動きません。これは演習 4 の本質が「Anthropic SDK のハンドラ詳細」ではなく「coordinator が部分的成功・競合・出所情報をどう構造化するか」にあるため、判断構造を読みやすさ優先で示しています。完全に runnable な実装が必要な場合は、Promise.allSettled と client.messages.create を直接使う形に各自で詰めてください。
構成の概要: コーディネーターが 1 レスポンスで Search / Analysis Agent を並列発行 → 部分的成功と競合を構造化して合成エージェントへ。
// 1. コーディネーターから並列 Task 発行
// 1 つの assistant レスポンスの content に複数 tool_use を含めれば並列
const coordinatorResponse = await client.messages.create({
model: 'claude-opus-4-7',
max_tokens: 8192,
tools: [TaskTool], // allowedTools: ["Task"] 相当
messages: [
{ role: 'user', content: 'Q4 売上トレンドと競合動向を比較した投資レポートを作成' }
]
})
// 期待される content:
// [
// { type: 'tool_use', name: 'Task', input: { description: 'Search Agent: ...', prompt: <full ctx> } },
// { type: 'tool_use', name: 'Task', input: { description: 'Analysis Agent: ...', prompt: <full ctx> } }
// ]
// 2. コンテキスト渡し — 元要求 + 前段結果 + 制約 + 出力スキーマを明示
function buildAnalysisPrompt(originalRequest: string, searchResults: unknown) {
return `## 元のユーザー要求
"${originalRequest}"
## 前段(Search Agent)の出力
${JSON.stringify(searchResults, null, 2)}
## あなたのタスク
上記の検索結果を分析し、以下の JSON 形式で返してください:
{
"trends": [
{ "metric": string, "direction": "up" | "down" | "flat", "evidence": string, "source_url": string, "published_date": string }
],
"competitors": [
{ "name": string, "signal": string, "source_url": string, "published_date": string, "excerpt": string }
]
}
## 制約
- 公開日 2024 年以降のソースのみ使用
- 各 claim に source_url と published_date を必ず含める
- 不確実な場合は trends/competitors から除外し、out_of_scope セクションに移すこと`
}
// 3. 部分的成功 + カバレッジギャップ注釈
type SubagentSuccess = { ok: true; data: unknown }
type SubagentFailure = {
ok: false
errorCategory: 'transient' | 'validation' | 'permission' | 'business'
attemptedQuery: string
partialResults: unknown | null
alternatives: string[]
}
type SubagentResult = SubagentSuccess | SubagentFailure
async function runSubagentsInParallel(
tasks: Array<{ name: string; prompt: string; query: string }>
): Promise<SubagentResult[]> {
const results = await Promise.allSettled(
tasks.map(t => callSubagent(t.name, t.prompt))
)
return results.map((r, i) => {
if (r.status === 'fulfilled') return { ok: true, data: r.value }
return {
ok: false,
errorCategory: 'transient',
attemptedQuery: tasks[i].query,
partialResults: null,
alternatives: [
'retry with narrower date range',
'fall back to cached results',
'skip and annotate as coverage_gap'
]
}
})
}
// 4. 合成エージェントの入力構築 — 失敗を「カバレッジギャップ」として明示
function buildSynthesisInput(
results: SubagentResult[],
originalRequest: string
) {
const successful = results.filter((r): r is SubagentSuccess => r.ok)
const failed = results.filter((r): r is SubagentFailure => !r.ok)
return {
original_request: originalRequest,
partial_results: successful.map(s => s.data),
coverage_gaps: failed.map(f => ({
attempted_query: f.attemptedQuery,
error_category: f.errorCategory,
suggested_alternatives: f.alternatives
})),
is_complete: failed.length === 0
}
// ❌ Anti-pattern: if (failed.length > 0) throw new Error('全体失敗')
// → 部分結果を捨ててしまう。コーディネーターは部分結果 + ギャップ注釈を必ず合成へ流す
}
// 5. 競合する情報源の扱い — 一方を選ばず両方を帰属付きで残す
type Claim = {
metric: string
value: number
source_url: string
published_date: string
}
type SynthesizedClaim = {
metric: string
values: Array<Claim>
conflict_detected: boolean
resolution_note: string | null
}
function reconcileClaims(claims: Claim[]): SynthesizedClaim[] {
const byMetric = new Map<string, Claim[]>()
for (const c of claims) {
if (!byMetric.has(c.metric)) byMetric.set(c.metric, [])
byMetric.get(c.metric)!.push(c)
}
return [...byMetric.entries()].map(([metric, vs]) => {
const values = vs.map(v => v.value)
const allEqual = values.every(v => v === values[0])
return {
metric,
values: vs, // 両方の出所を保持
conflict_detected: !allEqual,
resolution_note: allEqual
? null
: `Sources disagree on ${metric}. Reporting both with attribution; reader should choose primary source.`
}
})
// ❌ Anti-pattern: const winner = vs[0] // 任意選択 → 出所情報の消失
}設計上のポイント
Promise.allSettledを使うのは部分的成功を保つため。Promise.allだと 1 つの reject で全体が崩れるcoverage_gapsを合成エージェントに渡すと、レポートに「この観点は調べられなかった」を明示できる。ユーザーが判断の不確実性を認識できる- 競合データの解決を コードでも LLM でもなく「読者」に委ねる のは意図的。CCA-F が問う出所情報の保持は「機械が選ぶ」を明示的に拒否する設計
published_dateを必ず claim に含めるのは、新旧データの不一致と「ソースの正面衝突」を区別するため。古い記事 → 新しい記事 で値が変わるのは矛盾ではなく時系列差異
関連: 4 演習はここで一周する。演習 1 の
errorCategoryがここのSubagentFailureの核、演習 2 の Skillscontext: forkがサブエージェント並列化の Claude Code 側実装、演習 3 の retry-with-error-feedback がcoverage_gapsを合成側に渡す思想と一致。試験のシナリオベース問題は「これらを組み合わせた状況での判断」を問うので、4 演習を完走したら一度全体を俯瞰しておく。
4 演習をすべて手で実装すると、試験で問われる判断軸が体感的に身につきます。とくに 演習 4 のマルチエージェントは試験頻出 なので、最低 1 回は完走することを強く推奨します。