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

8. Tool CallingでLLMに外部APIを使わせる

3
Last updated at Posted at 2026-08-09

連載: NMS開発者がLLMブートキャンプで学んだこと

← 前回次回 →

前回は、LangChain を「LLM アプリを部品化するための道具」として整理しました。

prompt、LLM、parser、retriever を別々の部品として扱うと、単発の API 呼び出しよりも業務アプリに近づきます。

ただ、ここでまだ大きな壁が残ります。

LLM は、社内システムの今の状態を知りません。

NMS の画面で今どの装置が落ちているのか。

CMDB 上でその装置がどの拠点にあるのか。

直近のインターフェースエラーが増えているのか。

すでに同じ障害でチケットが作られているのか。

こうした情報は、モデルの中にありません。モデルにプロンプトで説明しない限り、LLM は知ることができません。

では、毎回すべての情報をプロンプトに貼り付けるのか。

それは現実的ではありません。

そこで出てくるのが Tool Calling です。

背景

RAG は、LLM に外部文書を読ませるための仕組みでした。

Runbook、FAQ、障害対応手順、過去チケットなどを検索し、その内容を context として LLM に渡します。

一方、NMS や CMDB、チケットシステムは、単なる文書の集まりではありません。装置や障害の状態を保持し、検索条件やユーザーの権限に応じて API から情報を取得したり、必要に応じてデータを更新したりするシステムです。

たとえば、NMS で装置情報を調べる場合、欲しいのは次のような静的な文章ではありません。

この装置は東京DCにあります。

欲しいのは、今その瞬間の状態です。

{
  "device_id": "router-tokyo-01",
  "site": "Tokyo DC",
  "status": "critical",
  "vendor": "Cisco",
  "os_version": "17.9",
  "last_seen": "2026-06-16T00:12:41+09:00"
}

これは RAG だけでは扱いづらいです。

検索文書ではなく、業務 API を呼ぶ必要があるからです。

Tool Calling は、この部分をつなぐための仕組みです。

LLM が直接データベースや API を勝手に触るわけではありません。

開発者が用意した tool の一覧を LLM に渡します。

LLM は、ユーザーの質問を読んで「この tool をこの引数で呼ぶべきだ」と判断します。

実際に API を呼び出すのは、アプリケーション側のコードです。

この距離感が重要でした。

LLM は実行者ではなく、呼び出し計画を作る役割に近いです。

授業で最初に見た Tool Calling

7週目の授業では、まず小さな関数を tool として登録するところから始めました。

たとえば、天気を返す関数です。

from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """天気を照会する関数です。"""
    weather_db = {
        "東京": "曇り, 19度, 湿度70%",
        "ソウル": "晴れ, 22度, 湿度45%",
        "ニューヨーク": "雨, 15度, 湿度80%",
    }
    return weather_db.get(city, f"{city}: 情報なし")

実際の授業ノートでは @tool を付けることで、普通の Python 関数が LangChain の tool として扱われていました。

そして、LLM に tool を渡します。

llm_with_tools = llm.bind_tools([get_weather])
response = llm_with_tools.invoke("東京の天気を教えて。")

ここで面白いのは、最初の LLM 応答が普通の文章ではないことです。

回答本文ではなく、tool_calls が返ってきます。

tool_calls = [
  {
    "name": "get_weather",
    "args": {"city": "東京"},
    "id": "call_..."
  }
]

つまり LLM は、

「東京の天気を答えるには get_weathercity='東京' で呼べばよい」

と判断しただけです。

その後、アプリケーション側で tool を実行します。

tool_map = {t.name: t for t in tools}

for tc in response.tool_calls:
    selected_tool = tool_map[tc["name"]]
    tool_result = selected_tool.invoke(tc["args"])

そして、その結果を ToolMessage として会話履歴に追加します。

from langchain_core.messages import ToolMessage

messages.append(
    ToolMessage(
        content=str(tool_result),
        tool_call_id=tc["id"],
    )
)

最後にもう一度 LLM を呼ぶと、LLM は tool の結果を読んで自然文の回答を作ります。

