スクラッチで学ぶAIエージェント:ReActパターンとツール利用の実装

LLMの限界とエージェントの役割

大規模言語モデル(LLM)はテキスト生成において卓越した能力を発揮しますが、ファイルシステムへのアクセスや外部APIの実行といった実世界への干渉はできません。例えば、「test.txtの中身を教えて」という指示に対し、純粋なLLMは「ファイルへのアクセス権限がありません」と応答するか、架空の内容を生成してしまう可能性があります。

この制約を打破する概念が「Agent(エージェント)」です。エージェントは、LLMを「思考エンジン」として利用し、外部ツールを操作する「手足」として機能させます。

特徴従来のLLMエージェント
能力テキスト生成のみ思考と実行の統合
情報源学習データ外部ツール、API、DB
自律性受動的能動的な意思決定
状態ステートレスコンテキストの保持

ReActフレームワークの原理

エージェント実装における標準的な設計パターンが「ReAct(Reasoning + Acting)」です。これはLLMに対し、以下のループを循環させるよう指示します。

  1. Thought(思考): ユーザーの意図を解析し、次の行動を決定する。
  2. Action(行動): 具体的なツールと入力パラメータを選択する。
  3. Observation(観察): ツールの実行結果を受け取る。
  4. Answer(回答): 結果に基づき最終的な回答を生成する。

実装:ツール定義とエージェントのコアロジック

LangChain等のフレームワークに依存せず、エージェントの動作原理を理解するためにスクラッチで実装します。

1. ツールの定義

まず、エージェントが使用可能なツール(ここではファイル読み込み機能)を定義します。LLMが理解できるよう、関数名と説明文(docstring)を明確にします。

# tools.py
import os

class ToolManager:
    """利用可能なツールを管理するクラス"""

    def __init__(self):
        self.tools = {
            "file_reader": {
                "function": self._read_file,
                "description": "指定されたパスのテキストファイルを読み込み、その内容を返します。パラメータ: file_path (str)"
            }
        }

    def _read_file(self, file_path: str) -> str:
        """ファイル内容を読み込む内部関数"""
        if not os.path.exists(file_path):
            return f"エラー: ファイル '{file_path}' が見つかりません。"
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                return f.read()
        except Exception as e:
            return f"読み込みエラー: {str(e)}"

    def execute(self, tool_name: str, **kwargs):
        """ツールを実行するインターフェース"""
        if tool_name not in self.tools:
            return "エラー: 指定されたツールは存在しません。"
        return self.tools[tool_name]["function"](**kwargs)

    def get_tool_descriptions(self):
        """LLMに提示するためのツール説明文を生成"""
        desc_text = ""
        for name, info in self.tools.items():
            desc_text += f"- {name}: {info['description']}\n"
        return desc_text

2. エージェントのコア実装

次に、記憶機能とツール実行機能を備えたエージェントのメインクラスを作成します。

# agent.py
import json
import os
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

