ReActアーキテクチャで作るローカルAIエージェント入門
2026-03-06
azblob://2026/03/06/eyecatch/2026-03-06-local-llm-ai-agent-000.png

はじめに

前回の記事では、業務用ノートPCでローカルLLMを動かし、モデルごとの性能を比較しました。GPU非搭載のマシンでも動かせるモデルがあるとわかったところで、次に気になったのは「じゃあ何に使えるのか」です。

チャットで質問に答えさせるだけなら、クラウドのAPIを使えば済みます。ローカルで動かす意味を考えると、外部に出せないデータを扱う処理や、ネットワークに依存しない自動化が浮かびます。そこで、LLMが自律的にツールを使って動く「AIエージェント」を、ローカルLLMで組んでみました。

この記事では、ReActというアーキテクチャをフレームワークなしのPythonで実装した過程を紹介します。

LLMだけでは足りないもの

LLMは与えられたテキストに対して応答を返す仕組みです。現在時刻を知らないし、計算も正確ではありません。学習データの範囲外のことには答えられず、答えようとするとハルシネーションが起きます。

ただ、この「できないこと」は外部ツールで埋められます。時刻が必要なら時刻取得APIを呼べばいいし、計算が必要なら計算関数を呼べばいい。LLMの役割は「何のツールを、いつ、どの引数で呼ぶか」を判断することに絞り、実行は別のコードに任せる。この分業がAIエージェントの基本構造です。

問題は、LLMにこの判断をどうやらせるかです。

ReActという設計パターン

ReAct(2022年、Yaoらの論文)は、LLMに「考えてから動く」を繰り返させるアーキテクチャです。ただし事前に計画を立てるわけではなく、一歩ずつ判断しながらゴールへ向かう設計パターンです。

それ以前にも、ステップを踏んで思考させるChain-of-Thoughtや、ツールを直接呼ばせるアプローチはありました。前者は思考できるが行動できない、後者は行動できるが判断が浅い。ReActはこの2つを交互に回します。


Thought: 今日の日付を調べる必要がある。
Action: get_current_dateAction 
Input: なし
Observation: 2026年03月06日 (金曜日)
Thought: 日付がわかった。回答する。
Final Answer: 今日は2026年3月6日、金曜日です。

Thought(考える)→ Action(実行する)→ Observation(結果を見る)を繰り返し、答えが出たらFinal Answerを返します。

実装してみたら、ループと文字列処理だった

エージェントと聞くと大掛かりな仕組みを想像しますが、実装してみると構造は単純でした。

  1. ユーザーの質問と、これまでの推論経過をLLMに渡す
  2. LLMの出力をパースして、Thought / Action / Final Answerに分ける
  3. Final Answerがあれば回答して終了
  4. Actionがあればツールを実行し、結果をObservationとして記録
  5. 1に戻る

自分で書いてみて初めて、エージェントの中身が「特別な技術」ではなく 「設計パターンの実装」 だとわかりました。

実装の詳細

構成

Ollama上でGemma 3 4Bを動かし、PythonからHTTPで呼び出しています。llama.cppではなくOllamaを選んだのは、APIサーバーとして使えるためコードからの呼び出しが楽だからです。Gemma 3 4Bは前回の検証で3Bクラスより指示追従性が安定しており、ReActのフォーマットを守らせやすいと判断しました。

ファイル構成は6つ、依存ライブラリはrequestsのみです。


main.py      エントリポイント。対話ループ
agent.py     ReActループとパーサー
llm.py       Ollama APIクライアント
tools.py     ツール定義(時刻取得・日付取得・電卓)
history.py   会話履歴管理
logger.py    ログ出力

プロンプトでフォーマットを守らせる

ReActはLLMの出力を文字列パースして処理を分岐します。フォーマットが崩れるとエージェント全体が壊れるため、システムプロンプトでルールを強調しています。


_SYSTEM = f"""あなたはReActエージェントです。以下のフォーマットを厳守してください。

## ツール一覧{tool_list()}

## 出力ルール(絶対に守ること)
- 必ず "Thought:" から始める- ツールが必要なら "Action:" と "Action Input:" を書く
- ツール不要・回答できる場合は "Final Answer:" を書く
- Action と Final Answer を同時に書かない
- Observationは書かない(システムが挿入する)"""

「絶対に守ること」「厳守」といった強い表現を入れているのは、小さいモデルほどフォーマットを無視しやすいためです。4Bでも指示が弱いと独自のフォーマットで返してくることがあり、プロンプトの書き方で精度が変わります。

Gemma 3 1Bで試してわかったこと

同じGemma 3の1Bモデルでも動かしてみましたが、プロンプトだけではReActのループを維持できませんでした。最初のThoughtは実行できていましたが、Step 2は思考せず、Step 1の結果だけ出力しています。


====================================================  
ReAct エージェント  
モデル: gemma3:1b  
ツール: get_current_time, get_current_date, calculator  
終了: quit
====================================================
あなた: 今何時? あと 12 * (3 + 7) も計算して
════════════════════════════════════════════════════
▶ 質問: 「今何時? あと 12 * (3 + 7) も計算して」
── Step 1 ────────────────────────────────────────────
💭 [Thought]      ツールが必要。現在時刻を確認し、計算を実行する。
⚡ [Action]       ツール=calculator  引数=3*(3+7)
👁  [Observation]  3*(3+7) = 30
── Step 2 ────────────────────────────────────────────
════════════════════════════════════════════════════
AI: 今、20時です。