現在東京の天気は曇りで、気温は19度、湿度は70%です。

この流れを初めて見たとき、Tool Calling は「LLM が API を自動実行する機能」だと思っていました。

でも正確には少し違います。

LLM が行うのは、どの tool をどの引数で呼ぶかの判断です。

実行、エラー処理、権限制御、ログ保存は、開発者側の責任です。

Tool Calling の基本フロー

授業で扱った流れを、NMS 開発者の感覚で書き直すとこうなります。

ユーザー質問
  ↓
LLM が必要な tool を選ぶ
  ↓
アプリケーションが tool_calls を受け取る
  ↓
tool 名と引数を検証する
  ↓
実際の API / 関数を実行する
  ↓
結果を ToolMessage として LLM に戻す
  ↓
LLM が最終回答を作る

これは、普通のバックエンド連携にかなり近いです。

違うのは、どの API を呼ぶかを固定の if 文だけで決めるのではなく、LLM に選ばせる点です。

たとえば、ユーザーがこう聞いたとします。

router-tokyo-01 の状態と直近のインターフェースエラーを見て、
障害チケットに書く一次対応メモを作って。

この質問には、少なくとも三つの処理が含まれています。

装置状態を調べる
インターフェースエラーを調べる
チケット向けの文章にまとめる

従来の実装なら、画面や API のユースケースごとに処理を組みます。

Tool Calling では、開発者が次のような tool を用意しておきます。

get_device_status
get_interface_errors
search_open_tickets
create_ticket_draft

LLM は質問を読んで、必要な tool を選びます。

これにより、ユーザーの自然文と業務 API の間に、LLM がルーティング層として入る形になります。

この流れを図にすると、次のようになります。

tool_calling_nms_flow.png

この図で一番大事なのは、青い線とオレンジの箱の分離です。

LLM は、質問を読んで「どの tool を使うか」を選びます。

しかし、実際に NMS API、CMDB、チケットシステムを呼び出すのはアプリケーション側です。

つまり、Tool Calling は「LLM に実行権限を丸ごと渡す仕組み」ではありません。

LLM は選ぶ。

アプリケーションが検証して実行する。

この責任分離を崩さないことが、NMS のような運用システムではかなり重要です。

StructuredTool とスキーマ設計

授業では、単純な @tool だけでなく、StructuredTool も扱いました。

これはかなり実務寄りでした。

@tool だけでも動きますが、引数が増えると曖昧になります。

たとえば ETF 検索の例では、カテゴリ、最小収益率、最大手数料のような条件を持つ tool を作っていました。

from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool

class ETFSearchInput(BaseModel):
    category: str = Field(description="ETF カテゴリ")
    min_return: float = Field(default=0, ge=0, le=100, description="最小収益率")
    max_expense: float = Field(default=1.0, ge=0, le=5, description="最大運用手数料")

search_etf = StructuredTool.from_function(
    func=search_etf_func,
    name="search_etf",
    description="条件に合う ETF 商品を検索します。",
    args_schema=ETFSearchInput,
)

この考え方は、NMS API にそのまま使えます。

たとえば、装置検索 tool を作るなら、次のようなスキーマが欲しくなります。

from pydantic import BaseModel, Field

class DeviceSearchInput(BaseModel):
    site: str = Field(description="拠点名。例: Tokyo DC, Osaka Branch")
    status: str = Field(
        default="any",
        description="装置状態。normal, warning, critical, any のいずれか",
    )
    vendor: str = Field(default="", description="ベンダー名。指定なしなら全体")

LLM にとって、tool の名前と description はかなり重要です。

人間が API ドキュメントを読んで使い方を理解するように、LLM は tool の説明とスキーマを見て使い方を推測します。

説明が曖昧だと、引数も曖昧になります。

授業でも、tool schema の設計が甘いと、LLM が期待と違うパラメータを渡すことがありました。

これは普通の API 設計でも同じです。

エンドポイントや DTO の定義が曖昧で、必須項目と任意項目の区別やエラーレスポンスの形式が整理されていない API は、人間にとって使いづらいものです。