class ReActAgent:
    def __init__(self, model_name="gpt-4o-mini"):
        self.client = OpenAI(
            api_key=os.getenv("OPENAI_API_KEY"),
            base_url=os.getenv("OPENAI_BASE_URL") # 必要に応じて設定
        )
        self.model_name = model_name
        self.tool_manager = ToolManager()
        self.memory = []  # 会話履歴
        self.cached_results = {}  # ツール実行結果のキャッシュ

    def _build_system_prompt(self):
        """システムプロンプトの構築"""
        tool_desc = self.tool_manager.get_tool_descriptions()
        
        prompt = f"""あなたはユーザーの質問に答えるための役立つアシスタントです。
以下のツールが利用可能です:
{tool_desc}

ツールを使用する必要がある場合は、以下のJSON形式で応答してください:
{{"action": "ツール名", "params": {{"引数名": "値"}}}}

ツールが不要な場合は、そのまま質問に答えてください。
"""
        return prompt

    def _construct_messages(self, user_input):
        """LLMに渡すメッセージリストの構築"""
        messages = [{"role": "system", "content": self._build_system_prompt()}]
        
        # 過去の会話履歴を追加(直近10件)
        self.memory.append({"role": "user", "content": user_input})
        messages.extend(self.memory[-10:])
        
        # キャッシュされたツール結果があれば追加
        for key, val in self.cached_results.items():
            messages.append({
                "role": "system",
                "content": f"参照情報 [{key}]:\n{val}"
            })
            
        return messages

    def run(self, user_input):
        """メインの実行ループ"""
        messages = self._construct_messages(user_input)
        
        # 1. LLMによる推論(Thought)
        response = self.client.chat.completions.create(
            model=self.model_name,
            messages=messages,
            temperature=0
        )
        content = response.choices[0].message.content
        self.memory.append({"role": "assistant", "content": content})

        # 2. アクションの判定と実行(Action & Observation)
        try:
            # JSON形式の指示を抽出
            if "{" in content and "}" in content:
                start_idx = content.find("{")
                end_idx = content.rfind("}") + 1
                json_str = content[start_idx:end_idx]
                action_data = json.loads(json_str)

                tool_name = action_data.get("action")
                params = action_data.get("params", {})

                if tool_name:
                    print(f"[LOG] ツール実行: {tool_name} params={params}")
                    
                    # ツールの実行
                    observation = self.tool_manager.execute(tool_name, **params)
                    
                    # 結果をキャッシュ
                    cache_key = f"{tool_name}({list(params.values())})"
                    self.cached_results[cache_key] = observation
                    print(f"[LOG] 実行結果取得: {len(observation)}文字")

                    # 3. 最終回答の生成
                    final_prompt = (
                        f"ツール '{tool_name}' の実行結果は以下の通りです:\n---\n{observation}\n---\n"
                        f"この結果に基づいて、ユーザーの質問 '{user_input}' に答えてください。"
                    )
                    
                    final_response = self.client.chat.completions.create(
                        model=self.model_name,
                        messages=[{"role": "user", "content": final_prompt}],
                        temperature=0
                    )
                    final_answer = final_response.choices[0].message.content
                    self.memory.append({"role": "assistant", "content": final_answer})
                    return final_answer

        except json.JSONDecodeError:
            pass # JSONではない場合、通常のテキスト回答として扱う

        return content

3. 実行ループ

エージェントを起動し、ユーザーからの入力を処理します。

# main.py
from agent import ReActAgent

if __name__ == "__main__":
    bot = ReActAgent()
    print("Agent Ready. (Type 'exit' to quit)")

    while True:
        inp = input("User > ").strip()
        if inp.lower() == "exit":
            break
            
        response = bot.run(inp)
        print(f"Agent > {response}\n")

技術的なポイント解説

なぜ2回のLLM呼び出しが必要か

この実装では、以下の2段階でLLMを呼び出しています。

  1. 意思決定フェーズ: ユーザーの入力を解析し、ツールを使うべきか判断し、パラメータを決定する。
  2. 回答生成フェーズ: ツールの実行結果(Observation)を踏まえて、自然言語でユーザーに回答する。

1回の呼び出しで済ませようとすると、プロンプトが複雑になりすぎたり、ツールの結果を踏まえた推論がおざなりになるリスクがあるため、このように分割するのが一般的です。

メモリとコンテキスト管理

エージェントが多ターンの会話をこなすためには、以下の2つの記憶領域を適切に管理する必要があります。

  • 会話メモリ (self.memory): ユーザーとアシスタントの過去の対話履歴。コンテキストウィンドウの制限があるため、通常は直近の数ターンのみを参照します。
  • ツール結果キャッシュ (self.cached_results): 一度実行したツールの結果を保存しておくことで、同じリソースを何度も読み込む無駄を省きます。「あのファイルの1行目は何?」といった追問に答えるために不可欠です。

Temperatureの設定

ツール実行を伴うタスクでは、LLMの創造性よりも決定論的な正確さが求められます。そのため、temperature=0 を設定し、出力のブレを極限まで抑えることが重要です。これにより、JSONフォーマットの出力ミスや幻覚(ハルシネーション)の発生率を低減できます。

拡張可能性

このシンプルな実装に、新たなツールを追加するのは容易です。例えば、計算機機能やWeb検索機能を追加する場合、ToolManager クラスの self.tools ディクショナリに関数を登録するだけで、エージェントは自動的にそのツールを認識し、状況に応じて使用できるようになります。

# 追加ツールの例
self.tools["calculator"] = {
    "function": lambda x: str(eval(x)), # 注: 実用時はサニタイズ必須
    "description": "数式を計算します。パラメータ: expression (str)"
}

タグ: AIエージェント Python LLM ReActフレームワーク OpenAI API

6月17日 20:16 投稿