ankuro.dev
← ブログ一覧に戻る
【CCA Foundations対策 / Claude API編 #9】Tool Useの基礎②——ツール実行とエージェントループ
2026-04-03#Claude#API#Python#生成AI#入門#Claude Certified Architect

【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_weatherget_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_idcontentis_error を含める。IDは対応する tool_use の id と必ず一致させる
  • ループ制御は stop_reason だけで判断する——"tool_use" なら継続、"end_turn" なら終了
  • テキスト内容や反復回数を主要な停止条件にするのはアンチパターン
  • 次回(#10)ではツールの追加・Message Batches API・Web検索ツールなど、Tool Useの応用を扱う

← 前回:Tool Useの基礎①次回:Tool Useの応用