そして、それは LLM にとっても同じでした。

外部検索 tool と API tool

7週目の授業では、Tavily、Wikipedia、DuckDuckGo のような検索 tool も扱いました。

from langchain_community.tools.tavily_search import TavilySearchResults

search_tool = TavilySearchResults(max_results=3)
llm_with_search = llm.bind_tools([search_tool])

ユーザーが最新情報を聞くと、LLM は検索 tool を呼びます。

tool: tavily_search_results_json
args: {"query": "2026 Nasdaq outlook prediction"}

検索結果を ToolMessage として戻すと、LLM はその内容をもとに回答します。

ここで重要なのは、検索結果をそのまま信じないことです。

授業ノートでも、検索結果の長さ、検索 tool ごとの結果の違い、fallback、source の扱いが出てきました。

これは NMS でも同じです。

NMS API から取得した情報であっても、必ずしも現在の状態を正確に表しているとは限りません。キャッシュやポーリングの遅延によって情報が古くなることもありますし、CMDB やチケットシステムなど、別のシステムと情報が一致しない場合もあります。

Tool Calling で外部 API を呼べるようになると、LLM は強くなります。

でも、外部 API の結果を検証せずに自然文へ混ぜると、業務上は危険です。

Mock NMS Tool を考えてみる

授業の天気、為替、ETF、ユーザー情報 API の例を見ながら、自分なら NMS でどう作るかを考えました。

最初は、読み取り専用の tool から始めるのがよさそうです。

いきなり「装置にコマンドを投入する」「チケットを作成する」「自動復旧する」まで進めるのは危険です。

まずは、情報照会だけにします。

from langchain_core.tools import tool
import json

MOCK_DEVICES = {
    "router-tokyo-01": {
        "site": "Tokyo DC",
        "role": "edge-router",
        "status": "critical",
        "vendor": "Cisco",
        "last_seen": "2026-06-16T00:12:41+09:00",
    },
    "switch-osaka-02": {
        "site": "Osaka Branch",
        "role": "access-switch",
        "status": "warning",
        "vendor": "Juniper",
        "last_seen": "2026-06-16T00:11:03+09:00",
    },
}

@tool
def get_device_status(device_id: str) -> str:
    """NMS から指定装置の現在状態を取得します。device_id を指定してください。"""
    device = MOCK_DEVICES.get(device_id)
    if not device:
        return json.dumps({"error": "device_not_found", "device_id": device_id})
    return json.dumps({"device_id": device_id, **device}, ensure_ascii=False)

インターフェースエラーも別 tool にします。

MOCK_INTERFACES = {
    "router-tokyo-01": [
        {"if_name": "Gi0/0", "status": "up", "crc_errors": 0, "drops": 12},
        {"if_name": "Gi0/1", "status": "down", "crc_errors": 241, "drops": 98},
    ]
}

@tool
def get_interface_errors(device_id: str) -> str:
    """指定装置のインターフェースエラーを取得します。CRC error や drop 数を返します。"""
    rows = MOCK_INTERFACES.get(device_id, [])
    return json.dumps(rows, ensure_ascii=False)

この二つを LLM に渡します。

tools = [get_device_status, get_interface_errors]
llm_nms = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}

ユーザーがこう聞いたとします。

router-tokyo-01 の障害状況を見て、一次対応の観点をまとめて。

LLM は get_device_statusget_interface_errors を呼ぶかもしれません。

get_device_status({"device_id": "router-tokyo-01"})
get_interface_errors({"device_id": "router-tokyo-01"})

アプリケーション側は、返ってきた tool_calls を見て実行します。

messages = [HumanMessage(content=query)]
ai_msg = llm_nms.invoke(messages)
messages.append(ai_msg)

for tc in ai_msg.tool_calls:
    tool_result = tool_map[tc["name"]].invoke(tc["args"])
    messages.append(
        ToolMessage(
            content=str(tool_result)[:3000],
            tool_call_id=tc["id"],
        )
    )

final = llm_nms.invoke(messages)

最終回答は、たとえば次のようになります。

