【LangChain】Tool登録の仕組みとLLMが関数を呼び出す(Tool Calling)内部の流れ
LangChainを使っているとよく目にする @tool デコレータ。
単に関数につけているだけに見えますが、「なぜLLMが適切なツールを選べるのか?」「チャットの文章からどうやって関数の引数を取り出しているのか?」 その裏側の仕組みは非常に洗練されています。
今回は、LangChainにおける Tool登録のメカニズム と、「ユーザーの入力 → LLMの判断 → ツール実行 → 結果の解釈」 という一連の流れを詳しく解説します。
Pythonの関数に @tool をつけると、LangChain内部では単なる関数から 「LLMが理解できる形式(JSON Schema)に変換されたオブジェクト」 へと拡張されます。
コード例
from langchain_core.tools import tool
@tool
def run_simulation(temp: float, pressure: float) -> str:
"""物理・熱シミュレーションを実行します。設定温度(temp: ℃)と設定圧力(pressure: kPa)を受け取ります。"""
# (実際の処理)
return result
このとき、LangChainは以下の3つの情報を自動的に抽出・生成しています。
-
関数名 (name): run_simulation
-
説明文 (description): docstring("""物理・熱...""")に書かれた文章
-
引数の型情報 (args_schema): 型ヒント(temp: float, pressure: float)から生成された JSON Schema
LLMに渡される実際のデータ(JSON Schema)
llm.bind_tools([run_simulation]) を呼び出したとき、LLM(ChatOllamaやOpenAI等)にはPythonコードそのものではなく、以下のような JSONデータ が送信されています。
{
"name": "run_simulation",
"description": "物理・熱シミュレーションを実行します。設定温度(temp: ℃)と設定圧力(pressure: kPa)を受け取ります。",
"parameters": {
"type": "object",
"properties": {
"temp": { "type": "number", "title": "Temp" },
"pressure": { "type": "number", "title": "Pressure" }
},
"required": ["temp", "pressure"]
}
}
💡 ポイント
LLMはプログラムを実行しているわけではなく、「自分が使えるツールの説明書(JSON)」 を読んで「どれを使うべきか」を判断しています。そのため、docstring(説明文)と型ヒントを丁寧に書くことがツール呼び出しの精度に直結します。
チャットのテキスト入力から最終回答が生成されるまで、やり取りは 2往復 行われます。
【1往復目:ツールの選定と引数抽出】
ユーザー: 「温度110度、圧力200kPaでシミュレーションして」
│
▼
[ LLM ] (登録されたツール一覧のJSONを参照)
│
└─► 「run_simulation を temp=110, pressure=200 で呼び出すべき」と判断
(テキスト回答ではなく Tool Call オブジェクトを返却)
【ローカル実行:Python側での処理】
[ Python ]
│
├─► LLMからの指示に基づき run_simulation.invoke() を実行
└─► シミュレーション結果 (JSON文字列など) を取得
【2往復目:結果の解釈と最終回答】
[ LLM ] (元々の会話 + ツール実行結果 を受け取る)
│
└─► 「シミュレーション結果によると、危険状態(DANGER)です...」
(人間にわかりやすい最終文章を生成)
3. コードで辿る内部データの変化
実際にコードを動かしたとき、メッセージ(messages)がどのように変化していくかを見ていきます。
Step 1: LLMへの入力(1回目)
ユーザーからの自然言語プロンプトを HumanMessage として渡します。
messages = [
HumanMessage(content="温度を 110 度、圧力を 200 kPa にしてシミュレーションを実行して")
]
response = llm_with_tools.invoke(messages)
LLMは文章を返さず、「ツール呼び出し命令」 が入った AIMessage を返します。
# response (AIMessage) の中身
AIMessage(
content="", # テキスト本文は空
tool_calls=[{
"name": "run_simulation",
"args": {"temp": 110.0, "pressure": 200.0}, # 文章から数値を自動抽出!
"id": "call_12345678" # 呼び出し識別ID
}]
)
Step 3: Python側でのツール実行と結果のセット
response.tool_calls が存在することを検知し、Python側で関数を実行して、その結果を ToolMessage として会話履歴に追加します。
# ツールの実行
tool_result = run_simulation.invoke(tool_call['args'])
# 履歴に追加(どの呼び出しIDに対する結果かを明記する)
messages.append(response) # AIMessage を追加
messages.append(
ToolMessage(
content=str(tool_result), # シミュレータから返ってきたJSONなどの結果
tool_call_id=tool_call['id']
)
)
この時点での messages の中身(会話履歴)は以下の3要素の配列になっています。
-
HumanMessage: 「温度110度、圧力200kPaで〜」
-
AIMessage: 「run_simulation(temp=110, pressure=200) を実行して」(ID: call_1234)
-
ToolMessage: 「ID: call_1234 の結果は {"status": "DANGER", ...} でした」
Step 4: 実行結果をLLMに渡して最終回答を得る(2回目)
この履歴(messages)をそのままもう一度 LLM に渡します。
final_response = llm_with_tools.invoke(messages)
print(final_response.content)
LLMは「自分が要求したツール(ID: call_1234)の実行結果(ToolMessage)が返ってきた」と認識し、その結果(DANGERや警告文)を読解して「安全ではありません。温度と圧力が閾値を超えています」 という最終的な回答を生成してくれます。
まとめ
LangChainにおける Tool Calling の核心は以下の通りです。
-
@tool は関数を JSON Schema に変換する仕組み: LLMはコードではなく「関数の説明書(JSON)」を読んで判断している。
-
会話履歴(messages)をバトンリレーする: HumanMessage ➔ AIMessage(ToolCall) ➔ ToolMessage(結果) の順で履歴を構築して再度LLMに渡すことで、結果を踏まえた解釈が可能になる。
この流れさえ押さえておけば、DB検索やWeb検索、API連携など、あらゆる自作ツールをAIエージェントに組み込めるようになります!