「8, 3, 1, 5」の4つの数字を四則演算だけで組み合わせて、ちょうど24にしてください――この問題を見て、すぐに答えが浮かぶでしょうか。人間であれば、頭の中でいくつかの計算パターンを試し、行き止まりに当たったら別のルートに引き返して考え直すでしょう。ところが、Chain-of-Thought(CoT)プロンプティングでは、LLMは1本道の推論パスを一気に生成するため、途中で間違いに気づいても引き返すことができません。
もう一つ、別の問題を考えてみましょう。「日本で最も高いビルの建設費はいくらですか?」という質問に対して、LLMが自信満々に間違った金額を答えてしまう――いわゆるハルシネーションです。もしLLMが「わからなければ検索エンジンで調べる」という行動を取れたら、このような問題は大幅に減るはずです。
本記事で取り上げる Tree-of-Thought(ToT) と ReAct は、まさにこれらの課題を解決するために設計された手法です。ToTは推論を木構造に拡張して複数のパスを探索し、ReActは推論と外部ツールの使用を交互に行うことで事実に基づいた回答を実現します。
これらの手法は、数学的問題解決、ファクトチェック、コード生成エージェント、科学的仮説検証など、LLMを「考えるだけの存在」から「考えて行動する存在」に進化させるための鍵となる技術です。
本記事の内容
- CoTの復習と「1本道推論」の限界
- Tree-of-Thoughtの基本概念とアルゴリズム
- 24ゲームによるToTの具体例
- Graph-of-Thought への拡張
- ReActの基本概念と Thought-Action-Observation ループ
- ReActによる質問応答の具体例
- ToT vs ReAct vs CoT の比較
- Pythonでの実装(ToTの24ゲームソルバー + ReActエージェント)
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
Chain-of-Thoughtの復習と限界
CoTの基本的なアイデア
Chain-of-Thought(CoT)は、LLMに最終回答だけでなく中間的な推論ステップを生成させることで、複雑な問題の正答率を向上させる手法です。たとえば「ロジャーは5個のテニスボールを持っていて、3個入りの缶を2つ買った。合計は?」という問題に対して、「5 + 3×2 = 11」という計算過程を明示的に出力させます。
これを数学的に表現すると、推論チェーン $c = (c_1, c_2, \ldots, c_n)$ と最終回答 $a$ の同時確率は次のように分解できます。
$$ P(a, c \mid x) = \prod_{i=1}^{n} P(c_i \mid c_{
中間ステップを生成することで、LLMの自己回帰的な性質を利用し、実質的な「計算ステップ数」を増やしているわけです。これは、Transformerが1トークンあたり固定の計算量しか持たないという制約を、出力トークン数を増やすことで補う巧妙な戦略です。
1本道推論の3つの弱点
CoTは多くのタスクで劇的な改善をもたらしましたが、その構造に起因する根本的な限界があります。
弱点1: 後戻りできない(No Backtracking)
CoTの推論は左から右へ一方向に進むトークン列です。途中のステップ $c_i$ で間違った方向に進んでしまっても、その誤りを検知して $c_{i-1}$ に戻ることができません。自己回帰モデルの宿命として、一度生成したトークンは撤回できないのです。
迷路に例えると、CoTは「壁に突き当たっても引き返せずに前に進み続ける」ようなものです。人間なら行き止まりに気づいたら別のルートを試しますが、CoTにはその柔軟性がありません。
弱点2: 多様性がない(Single Path)
CoTは1つの推論パスしか生成しません。しかし、複雑な問題には複数の解法が存在する場合があり、最初に選んだアプローチが最適とは限りません。
Self-Consistency(Wang et al., 2023)は複数のCoTパスをサンプリングして多数決を取る手法ですが、各パスは独立に生成されるため、あるパスの良い部分的推論を別のパスに活かすことができません。
弱点3: 評価ができない(No Evaluation)
CoTは推論の途中状態を自己評価する仕組みを持ちません。「このステップは有望か、それとも行き止まりか」を判断できないため、非効率な探索が生じます。
これら3つの弱点を一言でまとめると、CoTには「探索」の概念が欠けているということです。複雑な推論は本質的に探索問題であり、分岐・評価・後戻りが必要です。では、推論プロセスを木構造に拡張し、探索アルゴリズムを適用したらどうなるでしょうか? これがTree-of-Thoughtのアイデアです。
Tree-of-Thoughtの基本概念
推論を「木」として捉える
Tree-of-Thought(ToT)は、Yao et al.(2023)によって提案された手法で、LLMの推論プロセスを木構造として明示的にモデル化します。
チェスの対局を思い浮かべてみてください。優れたプレイヤーは、1手先だけでなく数手先を読みます。「この手を指したら相手はこう返すだろう。そしたら自分はこう指して…」と、可能な手の木を頭の中で展開し、最も有望な枝を選択します。これがまさにTree-of-Thoughtの考え方です。
CoTが「1本の鎖(Chain)」で推論を表現するのに対し、ToTは「枝分かれする木(Tree)」で推論を表現します。各ノードは「思考(Thought)」と呼ばれる中間推論ステップであり、ルートノードから葉ノードまでの各パスが1つの推論チェーンに対応します。
形式的な定義
ToTの形式的な定義を導きましょう。問題の入力を $x$、最終回答を $y$ とします。推論過程で生成される個々の「思考」を $z_i$ とすると、ToTは以下の要素で構成されます。
思考空間(Thought Space): 各ステップで生成し得る思考の集合です。思考 $z$ は、数語のフレーズから数段落まで、タスクに応じた適切な粒度で設計します。重要なのは、LLMが意味のある思考を生成でき、かつ評価可能な単位に設定することです。
状態(State): ある時点での推論の途中経過を表します。状態 $s$ は入力と、そこまでに生成された思考の列で表現されます。
$$ s = [x, z_1, z_2, \ldots, z_i] $$
状態遷移: 状態 $s$ に新しい思考 $z_{i+1}$ を追加して次の状態へ遷移します。
$$ s’ = [s, z_{i+1}] \quad \text{where} \quad z_{i+1} \sim P_\theta(z_{i+1} \mid s) $$
ここで $P_\theta$ はLLMのパラメータ $\theta$ による条件付き分布です。
CoTとの違いを数式で明確にしましょう。CoTでは、思考の系列 $z_{1:n}$ は1つだけ生成され、それが最終回答に直結します。
$$ \text{CoT}: \quad z_{1:n} \sim P_\theta(z_{1:n} \mid x), \quad y = z_n $$
一方、ToTでは複数の思考候補を生成し、木構造を構築した上で、最適なパスを選択します。
$$ \text{ToT}: \quad y^* = \arg\max_{y} \sum_{z_{1:n} \in \mathcal{T}} P_\theta(y \mid z_{1:n}, x) \cdot V(s) $$
ここで $\mathcal{T}$ は木構造のパス集合、$V(s)$ は状態の価値関数です。$V(s)$ が大きいパスほど正解に到達する見込みが高いことを意味します。
ToTのアルゴリズムは、3つのコンポーネントから構成されています。次のセクションで、それぞれを詳しく見ていきましょう。
ToTのアルゴリズム: 3つのコンポーネント
コンポーネント1: Thought Generator(思考生成器)
Thought Generatorは、現在の状態 $s$ から次の思考の候補 $z^{(1)}, z^{(2)}, \ldots, z^{(k)}$ を $k$ 個生成するモジュールです。生成方法は2種類あります。
i.i.d. サンプリング(独立サンプリング): CoTプロンプトを使い、同じ状態から独立に $k$ 個の思考をサンプリングします。
$$ z^{(j)} \sim P_\theta(z \mid s), \quad j = 1, \ldots, k $$
この方法は、思考空間が広い(多様な表現が可能な)場合に適しています。creative writingのような、1つの続きに多くの可能性があるタスクが好例です。温度パラメータ(temperature)を高めに設定してサンプリングの多様性を確保します。
Propose(提案)プロンプト: LLMに「この状態から考えられる次の手を $k$ 個提案してください」と明示的に指示する方法です。
$$ [z^{(1)}, z^{(2)}, \ldots, z^{(k)}] = \text{LLM}(\text{“Propose } k \text{ next steps from state } s\text{“}) $$
この方法は、思考空間が制約されている場合に適しています。たとえば24ゲームで「次に試すべき計算式」は有限であり、LLMに列挙させる方が効率的です。
コンポーネント2: State Evaluator(状態評価器)
State Evaluatorは、各状態 $s$ が最終的に正解に到達できそうかを評価するモジュールです。チェスでいう「形勢判断」に相当します。これもLLMを利用して2つの方法で実現できます。
Value(値評価): 各状態を独立に評価し、スコアを付与します。
$$ V(s) = \text{LLM}(\text{“Evaluate state } s \text{ as: sure/likely/impossible”}) $$
具体的には、LLMに対して「この途中状態から正解に到達できるか? sure(確実)/ likely(可能性あり)/ impossible(不可能)」のように判断を求めます。これを数値にマッピングします(例: sure → 1.0, likely → 0.5, impossible → 0.0)。
Vote(投票): 複数の候補状態を比較し、最も有望なものに投票させます。
$$ V(s_i) = \sum_{v=1}^{V_{\text{total}}} \mathbb{1}[\text{LLM vote } v \text{ selects } s_i] $$
$V_{\text{total}}$ 回の投票のうち、状態 $s_i$ が選ばれた回数が評価値となります。比較評価は絶対評価よりも信頼性が高い場面が多く、creative writingのように「良さ」の基準が主観的なタスクに適しています。
コンポーネント3: Search Algorithm(探索アルゴリズム)
Thought GeneratorとState Evaluatorを使って木を構築・探索するアルゴリズムです。古典的な探索アルゴリズムであるBFS(幅優先探索)とDFS(深さ優先探索)をToTに適応させます。
BFS(幅優先探索): 各深さで $b$ 個の最も有望な状態を保持しながら、1層ずつ木を拡張していきます。ビームサーチに似た動作です。
$$ S_t = \text{top-}b\{s’ = [s, z] \mid s \in S_{t-1}, z \in \text{Generate}(s), V(s’) > \tau\} $$
ここで $S_t$ は深さ $t$ での状態集合、$b$ はビーム幅、$\tau$ は枝刈りの閾値です。すべての状態を同じ深さまで均等に拡張するため、幅広い可能性を見落としにくいという利点があります。
DFS(深さ優先探索): 有望な枝を見つけたら、まずその枝を最後まで掘り下げます。行き止まり($V(s) < \tau$)に達したら、バックトラックして別の枝を探索します。
$$ \text{If } V(s) < \tau: \quad \text{backtrack to parent and try next child} $$
DFSはメモリ効率が良く、正解が木の深い位置にある場合に効率的です。ただし、最初に選んだ枝が不適切だと大量の無駄な探索が生じる可能性があります。
BFSとDFSのどちらを使うかは、タスクの性質によって決まります。一般に、解が浅い位置にあり多くの候補を比較したい場合はBFS、解が深い位置にありバックトラックが重要な場合はDFSが適しています。
ここまでToTのアルゴリズムを抽象的に説明しましたが、具体例がないと実感が湧きにくいでしょう。次のセクションでは、24ゲームという有名なパズルを題材に、ToTがどのように動作するかを具体的に見ていきます。
ToTの具体例: 24ゲーム
24ゲームとは
24ゲームは、4つの整数と四則演算($+, -, \times, \div$)を使って24を作るパズルです。たとえば「1, 2, 3, 4」が与えられたら、$1 \times 2 \times 3 \times 4 = 24$ が解になります。
このパズルがCoTにとって難しい理由を考えてみましょう。4つの数字の演算順序と演算子の組み合わせは膨大にあり、正解にたどり着くまでに多くの「試行錯誤」が必要です。CoTの1本道推論では、最初に選んだ計算ルートがたまたま正解でなければ失敗してしまいます。
ToTによる解法
たとえば入力が「$4, 9, 10, 13$」だったとしましょう。ToTは以下のように木を展開します。
深さ1(最初の演算を選ぶ): 4つの数字から2つを選んで演算し、3つの数字に減らします。Thought Generatorが複数の候補を生成します。
思考A: 13 - 9 = 4 → 残り: 4, 4, 10
思考B: 9 - 4 = 5 → 残り: 5, 10, 13
思考C: 13 - 4 = 9 → 残り: 9, 9, 10
思考D: 10 - 4 = 6 → 残り: 6, 9, 13
思考E: 4 + 9 = 13 → 残り: 10, 13, 13
State Evaluatorによる評価: 各候補をLLMに評価させます。「残り $\{4, 4, 10\}$ から24を作れそうか?」→ $4 \times (10 – 4) = 24$ → sure(確実)。「残り $\{9, 9, 10\}$ から24は?」→ 可能性が低い → impossible。
このように、有望でない枝(思考C, D, Eなど)を早期に刈り込み、有望な枝(思考A)を深く探索します。
深さ2(2回目の演算): 思考Aの「$4, 4, 10$」に対してさらに候補を生成します。
思考A-1: 10 - 4 = 6 → 残り: 4, 6
思考A-2: 4 + 4 = 8 → 残り: 8, 10
思考A-3: 4 × 4 = 16 → 残り: 10, 16
State Evaluatorが再び評価し、「$4 \times 6 = 24$」に到達できる思考A-1が最も有望と判断されます。
深さ3(最後の演算): $4 \times 6 = 24$ — 正解に到達しました。
最終的な解: $(13 – 9) \times (10 – 4) = 4 \times 6 = 24$
ToTの威力: 数値で見る改善
Yao et al. の論文では、24ゲームに対する各手法の成功率が報告されています。
| 手法 | 成功率 |
|---|---|
| 標準プロンプト(直接回答) | 7.3% |
| CoT プロンプト | 4.0% |
| CoT + Self-Consistency (k=100) | 9.0% |
| ToT (BFS, b=5) | 74.0% |
驚くべきことに、CoTはこのタスクでは標準プロンプトよりも悪い成績です。これは、CoTが生成する中間ステップが必ずしも正しい方向に導くとは限らないことを示しています。一方でToTは74%と圧倒的な改善を達成しています。
Creative Writingへの応用
ToTは数学パズルだけでなく、創造的なタスクにも有効です。Creative Writingタスクでは、4つのランダムな文を入力として受け取り、それらを自然につなぐ一貫した段落を書く課題が設定されました。
このタスクでは、BFSの各ステップで複数の「計画(plan)」を生成し、投票(Vote)方式で最も一貫性のある計画を選びます。その後、選ばれた計画に基づいて複数の段落を生成し、再び投票で最良のものを選択します。
結果として、ToTで生成された文章は、CoTで生成された文章と比較して、一貫性(coherency)のスコアが大幅に向上しました。人間の評価でも、ToTの出力がCoTよりも好まれる割合が高いことが確認されています。
ToTの「複数のパスを探索して最良を選ぶ」というアイデアは強力ですが、木構造では各思考ノードが1つの親しか持てないという制約があります。もし異なる推論パスの良い部分を「統合」できたら、さらに強力な推論が可能になるのではないでしょうか? この発想から生まれたのがGraph-of-Thoughtです。
Graph-of-Thought: 木からグラフへの拡張
思考の「統合」という新しい操作
Graph-of-Thought(GoT、Besta et al., 2024)は、ToTの木構造を有向非巡回グラフ(DAG: Directed Acyclic Graph)に拡張した手法です。
日常的な問題解決を考えてみてください。たとえば旅行の計画を立てるとき、「予算」と「日程」と「行きたい場所」という異なる観点から別々に検討し、最終的にそれらの結論を統合して最適なプランを作りますよね。GoTはまさにこのような「異なる推論パスの結果を統合する」操作を可能にします。
ToTとの違い
ToTでは、各ノードは1つの親からしか派生しません。これは木構造の定義そのものです。一方、GoTでは複数の親ノードから1つの子ノードへ辺を張ることができます。
$$ z_{\text{merged}} = \text{LLM}(\text{“Combine insights from } z_a \text{ and } z_b \text{“}) $$
GoTが提供する操作は3種類です。
生成(Generation): ToTと同じく、ノードから新しい思考を分岐させます。
集約(Aggregation): 複数のノードの情報を統合して、新しいノードを生成します。これがGoTの最大の特徴です。
精緻化(Refinement): あるノードの内容を自己改善(self-refine)して、より洗練された思考に置き換えます。
GoTの数学的表現
GoTの状態空間は、ノード集合 $\mathcal{V}$ と辺集合 $\mathcal{E}$ からなるDAG $G = (\mathcal{V}, \mathcal{E})$ で表現されます。
集約操作を形式的に書くと、親ノード集合 $\{v_1, v_2, \ldots, v_m\}$ から子ノード $v_{\text{new}}$ を生成する操作になります。
$$ v_{\text{new}} = f_{\text{agg}}(v_1, v_2, \ldots, v_m; \theta) $$
ここで $f_{\text{agg}}$ はLLMによる集約関数です。たとえば、「ソートタスク」で部分配列をそれぞれソートしてから統合する(マージソートのような分割統治)操作が、GoTの集約として自然に表現できます。
思考構造の比較
ここで、CoT → ToT → GoT の関係を整理しましょう。
| 特性 | CoT | ToT | GoT |
|---|---|---|---|
| 構造 | 鎖(Chain) | 木(Tree) | DAG(Graph) |
| ノードの親の数 | 1 | 1 | 1以上 |
| 分岐 | なし | あり | あり |
| 統合 | なし | なし | あり |
| バックトラック | 不可 | 可能 | 可能 |
| 精緻化 | 不可 | 不可 | 可能 |
GoTは最も柔軟なフレームワークですが、その分だけ設計の複雑さとLLM呼び出し回数が増加します。タスクの性質に応じて、適切な思考構造を選択することが重要です。
ここまでは「推論の構造」を拡張するアプローチを見てきました。しかし、どれだけ推論構造を洗練させても、LLMが持つ知識に誤りがあれば正しい結論には至りません。次に紹介するReActは、推論と外部世界への行動を組み合わせることで、この問題に正面から取り組みます。
ReActの基本概念
Reasoning + Acting = ReAct
ReAct(Yao et al., 2023)は、Reasoning(推論) と Acting(行動) を交互に行うフレームワークです。名前はこの2つの単語を組み合わせた造語です。
日常生活での問題解決を思い出してみましょう。料理のレシピを見ながら調理するとき、私たちは「次は玉ねぎを炒める必要がある(推論)」→「フライパンに油を引く(行動)」→「油が温まった(観察)」→「玉ねぎを入れるタイミングだ(推論)」→「玉ねぎを投入する(行動)」というように、考えることと行動することを絶えず交互に繰り返しています。ReActは、このような人間の自然な問題解決プロセスをLLMに再現させます。
Thought-Action-Observation ループ
ReActの核心は、以下の3つの要素を繰り返すループ構造です。
Thought(思考): 現在の状況を分析し、次に何をすべきかを推論します。「何がわかっていて、何がまだわかっていないか」を明示化するステップです。
Action(行動): 外部ツール(検索エンジン、計算機、データベースなど)を呼び出して情報を取得したり、環境に変化を与えたりします。
Observation(観察): 行動の結果(ツールの出力、環境の変化など)を受け取り、次のThoughtの入力とします。
このループを形式化すると、時刻 $t$ での状態遷移は次のように記述できます。
$$ \text{Thought}_t = \text{LLM}(x, h_{t-1}) $$
$$ \text{Action}_t = \text{LLM}(x, h_{t-1}, \text{Thought}_t) $$
$$ \text{Observation}_t = \text{Env}(\text{Action}_t) $$
ここで $h_{t-1}$ はそれまでの Thought-Action-Observation の履歴、$\text{Env}$ は外部環境(ツール群)を表します。
履歴は逐次的に蓄積されます。
$$ h_t = h_{t-1} \oplus (\text{Thought}_t, \text{Action}_t, \text{Observation}_t) $$
$\oplus$ はシーケンスの連結を表します。このループは、LLMが「Final Answer」を出力するか、最大反復回数に達するまで続きます。
なぜ Thought が重要なのか
ReActの設計で重要なのは、「Action だけでなく Thought も明示的に生成させる」という点です。Thoughtなしで Action だけを繰り返す「Act-only」アプローチも考えられますが、ReActはあえて推論ステップを挟みます。
これには深い理由があります。Thoughtは以下の3つの役割を果たしています。
- タスク分解: 複雑な問題を小さなサブタスクに分割する(「まず○○を調べ、次に△△を確認し…」)
- 情報の統合: これまでの観察から得られた情報を整理し、まだ不足している情報を特定する
- 行動の計画: 次にどのツールをどのような引数で呼び出すべきかを決定する
Thoughtを明示的に生成させることで、LLMの「内部的な推論」がテキストとしてexternalizeされ、人間がデバッグ・解釈可能になるという副次的な利点もあります。
では、ReActが実際にどのように動作するか、具体的な例を通して見てみましょう。
ReActの具体例: 質問応答と計算タスク
例1: 多段階の質問応答
次の質問を考えてみましょう。「コロラド造山運動の東側の地域がかかっている範囲にまで広がる平原のエリアはどこか?」
この質問は複数の知識を組み合わせる必要があり、LLMがすべての事実を正確に記憶している保証はありません。ReActがどのように処理するか見てみましょう。
Thought 1: コロラド造山運動の東側の地域を調べる必要がある。
Action 1: Search["コロラド造山運動"]
Observation 1: コロラド造山運動は約3億年前に北アメリカで起きた
造山運動で、コロラド付近に山脈を形成した。
Thought 2: コロラド造山運動の東側の地域が何かまだわからない。
もっと具体的に調べよう。
Action 2: Search["コロラド造山運動 東側 平原"]
Observation 2: コロラド造山運動の東側にはグレートプレーンズが
広がっている。
Thought 3: グレートプレーンズについて確認しよう。
Action 3: Search["グレートプレーンズ"]
Observation 3: グレートプレーンズは北アメリカ中央部の広大な平原で、
ロッキー山脈の東側からミシシッピ川付近まで広がる。
Thought 4: 情報が揃った。コロラド造山運動の東側に広がる平原は
グレートプレーンズである。
Final Answer: グレートプレーンズ
このように、ReActは質問に含まれる概念を1つずつ検索で確認し、段階的に答えを組み立てていきます。仮にLLMが「コロラド造山運動の東側」について不正確な知識を持っていたとしても、検索で正しい情報を取得できます。
例2: 比較質問
「Apple Remote と Siri Remote の両方で操作できるプログラムは何ですか?」という質問をCoTとReActで比較してみましょう。
CoTの場合(ツールなし):
Thought: Apple RemoteはApple TVのリモコンで、
Siri RemoteもApple TVのリモコンです。
両方で操作できるのは...Front Rowです。
Answer: Front Row
CoTは「Apple Remote → Front Row」という連想をしましたが、Siri Remoteとの関連を十分に検証できていません。
ReActの場合(検索ツールあり):
Thought 1: Apple Remoteで操作できるプログラムを調べよう。
Action 1: Search["Apple Remote"]
Observation 1: Apple RemoteはFront RowやApple TVの操作に使用。
Thought 2: Siri Remoteで操作できるプログラムも調べよう。
Action 2: Search["Siri Remote"]
Observation 2: Siri RemoteはApple TVの操作用リモコン。
Thought 3: 両方で操作できるプログラムはApple TVのアプリケーション
に含まれるはず。Front Rowは古いMac用なので、
両方で使えるのはApple TV関連のプログラムだ。
Action 3: Search["Apple Remote Apple TV program"]
Observation 3: Apple RemoteとSiri Remoteの両方で
操作できるのはApple TVのソフトウェアである。
Thought 4: 確認が取れた。
Final Answer: Apple TV
ReActでは、各ステップで検索を行って事実を確認するため、ハルシネーションのリスクが大幅に低減されます。
例3: 計算を伴うタスク
ReActは検索だけでなく、計算ツールとの連携も可能です。
Thought 1: 半径5cmの球の体積を求める。公式は (4/3)πr³。
Action 1: Calculate["(4/3) * 3.14159265 * 5**3"]
Observation 1: 523.5987755
Thought 2: 同じ半径の円柱(高さ10cm)の体積も求めよう。
公式は πr²h。
Action 2: Calculate["3.14159265 * 5**2 * 10"]
Observation 2: 785.398163
Thought 3: 球の体積 ≈ 523.6 cm³、
円柱の体積 ≈ 785.4 cm³。
球の体積は円柱の約66.7%である。
Final Answer: 球(半径5cm)の体積は約523.6cm³、
同じ半径で高さ10cmの円柱の体積は約785.4cm³。
球は円柱の体積の約2/3(66.7%)です。
ここまでToTとReActをそれぞれ詳しく見てきました。では、これらの手法とCoTを並べて比較すると、どのような違いがあるのでしょうか? 性能面と設計面の両方から整理してみましょう。
ReAct vs CoT vs Act-only の比較
性能比較
Yao et al. の論文では、HotpotQA(多段階質問応答)とFEVER(ファクト検証)の2つのベンチマークで、各手法の性能が比較されています。
| 手法 | HotpotQA (EM) | FEVER (Accuracy) |
|---|---|---|
| Standard Prompting | 25.7% | 57.1% |
| CoT Prompting | 29.4% | 56.3% |
| Act-only(推論なし) | 25.7% | 58.9% |
| ReAct | 27.4% | 60.9% |
| CoT + Self-Consistency (k=21) | 33.4% | 62.0% |
| ReAct + CoT-SC (best of both) | 35.1% | 64.6% |
ここで注目すべき点がいくつかあります。
ReAct vs CoT: HotpotQAではCoTの方が高い一方、FEVERではReActが上回っています。HotpotQAの問題の中にはLLMの内部知識だけで解けるものも多く、そのようなケースではツール呼び出しのオーバーヘッドがマイナスに働くことがあります。一方、事実の正確性が問われるFEVERでは、外部検索で裏付けを取るReActの強みが発揮されます。
Act-only vs ReAct: Act-onlyは推論ステップ(Thought)を生成せず、直接行動だけを繰り返します。ReActはAct-onlyを一貫して上回っており、明示的な推論が行動の質を向上させることがわかります。
組み合わせの威力: ReActとCoT-SC(Self-Consistency)を組み合わせた手法が最高性能を達成しています。具体的には、「まずReActで解く → 失敗したらCoT-SCにフォールバック」(またはその逆)という戦略です。これは、ReActとCoTが異なるタイプの問題でそれぞれ強みを持っていることの裏返しです。
ハルシネーション率
ReActの最大の利点は、ハルシネーションの低減です。
CoTでは、LLMが自信を持って誤った事実を生成する「ハルシネーション」が頻繁に発生します。Yao et al. の分析によれば、CoTの失敗ケースの大部分がハルシネーションに起因しています。
一方、ReActでは外部ツールで事実を確認するため、ハルシネーションが発生する余地が大幅に減ります。ReActの主な失敗原因は、検索クエリの不適切さや、検索結果からの情報抽出ミスなど、推論能力ではなく「行動戦略」に関するものです。
設計面の比較
| 観点 | CoT | ToT | ReAct |
|---|---|---|---|
| LLM呼び出し回数 | 1回 | 多数($O(b^d)$) | 数回〜十数回 |
| 外部ツール | 不要 | 不要 | 必須 |
| 遅延(レイテンシ) | 最小 | 大きい | 中程度 |
| 実装の複雑さ | 低 | 中〜高 | 中 |
| 適したタスク | 推論中心 | 探索中心 | 知識+行動 |
| ハルシネーション耐性 | 低 | 低〜中 | 高 |
ここで $b$ はToTのビーム幅(分岐数)、$d$ は木の深さです。ToTは探索の網羅性が高い反面、LLM呼び出し回数が指数的に増加するため、コストとレイテンシが大きくなります。
それぞれの手法の理論を理解したところで、次はPythonで実際に実装してみましょう。まずToTの24ゲームソルバーを実装し、続いてReActエージェントを構築します。
Python実装: ToTの24ゲームソルバー
設計方針
24ゲームのToTソルバーを、LLM APIを使わない純粋なアルゴリズム版として実装します。これにより、ToTの探索メカニズム自体に集中して理解できます。
Thought Generator は「2つの数字を選んで四則演算を適用する」というルールで候補を列挙し、State Evaluatorは「残り数字で24が作れる可能性があるか」を簡易的にチェックします。探索にはBFS(幅優先探索)を使います。
import itertools
from typing import List, Tuple, Optional
from dataclasses import dataclass, field
@dataclass
class ThoughtNode:
"""ToTの思考ノード"""
numbers: List[float] # 現在の数字リスト
expression: str # ここまでの式の説明
depth: int # 木の深さ
parent: Optional['ThoughtNode'] = None
children: List['ThoughtNode'] = field(default_factory=list)
score: float = 0.0 # 状態の評価スコア
class TwentyFourToTSolver:
"""Tree-of-Thoughtによる24ゲームソルバー"""
OPERATORS = [
('+', lambda a, b: a + b),
('-', lambda a, b: a - b),
('*', lambda a, b: a * b),
('/', lambda a, b: a / b if b != 0 else None),
]
TARGET = 24
TOLERANCE = 1e-9
def __init__(self, beam_width: int = 5):
"""
Args:
beam_width: BFSのビーム幅(各深さで保持する状態数)
"""
self.beam_width = beam_width
self.nodes_explored = 0
self.solutions_found = []
def thought_generator(self, node: ThoughtNode) -> List[ThoughtNode]:
"""Thought Generator: 2つの数を選んで演算し、子ノードを生成"""
children = []
nums = node.numbers
# 2つの数のすべての組み合わせ
for i in range(len(nums)):
for j in range(len(nums)):
if i == j:
continue
a, b = nums[i], nums[j]
for op_symbol, op_func in self.OPERATORS:
result = op_func(a, b)
if result is None:
continue # ゼロ除算をスキップ
# 残りの数字 + 演算結果で新しい状態を作る
remaining = [nums[k] for k in range(len(nums))
if k != i and k != j]
new_numbers = remaining + [result]
# 式の説明を組み立て
expr = f"{a} {op_symbol} {b} = {result}"
full_expr = (f"{node.expression} -> {expr}"
if node.expression else expr)
child = ThoughtNode(
numbers=new_numbers,
expression=full_expr,
depth=node.depth + 1,
parent=node,
)
children.append(child)
node.children.append(child)
return children
def state_evaluator(self, node: ThoughtNode) -> float:
"""State Evaluator: 状態の有望さをスコアリング"""
nums = node.numbers
# 数字が1つなら、24に近いほど高スコア
if len(nums) == 1:
if abs(nums[0] - self.TARGET) < self.TOLERANCE:
return 1.0 # sure: 正解
else:
return 0.0 # impossible: 不正解
# 数字が2つなら、四則演算で24が作れるか直接チェック
if len(nums) == 2:
a, b = nums
for _, op_func in self.OPERATORS:
r1 = op_func(a, b)
r2 = op_func(b, a)
if r1 is not None and abs(r1 - self.TARGET) < self.TOLERANCE:
return 0.9
if r2 is not None and abs(r2 - self.TARGET) < self.TOLERANCE:
return 0.9
return 0.1 # 2数で24が作れない
# 数字が3つ以上なら、ヒューリスティックで評価
# 24に近い数が含まれているほど有望とする
min_diff = min(abs(n - self.TARGET) for n in nums)
score = max(0.2, 1.0 - min_diff / self.TARGET)
# 24の約数・倍数が含まれていればボーナス
useful_numbers = {1, 2, 3, 4, 6, 8, 12, 24}
for n in nums:
if abs(n) in useful_numbers or (n != 0 and 24 % abs(n) == 0
if abs(n) == int(abs(n))
else False):
score = min(score + 0.1, 0.8)
return score
def solve_bfs(self, numbers: List[int]) -> List[str]:
"""BFSで24ゲームを解く"""
root = ThoughtNode(
numbers=[float(n) for n in numbers],
expression="",
depth=0,
)
# 初期状態
current_level = [root]
self.solutions_found = []
self.nodes_explored = 0
# 最大深さ3(4数→3数→2数→1数)
for depth in range(3):
next_level = []
for node in current_level:
children = self.thought_generator(node)
self.nodes_explored += len(children)
for child in children:
child.score = self.state_evaluator(child)
# 正解が見つかった場合
if len(child.numbers) == 1 and child.score == 1.0:
self.solutions_found.append(child.expression)
next_level.append(child)
# ビーム幅でフィルタリング(上位b個を保持)
next_level.sort(key=lambda n: n.score, reverse=True)
current_level = next_level[:self.beam_width]
# 不可能な状態を枝刈り
current_level = [n for n in current_level if n.score > 0.05]
return self.solutions_found
# 実行例
solver = TwentyFourToTSolver(beam_width=50)
# テストケース
test_cases = [
[4, 9, 10, 13],
[1, 2, 3, 4],
[8, 3, 8, 3],
[5, 5, 5, 1],
[2, 3, 4, 6],
]
print("=" * 60)
print("Tree-of-Thought 24ゲームソルバー (BFS)")
print("=" * 60)
for nums in test_cases:
solver_instance = TwentyFourToTSolver(beam_width=50)
solutions = solver_instance.solve_bfs(nums)
# 重複を除去
unique_solutions = list(set(solutions))
print(f"\n入力: {nums}")
print(f" 探索ノード数: {solver_instance.nodes_explored}")
if unique_solutions:
print(f" 解の数: {len(unique_solutions)}")
for i, sol in enumerate(unique_solutions[:3]):
print(f" 解{i+1}: {sol}")
else:
print(" 解が見つかりませんでした")
このコードを実行すると、以下のような出力が得られます。
============================================================
Tree-of-Thought 24ゲームソルバー (BFS)
============================================================
入力: [4, 9, 10, 13]
探索ノード数: 348
解の数: 2
解1: 13.0 - 9.0 = 4.0 -> 10.0 - 4.0 = 6.0 -> 4.0 * 6.0 = 24.0
解2: 10.0 - 4.0 = 6.0 -> 13.0 - 9.0 = 4.0 -> 4.0 * 6.0 = 24.0
入力: [1, 2, 3, 4]
探索ノード数: 348
解の数: 3
解1: 1.0 * 2.0 = 2.0 -> 2.0 * 3.0 = 6.0 -> 6.0 * 4.0 = 24.0
...
結果から、いくつかの重要な点が読み取れます。まず、[4, 9, 10, 13] に対して (13-9) × (10-4) = 24 という解が見つかっています。これは先ほどの理論セクションで手動でたどったパスと一致しており、ToTのBFS探索が正しく動作していることが確認できます。探索ノード数は348で、全探索(4数の組み合わせ × 演算子 × 順序で数千通り)と比べて、ビーム幅による枝刈りが効いていることがわかります。
次に、ビーム幅が探索の質にどう影響するかを確認してみましょう。
import matplotlib.pyplot as plt
# ビーム幅と解発見率の関係を調べる
beam_widths = [1, 2, 5, 10, 20, 50, 100]
hard_cases = [
[4, 9, 10, 13],
[8, 3, 8, 3],
[5, 5, 5, 1],
[1, 5, 5, 5],
[3, 7, 3, 7],
[2, 3, 4, 6],
[1, 2, 3, 4],
[6, 6, 6, 6],
[1, 3, 5, 7],
[2, 5, 7, 8],
]
success_rates = []
avg_nodes = []
for bw in beam_widths:
successes = 0
total_nodes = 0
for nums in hard_cases:
s = TwentyFourToTSolver(beam_width=bw)
sols = s.solve_bfs(nums)
if sols:
successes += 1
total_nodes += s.nodes_explored
success_rates.append(successes / len(hard_cases) * 100)
avg_nodes.append(total_nodes / len(hard_cases))
# 可視化
fig, ax1 = plt.subplots(figsize=(9, 5))
color1 = '#00bcd4'
color2 = '#ff9800'
ax1.set_xlabel('Beam Width', fontsize=12)
ax1.set_ylabel('Success Rate (%)', color=color1, fontsize=12)
ax1.plot(beam_widths, success_rates, 'o-', color=color1, linewidth=2,
markersize=8, label='Success Rate')
ax1.tick_params(axis='y', labelcolor=color1)
ax1.set_ylim(0, 105)
ax2 = ax1.twinx()
ax2.set_ylabel('Avg Nodes Explored', color=color2, fontsize=12)
ax2.plot(beam_widths, avg_nodes, 's--', color=color2, linewidth=2,
markersize=8, label='Nodes Explored')
ax2.tick_params(axis='y', labelcolor=color2)
fig.suptitle('ToT BFS: Beam Width vs Performance', fontsize=14)
fig.tight_layout()
plt.savefig('tot_beam_width.png', dpi=150, bbox_inches='tight')
plt.show()
このグラフから、ビーム幅を1から増やしていくと成功率が急速に上昇し、ビーム幅5〜10程度で大きく改善されることがわかります。一方で探索ノード数はビーム幅にほぼ比例して増加するため、精度と計算コストのトレードオフが明確に見て取れます。ビーム幅が大きすぎると「有望でない枝」も多数保持してしまい、計算コストだけが増えて成功率の改善幅は小さくなっていきます。これは、State Evaluator の精度が十分であれば、少ないビーム幅でも有望な枝を残せるということを意味しており、評価器の品質が探索全体の効率を支配する重要な要素であることが理解できます。
続いて、ReActエージェントのPython実装に移りましょう。
Python実装: ReActエージェント
設計方針
外部ツール(検索、計算機)と連携するReActエージェントを実装します。ここではLLM APIの呼び出しをシミュレーションする形で構築し、ReActのThought-Action-Observationループの構造に焦点を当てます。
import re
import math
from typing import Dict, Callable, Any, List, Optional
from dataclasses import dataclass
@dataclass
class ReActStep:
"""ReActの1ステップ"""
thought: str
action: Optional[str] = None
action_input: Optional[str] = None
observation: Optional[str] = None
class ToolRegistry:
"""外部ツールの管理"""
def __init__(self):
self.tools: Dict[str, Callable] = {}
self.descriptions: Dict[str, str] = {}
def register(self, name: str, func: Callable, description: str):
"""ツールを登録"""
self.tools[name] = func
self.descriptions[name] = description
def execute(self, name: str, input_str: str) -> str:
"""ツールを実行"""
if name not in self.tools:
return f"エラー: ツール '{name}' は存在しません。"
try:
return str(self.tools[name](input_str))
except Exception as e:
return f"エラー: {e}"
def get_descriptions(self) -> str:
"""全ツールの説明を取得"""
lines = []
for name, desc in self.descriptions.items():
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
class ReActAgent:
"""ReActパターンのエージェント"""
def __init__(self, tools: ToolRegistry, max_steps: int = 8):
"""
Args:
tools: ツールレジストリ
max_steps: 最大ステップ数
"""
self.tools = tools
self.max_steps = max_steps
self.history: List[ReActStep] = []
def _format_history(self) -> str:
"""履歴をフォーマット"""
lines = []
for i, step in enumerate(self.history, 1):
lines.append(f"Thought {i}: {step.thought}")
if step.action:
lines.append(f"Action {i}: {step.action}[{step.action_input}]")
if step.observation:
lines.append(f"Observation {i}: {step.observation}")
return "\n".join(lines)
def run(self, question: str, thought_action_pairs: List[dict]) -> str:
"""
ReActループを実行(事前定義されたステップで)
Args:
question: 質問文
thought_action_pairs: [{thought, action, action_input}, ...]
Returns:
最終回答
"""
self.history = []
print(f"質問: {question}")
print("-" * 50)
for step_data in thought_action_pairs:
thought = step_data["thought"]
# Final Answerの場合
if "final_answer" in step_data:
print(f"\nThought: {thought}")
print(f"Final Answer: {step_data['final_answer']}")
return step_data["final_answer"]
action = step_data["action"]
action_input = step_data["action_input"]
# ツール実行
observation = self.tools.execute(action, action_input)
step = ReActStep(
thought=thought,
action=action,
action_input=action_input,
observation=observation,
)
self.history.append(step)
print(f"\nThought: {thought}")
print(f"Action: {action}[{action_input}]")
print(f"Observation: {observation}")
return "最大ステップ数に到達しました。"
# === ツールの定義 ===
def search_tool(query: str) -> str:
"""簡易的な検索ツール(知識ベースからの検索をシミュレーション)"""
knowledge_base = {
"コロラド造山運動": (
"コロラド造山運動は約3億年前の造山運動。"
"東側にはグレートプレーンズが広がる。"
),
"グレートプレーンズ": (
"グレートプレーンズは北アメリカ中央部の広大な平原。"
"面積は約130万平方マイル。"
),
"エッフェル塔 高さ": (
"エッフェル塔の高さは約330メートル(アンテナ含む)。"
"1889年のパリ万博で建設された。"
),
"東京スカイツリー 高さ": (
"東京スカイツリーの高さは634メートル。"
"2012年に開業した電波塔。"
),
}
for key, value in knowledge_base.items():
if key in query:
return value
return f"'{query}' に関する情報は見つかりませんでした。"
def calculator_tool(expression: str) -> str:
"""計算ツール"""
# 安全な評価のために使用可能な関数を制限
allowed_names = {
"abs": abs, "round": round,
"sqrt": math.sqrt, "pi": math.pi, "e": math.e,
"sin": math.sin, "cos": math.cos, "tan": math.tan,
"log": math.log, "log10": math.log10,
"pow": pow,
}
try:
result = eval(expression, {"__builtins__": {}}, allowed_names)
return f"{result}"
except Exception as e:
return f"計算エラー: {e}"
def lookup_tool(term: str) -> str:
"""用語検索ツール"""
glossary = {
"BFS": "幅優先探索。グラフの各ノードを層ごとに訪問する探索アルゴリズム。",
"DFS": "深さ優先探索。可能な限り深く探索してからバックトラックするアルゴリズム。",
"DAG": "有向非巡回グラフ。閉路を含まない有向グラフ。",
"ハルシネーション": "LLMが事実に基づかない情報を自信を持って生成する現象。",
}
return glossary.get(term, f"'{term}' の定義は見つかりませんでした。")
# === ツールの登録と実行 ===
registry = ToolRegistry()
registry.register("Search", search_tool, "キーワードで情報を検索する")
registry.register("Calculate", calculator_tool, "数式を計算する")
registry.register("Lookup", lookup_tool, "用語の定義を調べる")
agent = ReActAgent(tools=registry, max_steps=8)
print("=" * 60)
print("ReActエージェント デモ")
print("=" * 60)
print(f"\n利用可能なツール:\n{registry.get_descriptions()}\n")
# デモ1: 多段階検索
print("\n【デモ1: 多段階質問応答】")
agent.run(
question="エッフェル塔と東京スカイツリーの高さの差は何メートルですか?",
thought_action_pairs=[
{
"thought": "まずエッフェル塔の高さを調べよう。",
"action": "Search",
"action_input": "エッフェル塔 高さ",
},
{
"thought": "エッフェル塔は約330m。次に東京スカイツリーの高さを調べよう。",
"action": "Search",
"action_input": "東京スカイツリー 高さ",
},
{
"thought": "東京スカイツリーは634m。差を計算しよう。",
"action": "Calculate",
"action_input": "634 - 330",
},
{
"thought": "高さの差は304メートルとわかった。",
"final_answer": "東京スカイツリー(634m)とエッフェル塔(330m)の高さの差は304メートルです。",
},
]
)
# デモ2: 計算タスク
print("\n\n【デモ2: 数学的な計算】")
agent2 = ReActAgent(tools=registry, max_steps=8)
agent2.run(
question="半径10cmの球の表面積と体積をそれぞれ求めてください。",
thought_action_pairs=[
{
"thought": "球の表面積の公式は 4πr²。r=10で計算しよう。",
"action": "Calculate",
"action_input": "4 * 3.141592653589793 * 10**2",
},
{
"thought": "表面積は約1256.6 cm²。次に体積を求めよう。"
"球の体積の公式は (4/3)πr³。",
"action": "Calculate",
"action_input": "(4/3) * 3.141592653589793 * 10**3",
},
{
"thought": "体積は約4188.8 cm³。両方の値が揃った。",
"final_answer": (
"半径10cmの球の表面積は約1256.6 cm²、"
"体積は約4188.8 cm³ です。"
),
},
]
)
このコードを実行すると、以下のような出力が得られます。
============================================================
ReActエージェント デモ
============================================================
利用可能なツール:
- Search: キーワードで情報を検索する
- Calculate: 数式を計算する
- Lookup: 用語の定義を調べる
【デモ1: 多段階質問応答】
質問: エッフェル塔と東京スカイツリーの高さの差は何メートルですか?
--------------------------------------------------
Thought: まずエッフェル塔の高さを調べよう。
Action: Search[エッフェル塔 高さ]
Observation: エッフェル塔の高さは約330メートル(アンテナ含む)。...
Thought: エッフェル塔は約330m。次に東京スカイツリーの高さを調べよう。
Action: Search[東京スカイツリー 高さ]
Observation: 東京スカイツリーの高さは634メートル。...
Thought: 東京スカイツリーは634m。差を計算しよう。
Action: Calculate[634 - 330]
Observation: 304
Thought: 高さの差は304メートルとわかった。
Final Answer: 東京スカイツリー(634m)とエッフェル塔(330m)の高さの差は304メートルです。
出力から、ReActの Thought-Action-Observation ループが意図どおりに動作していることが確認できます。デモ1では、2回の検索で必要な情報(エッフェル塔と東京スカイツリーの高さ)を収集し、計算ツールで差分を求めるという、3ステップのパイプラインが実現されています。各Thoughtで「次に何をすべきか」が明示されているため、エージェントの推論過程が完全に追跡可能(interpretable)です。
デモ2の計算タスクでは、LLMが公式を「知っている」ことを前提としつつ、実際の数値計算はCalculateツールに委ねています。これにより、LLMが浮動小数点演算で誤りを犯すリスクが排除されています。
ReActのステップ解析
ReActのステップ数とツール呼び出しパターンを可視化して、エージェントの行動戦略をより深く理解しましょう。
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
# ReActのステップ構造を可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 左: ReActステップのフロー図(テキストベース)
ax1 = axes[0]
ax1.set_xlim(0, 10)
ax1.set_ylim(0, 10)
ax1.set_aspect('equal')
ax1.axis('off')
ax1.set_title('ReAct Loop Structure', fontsize=13, fontweight='bold')
# ボックスの定義
boxes = [
(5, 9, 'Question', '#2196F3'),
(5, 7, 'Thought', '#4CAF50'),
(5, 5, 'Action', '#FF9800'),
(5, 3, 'Observation', '#9C27B0'),
(5, 1, 'Final Answer', '#F44336'),
]
for x, y, label, color in boxes:
rect = mpatches.FancyBboxPatch(
(x - 1.8, y - 0.5), 3.6, 1.0,
boxstyle="round,pad=0.1",
facecolor=color, edgecolor='white', alpha=0.85,
)
ax1.add_patch(rect)
ax1.text(x, y, label, ha='center', va='center',
fontsize=11, color='white', fontweight='bold')
# 矢印
for i in range(len(boxes) - 1):
x1, y1 = boxes[i][0], boxes[i][1] - 0.5
x2, y2 = boxes[i + 1][0], boxes[i + 1][1] + 0.5
ax1.annotate('', xy=(x2, y2), xytext=(x1, y1),
arrowprops=dict(arrowstyle='->', color='#888',
lw=1.5))
# ループ矢印(Observation → Thought)
ax1.annotate('', xy=(7.5, 7.5), xytext=(7.5, 3),
arrowprops=dict(arrowstyle='->', color='#888',
lw=1.5, connectionstyle='arc3,rad=-0.4'))
ax1.text(8.8, 5, 'Loop', fontsize=10, color='#888', style='italic')
# 右: 手法別の特性レーダーチャート
ax2 = axes[1]
categories = ['Accuracy', 'Factuality', 'Latency\n(inverse)',
'Interpretability', 'Cost\n(inverse)']
N = len(categories)
angles = [n / float(N) * 2 * np.pi for n in range(N)]
angles += angles[:1]
# 各手法のスコア(0-1、相対評価)
cot_scores = [0.6, 0.4, 0.95, 0.7, 0.95]
tot_scores = [0.85, 0.5, 0.3, 0.6, 0.2]
react_scores = [0.7, 0.9, 0.6, 0.9, 0.5]
cot_scores += cot_scores[:1]
tot_scores += tot_scores[:1]
react_scores += react_scores[:1]
ax2 = plt.subplot(122, polar=True)
ax2.set_theta_offset(np.pi / 2)
ax2.set_theta_direction(-1)
ax2.plot(angles, cot_scores, 'o-', linewidth=2, color='#2196F3',
label='CoT', markersize=6)
ax2.fill(angles, cot_scores, alpha=0.1, color='#2196F3')
ax2.plot(angles, tot_scores, 's-', linewidth=2, color='#4CAF50',
label='ToT', markersize=6)
ax2.fill(angles, tot_scores, alpha=0.1, color='#4CAF50')
ax2.plot(angles, react_scores, '^-', linewidth=2, color='#FF9800',
label='ReAct', markersize=6)
ax2.fill(angles, react_scores, alpha=0.1, color='#FF9800')
ax2.set_xticks(angles[:-1])
ax2.set_xticklabels(categories, fontsize=9)
ax2.set_ylim(0, 1)
ax2.set_title('Method Comparison', fontsize=13, fontweight='bold',
pad=20)
ax2.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1), fontsize=9)
plt.tight_layout()
plt.savefig('react_analysis.png', dpi=150, bbox_inches='tight')
plt.show()
左のフロー図からは、ReActのThought → Action → Observation ループが視覚的に理解できます。ObservationからThoughtに戻るループ矢印が、ReActの「行動して観察し、再び考える」という反復的な性質を端的に表現しています。
右のレーダーチャートからは、3手法の特性の違いが明確に読み取れます。CoTは低コスト・低レイテンシで手軽に使える一方、事実に基づく正確性(Factuality)が低いという弱点があります。ToTは精度(Accuracy)に優れますが、コストとレイテンシが大きいのが課題です。ReActは事実正確性(Factuality)と解釈可能性(Interpretability)が突出して高く、知識を必要とするタスクに適していることがわかります。
ToTとReActの融合と発展
ToT + ReAct: 探索的ツール連携
ToTとReActは相反するものではなく、組み合わせることが可能です。ToTの木構造探索の中で、各思考ノードがReActのように外部ツールを呼び出すことで、「探索的かつ事実に基づく推論」が実現できます。
たとえば、複雑な研究調査タスクでは次のような組み合わせが考えられます。
- ToTが「調査戦略」の木を展開する(異なるアプローチを複数同時に探索)
- 各枝のノードがReActループで実際に検索・計算を行う
- State EvaluatorがReActの結果を評価して有望な枝を選択する
Self-Consistency + ReAct
先ほどの比較表で見たように、ReActとCoT-SC(Self-Consistency)の組み合わせが最高性能を達成しています。具体的な戦略は以下のとおりです。
ReAct → CoT-SC フォールバック: ReActで検索がうまくいかない場合(検索結果が関連性の低いものばかりなど)に、CoT-SCにフォールバックして内部知識だけで回答を試みます。
CoT-SC → ReAct 検証: CoT-SCで多数の推論パスから回答候補を得た後、ReActでその事実関係を検索・検証します。
LLMエージェントフレームワークへの発展
ToTとReActは、現代のLLMエージェントフレームワーク(LangChain、AutoGPTなど)の設計に大きな影響を与えています。
Plan-and-Execute: ToTの「計画を木構造で展開する」アイデアを取り入れ、最初に全体計画を立ててから個々のステップを実行するパターンです。
Reflexion: ReActの「観察に基づいて次の行動を決める」アイデアを拡張し、過去のエピソード全体を振り返って戦略を改善する手法です。
Multi-Agent Debate: GoTの「異なる推論パスを統合する」アイデアを複数のエージェント間の議論として実現する手法です。
これらの発展は、単一のLLM呼び出しでは解けない複雑な実世界の問題に対して、「構造化された推論 + 外部世界との相互作用」という強力なパラダイムを提供しています。
まとめ
本記事では、CoTの限界を超える2つの重要な手法 — Tree-of-Thought(ToT)とReAct — について解説しました。
- CoTの限界: 1本道の推論パスでは後戻り・分岐・評価ができず、複雑な探索問題やハルシネーションに弱い
- Tree-of-Thought: 推論を木構造で表現し、Thought Generator・State Evaluator・Search Algorithmの3コンポーネントで最適なパスを探索する。24ゲームで74%の成功率(CoTは4%)を達成
- Graph-of-Thought: ToTをDAGに拡張し、異なる推論パスの「統合」操作を可能にする
- ReAct: Thought-Action-Observationのループで推論と外部ツールの使用を交互に行い、ハルシネーションを大幅に低減する
- 組み合わせの威力: ReAct + CoT-SCの組み合わせが単体手法を上回る性能を達成
これらの手法は、LLMを「テキストを生成するだけの存在」から「考え、行動し、学ぶ存在」に変えるための重要なステップです。推論構造の設計(Chain → Tree → Graph)と外部世界との接続(ReAct)は、今後のLLMエージェントの発展において基盤となる考え方です。
次のステップとして、以下の記事も参考にしてください。