【LLM全般】ローカルLLMでAgent機能を実装する方法:ReAct/Tool Useの実践ガイドとよくあるエラー解決

問題の概要:ローカルLLMでAgent機能を実装する際の課題

大規模言語モデル(LLM)に「Agent」機能を持たせ、外部ツール(検索、計算、API呼び出しなど)を自律的に使用させることは、AIアプリケーションの可能性を大きく広げます。しかし、ローカル環境(Ollama, llama.cpp, vLLMなど)で動作するオープンソースLLMを用いて、ReAct(Reasoning and Acting)やTool UseといったAgent機能を実装しようとすると、開発者、特に初心者〜中級者は以下のような課題に直面します。

  • エラー例1(プロンプト構造): モデルが期待したJSON形式や思考プロセスを出力せず、通常のチャット応答を続けてしまう。
    ユーザー: 東京の現在の天気を教えてください。
    アシスタント: 申し訳ありません、私はリアルタイムの天気情報にアクセスできません。
    (期待する出力: `{"thought": "ユーザーは天気を尋ねている。weather_toolを使う必要がある。", "action": "weather_tool", "action_input": {"location": "東京"}}`)
  • エラー例2(ツール呼び出しのパース失敗): モデルはツール使用を示す文を出力するが、プログラムがそれを正しく解析・実行できない。
    アシスタント: それでは計算ツールを使って、123 * 456を計算してみましょう。
    (期待する出力: `{"action": "calculator", "action_input": "123*456"}`)
  • エラー例3(無限ループ): 思考(Thought)と行動(Action)のループから抜け出せず、同じツールを繰り返し呼び出したり、最終答え(Final Answer)を出力しない。

これらの問題は、モデルの能力不足だけが原因ではなく、プロンプト設計、出力制御、ループ管理の不備に起因することがほとんどです。

原因の解説:なぜローカルLLMでのAgent実装は難しいのか

ChatGPTのFunction CallingやClaudeのTool Useのようなネイティブサポートがなく、汎用的なチャットモデルを使用する場合、以下の点が課題となります。

1. プロンプト設計の難しさ

モデルに「ツールを使うエージェント」として振る舞わせるには、役割、許可されたツールの詳細な説明、出力形式の厳密な指定をプロンプトに含める必要があります。説明が不十分だと、モデルは単なる知識ベースのチャットボットとして応答してしまいます。

2. 出力形式の制御(Structured Output)

Agentシステムでは、モデルの生のテキスト出力をプログラムが解析(パース)して、次のアクション(ツール実行)を決定します。ローカルLLMの多くは、JSON形式など構造化された出力を強制する機能を標準で持っていません。そのため、プロンプトによる誘導と、出力後の正規表現や部分的なJSONパースによる抽出が必須となり、ここで失敗が起こりがちです。

3. コンテキスト長と思考の質

ReActパターンでは、思考(Thought)、行動(Action)、観察(Observation)のステップが会話履歴に追加されていきます。ローカルLLMはコンテキスト長が限られており、長い対話や複雑なタスクでは、重要な初期のプロンプト(ツール定義)がコンテキストから外れたり、思考が浅くなってループに陥るリスクがあります。

解決方法:ステップバイステップ実装ガイド

ここでは、PythonとOllama(`llama3.1:8b`モデル想定)を使用した、基本的なReActエージェントの実装手順を示します。

ステップ1: 環境構築と必要なライブラリのインストール

# 必要ライブラリのインストール
pip install ollama requests

# Ollamaの起動とモデルプル(別ターミナルで実行)
# ollama run llama3.1:8b

ステップ2: コアとなるプロンプトテンプレートの作成

エージェントの振る舞いを規定する最も重要な部分です。ツールの説明と出力形式を明確に指示します。