router-tokyo-01 は Tokyo DC の edge-router で、現在 critical 状態です。
Gi0/1 が down で、CRC errors と drops が増えています。
まず物理リンク、対向装置、光モジュール、ケーブル状態を確認してください。
同時に、直近の設定変更と上位回線の障害有無も確認するのがよさそうです。

これは、LLM 単体では出せない回答です。

NMS の現在状態を tool 経由で見たからです。

5分で試す Mock NMS Tool Calling

ここまでの話を、API Key なしで動く小さな Python 例にしてみます。

本物の LLM は使いません。

代わりに、mock_llm_plan() という関数が LLM の tool_calls に近い構造を返します。

目的は、モデルの性能を試すことではありません。

「LLM は tool を選び、アプリケーションが実行する」という責任分離を、NMS の障害対応っぽいデータで確認することです。

この例では、次の三つの tool を用意します。

get_device_status      # Mock NMS API: 装置状態
get_interface_errors   # Mock NMS API: インターフェースエラー
search_open_tickets    # Mock Ticket API: 既存チケット

次のコードで動きます。

#mock_nms_tool_calling.py
import json
import re
import sys
from datetime import datetime

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8")

MOCK_DEVICES = {
    "router-tokyo-01": {
        "site": "Tokyo DC",
        "role": "edge-router",
        "status": "critical",
        "vendor": "Cisco",
        "last_seen": "2026-08-04T00:10:31+09:00",
    },
    "switch-osaka-02": {
        "site": "Osaka Branch",
        "role": "access-switch",
        "status": "warning",
        "vendor": "Juniper",
        "last_seen": "2026-08-04T00:09:12+09:00",
    },
}

MOCK_INTERFACES = {
    "router-tokyo-01": [
        {"if_name": "Gi0/0", "status": "up", "crc_errors": 0, "drops": 12},
        {"if_name": "Gi0/1", "status": "down", "crc_errors": 241, "drops": 98},
    ],
    "switch-osaka-02": [
        {"if_name": "xe-0/0/1", "status": "up", "crc_errors": 18, "drops": 7},
        {"if_name": "xe-0/0/2", "status": "up", "crc_errors": 0, "drops": 1},
    ],
}

MOCK_TICKETS = {
    "router-tokyo-01": [
        {
            "ticket_id": "INC-1042",
            "title": "Tokyo DC edge link down",
            "status": "open",
        }
    ],
    "switch-osaka-02": [],
}


def get_device_status(device_id):
    device = MOCK_DEVICES.get(device_id)
    if not device:
        return {"ok": False, "error": "device_not_found", "device_id": device_id}
    return {"ok": True, "device_id": device_id, **device}


def get_interface_errors(device_id):
    return {
        "ok": True,
        "device_id": device_id,
        "interfaces": MOCK_INTERFACES.get(device_id, []),
    }


def search_open_tickets(device_id):
    return {
        "ok": True,
        "device_id": device_id,
        "tickets": MOCK_TICKETS.get(device_id, []),
    }


TOOLS = {
    "get_device_status": get_device_status,
    "get_interface_errors": get_interface_errors,
    "search_open_tickets": search_open_tickets,
}


def mock_llm_plan(user_message):
    """LLM の tool_calls っぽい構造を返す簡易ルーター。"""
    match = re.search(r"[a-z]+-[a-z]+-\d+", user_message)
    device_id = match.group(0) if match else "router-tokyo-01"

    tool_calls = [
        {
            "id": "call_001",
            "name": "get_device_status",
            "args": {"device_id": device_id},
        },
        {
            "id": "call_002",
            "name": "get_interface_errors",
            "args": {"device_id": device_id},
        },
    ]

    if "ticket" in user_message.lower() or "チケット" in user_message:
        tool_calls.append(
            {
                "id": "call_003",
                "name": "search_open_tickets",
                "args": {"device_id": device_id},
            }
        )

    return tool_calls


