0
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?

「読むだけ」の LLM から「操作する」LLM へ — tool-use 設計の最小メモ

0
Posted at

導入

先週、社内向けに LLM を組み込んでいる同僚から相談を受けた。「RAG は動いているのだが、質問に答えるだけで、そこから先の処理は結局人間が手でやっている。この距離がしんどい」。

工数を数えると、LLM が「答えを出す」時間より、その答えを見て人間が SaaS 画面をポチポチしている時間のほうが長い。読める LLM と、動く業務の間には、まだ谷がある。

その谷を埋めるのが tool-use、いわゆる function calling だ。以下、直近の案件で使った最小設計と、そこで踏んだ罠を残しておく。次に同じ落とし穴に落ちる人が 30 分節約できたら十分だ。

AIエージェントの tool-use 設計を書き留めるデスクの風景

そもそも 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 個補充してください」

期待した動作:

  1. get_stock(sku="SKU-8821") を呼ぶ

  2. 結果を見て 10 個以下なら adjust_stock を提案

  3. 人間承認を待つ

  4. 承認 → 実行 → 結果を自然文で返す

ログ (抜粋):

[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 エージェントの業務組み込みを担当している。

0
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
0
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?