12: エージェントのためのツールの構築
第IV部では、エージェント(agent) を構築していきます。これは、ユーザーのリクエストに対してチャットボットのように単に応答するのではなく、何をすべきかを自分で判断して実行するAIです。
その違いを実際に見てみましょう。ユーザーが「注文番号 #12345 をキャンセルしてください」と尋ねたとします。チャットボットなら、「マイページ > 注文履歴に移動して、その注文の『キャンセル』ボタンをクリックしてください」というような答えを返すでしょう。そこから先は、ユーザー自身がその手順に従う必要があります。一方、エージェントは代わりにその作業を行います。注文を検索し、キャンセル可能かどうかを確認して、実際にキャンセルします。エージェントはアクションを起こすのです。
それを可能にするのがツール(tool) です。注文を検索するツール、注文をキャンセルするツール、メールを送信するツールなどです。ツールがあることで、LLMはテキストを生成するだけにとどまらず、実際に物事を成し遂げられるようになります。
第IV部全体を通して、これを少しずつ構築していきます。まずエージェントが使うツール(本章)、次にそれらのツールをLLMに接続すること(第13章)、そして最後に判断 → 実行 → 観察を繰り返すエージェントループ(第14章)です。
本章では最初の部分、つまりツールを定義し、実際のデータに接続し、エラーを安全に処理することを扱います。
12.1) @tool デコレータでツールを定義する
12.1.1) ツールはどのように動作するのか?
先ほど、エージェントがツールを使って注文を検索しキャンセルする様子を見ました。先に進む前に、はっきりさせておきたいことが1つあります。「LLMがツールを使う」と言うと、まるでLLM自身が直接ツールを呼び出しているように聞こえます。しかし、そうではありません。LLMが何かを実行することは決してありません。LLMがするのは、特定の引数でツールを呼び出すよう依頼することだけです。実際の実行は私たちのコードの中で行われます。
これを機能させるには、LLMがどのツールが存在し、それぞれがいつ適用されるのかを知っている必要があります。そのため、すべてのツールには3つのメタデータが付属します。
name—get_orderのような短い識別子で、LLMがどのツールを使いたいかを指定するために使います。description— そのツールが何をするのか、いつ使うのかを説明する文です。LLMはこれを読んで、その仕事に適したツールを選びます。- 入力スキーマ — ツールのパラメータが何か、つまりその名前、型、意味です。LLMが引数を正しく埋めるために必要です。
これらはどれも、あなたが余分な作業をする必要はありません。name は関数の名前からそのまま取られ、description はそのドキュメント文字列(docstring)から取られ、入力スキーマはパラメータの型ヒントから取られます。あなたがすべきことは、LangChainの @tool デコレータを付けることだけです。
12.1.2) 最初のツールを構築する
これを実践してみましょう。型ヒントとドキュメント文字列を持つ関数を書き、それを @tool でデコレートします。
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""指定された都市の現在の天気を取得します。"""
return f"It's always sunny in {city}!"@tool が私たちのために何を生成したかを確認してみましょう。
print(get_weather.name)
# 出力: get_weather
print(get_weather.description)
# 出力: 指定された都市の現在の天気を取得します。
print(get_weather.args)
# 出力: {'city': {'title': 'City', 'type': 'string'}}関数名 get_weather がその name になり、ドキュメント文字列がその description になり、型ヒント city: str がその入力スキーマになりました。前のセクションで挙げた3つのメタデータがすべて自動的に生成されたのです。これがLLMがツールを選び、その引数を埋めるために使うものです。
@tool が付けられると、その関数はLangChainのツールオブジェクトになります。つまり、もはや通常の関数のように呼び出すことはできません。get_weather("Paris") は機能しません。代わりに .invoke() で呼び出します。これは第6章でチェーンに使ったのと同じ標準的な実行メソッドです。引数は辞書として渡します。
result = get_weather.invoke({"city": "Paris"})
print(result)
# 出力: It's always sunny in Paris!12.1.3) name と description のカスタマイズ
デフォルトでは、name は関数名から、description はドキュメント文字列から取られます。どちらも上書きできます。
@tool の第1引数として名前を渡します。
@tool("web_search")
def search(query: str) -> str:
"""ウェブで情報を検索します。"""
return f"Results for: {query}"
print(search.name)
# 出力: web_searchdescription パラメータを使って説明も上書きできます。これは、ドキュメント文字列を他の開発者向けのメモとして残しつつ、LLMにはより適した内容を与えたい場合に便利です。
@tool("calculator", description="算術演算を実行します。あらゆる数学の問題にこれを使ってください。")
def calc(expression: str) -> str:
"""数式の文字列を評価します。"""
return str(eval(expression)) # 警告: eval() は安全ではありません。本番環境では絶対に使わないでください。ツール名には snake_case を使いましょう。一部のLLMプロバイダーは、スペースや特殊文字を含む名前を拒否します。
12.1.4) Pydantic で入力スキーマを定義する
ツールが複数のパラメータを取る場合や、各パラメータを個別に説明したい場合は、代わりにPydanticモデルで入力スキーマを定義します。これは第7章で構造化出力に使ったのと同じ BaseModel と Field です。
from pydantic import BaseModel, Field
from langchain.tools import tool
class WeatherInput(BaseModel):
"""天気クエリのための入力です。"""
location: str = Field(description="都市名 (例: ソウル、東京)")
units: str = Field(default="celsius", description="温度の単位 (celsius または fahrenheit)")
@tool(args_schema=WeatherInput)
def get_weather_detailed(location: str, units: str = "celsius") -> str:
"""選択した温度の単位で現在の天気を取得します。"""
temp = 22 if units == "celsius" else 72
return f"Current weather in {location}: {temp} degrees {units[0].upper()}"Field(description=...) に書いた内容は、LLMが読む入力スキーマの一部になるので、各パラメータが何を意味するのかを正確に把握できます。ほとんどの場合、型ヒントと明確なドキュメント文字列だけで十分です。パラメータごとにそのような詳細なレベルが必要なときだけ args_schema を使いましょう。
12.2) ツールのエラーを処理する
現実の世界では、ツールが失敗することがあります。データベース接続が切れたり、想定していなかった入力が現れたりします。このセクションでは、これらのエラーをツール自体の内部で処理し、エージェントが突然停止する代わりに適切に応答できるようにします。まず、ツールが依存する関数をセットアップしましょう。
12.2.1) セットアップ: 商品検索関数
# product_service.py
PRODUCTS = {
1: {"name": "Wireless Mouse", "price": 29.99, "stock": 120},
2: {"name": "Mechanical Keyboard", "price": 89.99, "stock": 0},
3: {"name": "USB-C Hub", "price": 45.50, "stock": 35},
4: {"name": "Laptop Stand", "price": 39.00, "stock": 8},
}
def fetch_product(product_id: int) -> dict:
"""IDで商品情報を検索します。"""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product
def fetch_stock(product_id: int) -> int:
"""商品の在庫数量を返します。"""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product["stock"]どちらの関数も、存在しない商品IDが渡されると ValueError を発生させます。
12.2.2) ツールでエラーを処理する
fetch_product を get_product ツールでラップしてみましょう。
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""IDで商品を検索します。その名前、価格、在庫レベルを返します。"""
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."有効なIDなら、期待通りに動作します。
print(get_product.invoke({"product_id": 1}))
# 出力: Product 1: Wireless Mouse — $29.99, 120 in stock.しかし、存在しないIDを渡すと、fetch_product が何にもキャッチされない ValueError を発生させ、エージェントの実行はそこで停止してしまいます。
print(get_product.invoke({"product_id": 99}))
# ValueError: Product with ID 99 not found.解決策は単純です。例外をツールの内部でキャッチし、それを伝播させる代わりに、LLMが理解できる文字列を返します。 成功でも失敗でも、ツールは常に文字列を返し、LLMはその文字列を使って次に何をするかを判断します。
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""IDで商品を検索します。その名前、価格、在庫レベルを返します。"""
try:
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error looking up product {product_id}: {e}"print(get_product.invoke({"product_id": 1}))
# 出力: Product 1: Wireless Mouse — $29.99, 120 in stock.
print(get_product.invoke({"product_id": 99}))
# 出力: Error: Product with ID 99 not found.存在しないIDはもはや例外を発生させません。代わりに、LLMが理解できるエラーメッセージを返します。
同じパターンを check_stock にも適用しましょう。
from product_service import fetch_stock
@tool
def check_stock(product_id: int) -> str:
"""商品が現在在庫にあるかどうかを確認します。"""
try:
stock = fetch_stock(product_id)
if stock > 0:
return f"{stock} units available."
return "Out of stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error checking stock for product {product_id}: {e}"このように構築されたツールは、.invoke() で直接テストできます。LLMに接続する前に、ツールが単体で正しく動作することを確認しましょう。そうしないと、後でエージェントの内部で何か問題が起きたときに、ツールが壊れているのか、それともモデルが単に誤った判断をしただけなのかを見分けられなくなります。