LLMの限界とエージェントの役割
大規模言語モデル(LLM)はテキスト生成において卓越した能力を発揮しますが、ファイルシステムへのアクセスや外部APIの実行といった実世界への干渉はできません。例えば、「test.txtの中身を教えて」という指示に対し、純粋なLLMは「ファイルへのアクセス権限がありません」と応答するか、架空の内容を生成してしまう可能性があります。
この制約を打破する概念が「Agent(エージェント)」です。エージェントは、LLMを「思考エンジン」として利用し、外部ツールを操作する「手足」として機能させます。
| 特徴 | 従来のLLM | エージェント |
|---|---|---|
| 能力 | テキスト生成のみ | 思考と実行の統合 |
| 情報源 | 学習データ | 外部ツール、API、DB |
| 自律性 | 受動的 | 能動的な意思決定 |
| 状態 | ステートレス | コンテキストの保持 |
ReActフレームワークの原理
エージェント実装における標準的な設計パターンが「ReAct(Reasoning + Acting)」です。これはLLMに対し、以下のループを循環させるよう指示します。
- Thought(思考): ユーザーの意図を解析し、次の行動を決定する。
- Action(行動): 具体的なツールと入力パラメータを選択する。
- Observation(観察): ツールの実行結果を受け取る。
- 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を呼び出しています。
- 意思決定フェーズ: ユーザーの入力を解析し、ツールを使うべきか判断し、パラメータを決定する。
- 回答生成フェーズ: ツールの実行結果(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)"
}