SYSTEM_PROMPT = """あなたは優秀なアシスタントです。質問に答えるために、必要に応じて以下のツールを使用できます。あなたの応答は必ず以下のJSON形式でなければなりません。
{"thought": "ここに次の行動を決定するための理由を書く", "action": "ツール名またはFinal Answer", "action_input": "ツールへの入力文字列または最終回答"}

利用可能なツール:
1. search_web(query): ウェブ検索を行います。queryは検索キーワードです。
2. calculator(expression): 数式を計算します。expressionは"123+456"のような文字列です。
3. get_current_time(): 現在の日時を取得します。入力は不要です。

ルール:
- ツールが必要ない場合は、actionを"Final Answer"に、action_inputに直接答えを書いてください。
- ツールを使う場合は、actionにツール名を、action_inputに適切な引数を書いてください。
- 出力はJSONのみです。他のテキストは一切含めないでください。
"""

ステップ3: ツール関数と出力パーサーの実装

import json
import re
import requests
from datetime import datetime

# シンプルなツール群の実装(モック)
def search_web(query):
    # 実際はSerpAPIなどの利用を想定
    return f"「{query}」の検索結果: これはモックです。"

def calculator(expression):
    try:
        # 注意: evalはセキュリティリスクがあるため、実際のプロダクションでは安全な評価ライブラリを使用
        result = eval(expression)
        return f"計算結果: {expression} = {result}"
    except:
        return "計算式の評価中にエラーが発生しました。"

def get_current_time():
    return f"現在の日時: {datetime.now().strftime('%Y/%m/%d %H:%M:%S')}"

# モデルの生出力からJSON部分を抽出するパーサー
def parse_llm_output(raw_output):
    # JSONブロックを探す(```json ... ``` または {...} の形式)
    json_match = re.search(r'```jsons*(.*?)s*```', raw_output, re.DOTALL)
    if not json_match:
        json_match = re.search(r'({.*})', raw_output, re.DOTALL)
    
    if json_match:
        json_str = json_match.group(1)
        try:
            return json.loads(json_str)
        except json.JSONDecodeError as e:
            print(f"JSONパースエラー: {e}, 生出力: {raw_output[:200]}")
            return None
    else:
        print(f"JSON形式が見つかりません: {raw_output[:200]}")
        return None

ステップ4: ReActループの実装

import ollama

class SimpleReActAgent:
    def __init__(self, system_prompt=SYSTEM_PROMPT, max_turns=5):
        self.system_prompt = system_prompt
        self.max_turns = max_turns
        self.conversation_history = []
        
    def run(self, user_query):
        print(f"ユーザー: {user_query}")
        prompt = self.system_prompt + "nnユーザーの質問: " + user_query
        
        for turn in range(self.max_turns):
            # LLM呼び出し
            response = ollama.chat(model='llama3.1:8b', messages=[
                {'role': 'system', 'content': self.system_prompt},
                {'role': 'user', 'content': user_query}
            ])
            llm_output = response['message']['content']
            print(f"LLM生出力: {llm_output}")
            
            # 出力をパース
            parsed = parse_llm_output(llm_output)
            if not parsed:
                return "エージェントの出力を解析できませんでした。"
            
            thought = parsed.get('thought', '')
            action = parsed.get('action', '')
            action_input = parsed.get('action_input', '')
            
            print(f"思考: {thought}")
            print(f"アクション: {action}, 入力: {action_input}")
            
            # アクションの実行
            if action == 'Final Answer':
                print(f"最終回答: {action_input}")
                return action_input
            elif action == 'search_web':
                observation = search_web(action_input)
            elif action == 'calculator':
                observation = calculator(action_input)
            elif action == 'get_current_time':
                observation = get_current_time()
            else:
                observation = f"エラー: 未知のアクション '{action}' です。"
            
            print(f"観察: {observation}")
            
            # 次のターンのためにプロンプトを更新(シンプルな実装)
            # 実際は会話履歴全体を管理する必要あり
            user_query = f"前回の観察: {observation}. この情報をもとに、最初の質問に答えてください。"
            
        return "最大ターン数に達しました。タスクを完了できませんでした。"

