導入
先週、社内向けに LLM を組み込んでいる同僚から相談を受けた。「RAG は動いているのだが、質問に答えるだけで、そこから先の処理は結局人間が手でやっている。この距離がしんどい」。
工数を数えると、LLM が「答えを出す」時間より、その答えを見て人間が SaaS 画面をポチポチしている時間のほうが長い。読める LLM と、動く業務の間には、まだ谷がある。
その谷を埋めるのが tool-use、いわゆる function calling だ。以下、直近の案件で使った最小設計と、そこで踏んだ罠を残しておく。次に同じ落とし穴に落ちる人が 30 分節約できたら十分だ。

そもそも tool-use とは何か
ざっくり言えば、LLM に「使える道具」を渡し、状況を見て道具を選ばせる仕組みだ。ドキュメントを引くだけだった RAG が、社内 API を叩けるようになる — と書くと単純だが、この一歩の重みは大きい。
「読む」だけの LLM はプロンプトの外に副作用を持たない。答えを間違えても、原稿を書き直せば済む。「操作する」LLM は違う。DB を更新する。メールを送る。在庫を書き換える。副作用は消せない。
だから設計の重心も変わる。「良い答えを返せるか」から、「良い順序で、正しい範囲だけ、安全に叩けるか」へ、と言い換えてもいい。
前提
Python 3.11
Anthropic SDK 最新版 (Claude Sonnet 4.5 / Opus 4.5 の並列 tool-use が使える)
検証用のダミー在庫システム (社内 API を模したローカル FastAPI サーバ)
以下のコードはそのまま動く最小構成に近い。API キーは環境変数から読ませる。
実装ステップ
1. 道具を定義する — 粒度と境界
最初にハマったのがここだった。要件書には「在庫を管理する tool を作る」と書いてある。素直に manage_inventory を 1 個だけ作ろうとしたら、内部で 5 分岐が発生した。読む・書く・削除・移動・棚卸し。すべて同じ tool にまとめてあるのだ。
この単一 tool を LLM に渡すと、選ぶ楽さの代わりに動作の予測が難しくなる。「manage_inventory を呼ぶ」時点で何が起きるのかがわからない。ログを見ても、LLM の意図の内訳が復元できない。
分けた。読む系と書く系で完全に別の tool にする。
tools = [
{
"name": "get_stock",
"description": (
"指定した SKU の在庫数を取得する。読み取り専用。"
"在庫が存在しない SKU の場合は 0 ではなく null を返す。"
"在庫を変更する用途では絶対に使わないこと。"
),
"input_schema": {
"type": "object",
"properties": {
"sku": {"type": "string", "description": "商品コード。例: SKU-8821"},
},
"required": ["sku"],
},
},
{
"name": "adjust_stock",
"description": (
"在庫数を +/- で調整する書き込み系の tool。"
"副作用があるため、ユーザーが明示的に依頼した場合のみ使う。"
"推測での実行は禁止。実行前に必ず get_stock で現状を確認すること。"
),
"input_schema": {
"type": "object",
"properties": {
"sku": {"type": "string"},
"delta": {"type": "integer", "description": "増減数。負の値は減少"},
"idempotency_key": {
"type": "string",
"description": "同一操作を二重実行しないための一意なキー",
},
"reason": {"type": "string", "description": "調整理由。監査ログに残す"},
},
"required": ["sku", "delta", "idempotency_key", "reason"],
},
},
]
description はラベルではなく、LLM に読ませる小さな指示書だと思ったほうがいい。「読み取り専用」「書き込み系」「推測禁止」「事前に get_stock で確認」といった条件を、日本語で明示的に書く。丁寧に書くほど、後段の失敗率が下がる。
2. 権限スコープ — 最小権限で始める
tool 定義とは別の層で、実行側にも権限を持たせる。LLM が呼び出せる tool と、実行環境が実際に叩ける API を分けるのが基本設計だ。
素朴な実装:
def dispatch(tool_name: str, params: dict, session_scope: set[str]) -> dict:
required_scope = TOOL_SCOPE[tool_name] # 例: "stock:read", "stock:write"
if required_scope not in session_scope:
return {"error": f"scope '{required_scope}' not granted for this session"}
return TOOL_HANDLERS[tool_name](**params)
セッションごとに session_scope = {"stock:read"} のように渡す。読み取り専用のセッションでは、LLM がどれだけ書き込み tool を呼びたがっても、実行層で弾く。プロンプトインジェクションで LLM が騙されたとしても、境界の外は動かない。
現場でよく議論になるのが「読みも書きも同じセッションで持たせるべきか」だ。案件によるが、私は初期は read だけで組み、後から明示的に write スコープを追加する運用にすることが多い。最初から両方渡すと、テスト段階で「間違えて更新した」事故が起きやすい。
3. 冪等性 — 二重実行の防波堤
LLM は素直にリトライする。ネットワークが揺れたとき、モデル側で prompt を再送したとき、同じ tool が 2 回呼ばれることがある。読み取りなら影響がないが、書き込みが 2 回走ると在庫が二重に減る。
対策は昔ながらの idempotency key だ。上の adjust_stock の schema に含めてある idempotency_key を、実装側でチェックする:
_processed_keys: set[str] = set()
def handle_adjust_stock(sku: str, delta: int, idempotency_key: str, reason: str):
if idempotency_key in _processed_keys:
current = read_stock(sku)
return {"status": "already_processed", "current_stock": current}
_processed_keys.add(idempotency_key)
new_stock = write_stock_delta(sku, delta, reason=reason)
return {"status": "ok", "new_stock": new_stock}
キー生成は LLM 任せにしないほうが安定する。実案件では、ユーザーのリクエスト ID にツール呼び出しの通番を suffix して渡す方式にした。「ランダムな uuid を作ってください」だとモデル差異で決定性が壊れるし、リプレイができない。
4. 承認境界 — 危険操作は必ず人間を挟む
削除、大量書き込み、外部送信、支払い系。この 4 つは、絶対に LLM 単独で走らせない。tool の呼び出しはさせつつ、実行の直前に人間の確認を挟む。
DANGEROUS_TOOLS = {"delete_record", "send_external_email", "adjust_stock"}
def dispatch(tool_name, params, session_scope):
if tool_name in DANGEROUS_TOOLS:
approval = request_human_approval(tool_name, params)
if not approval.granted:
return {"error": "user_denied", "message": approval.comment}
return TOOL_HANDLERStool_name
request_human_approval の実装は現場によって変わる。Slack に投げて 30 秒だけ待つ、社内ダッシュボードに承認キューを積む、CLI で y/n を待つ — 案件の重さで違う。共通なのは「LLM が判断しない範囲を、コードで先に決めておく」ことだ。この境界がぼやけると、後で必ず事故が起きる。
5. ループを回す — 並列 tool-use と停止条件
Claude Sonnet 4.5 / Opus 4.5 は、1 レスポンスで複数の tool_use ブロックを返してくることがある。independent な呼び出しなら並列に実行して、次のターンで tool_result を全部まとめて返すのが速い。
from anthropic import Anthropic
import concurrent.futures
client = Anthropic()
def run_agent(user_msg: str, session_scope: set[str], max_steps: int = 8):
messages = [{"role": "user", "content": user_msg}]
for step in range(max_steps):
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
return response
tool_uses = [b for b in response.content if b.type == "tool_use"]
if not tool_uses:
return response
with concurrent.futures.ThreadPoolExecutor() as ex:
results = list(ex.map(
lambda t: (t.id, dispatch(t.name, t.input, session_scope)),
tool_uses,
))
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": tid, "content": str(res)}
for tid, res in results
],
})
raise RuntimeError(f"agent did not converge in {max_steps} steps")
ここで重要なのは max_steps だ。エージェントが同じ tool を呼び続ける状況は、実際によく起きる。無限に回すとコストが青天井になる。8 とか 12 とか、案件に応じて上限を必ず入れる。
動作確認
上のコードに対して、次を投げてみた。
「SKU-8821 の在庫を確認して、もし 10 個以下なら 50 個補充してください」
期待した動作:
get_stock(sku="SKU-8821")を呼ぶ結果を見て 10 個以下なら
adjust_stockを提案人間承認を待つ
承認 → 実行 → 結果を自然文で返す
ログ (抜粋):
[step 0] tool_use: get_stock(sku="SKU-8821")
[step 0] tool_result: {"stock": 7}
[step 1] tool_use: adjust_stock(sku="SKU-8821", delta=50, idempotency_key="req-abc-1", reason="threshold_refill")
[step 1] approval: pending -> granted (comment="ok")
[step 1] tool_result: {"status": "ok", "new_stock": 57}
[step 2] end_turn: 「SKU-8821 の在庫は 7 個でしたので、50 個補充しました。現在の在庫は 57 個です」
きれいに動いた。ただ、初回で通ったわけではない。最初のプロトタイプでは adjust_stock の description に「読み取り専用」と間違って書いていて、モデルは呼ばずに黙って end_turn を返した。description を直したら通った。「モデルが動かない」ときの原因は、8 割は description の書きぶりだと思っていい。
応用と発展
同じ骨組みで、業種を変えて動く。
経理: 「先月の交通費申請を仕訳に落とす」→ 読み系 tool で申請一覧を取得、書き系 tool で仕訳作成、書き込み前に承認
採用: 「応募者の書類を確認して評価テンプレを埋める」→ ATS 読み込み・スコアリング・下書き保存まで自動、送信は人間
カスタマーサポート: 「よくある問い合わせを一次対応」→ FAQ 検索と返答テンプレの下書き。返信ボタンだけ人間が押す
パターンとしては、常に「読む → 提案する → 承認 → 書く」の 4 段が骨だ。この骨に沿ってさえいれば、ドメインが変わってもコードの構造はほぼ再利用できる。
まとめ
読む LLM と操作する LLM の差は、tool_use を有効にする一行では終わらない。実装の重心は、tool を増やすことより、境界を引くことに移る。粒度、権限、冪等性、承認。この 4 つを最初に決めておけば、あとから安全に tool を足していける。
次回は、この単発の tool 群を MCP (Model Context Protocol) で束ねて、社内の複数システム横断のエージェントに拡張する話を書く予定だ。単一システムで動いた設計を、社内 API・DB・SaaS を同時に触るエージェントに広げるとどうなるか、そこで新しく必要になる抽象は何かを扱う。
筆者は 5years+ で、韓国と日本の企業向けに LLM/AI エージェントの業務組み込みを担当している。