問題の概要:ローカルLLMでAgent機能を実装する際の課題
大規模言語モデル(LLM)を単なるテキスト生成器から、外部ツールを活用して自律的に問題解決を行う「Agent」へと進化させることは、現在のAI開発における重要なトピックです。特に、ローカル環境で動作するLLM(Llama 3, Mixtral, Gemmaなど)を用いてAgent機能を実装しようとする開発者からは、以下のような具体的な課題やエラーメッセージが報告されています。
- エラー例1(構造化出力の失敗):
Error: Failed to parse LLM output. Expected JSON with 'thought', 'action', 'action_input' keys, but got: "まず、ユーザーの質問を理解します。天気を知りたいようです。では、天気APIを呼び出しましょう。" - エラー例2(無限ループ): Agentが
Final Answerを生成せず、同じ思考(Thought)と行動(Action)を繰り返すループに陥る。 - 課題例3(ツール選択の誤り): 計算が必要な問題に対して検索ツールを呼び出したり、逆に事実確認が必要な問題で電卓ツールを使用しようとする。
- 課題例4(プロンプトの肥大化): ツールの説明が長くなり、コンテキストウィンドウを圧迫し、処理速度が低下する。
これらの問題は、ReAct(Reasoning + Acting)フレームワークやTool Use機能をローカルLLMに実装する際の、プロンプト設計、出力解析、実行ループの制御に関する理解不足に起因することが多いです。
原因の解説:なぜローカルLLMでのAgent実装は難しいのか?
OpenAIのGPT-4などの大規模クラウドモデルは、関数呼び出し(Function Calling)機能が最初から強力に組み込まれています。一方、多くのローカルLLMは、基本的なテキスト補完やチャットに最適化されており、Agent動作に必要な以下の能力が相対的に弱い傾向にあります。
1. 構造化出力の難しさ
Agentフレームワーク(特にReAct)では、LLMの出力をThought:, Action:, Action Input:, Observation:のような厳密な形式で要求します。多くのローカルLLMは、このような構造化されたフォーマットを厳密に遵守することが苦手です。プロンプトで指定しても、自由な形式の文章を生成してしまい、後続のパーサーがエラーを起こします。
2. 自己認識とループ制御の不足
Agentは、ツールの実行結果(Observation)を踏まえて「次の思考」を行い、最終的に答えが得られたらFinal Answer:を出力してループを終了しなければなりません。能力が限られるローカルLLMは、この「現在が思考段階なのか、最終回答段階なのか」というメタな判断を誤り、無限ループに陥りがちです。
3. ツール説明の理解限界
使用可能なツールのリストとその詳細な説明(引数、仕様)は全てプロンプトに含める必要があります。ツールが増えるとコンテキストが肥大化し、LLMがツールの選択と使用方法を正確に理解できなくなります。
解決方法:ステップバイステップ実装ガイド
ここでは、PythonとLangChainライブラリを用いて、ローカルLLM(例:Ollamaで実行するLlama 3)でシンプルなReAct Agentを構築する手順を説明します。OllamaとLangChainはローカルLLMの操作を大幅に簡素化するツールです。
ステップ1: 環境構築と必要なライブラリのインストール
まず、ベースとなる環境を準備します。Ollamaのインストールとモデルのダウンロードから始めましょう。
# Ollamaのインストール(Mac/Linux)
curl -fsSL https://ollama.com/install.sh | sh
# モデルのプル(例:Llama 3 8B)
ollama pull llama3:8b
# Python環境の準備とLangChainのインストール
pip install langchain langchain-community langchain-experimental chromadb
ステップ2: カスタムプロンプトテンプレートの作成
ローカルLLMに強く構造化を促すための、詳細なプロンプトテンプレートを作成します。これが成功の鍵です。
from langchain.prompts import StringPromptTemplate
from typing import List, Dict
class ReActPromptTemplate(StringPromptTemplate):
template: str = """あなたは思考と行動を繰り返して問題を解決するアシスタントです。
以下のフォーマットで**必ず**回答してください。
Thought: ここに現在の状況についての分析や次のステップを書く
Action: 使用するツールの名前(以下のツールリストから一つだけ選択)
Action Input: ツールへの入力(JSON形式が望ましい)
Observation: ツールから返された結果
... (このThought/Action/Action Input/Observationは繰り返すことができます)
最終的な答えが得られたら、以下のフォーマットで終了します。
Thought: 質問に対する答えが得られました。
Final Answer: ここに最終的な答えを書く
使用可能なツールは以下の通りです:
{tools}
ツールの入力は常にJSON形式の文字列です。
ユーザーの質問: {input}
これが今までのやり取りです:
{agent_scratchpad}
"""
def format(self, **kwargs) -> str:
# ツールの説明文を整形
tools_text = "n".join([f"- {tool.name}: {tool.description}" for tool in kwargs["tools"]])
kwargs["tools"] = tools_text
return self.template.format(**kwargs)
ステップ3: ツールの定義とLLMのセットアップ
Agentが使用するシンプルなツール(電卓、検索シミュレータ)を定義し、Ollamaで動くLLMをLangChainに組み込みます。
from langchain.tools import Tool
from langchain_community.llms import Ollama
from langchain_experimental.tools import PythonREPLTool
import json
import math
# シンプルなツールを定義
def search_tool(query: str) -> str:
"""ウェブ検索をシミュレートするツール。実際にはダミーデータを返します。"""
dummy_data = {
"東京の天気": "晴れ、気温25度",
"Pythonの作者": "グイド・ヴァンロッサム",
"最新のAIニュース": "マルチモーダルLLMが進化中"
}
return dummy_data.get(query, f"'{query}'に関する情報は見つかりませんでした。")
def calculator_tool(expression: str) -> str:
"""数式を計算するツール。"""
try:
# 安全のため、限定的な評価を行う
result = eval(expression, {"__builtins__": None}, {"math": math})
return str(result)
except Exception as e:
return f"計算エラー: {e}"
# Toolオブジェクトの作成
tools = [
Tool(name="Search", func=search_tool, description="事実情報や最新ニュースを検索する時に有用です。入力は検索クエリの文字列です。"),
Tool(name="Calculator", func=calculator_tool, description="数学的な計算が必要な時に使用します。入力は'3 + 5 * 2'のような計算式の文字列です。"),
]
# Ollama LLMのセットアップ(温度は低く設定して出力を安定させる)
llm = Ollama(model="llama3:8b", temperature=0.1)
ステップ4: 出力パーサーとAgentエグゼキューターの構築
LLMの生の出力を解析(Parse)して、思考、行動、最終回答に分解するコンポーネントと、全体の実行ループを制御する部分を作成します。
import re
from langchain.schema import AgentAction, AgentFinish
def parse_llm_output(llm_output: str) -> Dict:
"""LLMの出力を解析し、次のアクションか最終回答かを判断する。"""
# Final Answer を探す
if "Final Answer:" in llm_output:
return {"answer": llm_output.split("Final Answer:")[-1].strip()}
# Thought, Action, Action Input を正規表現で抽出(より頑健に)
thought_match = re.search(r"Thought:s*(.*?)(?=nAction:|nFinal Answer:|$)", llm_output, re.DOTALL)
action_match = re.search(r"Action:s*(.*?)(?=nAction Input:|$)", llm_output)
action_input_match = re.search(r"Action Input:s*(.*?)(?=nObservation:|$)", llm_output, re.DOTALL)
if not (thought_match and action_match and action_input_match):
# パースに失敗した場合は、エラーではなく「最終回答」として扱い、ループを止める安全策
return {"answer": f"出力の解析に失敗しました。生の出力: {llm_output[:200]}..."}
thought = thought_match.group(1).strip()
action = action_match.group(1).strip()
action_input = action_input_match.group(1).strip()
# Action InputがJSON文字列の場合、クリーンアップ
if action_input.startswith("```json"):
action_input = action_input[7:-3].strip() # ```json ... ``` を除去
elif action_input.startswith("`") and action_input.endswith("`"):
action_input = action_input[1:-1].strip()
return {
"thought": thought,
"action": action,
"action_input": action_input
}
def run_agent(user_input: str, max_steps: int = 5):
"""Agentの実行ループを制御するメイン関数。"""
prompt_template = ReActPromptTemplate()
scratchpad = ""
for step in range(max_steps):
# プロンプトの生成
full_prompt = prompt_template.format(
input=user_input,
tools=tools,
agent_scratchpad=scratchpad
)
# LLMの呼び出し
llm_response = llm.invoke(full_prompt)
print(f"n--- Step {step+1} ---")
print(f"LLM Output:n{llm_response}")
# 出力の解析
parsed = parse_llm_output(llm_response)
if "answer" in parsed:
print(f"n✅ 完了: {parsed['answer']}")
return parsed['answer']
# ツールの実行
tool_to_use = next((tool for tool in tools if tool.name == parsed["action"]), None)
if not tool_to_use:
observation = f"エラー: ツール '{parsed['action']}' は見つかりません。使用可能なツールは {[t.name for t in tools]} です。"
else:
try:
observation = tool_to_use.run(parsed["action_input"])
except Exception as e:
observation = f"ツール実行エラー: {e}"
print(f"Observation: {observation}")
# スクラッチパッド(対話履歴)を更新
scratchpad += f"nThought: {parsed['thought']}nAction: {parsed['action']}nAction Input: {parsed['action_input']}nObservation: {observation}"
# 最大ステップ数に達したら強制終了
return f"エラー: 最大ステップ数({max_steps})に達しました。問題が解決しませんでした。"
ステップ5: Agentの実行とテスト
構築したAgentを実際に動かしてみましょう。
# テスト実行
if __name__ == "__main__":
# テストクエリ1: 計算を含む問題
result1 = run_agent("15の3乗はいくつですか?それに17を足した値は?")
print(f"nテスト1結果: {result1}")
# テストクエリ2: 知識を必要とする問題
result2 = run_agent("Pythonというプログラミング言語の作者は誰ですか?")
print(f"nテスト2結果: {result2}")
# テストクエリ3: 複合的な問題(失敗しやすい例)
result3 = run_agent("円周率を3.14として、半径5cmの円の面積を計算し、その値を教えてください。")
print(f"nテスト3結果: {result3}")
コード例・コマンド例:よくあるエラーとその対処法
エラー1: 出力パース失敗時のフォールバック
前述のparse_llm_output関数では、正規表現による柔軟なマッチングと、失敗時の安全策(最終回答扱い)を実装しています。これにより、構造化から少し外れた出力でもエラーでクラッシュせず、処理を続行または穏便に終了できます。
コマンド例: Ollamaモデルのパラメータ調整
Agentの動作が不安定な場合、Ollamaの呼び出しパラメータを調整することで改善できることがあります。
# より予測可能な出力のために温度を下げる
llm = Ollama(model="llama3:8b", temperature=0.0, top_p=0.9)
# コンテキストウィンドウを明示(長いプロンプト用)
llm = Ollama(model="llama3:8b", num_ctx=4096)
# JSON形式の出力を促すシステムプロンプト(モデルが対応している場合)
# Ollamaのモデル設定ファイル(Modelfile)で事前に設定するのがベター
まとめ・補足情報
ローカルLLMで安定したAgent機能を実装するには、「LLMの能力限界を理解したプロンプト設計」と「堅牢な出力解析とエラーハンドリング」が最も重要です。本記事で紹介した方法は、シンプルなReActパターンに基づいていますが、以下の発展的なトピックに取り組むことで、さらに強力なAgentを構築できます。
- エージェントの種類: ReAct以外にも、Plan-and-Execute(計画と実行を分離)や、AutoGPTのようなマルチステップ自律エージェントなどのアーキテクチャがあります。
- ツールの拡張: 実際のWeb検索API(SerpAPI)、データベース接続、ファイル操作ツールなどを組み込むことで、実用性が高まります。
- フレームワークの活用: LangChainやAutoGen、AutoGPTなどの高レベルフレームワークを利用すると、エージェントの基盤部分をより簡単に構築できます。
- モデルの選択: コード生成や構造化出力に特化してファインチューンされたモデル(例えば、CodeLlamaや特定のFunction Calling用モデル)を使用すると、パフォーマンスが向上する可能性があります。
ローカル環境でのAgent開発は、データプライバシー、コスト削減、カスタマイズ性の高さといった利点があります。初期段階では想定通りに動作しないことも多いですが、プロンプトとパイプラインを段階的に調整・強化していくことで、実用的な自律型AIアシスタントの実現に近づくことができるでしょう。