# エージェントの実行例
if __name__ == "__main__":
    agent = SimpleReActAgent()
    result = agent.run("123に456を掛けた値に、今日の日付の日(例: 15日なら15)を足すと?")
    print(f"n最終結果: {result}")

コード例・コマンド例:よくあるエラーとその対処法

エラー1: JSONDecodeErrorが頻発する

現象: json.loads() でデコードエラーが発生する。

解決策: パーサーを強化し、モデルの出力傾向に合わせる。

def robust_parse_llm_output(raw_output):
    # 1. 前後の空白を除去
    text = raw_output.strip()
    # 2. JSON以外の説明文が前に付く場合を想定("これはJSONです: {...}")
    start_idx = text.find('{')
    end_idx = text.rfind('}') + 1
    if start_idx != -1 and end_idx != 0:
        json_str = text[start_idx:end_idx]
        # 3. よくあるエスケープ漏れを修正(オプション)
        json_str = json_str.replace('n', '\n').replace('t', '\t')
        try:
            return json.loads(json_str)
        except json.JSONDecodeError:
            pass
    # 4. どうしてもダメな場合は、キーと値から再構築を試みる(最終手段)
    # ... (複雑なため割愛)
    return None

エラー2: モデルがツールを使わず、知識だけで答えようとする

現象: 天気や最新情報を尋ねても、「私はその情報を持っていません」と答える。

解決策: プロンプトを強化し、few-shot examples(具体例)を含める。

SYSTEM_PROMPT_WITH_EXAMPLES = SYSTEM_PROMPT + """

例1:
ユーザー: ニューヨークの現在の時刻は?
{"thought": "ユーザーは現在の時刻を尋ねている。get_current_timeツールが使える。", "action": "get_current_time", "action_input": ""}

例2:
ユーザー: 円周率は何ですか?
{"thought": "円周率は一般的な知識であり、ツールは必要ない。", "action": "Final Answer", "action_input": "円周率(π)は約3.14159です。"}
"""

エラー3: 無限ループに陥る

現象: 思考と行動を繰り返し、最終回答に到達しない。

解決策: ループ検出と強制終了ロジックを追加。

def run_with_loop_detection(self, user_query):
    previous_actions = []
    for turn in range(self.max_turns):
        # ... (LLM呼び出しとパース)
        current_action = f"{action}:{action_input}"
        if current_action in previous_actions:
            return f"同じアクションが繰り返されました。ループを終了します。直前の観察: {observation}"
        previous_actions.append(current_action)
        # ... (アクション実行)
    # ... (最大ターン数処理)

まとめ・補足情報

ローカルLLMで実用的なAgent機能を実装するには、「プロンプト設計」「構造化出力の抽出」「ループ制御」の3つが鍵となります。本記事で紹介した基本フレームワークを出発点として、以下の点を発展させていくことをお勧めします。

  • より高度なフレームワークの利用: LangChain、LlamaIndex、Microsoft AutoGenといったフレームワークは、これらの基盤機能を提供しており、開発効率が大幅に向上します。
  • モデルの選択: CodeLlamaやMistral、Command-Rなど、指示追従や構造化出力に強いとされるモデルを試すと、実装が容易になる場合があります。
  • 出力形式の強制: llama.cppの`grammar`オプションや、vLLMのサンプリングパラメータを調整することで、JSON出力をより確実にすることが可能です。
  • エラーハンドリングの強化: パース失敗時やツール実行エラー時に、モデルに状況をフィードバックして再試行させる「復旧ループ」を実装することで、堅牢性が高まります。

ローカル環境でのAgent開発は、クラウドAPIへの依存を減らし、データプライバシーを守り、カスタマイズ性を最大化できるという大きな利点があります。初期の実装では課題も多いですが、本ガイドを参考に段階的に改善を重ねることで、強力で自律的なAIアシスタントを構築する道が開けます。

この記事は役に立ちましたか?