1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

クライアント側ツール実行を本番で壊さない設計:ラウンドトリップ・strict・並列・コストの勘所

1
Posted at

Claude にツールを 1 個渡して動かすのは簡単です。難しいのは、その先です。ツールが複数同時に呼ばれる、引数が欠けている、バックエンドが例外を投げる、トークン課金が読めない——こうした条件が重なる本番環境で、クライアント側ツール実行のループは静かに崩れます。本稿(2026-10-09 検証)は、Claude API のクライアントツールを「ゼロから完成まで通る 1 本の実装」として組み立て、ラウンドトリップの正しい形、strict と tool_choice、並列ツール使用、本番で詰まる箇所、そして運用設計までを一通り示します。

クライアントツールとサーバーツールの境界

まず「なぜ自分で実行コードを書く必要があるのか」を押さえます。ツールには実行場所の違う 2 種類があります。クライアントツール(ユーザー定義ツール、および bash や text_editor のような Anthropic 定義スキーマを持つツール)は、あなたのアプリケーション内で実行されます。Claude は stop_reason: "tool_use" と 1 つ以上の tool_use ブロックを返し、あなたのコードが操作を実行して tool_result を送り返します。

一方、web_search・web_fetch・code_execution・tool_search などのサーバーツールは Anthropic のインフラ上で実行され、結果が直接返るため、アプリケーション側に実行ハンドラーは不要です。つまり「実装の責務が発生するのはクライアントツール側だけ」です。本稿が対象とするのはこちらです。

ラウンドトリップの正しい実装

クライアントツールの基本は 1 往復です。レスポンスが tool_use ブロックを運び、あなたのコードがルックアップを実行し、二度目のリクエストで tool_result ブロックに結果を乗せて送り返すと、Claude が答えを返します。この「二度目のリクエスト」を正しく組むことが本番品質の分かれ目です。

空ディレクトリから作ります。

mkdir order-agent && cd order-agent
python -m venv .venv && source .venv/bin/activate
echo "anthropic>=0.40" > requirements.txt
pip install -r requirements.txt
export ANTHROPIC_API_KEY=sk-ant-...

tools.py(ツール定義とディスパッチ層):

# tools.py
TOOLS = [
    {
        "name": "get_order_status",
        "description": (
            "Look up the current status of a customer order by its "
            "order ID. Use when the user asks about shipping or delivery."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string",
                             "description": "The order ID, e.g. 'A-10293'."}
            },
            "required": ["order_id"],
            "additionalProperties": False,
        },
        "strict": True,
    }
]

_ORDERS = {
    "A-10293": {"status": "shipped", "carrier": "Yamato", "eta": "2026-10-11"},
    "A-55012": {"status": "processing", "carrier": None, "eta": None},
}

def get_order_status(order_id: str) -> dict:
    order = _ORDERS.get(order_id)
    if order is None:
        return {"error": "not_found", "order_id": order_id}
    return {"order_id": order_id, **order}

HANDLERS = {"get_order_status": get_order_status}

def run_tool(name: str, args: dict) -> dict:
    handler = HANDLERS.get(name)
    if handler is None:
        return {"error": "unknown_tool", "name": name}
    try:
        return handler(**args)
    except Exception as e:
        return {"error": "tool_exception", "detail": str(e)}

agent.py(ラウンドトリップ・ループ):

# agent.py
import json
from anthropic import Anthropic
from tools import TOOLS, run_tool

client = Anthropic()
MODEL = "claude-opus-5-5"
MAX_TURNS = 8  # 無限ループ防止の上限

def chat(user_text: str) -> str:
    messages = [{"role": "user", "content": user_text}]
    for _ in range(MAX_TURNS):
        resp = client.messages.create(
            model=MODEL,
            max_tokens=1024,
            tools=TOOLS,
            tool_choice={"type": "auto", "disable_parallel_tool_use": True},
            messages=messages,
        )

        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")

        # 1) アシスタントのターン(tool_use を含む)をそのまま追加する
        messages.append({"role": "assistant", "content": resp.content})

        # 2) 各 tool_use を実行し、tool_use_id ごとに tool_result を作る
        tool_results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            print(f"[tool] {block.name}({block.input})")
            result = run_tool(block.name, block.input)
            print(f"[result] {result}")
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps(result, ensure_ascii=False),
                "is_error": "error" in result,
            })

        # 3) 二度目のリクエストで結果を返す(次ループで送信)
        messages.append({"role": "user", "content": tool_results})

    return "max turns reached"

if __name__ == "__main__":
    print(chat("注文 A-10293 はいまどこですか?"))

動作確認と期待される挙動

python agent.py を実行します。終了コードは 0、標準出力にはまずディスパッチ層のログが出ます。[tool] / [result] の行は自分の print 由来なので内容は決まっています(order_id の値は Claude のツールコールに依存するため、通常は入力した注文 ID になります)。成功時の出力はおおよそ次の形です。

[tool] get_order_status({'order_id': 'A-10293'})
[result] {'order_id': 'A-10293', 'status': 'shipped', 'carrier': 'Yamato', 'eta': '2026-10-11'}
(最後に Claude の自然文の回答が 1 行。配送済み・Yamato・2026-10-11 を踏まえた文面が返る)

