システム受託開発やITコンサルティングの現場で、エンジニアたちが頭を抱える瞬間があります。
それは、苦労して実装したAIエージェントが、本番環境で自社APIを呼び出す際に「謎の引数」を捏造してエラーを吐き出した時です。
「プロンプトで『正確に入力しろ』と指示したのに!」
そう叫びたくなる気持ちはわかります。しかし、プロジェクトマネジメントの観点から厳しいことを言うようですが、それはコード設計の敗北です。
多くのLangChain入門記事では、GoogleSearchToolのような既存ツールを呼び出す方法は解説されていますが、複雑なビジネスロジックを持つ「自社システムへの接続」については、驚くほど情報が少ないのが現状です。
「とりあえず動けばいい」という発想で書かれたツール定義は、確率的な挙動をするLLMの前では無力です。実務において目指すべきは、LLMが「間違えようがない」ほど強固に型定義されたインターフェースです。
この記事では、単なるツールの作り方ではなく、Pydanticのバリデーション機能をフル活用して、エージェントの「幻覚(ハルシネーション)」を技術的に封じ込める実装パターンを解説します。技術的な実現可能性とビジネス上の成果を両立させるための、再現性の高い手法をお届けします。
1. なぜ「ツール定義」がエージェントの品質を決めるのか
AIエージェント、特に推論と行動を組み合わせる(ReActなどの)アプローチを採用したシステムは、魔法で動いているわけではありません。
裏側では、LLMが「思考」し、「どのツールを、どんな引数で使うか」を決定し、その結果を受け取ってまた「思考」するというサイクルを回しています。
LLMがツールを使う仕組み(Tool Calling)
OpenAIのChatGPT(最新モデル)などをバックエンドにする場合、LangChainはツールの定義情報を「Tool Calling(ツール呼び出し)」の形式に変換してAPIに送ります。かつてはFunction Callingと呼ばれていましたが、現在はより汎用的なツール利用の仕組みとして統合・進化しています。
ここで重要なのが、ツールの説明文(Description)と引数の型定義(Schema)こそが、LLMに対する「仕様書」になるという点です。
人間が曖昧な仕様書を渡されたら仕事ができないのと同じで、LLMに「いい感じでデータを検索して」というツールを渡しても、期待通りには動きません。「検索クエリは必須なのか?」「日付のフォーマットは?」といった情報が欠落していると、LLMは確率的に(つまり適当に)値を埋めようとします。最新のモデルは推論能力が向上していますが、それでも曖昧な定義は予期せぬ挙動の温床となります。
曖昧なツール定義が引き起こす事故
よくある失敗例として、以下のようなケースが挙げられます。
- 引数不足エラー: 必須パラメータなのに、LLMが「不要だろう」と判断して省略してしまう。
- 型不一致: 数値IDを入れるべき場所に、勝手に「ID_123」のような文字列を入れてしまう。
- 存在しないオプション: 定義されていないフィルタリング条件を勝手に捏造してAPIに投げつける。
これらはすべて、プロンプトエンジニアリング(指示出し)だけで防ぐには限界があります。システム側で「この型以外は受け付けない」という強固なガードレールを敷く必要があるのです。特にLangChainの最新環境(langchain-core)ではスキーマ処理の防御機構が強化されていますが、その効果を最大限に活かすためにも正確な定義が求められます。
本記事のゴール:型安全なカスタムツールの実装
今回は、以下のステップで「壊れにくい」エージェントを構築する手法を解説します。
- 最小限の環境構築
@toolデコレータを使った手軽な実装(プロトタイプ向け)Pydanticモデルを組み合わせたクラスベースの実装(本番向け)- エラー発生時にLLMに自己修正させるリカバリー機構
システム開発の実務において、不確実なAIの挙動を、確実なコードでコントロールする手法を身につけることが重要です。
2. 環境構築とベースラインの実装
まずは、手を動かせる環境を整えましょう。複雑な依存関係は避け、必要最小限のライブラリで構成します。この段階での目標は、独自のツールを組み込む前の「素の状態」でエージェントが正常に稼働することを確認することです。
必要なライブラリのインストール
Python環境(3.9以上推奨)にて、以下のコマンドを実行してください。仮想環境(venvやconda)内での実行を強く推奨します。
pip install langchain langchain-openai pydantic python-dotenv
OpenAI APIキーの設定
プロジェクトのルートディレクトリに .env ファイルを作成し、APIキーを設定します。セキュリティの観点から、コードへの直書きは絶対に避けてください。
OPENAI_API_KEY=sk-your-api-key-here
最小構成のエージェント動作確認
まずは、ツールを持たない素のLLMが動くことを確認します。これがすべてのベースラインとなります。
モデルの選定についてですが、AIモデルの進化サイクルは非常に高速です。コード内では汎用的なモデル名を指定していますが、本番運用時にはその時点での最新の安定版モデル(例:ChatGPTの最新モデルや、推論能力が強化されたモデルなど)を公式ドキュメントで確認し、指定することをお勧めします。
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
# 環境変数の読み込み
load_dotenv()
# モデルの初期化
# model: 利用時点での最新の安定版モデルを指定してください(例: ChatGPT, ChatGPTなど)
# temperature=0: ツール利用時の決定論的な挙動(毎回同じ結果を返すこと)を期待する場合に推奨されます
# ※最新の推論強化型モデル(OpenAIの推論モデルシリーズ等)を使用する場合は、パラメータ設定が異なる場合があるため公式ドキュメントを確認してください
llm = ChatOpenAI(model="ChatGPT", temperature=0)
# 動作確認
try:
response = llm.invoke("こんにちは、あなたはどのようなタスクが得意ですか?")
print(f"Response: {response.content}")
except Exception as e:
print(f"Error: {e}")
print("ヒント: APIキーが正しく設定されているか、またはモデル名が有効か確認してください。")
これで準備は整いました。次から、いよいよ独自の「手足」となるツールを実装していきます。
3. 【基本編】@toolデコレータで作る軽量ツール
LangChainには、Python関数を簡単にツール化できる @tool デコレータが用意されています。小規模なスクリプトや、引数が単純な場合にはこれが最も手軽です。
関数をそのままツール化する方法
ここでは例として、「商品の在庫数を検索する」という仮想的なツールを作ってみましょう。
重要なポイント:関数のdocstring(ドキュメント文字列)が、そのままLLMへの説明文になります。ここを適当に書くと、LLMはツールの使い道を理解できません。
from langchain_core.tools import tool
# @toolデコレータで関数をラップ
@tool
def get_product_stock(product_id: str) -> str:
"""
製品IDに基づいて、現在の在庫状況を検索します。
ユーザーが在庫を知りたがっている場合に使用してください。
Args:
product_id (str): 検索対象の製品ID(例: 'P-12345')
"""
# 実際にはここでDBやAPIにアクセスします
# 今回はダミーデータを返します
mock_db = {
"P-100": "在庫あり (50個)",
"P-200": "在庫切れ (入荷未定)",
"P-300": "残りわずか (3個)"
}
return mock_db.get(product_id, "該当する製品IDが見つかりませんでした。")
# ツールの情報を確認
print(f"Tool Name: {get_product_stock.name}")
print(f"Tool Description: {get_product_stock.description}")
print(f"Tool Args: {get_product_stock.args}")
デコレータパターンの限界
この方法は非常に簡単ですが、実務レベルの開発では以下の課題に直面します。
- 複雑なバリデーションが困難: 「IDは必ず'P-'で始まる必要がある」といった制約を関数の内部ロジックで書くことになり、コードの見通しが悪くなる。
- 引数のメタデータ不足: 引数ごとの詳細な説明(description)をdocstringからパースさせるのは、解析精度に依存するため不安定になりがち。
そこで登場するのが、Pydanticを活用したクラスベースの実装です。
4. 【応用編】StructuredToolとPydanticによる堅牢な設計
ここからが本記事の核心です。
AIエージェント開発において、Pydanticは単なるバリデーションライブラリではありません。LLMとアプリケーションをつなぐ共通言語の定義ツールです。
複数引数を持つ複雑なAPI連携の課題
例えば、「配送日時を指定して注文を作成する」というAPIを考えます。この場合、「日付フォーマット」や「配送区分の選択肢」など、守るべきルールが多数あります。
これをPydanticモデルで定義し、LangChainの BaseTool を継承したクラスとして実装します。
Pydanticモデルによる引数スキーマの定義
まず、引数の構造(スキーマ)を定義します。Field の description は、LLMに「この引数には何を入れるべきか」を教えるためのプロンプトとして機能します。
from typing import Optional, Literal
from pydantic import BaseModel, Field
# 引数のスキーマ定義
class OrderInput(BaseModel):
product_id: str = Field(
...,
description="注文する製品のID。必ず'P-'で始まる形式である必要があります。"
)
quantity: int = Field(
...,
ge=1,
le=100,
description="注文数量。1以上100以下の整数を指定してください。"
)
delivery_option: Literal["standard", "express"] = Field(
"standard",
description="配送オプション。'standard'(通常配送)または'express'(速達)を選択。"
)
BaseToolを継承したクラスベースの実装パターン
次に、このスキーマを使ったツールクラスを作成します。
from typing import Type
from langchain_core.tools import BaseTool
class CreateOrderTool(BaseTool):
name: str = "create_order"
description: str = "ユーザーからの注文リクエストを処理し、注文を確定します。"
args_schema: Type[BaseModel] = OrderInput # ここでPydanticモデルを指定
def _run(self, product_id: str, quantity: int, delivery_option: str = "standard") -> str:
"""同期実行時のロジック"""
# ここでビジネスロジックを実行
# Pydanticにより、ここに来る時点で型と値の範囲は保証されている
# 擬似的な処理結果
price_map = {"standard": 0, "express": 1000}
shipping_cost = price_map.get(delivery_option, 0)
return (
f"注文を受け付けました。\n"
f"製品ID: {product_id}\n"
f"数量: {quantity}\n"
f"配送: {delivery_option} (送料: {shipping_cost}円)\n"
f"ステータス: 処理中"
)
async def _arun(self, product_id: str, quantity: int, delivery_option: str = "standard") -> str:
"""非同期実行時のロジック(必要に応じて実装)"""
raise NotImplementedError("このツールは非同期実行をサポートしていません。")
# エージェントへの組み込み
tools = [CreateOrderTool()]
llm_with_tools = llm.bind_tools(tools)
# 実行テスト:わざと曖昧な指示を出してみる
query = "P-500を、なるべく早く5個欲しいんだけど。"
print(f"User Query: {query}")
# 実際の対話フロー(簡略化)
messages = [{"role": "user", "content": query}]
ai_msg = llm_with_tools.invoke(messages)
# LLMがどのツールをどう呼び出そうとしたか確認
for tool_call in ai_msg.tool_calls:
print(f"Selected Tool: {tool_call['name']}")
print(f"Arguments: {tool_call['args']}")
解説:
ユーザーが「なるべく早く」と言ったのを、LLMが文脈から解釈し、delivery_option に express を自動的に割り当てている点に注目してください。これが、Field の description と Literal 型定義の効果です。
5. エラーハンドリングとデバッグの定石
どんなに堅牢に設計しても、APIサーバーがダウンしていたり、論理的な矛盾(在庫不足など)が発生したりすることはあります。
重要なのは、エラーが起きたときにエージェントをクラッシュさせないことです。エラーメッセージをLLMに戻し、「じゃあこうしよう」と考え直させる機会を与えます。
handle_tool_errorによるリカバリー実装
BaseTool には handle_tool_error というパラメータ設定やメソッドがあり、例外発生時の挙動を制御できます。
from langchain_core.tools import ToolException
class SafeStockCheckTool(BaseTool):
name: str = "safe_stock_check"
description: str = "在庫確認ツール。エラー時には再試行を促します。"
def _run(self, product_id: str) -> str:
# 特定の条件下でエラーを発生させるシミュレーション
if product_id == "ERROR_TEST":
raise ToolException("データベース接続エラーが発生しました。一時的な障害です。")
return f"製品 {product_id} は在庫ありです。"
# エラーハンドリングのロジック
# Trueを設定すると、例外メッセージをLLMへの観測結果として返す
handle_tool_error: bool = True
# 実行テスト
tool = SafeStockCheckTool()
try:
# 直接呼び出しでエラーを発生させる
result = tool.invoke("ERROR_TEST")
print(f"Tool Output: {result}")
except Exception as e:
# handle_tool_error=True なので、ここには来ないはず
print(f"System Error: {e}")
LLMへのフィードバックループ
handle_tool_error=True に設定すると、ツール実行時に例外が発生してもPythonプログラムは停止しません。代わりに、例外メッセージ(例:「データベース接続エラー...」)がテキストとしてLLMに返されます。
これを受け取ったLLMは、「おっと、エラーが出たな。ではユーザーに『現在システム障害で確認できません』と伝えよう」あるいは「別の検索方法を試そう」と判断できます。
この「失敗をコンテキストに含める」という設計思想が、実用的なエージェント構築の鍵となります。
6. まとめ:実用的なエージェント構築に向けて
ここまで、LangChainとPydanticを用いたカスタムツールの実装方法を解説しました。
単にAPIをつなぐだけでなく、「型」という契約を通じてLLMの挙動を縛るアプローチの重要性が理解できるはずです。
ツール設計のチェックリスト
最後に、本番導入前に必ず確認すべきポイントをまとめます。
- 引数の型は厳格か?:
Anyやdictなどの曖昧な型は極力避ける。 - Descriptionは具体的か?: 人間が読んでも使い方がわかるレベルで記述されているか。
- エラーは情報は適切か?: LLMが復旧アクションを取れるようなエラーメッセージを返しているか。
- 権限管理(Security): データの削除や更新を行うツールには、必ず「Human-in-the-loop(人間による承認)」プロセスを挟む。
エージェント開発は、プログラミングと自然言語による指示が融合した新しいエンジニアリング領域です。
今回解説した「型安全」なアプローチを取り入れることで、開発するエージェントは実務に耐えうる信頼性の高いシステムへと進化します。
ぜひ、実際のプロジェクトマネジメントや開発業務で実践してみてください。
コメント