def execute_tool_calls(tool_calls):
    tool_results = []
    audit_log = []

    for call in tool_calls:
        tool_name = call["name"]
        args = call["args"]
        started_at = datetime.now().isoformat(timespec="seconds")

        if tool_name not in TOOLS:
            result = {"ok": False, "error": "unknown_tool", "tool": tool_name}
        else:
            try:
                result = TOOLS[tool_name](**args)
            except Exception as exc:
                result = {"ok": False, "error": type(exc).__name__, "message": str(exc)}

        tool_results.append(
            {
                "tool_call_id": call["id"],
                "tool_name": tool_name,
                "content": result,
            }
        )
        audit_log.append(
            {
                "time": started_at,
                "tool": tool_name,
                "args": args,
                "ok": result.get("ok", False),
            }
        )

    return tool_results, audit_log


def make_operator_summary(tool_results):
    merged = {item["tool_name"]: item["content"] for item in tool_results}
    status = merged.get("get_device_status", {})
    interfaces = merged.get("get_interface_errors", {}).get("interfaces", [])
    tickets = merged.get("search_open_tickets", {}).get("tickets", [])

    risky_ports = [
        row
        for row in interfaces
        if row["status"] != "up" or row["crc_errors"] > 100 or row["drops"] > 50
    ]

    lines = [
        "=== Operator Summary ===",
        f"Device: {status.get('device_id', 'unknown')}",
        f"Site: {status.get('site', 'unknown')}",
        f"Status: {status.get('status', 'unknown')}",
        f"Role: {status.get('role', 'unknown')}",
        "",
        "Suspected points:",
    ]

    if risky_ports:
        for port in risky_ports:
            lines.append(
                "- {if_name}: status={status}, crc_errors={crc_errors}, drops={drops}".format(
                    **port
                )
            )
    else:
        lines.append("- No high-risk interface counters in mock data.")

    lines.extend(
        [
            "",
            "Suggested first actions:",
            "- Check physical link and peer device.",
            "- Compare with recent config changes.",
            "- Avoid automatic recovery until an operator confirms the scope.",
            "",
            "Open tickets:",
        ]
    )

    if tickets:
        for ticket in tickets:
            lines.append(f"- {ticket['ticket_id']}: {ticket['title']} ({ticket['status']})")
    else:
        lines.append("- No open ticket found for this device.")

    return "\n".join(lines)


def main():
    user_message = (
        "router-tokyo-01 の状態、インターフェースエラー、"
        "既存チケットを見て一次対応メモを作って"
    )

    tool_calls = mock_llm_plan(user_message)
    tool_results, audit_log = execute_tool_calls(tool_calls)

    print("1) LLM planned tool_calls:")
    print(json.dumps(tool_calls, ensure_ascii=False, indent=2))
    print()

    print("2) Application executed tools:")
    print(json.dumps(tool_results, ensure_ascii=False, indent=2))
    print()

    print("3) Audit log:")
    print(json.dumps(audit_log, ensure_ascii=False, indent=2))
    print()

    print("4) Final answer:")
    print(make_operator_summary(tool_results))


if __name__ == "__main__":
    main()

実行方法はシンプルです。

python mock_nms_tool_calling.py

外部ライブラリ、サーバー、DB、Docker は不要です。

実行すると、まず Mock LLM が選んだ tool_calls が表示されます。

[
  {
    "id": "call_001",
    "name": "get_device_status",
    "args": {
      "device_id": "router-tokyo-01"
    }
  },
  {
    "id": "call_002",
    "name": "get_interface_errors",
    "args": {
      "device_id": "router-tokyo-01"
    }
  },
  {
    "id": "call_003",
    "name": "search_open_tickets",
    "args": {
      "device_id": "router-tokyo-01"
    }
  }
]

最後には、運用者向けの一次対応メモが出ます。

=== Operator Summary ===
Device: router-tokyo-01
Site: Tokyo DC
Status: critical
Role: edge-router

Suspected points:
- Gi0/1: status=down, crc_errors=241, drops=98

Suggested first actions:
- Check physical link and peer device.
- Compare with recent config changes.
- Avoid automatic recovery until an operator confirms the scope.

Open tickets:
- INC-1042: Tokyo DC edge link down (open)

この小さな例で見たいのは、mock_llm_plan() の部分ではありません。

