はじめに
この記事でわかること
- すべてのAIエージェントの中核をなす Tool-Use Loop の仕組み
- Tool(ツール) と Skill(スキル) の本質的な違い
- LLMがツールを呼び出す際の 実際のAPIリクエスト/レスポンス の中身
- Anthropic / OpenAI / Google の Function Calling形式の比較
- AIコーディングアシスタントの 最初の1回目のAPIコール に何が詰まっているか
-
systempromptがあるのに、なぜ<system-reminder>でもう一度繰り返すのか - 自分で Tool Call + Skillシステムを設計する ための最小実装コード
対象読者
- LLMのAPIを使ったことがあるが、Tool Callの仕組みをちゃんと理解したい方
- AIエージェント(Claude Code、Cursor、Cline等)を使っていて、裏側の仕組みが気になる方
- 自分でAIエージェントを設計・実装したい方
きっかけ
Claude Code(Opus 4.6)を使って開発していたときに、ふと疑問に思った。
「Claude Codeが自分でファイルを読んだり、コマンドを実行したりしているけど、あの"ツール呼び出し"って、裏側のAPIレベルでは一体何が起きているんだ?」
この疑問をClaude自身にぶつけて深掘りしていった結果、AIエージェントの"魔法"は、実はたった3つのJSONフィールド(system・tools・messages)にテキストを詰め込んでいるだけだとわかった。
本記事では、その全容を図解付きで解説する。
1. Tool-Use Loop ― すべてのAIエージェントの中核
基本ループ
あらゆるAIエージェント(Claude Code、Cursor、Cline、自作エージェント)が共有する、たった1つの中核メカニズムがある。
┌──────────────── API Call 1 ────────────────┐
│ │
│ Input: ユーザーメッセージ + system prompt │
│ + tools定義 │
│ │
│ Output: 「tool1を呼び出す必要がある」 │
│ type: "tool_use" │
│ stop_reason: "tool_use" │
│ │
└─────────────────────┬──────────────────────┘
↓
[システムがtool1を実行し、結果を取得]
↓
┌──────────────── API Call 2 ────────────────┐
│ │
│ Input: 完全な履歴 + tool_result │
│ │
│ Output: 最終回答 │
│ OR「さらにtool2を呼び出す」 │
│ stop_reason: "end_turn" or 続行 │
│ │
└─────────────────────┬──────────────────────┘
↓
(N回繰り返す可能性あり)
ToolもSkillも同じループを使う
違いはループ自体ではなく、ループの入口にある:
Tool呼び出し:
ユーザーメッセージ → LLMが自主的にtoolを選択 → ループ → 回答
Skill呼び出し:
/commit → Skillのプロンプトを注入 → LLMがtoolsで実行 → ループ → 回答
↑
唯一の違い:「専門家の指示書」が追加注入される
SkillはAPIプロトコルレベルでは存在しない。 type: "skill_use" のようなものは無い。Skillの本質は「.mdファイルの内容をmessagesに注入するタイミング制御」にすぎない。
手動呼び出し vs 自動呼び出し
手動(ユーザーが /commit と入力):
┌──────────────────────────────────┐
│ クライアントが直接Skillを展開 │ ← LLMの判断不要
└──────────────┬───────────────────┘
↓
API Call 1: [Skill prompt + メッセージ] → LLM → ツール使用開始
自動(ユーザーが「コミットして」と入力):
┌──────────────────────────────────┐
│ API Call 0: LLMが意図を判断 │ ← この1ラウンドが追加
│ Input: 「コミットして」 │
│ Output: Skill("commit") │
└──────────────┬───────────────────┘
↓
[Skill promptを展開]
↓
API Call 1: [Skill prompt + コンテキスト] → LLM → ツール使用開始
スラッシュコマンドは自然言語指示より1回分のAPIコールが速い。これがスラッシュコマンドの実用的な価値の1つだ。
2. ToolとSkillの定義 ― 根本的に何が違うのか
AIエージェントには Tool と Skill という2つの概念がある。似ているようで本質が全く違う。
Tool(ツール)= 能力
アトミックな単一操作。LLMが外部の機能を呼び出す手段。
答える問い:「私は何ができるか?」
Tool = インターフェースが定義された単一操作
入力 → 実行 → 出力
- アトミック ― 1回の呼び出しで1つのことをする
- 明確なスキーマ ― name、description、parameters
- ステートレス ― 毎回独立して実行
- 常に利用可能 ― モデルがいつでも呼び出せる
Skill(スキル)= 方法論
複数のツールとドメイン知識と戦略を組み合わせた高レベルのワークフロー。
答える問い:「私はどうすればうまくできるか?」
Skill = 知識 + 戦略 + ツールのオーケストレーション
「どんな状況で、どのツールを、どの順序で、どんな判断基準で使うか」
- 複合的 ― 複数のツール呼び出しを編成する
- ドメイン知識と判断ロジックを含む
- オンデマンドでロード ― トリガーされたときだけコンテキストに注入
- 目標/ワークフロー指向
最も根本的な違い
Tool = 金槌、ノコギリ、ドリル (能力 / 手段)
Skill = 大工の技術 (知識 + 判断 + 段取りの組み合わせ)
| 観点 | Tool | Skill |
|---|---|---|
| 本質 | 能力(Capability) | 知識 + 戦略(Knowledge + Strategy) |
| 粒度 | アトミック操作 | 複合ワークフロー |
| 一言で | 「Xができる」 | 「Xをうまくやる方法を知っている」 |
| 例え | APIエンドポイント | 複数のAPIを呼ぶSOP(標準作業手順書) |
| ロード | 常に利用可能 | オンデマンドで注入 |
一言でまとめると:Toolは「手」、Skillは「頭の中の方法論」。
/commit で見る違い
Claude Codeの /commit コマンドを例に取ると、Skillの構造が見える。
/commit (Skill)
│
├─ 1. Bash("git status") ← Tool呼び出し
├─ 2. Bash("git diff") ← Tool呼び出し
├─ 3. Bash("git log --oneline") ← Tool呼び出し
├─ 4. [AI判断:変更内容を分析] ← 戦略 / 知識
├─ 5. [AI判断:コミットメッセージ作成] ← 戦略 / 知識
├─ 6. Bash("git add ...") ← Tool呼び出し
└─ 7. Bash("git commit -m ...") ← Tool呼び出し
Skillが提供しているのは「gitコマンドを実行できること」ではない(それはBash Toolの仕事)。Skillが提供しているのは**「コミットの正しい手順、規約、判断基準を知っていること」**だ。
3. 実際のAPIリクエスト/レスポンス ― Tool Callの生データ
Tool Callの仕組みを本当に理解するには、実際のJSONを見るのが一番早い。
Step 1: APIリクエスト(LLMへの入力)
{
"model": "claude-opus-4-6",
"system": "You are an AI assistant...",
"tools": [
{
"name": "Read",
"description": "Reads a file from the local filesystem.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "The absolute path to the file to read"
}
},
"required": ["file_path"]
}
},
{
"name": "Bash",
"description": "Executes a given bash command.",
"input_schema": {
"type": "object",
"properties": {
"command": { "type": "string" }
},
"required": ["command"]
}
}
],
"messages": [
{ "role": "user", "content": "config.tsの内容を読んでください" }
]
}
tools 配列は、ただのJSONスキーマのリスト。各ツールには name、description、input_schema がある。モデルは description を読んで、いつそのツールを使うべきかを理解する。これが全てだ。
Step 2: APIレスポンス ― LLMがツールを呼ぶ
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "ファイルを読み取ります。"
},
{
"type": "tool_use",
"id": "toolu_01XYZ789",
"name": "Read",
"input": {
"file_path": "/project/config.ts"
}
}
],
"stop_reason": "tool_use"
}
stop_reason: "tool_use" がポイント。これがクライアントへの合図:「まだ終わっていない。まずツールを実行してくれ。」
Step 3: ツール実行結果を返す
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01XYZ789",
"content": "export const config = {\n port: 8787,\n debug: true\n}"
}
]
}
ツール結果は role: "user" として送信される(Anthropicの形式)。tool_use_id で前のtool_useと紐付けている。
Step 4: 2回目のAPIコール ― 完全な履歴付き
{
"model": "claude-opus-4-6",
"system": "(同じsystem prompt)",
"tools": ["(同じtools)"],
"messages": [
{ "role": "user", "content": "config.tsの内容を読んでください" },
{ "role": "assistant", "content": [
{ "type": "text", "text": "ファイルを読み取ります。" },
{ "type": "tool_use", "id": "toolu_01XYZ789", "name": "Read",
"input": { "file_path": "/project/config.ts" } }
]},
{ "role": "user", "content": [
{ "type": "tool_result", "tool_use_id": "toolu_01XYZ789",
"content": "export const config = { port: 8787, debug: true }" }
]}
]
}
Step 5: 最終回答
{
"content": [
{ "type": "text", "text": "config.tsの内容は設定オブジェクトで、port: 8787とdebug: trueが含まれています。" }
],
"stop_reason": "end_turn"
}
stop_reason: "end_turn" = もうツール呼び出しは不要。最終回答。
全体フロー図
ユーザー入力
↓
┌────────────────── API Call 1 ──────────────────┐
│ Input: │
│ system: システムプロンプト │
│ tools: [{name, description, schema}, ...] │ ← 毎回渡す
│ messages: [user: "config.tsを読んで"] │
│ │
│ Output: │
│ content: [ │
│ {type:"text", text:"読み取ります"} │
│ {type:"tool_use", name:"Read", input:{…}} │ ← ツール呼び出し
│ ] │
│ stop_reason: "tool_use" │ ← まだ終わらない
└──────────────────────┬─────────────────────────┘
↓
[クライアントがReadツールを実行]
↓
┌────────────────── API Call 2 ──────────────────┐
│ Input: │
│ messages: [ │
│ user: "config.tsを読んで", │
│ assistant: [text + tool_use], │ ← 完全な履歴
│ user: [tool_result: "ファイル内容..."] │ ← 実行結果
│ ] │
│ │
│ Output: │
│ content: [{type:"text", text:"最終回答..."}] │
│ stop_reason: "end_turn" │ ← 完了
└────────────────────────────────────────────────┘
↓
ユーザーに最終回答を表示
4. 各社のFunction Calling形式比較
コンセプトは100%同じ。JSONの形式だけが違う。
Anthropic (Claude)
// ツール定義
"tools": [{
"name": "search",
"description": "Search the database",
"input_schema": { "type": "object", "properties": { "query": {"type":"string"} } }
}]
// LLMの呼び出し
"content": [
{ "type": "tool_use", "id": "toolu_abc", "name": "search", "input": {"query": "..."} }
]
"stop_reason": "tool_use"
// 結果の返却
{ "role": "user", "content": [
{ "type": "tool_result", "tool_use_id": "toolu_abc", "content": "結果..." }
]}
OpenAI (GPT)
// ツール定義
"tools": [{
"type": "function",
"function": {
"name": "search",
"description": "Search the database",
"parameters": { "type": "object", "properties": { "query": {"type":"string"} } }
}
}]
// LLMの呼び出し
"choices": [{ "message": {
"tool_calls": [{
"id": "call_abc",
"type": "function",
"function": { "name": "search", "arguments": "{\"query\":\"...\"}" }
}]
}, "finish_reason": "tool_calls" }]
// 結果の返却
{ "role": "tool", "tool_call_id": "call_abc", "content": "結果..." }
Google (Gemini)
// ツール定義
"tools": [{ "function_declarations": [{
"name": "search",
"description": "Search the database",
"parameters": { "type": "object", "properties": { "query": {"type":"string"} } }
}]}]
// LLMの呼び出し
"candidates": [{ "content": { "parts": [
{ "functionCall": { "name": "search", "args": {"query": "..."} } }
]}}]
// 結果の返却
{ "role": "function", "parts": [
{ "functionResponse": { "name": "search", "response": {"content": "結果..."} } }
]}
比較表
| 観点 | Anthropic (Claude) | OpenAI (GPT) | Google (Gemini) |
|---|---|---|---|
| ツール定義キー | input_schema |
function.parameters |
function_declarations[].parameters |
| 呼び出しシグナル | type: "tool_use" |
tool_calls[] |
functionCall |
| 停止理由 | "tool_use" |
"tool_calls" |
明示的フィールドなし |
| 結果のrole |
"user" + tool_result
|
"tool" |
"function" |
| 引数の形式 | JSONオブジェクト | JSON文字列(要parse) | JSONオブジェクト |
Vercel AI SDKやLangChainが存在する理由がこれ ― 各社の形式差異を抽象化し、1つのコードで全プロバイダに対応するため。
番外:XMLタグで自作する方法
標準のFunction Callingを使わず、XMLタグで自前のツール呼び出しを実装することもできる。
// system promptにツール定義を文章で記述
「<db-query>SQL文</db-query> タグでクエリを実行できます...」
// LLMの出力:普通のテキストにタグを埋め込む
「調べてみます <db-query>SELECT count(*) FROM items</db-query>」
// バックエンド:正規表現でタグを解析 → 実行 → 結果をuserメッセージとして返却
| 観点 | 標準Function Calling | XMLタグ自作 |
|---|---|---|
| メリット | モデルがこの形式で訓練済み、安定 | プロバイダ非依存、どのLLMでも動く |
| デメリット | 特定プロバイダの形式に依存 | モデルが訓練されていないため、たまに形式エラー |
5. Skillの本質 ― APIレベルでは存在しない
Skill = .mdファイル + 注入タイミング
繰り返すが、SkillはAPIプロトコルに存在しない概念だ。
ユーザーが /deploy と入力
↓
┌────────────────────────────────────────────┐
│ クライアント側の処理(APIでもLLMでもない): │
│ 1. /deploy がスラッシュコマンドだと認識 │
│ 2. .commands/deploy.md を読み込む │
│ 3. その中身をmessagesに詰める │
└──────────────────┬─────────────────────────┘
↓
┌────────────── API Call 1 ──────────────────┐
│ messages: [ │
│ { │
│ role: "user", │
│ content: "/deployの指示内容: │
│ 1. git statusで作業ツリーを確認 │ ← .mdの中身が
│ 2. デプロイスクリプトを実行 │ ただのuserメッセージ
│ 3. ヘルスチェックを実行..." │ として送られる
│ } │
│ ] │
│ │
│ → 以降は通常のtool-useループ │
└────────────────────────────────────────────┘
APIから見ると、以下の2つは完全に同じ:
-
/deploy→ .mdファイルの中身が自動注入 - ユーザーが手動でデプロイ手順を全部打ち込む
Skillファイルの実例
指定された環境にデプロイする。引数: $ARGUMENTS (stg または prod)
## 事前チェック
1. `git status` でワーキングツリーがクリーンか確認
2. ブランチ確認:stgはdev、prodはmainであること
## デプロイ手順
1. `./scripts/deploy.sh $ARGUMENTS` を実行
2. `./scripts/health-check.sh $ARGUMENTS` を実行
## 失敗時の対応
- デプロイ失敗時は `./scripts/deploy.sh` を読んで原因を分析
- prodの自動ロールバックは行わない。問題を報告して判断を仰ぐ
## 成功時の出力
デプロイ環境、URL、所要時間を報告
このMarkdownは人間向けのドキュメントではなく、LLMへの指示書だ。LLMに「何を、どの順序で、どう判断するか」を教えている。
6. Skill実行時のLLMの視野 ― 段階的情報開示
Skillがスクリプト ./scripts/deploy.sh を参照しているとき、LLMはそのスクリプトの中身を最初から見ているわけではない。
LLMの視野は段階的に広がる
┌─────────────────────────────────────────────────────────┐
│ 第0層 ― 常に見える(.md内容の注入) │
│ │
│ 「1. ./scripts/deploy.sh stg を実行 │
│ 2. ./scripts/health-check.sh を実行 │
│ 3. 失敗したら原因を報告」 │
│ │
│ LLMは指示を見ているが、deploy.shの中身は見えない │
└────────────────────────┬────────────────────────────────┘
↓ LLMの判断:Bashを呼ぶ
┌─────────────────────────────────────────────────────────┐
│ 第1層 ― 実行後に見える(ツール出力) │
│ │
│ Bash("./scripts/deploy.sh stg") │
│ │
│ stdout: "Building... Done. │
│ Deploying to stg... OK │
│ URL: https://stg.example.com" │
│ │
│ LLMはstdout/stderrだけ見える。まだスクリプト本体は見ない │
└────────────────────────┬────────────────────────────────┘
↓ 成功 → 終了 / 失敗 → 続行
┌─────────────────────────────────────────────────────────┐
│ 第2層 ― 必要時のみ見える(LLMが自主的にRead) │
│ │
│ 失敗!stderr: "Error: DB migration failed, exit 1" │
│ │
│ LLMが自ら判断:エラー情報だけでは不十分、ソースを見よう │
│ → Read("./scripts/deploy.sh") │
│ │
│ #!/bin/bash │
│ set -e │
│ cd backend │
│ pnpm db:migrate:stg ← LLMがここに問題を発見 │
│ pnpm wrangler deploy --env stg │
└────────────────────────┬────────────────────────────────┘
↓ まだ足りない?
┌─────────────────────────────────────────────────────────┐
│ 第3層 ― 深層デバッグ(さらにファイルを読む) │
│ │
│ → Read("backend/package.json") │
│ → Read("backend/migrations/latest.sql") │
│ → ... │
└─────────────────────────────────────────────────────────┘
段階的開示の全体像
LLMがデフォルトで見える範囲
│
┌───────────┴───────────┐
▼ │
┌─────────┐ │
│ 第0層 │ .mdファイル内容 │ ← 常に注入、無条件で見える
│ (無料) │ │
└────┬────┘ │
│ LLMがBash実行 │
▼ │
┌─────────┐ │ LLMの視野が段階的に拡大
│ 第1層 │ stdout/stderr │ ← 実行して初めて見える
│ (1回呼出) │ │
└────┬────┘ │
│ 失敗?LLMがRead │
▼ │
┌─────────┐ │
│ 第2層 │ スクリプト本体 │ ← デバッグ時のみ
│ (追加呼出) │ │
└────┬────┘ │
│ まだ?さらにRead │
▼ │
┌─────────┐ │
│ 第3層 │ 設定ファイル等 │ ← 深層デバッグ
│ (追加呼出) │ │
└─────────┘ │
▲ │
└───────────────────────┘
LLMが必要に応じて探索する範囲
| シナリオ | LLMが見るもの | トークンコスト |
|---|---|---|
| 成功 | .md + Bash出力のみ | 極めて低い |
| 失敗 | .md + Bash出力 + スクリプト本体 + 設定ファイル... | 必要に応じて増加 |
LLMは自律的なエージェントだ。「失敗したらソースを読め」と誰かが命令しているわけではない ― LLM自身がそう判断する。ただし、.mdファイル内でこの行動を誘導したり制限したりすることは可能。.mdの内容=LLMのプロンプトだからだ。
7. 最初の1回目のAPIコール ― 全体像
AIコーディングアシスタントの最初のAPIコールには、実は3つのトップレベルフィールドしかない:system・tools・messages。すべての"魔法"は、この3つの組み立て方に隠れている。
7.1 system フィールド ― アイデンティティと汎用ルール
┌─────────────────── system prompt の構成 ──────────────────┐
│ │
│ ① コア指示(製品会社が作成、ユーザーからは見えない) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ "You are an AI coding assistant..." │ │
│ │ │ │
│ │ # ツール使用ルール │ │
│ │ - catの代わりにRead、sedの代わりにEditを使え │ │
│ │ │ │
│ │ # Gitの操作規約 │ │
│ │ - force pushするな、hookをスキップするな │ │
│ │ │ │
│ │ # セキュリティ指針 │ │
│ │ - XSS、SQLインジェクションを書くな │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ② 永続メモリの参照 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ "You have a persistent memory directory at ..." │ │
│ │ (LLMが自分で読みに行くかどうかを判断) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ③ 環境情報(自動検出) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Platform: darwin / Shell: zsh / Model: claude-opus │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ④ バージョン管理のスナップショット │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Current branch: dev / Status: clean │ │
│ │ Recent commits: abc1234 feat: add user auth │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────┘
system の内容はほぼ変わらない ― ユーザーが何を聞いても、会話がどれだけ長くなっても同じ。これがPrompt Cachingにとって重要(後述)。
7.2 tools フィールド ― 能力の登録
"tools": [
// ─── 組み込みツール(製品会社が提供)───
{ "name": "Read", "description": "ファイル読み取り", "input_schema": {...} },
{ "name": "Write", "description": "ファイル書き込み", "input_schema": {...} },
{ "name": "Edit", "description": "ファイル編集", "input_schema": {...} },
{ "name": "Bash", "description": "コマンド実行", "input_schema": {...} },
{ "name": "Grep", "description": "コンテンツ検索", "input_schema": {...} },
{ "name": "Task", "description": "サブエージェント起動", "input_schema": {...} },
{ "name": "Skill", "description": "スキル実行", "input_schema": {...} },
// ...
// ─── MCPツール(ユーザー設定のMCPサーバーが動的に提供)───
{ "name": "mcp__chrome-devtools__click", "description": "...", ... },
{ "name": "mcp__chrome-devtools__take_screenshot", "description": "...", ... },
{ "name": "mcp__vercel__list_deployments", "description": "...", ... },
{ "name": "mcp__cloudflare__execute", "description": "...", ... },
// ...
]
MCPツールと組み込みツールはAPIレベルで完全に同格。 name に mcp__ プレフィックスが付いているだけで、LLMにとってはすべて同じ「ツール」だ。MCPツールは起動時にMCPサーバーから動的に取得され、tools 配列にマージされる。
7.3 messages フィールド ― 会話 + 動的注入
messages: [
{
"role": "user",
"content": [
⑤ <system-reminder> プロジェクト設定ファイルの全文
┌──────────────────────────────────────────────┐
│ <system-reminder> │
│ Contents of CLAUDE.md: │
│ │
│ # Project Instructions │
│ ## Tech Stack │
│ Frontend: Next.js 15 + Tailwind │
│ ## Database Schema ... │
│ ## Development Commands ... │
│ </system-reminder> │
└──────────────────────────────────────────────┘
⑥ <system-reminder> 利用可能なSkillのリスト
┌──────────────────────────────────────────────┐
│ <system-reminder> │
│ Available skills: │
│ - deploy: Deploy to staging or production │
│ - run-tests: Execute test suite │
│ </system-reminder> │
└──────────────────────────────────────────────┘
⑦ <system-reminder> その他の動的リマインダー
┌──────────────────────────────────────────────┐
│ <system-reminder> │
│ タスクツールが最近使われていません... │
│ </system-reminder> │
└──────────────────────────────────────────────┘
⑧ ユーザーの実際のメッセージ
┌──────────────────────────────────────────────┐
│ 「ユーザー認証機能を追加してください」 │
└──────────────────────────────────────────────┘
]
}
]
⑤⑥⑦はすべて <system-reminder> タグで包まれたテキストで、⑧と一緒に role: "user" として送信される。APIレベルではただの文字列。しかし LLMは <system-reminder> をシステムレベルの指示として遵守するよう訓練されている。
7.4 Sub-Agent ― 独立した会話
Sub-Agentは最初のAPIコールには含まれない。LLMが Task ツールを呼び出した後に生成される完全に独立した会話だ。
メイン会話 API Call 1:
→ output: tool_use(Task, {type:"Explore", prompt:"パフォーマンス問題を調査..."})
┌──────────────────────────────────────────┐
│ Sub-Agentの独立会話(メインとは分離) │
│ │
│ system: "You are an Explore agent..." │ ← 別のsystem prompt
│ tools: [Read, Grep, Glob, Bash, ...] │ ← ツールのサブセット
│ messages: [user: "パフォーマンス問題..."] │
│ │
│ (内部で5-10回のtool-useループを実行) │
│ (メイン会話はこの中間過程を見ない) │
│ │
│ 最終結果: "3つのボトルネックを発見: ..." │
└──────────────────┬───────────────────────┘
↓
メイン会話 API Call 2:
user: [tool_result: "3つのボトルネックを発見: ..."] ← 最終結果だけ受け取る
メイン会話はSub-Agentの最終結果しか見えない。これにより:
- コンテキストの分離 ― Sub-Agentの大量の中間出力がメイン会話を汚染しない
- 並列実行 ― 複数のSub-Agentを同時に実行できる
- 専門化 ― Sub-Agentの種類ごとにsystem promptとツールセットが異なる
7.5 全体像
┌──────────────────────────────────────────────────────────────┐
│ API Call 1 の完全な構造 │
│ │
│ system: ┌───────────────────────────────────────────┐ │
│ │ ① コア指示(製品会社作成、ほぼ不変) │ │
│ │ ② 永続メモリの参照パス │ │
│ │ ③ 環境情報 (OS, shell, model) │ │
│ │ ④ バージョン管理スナップショット │ │
│ └───────────────────────────────────────────┘ │
│ ↑ 安定、キャッシュ可能 │
│ │
│ tools: ┌───────────────────────────────────────────┐ │
│ │ ⑨ 組み込みツール (Read, Bash, Task, Skill) │ │
│ │ ⑩ MCPツール (chrome-devtools, vercel...) │ │
│ └───────────────────────────────────────────┘ │
│ ↑ 起動時に確定 │
│ │
│ messages: ┌─────────────────────────────────────────┐ │
│ │ ⑤ <system-reminder> プロジェクト設定全文 │ │
│ │ ⑥ <system-reminder> Skills一覧 │ │
│ │ ⑦ <system-reminder> 動的リマインダー │ │
│ │ ⑧ ユーザーの実際のメッセージ │ │
│ └─────────────────────────────────────────┘ │
│ ↑ 毎ターン動的に組み立て │
│ │
│ 注入タイミング: │
│ ①②③④ ― クライアント起動時に組み立て、セッション中は不変 │
│ ⑤⑥⑦ ― 毎ターン最新のuserメッセージに自動付与 │
│ ⑧ ― ユーザー入力 │
│ ⑨ ― クライアント組み込み、常に存在 │
│ ⑩ ― MCPサーバーから起動時に動的取得 │
└──────────────────────────────────────────────────────────────┘
注入メカニズム一覧
| メカニズム | 注入先 | 注入タイミング | LLMの認識 |
|---|---|---|---|
| コア指示 | system |
起動時、不変 | アイデンティティと基本ルール |
| プロジェクト設定 |
messages内の<system-reminder>
|
毎ターン自動 | プロジェクト固有の指示として遵守 |
| 永続メモリ |
systemでパス参照 |
起動時 | LLMが自分でReadするか判断 |
| MCPツール |
tools配列 |
MCPサーバーから取得 | 組み込みツールと完全同格 |
| Skillsリスト |
messages内の<system-reminder>
|
毎ターン自動 | どのSkillが使えるか把握 |
| Sub-Agent | 独立した会話 | Task tool呼び出し時 | メインは最終結果のみ |
すべての"魔法"は、system・tools・messages の3つのフィールドにテキストを詰め込んでいるだけ。 神秘的なメカニズムは何もない ― 正しい場所に、正しいタイミングで、正しいテキストを注入しているだけだ。
8. なぜ system があるのに <system-reminder> が必要なのか
疑問
system フィールドは毎回のAPIコールで渡されている。LLMは理論上「見えている」。なのに、なぜ messages で <system-reminder> を使ってもう一度繰り返すのか?
根本原因:見えている ≠ 注意を向けている
Transformerの注意力分布は U字型カーブ を描く。これは学術的に検証された現象で、"Lost in the Middle" と呼ばれている。
- 先頭(system prompt):注意力が高い
- 中間(何十ターンもの会話履歴):注意力が最も低い
- 末尾(最新のメッセージ):注意力が最も高い
注意力
強度
▲
│ █ █ █
│ █ █ █ █ █
│ █ █ █ █ █ █ █
│ █ █ █ █ █ █ █ █ █
│ █ █ █ █ █ ▄ ▄ █ █ █ █ █ █
│ █ █ █ █ █ █ ▄ ▄ ▄ ▄ █ █ █ █ █ █ █ █
│ █ █ █ █ █ █ █ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ █ █ █ █ █ █ █ █ █
└──────────────────────────────────────────────────────────→ 位置
↑ system ↑ 会話の中間 ↑ 最新メッセージ
prompt (数万tokens)
会話が長くなるほど、system promptと最新メッセージの「距離」が離れる。LLMはsystemの内容を「忘れた」わけではない ― 中間の数万トークンの会話に注意力が分散されて、systemへの実効的な注意力が薄まっているのだ。
<system-reminder> = 位置ブースト
system-reminderなし:
[system: ルールABC] [...50ターンの会話...] [user: 最新メッセージ]
↑ ↑
位置0 位置100,000
注意力:中程度 注意力:最大
(距離が遠すぎて無視されうる) (ルールがここにはない)
───────────────────────────────────────────────────────────────
system-reminderあり:
[system: ルールABC] [...50ターンの会話...] [user: <reminder>ルールA</reminder>
↑ + 最新メッセージ]
位置0 位置100,000
ルールは先頭にもある ルールAが注意力最大の
位置で再度強調される ✓
最も重要な指示を「先頭」から「末尾」にコピーすることで、recency biasを活用してLLMが回答生成時に確実にルールを意識するようにしている。
例え話
先生(system prompt)が黒板に授業ルールを書いた。
- 1時限目:生徒は黒板を見て、ルールを守る
- 10時限目:黒板の文字はそのままだが、もう誰も見ていない
- 先生の口頭注意(system-reminder):「みんな、もう一度黒板のルールを見て」
黒板は変わっていない。ルールは消えていない。しかし口頭の注意が、生徒の意識をルールに引き戻した。
エンジニアリング上の理由:Prompt Caching
注意力の問題に加えて、分離には実務的なメリットもある。
system フィールドの内容が変わらなければ、APIはリクエスト間でキャッシュできる(一部プロバイダが対応):
system(安定したコンテンツ) → 長期キャッシュヒット、コスト削減
messages(動的コンテンツ) → プロジェクト設定は随時編集されうる、systemキャッシュに影響しない
もしプロジェクト設定をsystemに入れたら、ユーザーがファイルを編集するたびにsystem全体のキャッシュが無効化される。分離することでキャッシュの安定性と内容の柔軟性を両立している。
まとめ表
| 観点 | system |
messages内の<system-reminder>
|
|---|---|---|
| コンテンツ | 汎用指示(ほぼ不変) | プロジェクト固有の指示、Skills、動的リマインダー(可変) |
| キャッシュ | 長期ヒット、コスト削減 | systemキャッシュに影響しない |
| 注意力の位置 | コンテキスト先頭(長い会話後は薄まりうる) | 最新メッセージ内(注意力が最も集中する位置) |
| 優先度 | 基本的なデフォルト行動 | デフォルトを上書き可能(recency bias) |
| 更新反映 | 製品のアップデートが必要 | ファイル編集で即座に反映 |
一言で:system は「私は誰か」、<system-reminder> は「今一番重要なルール、もう一回見て」。
9. Tool Call + Skillシステムの最小実装
ここまでの仕組みを理解していれば、自前のTool Call + Skillシステムはとてもシンプルに実装できる。
Tool Callのコアループ
// 1. ツール定義(モデルに見せるスキーマ)
const tools = [{
name: "db_query",
description: "データベースにSELECTクエリを実行する",
input_schema: {
type: "object",
properties: {
sql: { type: "string", description: "SELECTクエリ" }
},
required: ["sql"]
}
}]
// 2. Tool-useループ(肝はこの数行だけ)
async function agentLoop(userMessage: string) {
const messages = [{ role: "user", content: userMessage }]
while (true) {
const response = await callLLM({ system, tools, messages })
// ツール呼び出しがなければ最終回答
if (response.stop_reason === "end_turn") {
return response.text
}
// ツールを実行して結果を返す
messages.push({ role: "assistant", content: response.content })
for (const block of response.content) {
if (block.type === "tool_use") {
const result = await executeTool(block.name, block.input)
messages.push({
role: "user",
content: [{
type: "tool_result",
tool_use_id: block.id,
content: result
}]
})
}
}
// ループ継続 → tool_resultを持って再度LLMを呼ぶ
}
}
Skill対応を追加
// Skill = .mdファイル + 注入タイミング。これだけ。
async function handleUserInput(input: string) {
if (input.startsWith("/")) {
const skillName = input.slice(1).split(" ")[0]
const args = input.slice(1 + skillName.length).trim()
// .mdファイルを読んで引数を展開
const skillPrompt = await readFile(`.commands/${skillName}.md`)
const expanded = skillPrompt.replace("$ARGUMENTS", args)
return agentLoop(expanded)
}
return agentLoop(input)
}
<system-reminder> 対応を追加
function buildMessages(userMessage: string, history: Message[]) {
// 毎ターン、プロジェクト設定とSkill一覧を再注入
const projectConfig = readFileSync("CLAUDE.md", "utf-8")
const skillsList = listAvailableSkills()
const latest = {
role: "user",
content: [
`<system-reminder>\n${projectConfig}\n</system-reminder>`,
`<system-reminder>\nAvailable skills: ${skillsList}\n</system-reminder>`,
userMessage
].join("\n\n")
}
return [...history, latest]
}
アーキテクチャ全体
ユーザー入力
│
├─ /command? ──→ .md読み込み → promptに展開 ──→ agentLoop(prompt)
│ │
└─ 通常メッセージ ──→ messages構築 ──→ agentLoop(msg) │
(system-reminder注入) │
↓
┌── whileループ ──┐
│ │
│ callLLM() │
│ ↓ │
│ tool_use? │
│ ├─ yes → 実行 │
│ │ → 結果返却 │
│ │ → ループ継続 │
│ └─ no → return │
│ │
└─────────────────┘
まとめ
| # | ポイント | 一言 |
|---|---|---|
| 1 | Tool-use loopがすべての基盤 |
while (stop_reason !== "end_turn") ― これだけ |
| 2 | Tool = 能力、Skill = 方法論 | Toolは「手」、Skillは「頭の中のSOP」 |
| 3 | SkillはAPIレベルで存在しない | .mdファイルをmessagesに注入するだけ |
| 4 | 段階的情報開示が全体を貫く | .md → stdout → ソースコード → 設定ファイル。必要に応じて視野が広がる |
| 5 | 魔法 = system + tools + messages |
3つのJSONフィールドにテキストを詰めるだけ。他には何もない |
| 6 | <system-reminder> は注意力の位置ブースト |
Lost in the Middleに対抗し、最新メッセージ位置で重要ルールを再強調する |
| 7 | 各社Function Callingはコンセプト同一、形式だけ異なる | SDK/フレームワークが差異を吸収する |
| 8 | Sub-Agentは独立した会話 | 専用のsystem prompt・ツールサブセットを持ち、メイン会話には最終結果だけ返す |
この記事の成り立ち
普段、自分のプロダクトでLLMにFunction Callingを組み込む実装をしている。AIチャット機能にSQLクエリツールを持たせたり、RAG検索を組み合わせたり、といったプロンプトエンジニアリングの実務だ。
その一方で、開発そのものにも Claude Code(Opus 4.6)をフルに使っている。コードを書かせ、デバッグさせ、リファクタさせる日々の中で、ふと気づいた ― 自分がFunction Callingを設計する側であると同時に、Claude Codeという巨大なFunction Callingシステムの上で開発をしている。
「自分が作っているツール呼び出しの仕組みと、今まさに自分を助けているClaude Codeの仕組みは、本質的に同じなのか?違うのか?」
この疑問をClaude自身にぶつけてみた。すると、Tool / Skill / Prompt注入 / <system-reminder> の位置ブースト / Sub-Agentの分離 ― Claude Codeの内部アーキテクチャが、自分が日常的に設計しているシステムの延長線上にあることが鮮明に見えてきた。
本記事はその対話の内容をベースに、筆者が構成・編集してまとめたものだ。図解やコード例の多くはClaude Codeとの対話から生まれている。「AIエージェントの仕組みを、AIエージェント自身に解説させ、記事にまとめた」― この入れ子構造自体が、今のAIの実力を物語っているのかもしれない。
最後まで読んでいただきありがとうございます。
この記事が「なるほど、中身はこうなっていたのか」という理解の助けになれば幸いです。
質問やフィードバックがあればコメント欄でお気軽にどうぞ。