
【CCA Foundations対策 / Claude API編 #9】Tool Useの基礎②——ツール実行とエージェントループ
Anthropic Academyの「Claude APIを使用した構築」コースをもとに解説しています。
前回(#8)はツール関数の定義とJSON schemaの設計を扱った。今回はその続き——Claudeにスキーマを渡してからの「レスポンスを読む → ツールを実行する → 結果をClaudeに返す → 繰り返す」という実行フローの実装を解説する。
この記事でわかること:
- tool_use レスポンスのマルチブロック構造と、正しい履歴の保存方法
- tool_result ブロックの作り方とIDマッチングの仕組み
- stop_reason でエージェントループを制御する実装パターン
- ループ終了の判断を誤るアンチパターンとその理由
レスポンスを読む——マルチブロックメッセージの構造
通常の会話では response.content[0].text だけ読めばよかった。ツールを使う場面では、レスポンスの構造が変わる。
Claudeがツールを呼び出したいと判断したとき、レスポンスの content は複数のブロックのリストになる。
response.content = [
TextBlock(type="text", text="現在の大阪の気象情報を調べます。"),
ToolUseBlock(
type="tool_use",
id="toolu_01Abc...",
name="get_weather",
input={"city": "大阪"}
)
]
ToolUse ブロックの3つのフィールドが特に重要:
| フィールド | 内容 |
|---|---|
id |
このツール呼び出しを一意に識別するID。結果を返すときに必ず使う |
name |
呼び出すツール関数の名前 |
input |
Claudeが渡してきた引数(辞書形式) |
また、stop_reason フィールドが "tool_use" になる。この値がループ制御の判断基準になる(後述)。
会話履歴への保存は全ブロックまとめて
重要な落とし穴として、TextBlock だけを保存してはいけない。tool_use ブロックを含む全体を保存しないと、次のAPIコールでエラーになる。
# ❌ text だけ保存するのはNG
messages.append({
"role": "assistant",
"content": response.content[0].text # ToolUseBlockが消える
})
# ✅ content リスト全体を保存する
messages.append({
"role": "assistant",
"content": response.content # TextBlock + ToolUseBlock 両方
})
Claudeが「このツールを呼んでほしい」と伝えた記録を消してしまうと、次のターンでClaudeが自分のリクエストと結果の対応を理解できなくなる。
ツール結果をClaudeに返す
アプリ側でツールを実行したら、その結果を tool_result ブロックとしてClaudeに返す。tool_result ブロックは user ロールのメッセージの content リストに含める。
import json
def get_weather(city: str) -> dict:
"""指定した都市の現在の気象情報を取得する(モック実装)"""
mock_data = {
"大阪": {"temp": 18, "condition": "晴れ", "humidity": 55},
"東京": {"temp": 15, "condition": "曇り", "humidity": 70},
"札幌": {"temp": 3, "condition": "雪", "humidity": 80},
}
data = mock_data.get(city, {"temp": 20, "condition": "晴れ", "humidity": 60})
return {"city": city, **data}
# ① ユーザーの質問 + ツールスキーマを送信
messages = [{"role": "user", "content": "大阪の今の気温を教えて"}]
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=messages,
tools=[get_weather_schema],
)
# ② Claudeのレスポンス(tool_use ブロック含む)を履歴に追加
messages.append({"role": "assistant", "content": response.content})
# ③ ToolUseBlock から引数を取り出してツールを実行
tool_block = response.content[1] # ToolUseBlock
tool_output = get_weather(**tool_block.input)
# ④ tool_result ブロックを作って履歴に追加
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_block.id, # ③で使ったブロックのIDと一致させる
"content": json.dumps(tool_output, ensure_ascii=False),
"is_error": False,
}]
})
# ⑤ 最終回答を取得(tools パラメータは引き続き渡す)
final_response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=messages,
tools=[get_weather_schema], # 会話履歴の参照に必要
)
print(final_response.content[0].text)
現在の大阪の気温は18°Cで、天気は晴れ、湿度は55%です。
tool_result の3つのフィールド
| フィールド | 役割 |
|---|---|
tool_use_id |
どのツール呼び出しへの結果かを示すID。ToolUseBlock の id と必ず一致させる |
content |
ツールの実行結果。文字列として渡す(辞書は json.dumps で変換) |
is_error |
エラーが発生した場合は True。Claudeがエラーメッセージを読んで再試行できる |
フォローアップリクエストにも tools が必要な理由
最終回答を取得するリクエストでも tools パラメータを渡している点に注目。これは「また呼んでほしい」という意図ではなく、会話履歴の中にある tool_use ブロックをClaudeが解釈するために必要だから。省略するとAPIエラーになる。
エージェントループを実装する
「今週の東京の天気概況を教えて」という質問には、現在の気象情報と今後の予報の両方が必要になる。Claudeは順番に複数のツールを呼び出して、段階的に情報を集める。
このような複数ターンの処理を自動で回すのがエージェントループ。
stop_reason でループを制御する
ループの制御は stop_reason だけで判断する:
"tool_use"→ Claudeがまだツールを必要としている。ツールを実行して結果を返す"end_turn"→ Claudeが最終回答を生成した。ループを終了する
import json
import anthropic
client = anthropic.Anthropic()
model = "claude-sonnet-4-6"
def get_weather(city: str) -> dict:
"""指定都市の現在の気象情報を返す"""
mock_data = {
"東京": {"temp": 15, "condition": "曇り", "humidity": 70},
"大阪": {"temp": 18, "condition": "晴れ", "humidity": 55},
}
data = mock_data.get(city, {"temp": 20, "condition": "晴れ", "humidity": 60})
return {"city": city, **data}
def get_forecast(city: str, days: int) -> list:
"""指定都市の気象予報を返す"""
templates = ["晴れ", "曇り", "雨", "晴れのち曇り", "曇り時々晴れ"]
return [
{"day": i + 1, "condition": templates[i % len(templates)], "high": 15 + i, "low": 8 + i}
for i in range(days)
]
def run_tool(name: str, tool_input: dict) -> dict | list:
"""ツール名から対応する関数を呼び出すルーター"""
if name == "get_weather":
return get_weather(**tool_input)
elif name == "get_forecast":
return get_forecast(**tool_input)
raise ValueError(f"未知のツール: {name}")
def run_tools(message) -> list:
"""レスポンス内の全 tool_use ブロックを処理して tool_result リストを返す"""
tool_results = []
for block in message.content:
if block.type != "tool_use":
continue
try:
output = run_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(output, ensure_ascii=False),
"is_error": False,
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"エラー: {e}",
"is_error": True,
})
return tool_results
def run_conversation(user_message: str, tools: list) -> str:
"""エージェントループを回して最終回答を返す"""
messages = [{"role": "user", "content": user_message}]
while True:
response = client.messages.create(
model=model,
max_tokens=1024,
messages=messages,
tools=tools,
)
# Claudeのレスポンスを全ブロックごと履歴に追加
messages.append({"role": "assistant", "content": response.content})
# stop_reason が "end_turn" なら終了
if response.stop_reason != "tool_use":
break
# ツールを実行して結果を追加し、次のターンへ
tool_results = run_tools(response)
messages.append({"role": "user", "content": tool_results})
# テキストブロックを結合して返す
return "\n".join(
block.text for block in response.content if block.type == "text"
)
result = run_conversation(
"今週の東京の天気概況を教えて",
tools=[get_weather_schema, get_forecast_schema],
)
print(result)
現在の東京は気温15°C・曇りです。
今週の予報は晴れ→曇り→雨→晴れのち曇り→曇り時々晴れと変化する見込みで、
気温は徐々に上昇し週末には高温が20°C前後まで上がる予想です。
傘の準備は水曜日までに済ませておくと安心です。
ループが回っている間、Claudeは get_weather と get_forecast を順番に呼び出して情報を集め、最後に自然な文章で回答をまとめる。
📋 試験ガイドより
公式試験ガイドのDomain 1(Agentic Architecture & Orchestration)Task 1.1では、agentic loop lifecycle として「stop_reason が "tool_use" → ツールを実行して結果を追加 → 継続、"end_turn" → 終了」という制御フローが設計判断のポイントとして明記されている。また、tool_results を会話履歴に追加することで「モデルが次のアクションを推論できる」ことも重要な知識として取り上げられている。
ループ終了のアンチパターン
stop_reason != "tool_use" でループを終了するのが正しい方法。これに対して、よくある誤った実装パターンが3つある。
① テキスト内容でループ終了を判断する
# ❌ アンチパターン:「わかりました」「以上です」などの文字列を検索する
text = response.content[0].text
if "以上です" in text or "わかりました" in text:
break
Claudeが使う言い回しは状況によって変わる。特定の文字列がない場合でも最終回答になることがあり、文字列マッチングは信頼性が低い。
② 反復回数の上限を主要な停止条件にする
# ❌ アンチパターン:カウンターを主要な停止条件にしている
for i in range(10):
response = ...
if i == 9:
break # ← これが主な終了判断になっている
上限に達した時点でClaudeがまだツール実行の途中の場合、処理が中断される。上限はあくまで無限ループへの保険として添える程度にとどめ、主な停止条件は stop_reason にする。
# ✅ 上限は保険として添える
MAX_TURNS = 20
for _ in range(MAX_TURNS):
response = ...
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
tool_results = run_tools(response)
messages.append({"role": "user", "content": tool_results})
③ テキストブロックの存在を完了の指標にする
# ❌ アンチパターン:text ブロックがあれば終了とみなす
if any(block.type == "text" for block in response.content):
break
tool_use と同時に text ブロックが返ってくることがある(「調べてみます」などの説明文)。テキストブロックの有無は完了の指標にならない。
なぜ stop_reason だけが信頼できるのか。 Claudeは自分がまだツールを必要としているかどうかを内部で判断し、その結果を stop_reason に明示的に書き出す。これはAPIが保証する構造化された信号で、テキスト内容のような曖昧な推測とは本質的に異なる。
よくある誤解まとめ
| 誤解 | 実際 |
|---|---|
| tool_use ブロックを無視して text だけ履歴に保存してよい | tool_use ブロックを省くと次のAPIコールでエラーになる。response.content 全体を保存する |
| フォローアップリクエストには tools を渡さなくていい | 会話履歴の tool_use を解釈するためにClaudeは tools が必要。省略するとエラー |
| stop_reason が "end_turn" なら必ずテキスト回答が来る | text ブロックが空の場合もある。block.type == "text" でフィルタしてから使う |
| テキスト内容でループ終了を判断できる | Claudeの言い回しは状況によって変わる。stop_reason だけを停止条件にする |
| is_error=True にするとClaudeが処理を止める | Claudeはエラーメッセージを読んで引数を修正して再試行できる。エラー時も tool_result は必ず返す |
| 複数の tool_use ブロックは順番に1つずつ処理すればよい | 全 tool_use ブロックの結果を1つの user メッセージに含めてから次のAPIコールを送る |
設計の判断基準
| 場面 | やりがちな選択 | 正しい選択 | 判断の根拠 |
|---|---|---|---|
| エージェントループの終了を判断する | テキスト内容の文字列検索や反復回数を主な終了条件にする | stop_reason != "tool_use" のみを終了条件にする |
stop_reasonはAPIが保証する構造化されたシグナル。テキスト内容はClaudeの表現次第で変わり信頼できない |
| ツール実行でエラーが発生した | エラーをアプリ側で処理してデフォルト値を返す | is_error=True と具体的なエラーメッセージをtool_resultで返す |
Claudeがエラーを読んで引数を修正して再試行できる。握りつぶすとClaudeが誤りに気づかない |
| Claudeが複数ツールを同時リクエストしてきた | 1つずつ個別に処理して別々のuserメッセージで返す | 全tool_useブロックをまとめて処理し1つのuserメッセージで返す | まとめて返すことでAPIラウンドトリップを削減でき、Claudeも全結果をまとめて推論できる |
まとめ
- Claudeがツールを使う場合、レスポンスは text + tool_use のマルチブロック構造になる
- 会話履歴には
response.content全体(全ブロック)を保存する - tool_result ブロックには
tool_use_id・content・is_errorを含める。IDは対応する tool_use のidと必ず一致させる - ループ制御は
stop_reasonだけで判断する——"tool_use"なら継続、"end_turn"なら終了 - テキスト内容や反復回数を主要な停止条件にするのはアンチパターン
- 次回(#10)ではツールの追加・Message Batches API・Web検索ツールなど、Tool Useの応用を扱う