重要なのは、その後です。

tool 名を確認する
引数を取り出す
許可された tool だけを実行する
結果を保存する
監査ログを残す
運用者向けにまとめる

実際の NMS では、get_device_status() を SNMP、Telemetry、NMS REST API に置き換えられます。

get_interface_errors() はインターフェース統計 API に置き換えられます。

search_open_tickets() は Jira、ServiceNow、Redmine などのチケット検索 API に置き換えられます。

ただし、最初から restart_interfaceexecute_device_command のような更新系 tool を渡すのは危険です。

まずは読み取り専用 tool とチケット draft 作成から始め、人間承認を境界に置く方が現実的です。

LegalSearchAgent から学んだこと

LLM エージェントの構成を考えるうえで、法律検索を目的とした LegalSearchAgent の仕組みは参考になります。

法律と NMS では扱う情報は異なりますが、複数の tool を使い分けながら必要な情報を集め、最終的な回答を作るという流れは共通しています。

たとえば、LegalSearchAgent では次のような tool を利用します

search_law
interpret_law
compare_articles
search_cases
explain_term

search_law で法律条文を検索し、interpret_law で内容を分かりやすく解釈します。必要に応じて compare_articles で複数の条文を比較したり、判例や法律用語を調べたりすることもできます。

重要なのは、単に複数の tool を呼び出せることだけではありません。

エージェントがどの tool を使い、どのような情報をもとに回答を作ったのかを記録しておくことも重要です。

たとえば、次のような実行情報をログとして残すことができます。

record = {
    "query": query,
    "answer": final_answer,
    "tools_used": tools_used,
    "elapsed": elapsed,
    "turns": max_turns,
}
self.session_log.append(record)

これは NMS でもほぼ同じです。

障害対応アシスタントなら、少なくとも次のログは残したくなります。

誰が質問したか
どの障害 ID に対する質問か
どの tool を呼んだか
どの引数で呼んだか
API が何を返したか
LLM が最終的に何を答えたか
何秒かかったか
エラーがあったか

LLM の回答だけを保存しても、後から検証できません。

どの tool 結果を根拠にしたのかが必要です。

NMS やチケットシステムに組み込むなら、これは監査ログに近い扱いになります。

実務で詰まりやすいところ

Tool Calling は動き始めると便利ですが、NMS のような運用システムでは「動いた」だけでは足りません。

特に気をつけたいのは、次の四つです。

1. tool schema が曖昧
2. API 結果をそのまま信じる
3. tool を呼びすぎる
4. 更新系 tool を早く渡しすぎる

まず、tool schema が曖昧だと LLM は迷います。

たとえば get_info(query) のような tool は、人間にも LLM にも使い方が分かりづらいです。

NMS なら、次のように責務を分けた方が扱いやすいです。

get_device_status(device_id)
get_interface_errors(device_id)
search_open_tickets(device_id, hours)
get_recent_config_changes(device_id, hours)

次に、API 結果をそのまま信じないことも大事です。

NMS API や CMDB API でも、timeout、認証エラー、古いキャッシュ、空配列、想定外の JSON は起こります。

そのため、tool の結果は自然文だけで返すより、できるだけ構造化しておきたいです。

{
  "ok": false,
  "error_code": "timeout",
  "message": "NMS API timeout",
  "retryable": true
}

また、tool 呼び出し回数にも上限が必要です。

LLM が NMS API、CMDB API、チケット API を何度も呼ぶと、rate limit やシステム負荷の問題になります。

同じ tool と同じ引数の重複呼び出しを避ける、timeout を設定する、cache を使う、といった制御はアプリケーション側で持つ必要があります。

最後に、更新系 tool は特に慎重に扱うべきです。

create_ticket
close_ticket
restart_interface
clear_alarm
execute_device_command

このような tool を最初から LLM に渡すのは危険です。

まずは create_ticket_draftgenerate_operator_checklist のような draft 系、読み取り系から始め、人間承認を境界に置く方が現実的です。

NMS に適用するなら

業務に寄せて考えると、Tool Calling は NMS の周辺機能と相性がよいです。