成功の見分け方は 3 つです。(1) [result] に error キーが無い、(2) ループが 2 周目で stop_reason != "tool_use" に落ちて自然文が返る、(3) 存在しない ID(例: A-99999)を渡すと [result] が {'error': 'not_found', ...} になり、is_error: True の tool_result を受けて Claude が「見つからない」旨を返す。最後の自然文は毎回同一とは限らないため、断定はできません。

引数欠落の挙動はモデルで差が出ます。「私の注文はどこ?」のように ID 無しで尋ねた場合、Claude Opus は不足パラメータを認識して問い返す可能性が高く、stop_reason は tool_use ではなく end_turn(ID を尋ねる自然文)になりやすいです。Claude Sonnet は尋ねることもあれば、妥当な値を推測して埋めることもあります。この差は本番でのバリデーション設計に直結します。

スキーマを厳密化する: strict と tool_choice

ツールコールがスキーマ定義に正確に一致することを求めるには、ツール定義に strict: true を追加します。本稿では additionalProperties: false と併用していますが、strict 下で使えるスキーマ型・機能の範囲には制約があり得るため、適用前に公式ドキュメントの制約事項を確認してください。

ツール呼び出しを強制したい場合、プロンプトでお願いするのではなく tool_choice を設定するのが推奨です。既定の {"type": "auto"} は、リクエストがそのツールの説明された機能に対応し、かつ答えがまだコンテキスト内に無いときに呼び出します。分類器のように「必ず 1 回ツールを通す」設計なら auto ではなく明示指定が確実です。

並列ツール使用とステートフルなツール

既定では Claude は 1 レスポンスで複数の tool_use ブロックを返し得ます。これを無効化するには tool_choice に disable_parallel_tool_use: True を設定します。本稿の agent.py では読み取り専用の 1 ツールですが、あえて無効化しています。理由は設計判断で、ツールが状態を変更する(在庫を引き当てる、課金する)、または結果が次の呼び出しの入力になる依存関係がある場合、並列実行は競合や二重実行の温床になるからです。読み取りのみ・独立・冪等なツール群なら、並列を許可してレイテンシを稼ぐほうが合理的です。

本番で詰まる 6 つの箇所

  1. アシスタントのターンを戻し忘れる: tool_result を送る前に、tool_use を含むアシスタントの content を会話履歴へそのまま追加する必要があります。これを省くと API エラーになります。
  2. tool_use_id の取りこぼし: 並列で複数ブロックが来たとき、すべての tool_use_id に対応する tool_result を返さないとエラーになります。ループは「全 tool_use を走査して漏れなく返す」構造にします。
  3. ループの停止条件: stop_reason を見て終了判定しつつ、MAX_TURNS の上限を必ず置きます。モデルがツールを呼び続けるケースで無限ループを防ぎます。
  4. 例外を投げてループを殺す: ハンドラ内の失敗は raise せず、構造化した error を返し is_error: True の tool_result にします。Claude 側で回復(別 ID の確認、ユーザーへの質問)が可能になります。
  5. content を文字列化し忘れる: tool_result の content は文字列です。dict はそのまま入れず JSON 文字列化します。
  6. コスト計上を見落とす: 後述。

設計:ディスパッチ層・タイムアウト・冪等性(私ならこう作る)

ここが公式ドキュメントの引き写しには無い部分です。あくまで設計上の意見として、事実と区別して述べます。

第一に、ツール実行は必ずディスパッチ registry(名前→関数の辞書)に通し、ループ本体から直接関数を呼ばない設計にします。ツールが増えても agent.py の制御フローは不変で、ハンドラの追加・テスト・権限チェックが 1 箇所に集約されます。本稿の HANDLERS と run_tool がその最小形です。

第二に、各ハンドラにタイムアウトを課します。外部 API を叩くツールが応答しないと、ループ全体が固まり、ユーザー体感と課金の両方が悪化します。run_tool 内で concurrent.futures などにより上限時間を設け、超過時は {"error": "timeout"} を返して Claude に判断を委ねます。

第三に、状態変更ツールは冪等キーを持たせます。並列を無効化しても、ネットワーク再送でハンドラが二度走ることはあり得ます。注文確定のような操作は idempotency_key を引数スキーマに含め、バックエンド側で重複を吸収する前提で組むのが安全です。

Tool Runner を使うか自前ループを書くかも設計判断です。Tool Runner を使うと SDK がツールを自動実行して結果を送り返すため、ラウンドトリップ実装を自前で書かずに済みます。独立・冪等なツール中心なら Tool Runner が速い。逆に、権限分岐・監査ログ・細かいタイムアウト・部分失敗時の挙動を自分で握りたいなら、本稿のような明示ループが向きます。利用可能な SDK とバージョン要件は公式ドキュメントで確認してください。

コストの見積もり

クライアント側ツールは通常の Claude API リクエストと同一料金で、サーバーツールのような使用量ベースの追加課金は発生しません。追加トークンが乗るのは 3 箇所です。tools パラメータ(名前・説明・スキーマ)、リクエスト/レスポンス双方の tool_use ブロック、そしてリクエストの tool_result ブロック。説明文を冗長にすると毎リクエストで課金されるため、ツール説明は必要十分に保ちます。

加えて、ツール使用のためのシステムプロンプトにも固定費があります。2026-10-09 時点の公式トークン数テーブルでは、tool_choice が auto または none のとき、Claude Opus 5.5 と Claude Sonnet 5.5 はいずれも 286 トークンです。ツールを一切渡さないケースで tool_choice を none にすれば、この追加分は 0 になります。

References

[1] https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?