このようなループの逸脱を防ぐために、LLMの出力からThoughtが検出できなければ強制的に補完する、といった処理を追加していました。

この経験から、 エージェント設計にはプロンプト設計とコード制御が必要 だと感じました。モデルの性能が下がるほど、コード側で補う必要が出てきます。逆に、コード側の工夫次第で小さいモデルでもある程度動かせるエージェントが作れる、とも言えます。

4Bでの実行結果

同じ質問をGemma 3 4Bに投げた結果です。


あなた: 今何時? あと 12 * (3 + 7) も計算して
════════════════════════════════════════════════════
▶ 質問: 「今何時? あと 12 * (3 + 7) も計算して」
── Step 1 ────────────────────────────────────────────
💭 [Thought]      現在の時刻と計算を行う必要がある。まず計算を行う。
⚡ [Action]       ツール=calculator  引数=12 * (3 + 7)
👁  [Observation]  12 * (3 + 7) = 120
── Step 2 ────────────────────────────────────────────
💭 [Thought]      次に現在の時刻を調べる。
════════════════════════════════════════════════════
AI: 今は12時です。12 * (3 + 7) = 120です。

1Bとの違いが見えます。4Bは計算式を正しく12 * (3 + 7)として渡し、Step 2でも「次に時刻を調べる」と思考を続けています。1Bは計算式を3*(3+7)に変えてしまい、Step 2では思考なしで終了しました。

ただし4Bでも、Step 2で時刻取得ツールを呼ばずに「今は12時です」とハルシネーションしています。ReActのループを回す能力はあるものの、すべてのステップでツールを正しく使い切るにはまだ課題が残ります。

ReActループの核心

agent.pyから、ループの中心部分を抜粋します。


for step in range(1, self._max_steps + 1):
    msgs = messages.copy()    
    if scratchpad:
        msgs.append({"role": "assistant", "content": scratchpad})

    raw = self._llm.call(msgs)
    thought, action, action_input, final_answer = _parse(raw)

    if final_answer:
        return final_answer

    if action:
        obs = TOOLS[action]["fn"](action_input)
        scratchpad += (
            f"Thought: {thought}\n"
            f"Action: {action}\n"
            f"Action Input: {action_input}\n"
            f"Observation: {obs}\n"
        )

scratchpadがこのループの鍵です。Thought→Action→Observationの履歴を文字列として蓄積し、次のステップでLLMに丸ごと渡します。LLMは過去の推論経過を見て「次に何をすべきか」を判断する。この仕組みがReActの実体です。

ツールの追加は辞書への登録だけ

ツールはPython関数と説明文のセットです。


TOOLS = {
    "get_current_time": {
        "fn": get_current_time,
        "desc": "現在の時刻・日時を取得。引数不要。",
    },
    "get_current_date": {
        "fn": get_current_date,
        "desc": "今日の日付・曜日を取得。引数不要。",
    },
    "calculator": {
        "fn": calculator,
        "desc": "四則演算。引数に数式を渡す。例: 3*(4+2)",
    },
}

関数を書いてこの辞書に追加すれば、システムプロンプトのツール一覧にも自動で反映されます。

エージェントの価値と課題

今回は時刻取得と電卓という最小構成ですが、ReActの仕組み自体は汎用です。

たとえばデータベース検索を追加すれば社内情報を参照して回答するエージェントになり、ファイル操作を追加すればドキュメント生成エージェントになります。エージェントの価値はツールの設計で高めていけるという感覚は、実装を通じて強く感じました。

一方で、業務用PCのスペックで動かすからこその課題もあります。

  • モデル性能の限界: 4Bクラスではフォーマットを逸脱することがあり、ループが壊れるリスクがある。ツールが増えてプロンプトが長くなるほど顕著になる
  • 応答速度: GPU非搭載の環境ではステップごとに数秒〜十数秒かかる。ReActは複数ステップ回すため、体感の待ち時間が積み重なる

クラウドAPIの大規模モデルを使えばこれらの課題は緩和されますが、「外部にデータを出さずに動かせる」というローカルLLMの利点とはトレードオフです。

まとめ

今回の実装を通じて、エージェント設計には3つの軸があると感じました。

  1. ツール設計 — エージェントの価値はどんなツールを持たせるかで決まる。今回は時刻取得と電卓だけだったが、ここをDB検索やAPI連携に差し替えれば用途が一変する
  2. プロンプト設計 — ReActのループを維持できるかはプロンプトの書き方に左右される
  3. コード制御 — プロンプトだけでは限界がある。1Bで見たように、モデルの性能が下がるほどコード側での補完・矯正が必要になる

この3つのバランスはモデルの性能によって変わります。大きいモデルならプロンプトの比重が大きく、小さいモデルならコード制御の比重が大きくなる。どこに力を入れるかの判断自体が、エージェント設計の本質だと感じました。

ReActは数あるアーキテクチャの一つですが、構造がシンプルで中身が見えやすく、この3つの軸を体感するのに向いています。業務用PCで動くLLMに何をさせるか――今回はその問いに対して、ReActエージェントという形で一つの答えを出してみました。