特に、次のような読み取り系の業務から始めるのが現実的だと思いました。

装置情報の照会
インターフェース状態の照会
直近アラームの照会
過去チケットの検索
Runbook の検索
CMDB 情報の照会
メンテナンス予定の照会

この段階では、LLM は「調べて、まとめる」役割です。

たとえば、障害アラートを受け取ったときに、裏側で複数の tool を呼びます。

alert_id を受け取る
  ↓
NMS から alert 詳細を取得
  ↓
device_id で装置情報を取得
  ↓
CMDB から拠点と担当チームを取得
  ↓
過去24時間の同一装置チケットを検索
  ↓
Runbook を RAG で検索
  ↓
一次対応メモを生成

これを人間が手でやると、複数画面を行ったり来たりします。

NMS、CMDB、チケット、Runbook、チャット。

情報を集めるだけで時間がかかります。

Tool Calling を使えば、LLM が自然文の依頼を受け取り、必要な API 呼び出しを組み合わせ、運用者向けの形にまとめられます。

ただし、ここでも最終判断は人間に残すべきです。

LLM: 情報収集と整理
人間: 判断と承認
システム: 記録と実行

この分担が、最初の現実的な落としどころだと思います。

Tool Calling は Agent の入口

Tool Calling を学んで感じたのは、これは Agent の入口だということです。

単純な Tool Calling では、次のようなループを書きます。

for turn in range(max_turns):
    response = llm_with_tools.invoke(messages)
    messages.append(response)

    if not response.tool_calls:
        return response.content

    for tc in response.tool_calls:
        result = tool_map[tc["name"]].invoke(tc["args"])
        messages.append(
            ToolMessage(content=str(result), tool_call_id=tc["id"])
        )

このループは分かりやすいです。

ただ、業務フローが複雑になると、単純な for ループだけでは苦しくなります。

たとえば障害対応では、状態があります。

障害受付済み
装置情報取得済み
原因候補作成済み
Runbook 参照済み
人間承認待ち
チケット更新済み

さらに、条件分岐もあります。

critical なら担当者に通知
maintenance window 中なら自動復旧しない
同一チケットがあるなら新規作成しない
API が失敗したら fallback
人間が拒否したら終了

ここまで来ると、Tool Calling だけでは足りません。

状態と分岐を管理する仕組みが必要になります。

次に出てくるのが LangGraph です。

Tool Calling が「LLM に外部 API を使わせる」仕組みだとすると、LangGraph は「tool を含む複数ステップの業務フローを状態付きで動かす」仕組みに近いです。

まとめ

Tool Calling は、LLM に外部 API を使わせるための仕組みです。

ただし、LLM が自由に API を実行するわけではありません。

開発者が tool を定義し、LLM が tool 名と引数を選び、アプリケーションが実行し、その結果を LLM に戻します。

授業では、天気、為替、ETF 検索、Tavily や Wikipedia 検索、JSONPlaceholder API、法律検索エージェントなどを通して、この流れを段階的に学びました。

自分の NMS 文脈に置き換えると、Tool Calling は次のような使い方が現実的です。

NMS API で装置状態を取得する
CMDB API で拠点や担当を確認する
過去チケットを検索する
Runbook を RAG で参照する
一次対応メモを作る
チケット本文の draft を作る

一方で、tool schema、エラー処理、呼び出し上限、監査ログ、権限制御、人間承認を設計しないと危険です。

Tool Calling は、LLM を賢く見せる機能ではありません。

LLM を既存システムと接続するための API 設計です。

今回の結論は、こうです。

LLM は単体では業務システムになりません。

しかし、Tool Calling によって NMS、CMDB、チケットシステム、検索基盤とつながると、単なるチャットから業務アシスタントに近づきます。

次回は、この Tool Calling を複数ステップの障害対応フローとして組み立てる LangGraph を整理します。

障害受付、装置照会、原因候補、対応案、人間承認。

そこまで行くと、LLM アプリは「質問に答えるもの」から「業務の流れを一緒に進めるもの」に少し近づきます。

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