2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ReActとPlan-Executeを実装して比較する — タスク特性ごとの向き不向き【TypeScript】

2
Posted at

はじめに:1人会社で「エージェントの型」を間違えると何が起きるか

僕は合同会社ジョインクラスという1人会社で、launchd のジョブ17本を回しながら出版・受託開発・SNS配信をほぼ自動で動かしています。

その運用で何度も痛い目を見たのが、「エージェントにどこまで考えさせるか」の設計ミスです。

  • 営業メールを「状況を見て判断して」と自由に動かした結果、敬語がおかしいメールが取引先に届いた
  • 逆に、Zenn公開を固定手順で組んだら、レートリミットで失敗したときに自力で回復できなかった

前者は「自由すぎた」失敗、後者は「固すぎた」失敗です。この2つはそれぞれ ReAct と Plan-Execute という代表的なエージェントパターンに対応しています。

この記事では両方を TypeScript + Anthropic SDK で最小実装し、同じツールセットで走らせて比較します。最後に、当社の実スクリプトをどちらの型で書いているか、その判断基準も公開します。

この記事でできること

  • ReAct ループと Plan-Execute ループを、それぞれ100行程度で実装する
  • 同じタスクを両方で実行し、APIコール数・トークン数・成功/失敗をログで比較する
  • タスク特性(手順が既知か / 途中で分岐するか / 副作用があるか)から型を選べるようになる

2つのパターンの違い

ReAct Plan-Execute
ループ 考える→ツール実行→観察→考える…を1ステップずつ 最初に計画を全部立てる→順に実行
LLM呼び出し ステップごと(多い) 計画1回+必要時の再計画(少ない)
強い場面 手順が事前に分からない調査・デバッグ 手順が既知の定型処理・バッチ
弱い場面 長いタスクで脱線・ループ・コスト増 想定外の結果に気づきにくい
監査しやすさ 低い(実行中に決まる) 高い(計画を事前にレビューできる)

最後の「監査しやすさ」が、実運用では一番効いてきます。後述します。

環境準備

mkdir agent-patterns && cd agent-patterns
npm init -y
npm i @anthropic-ai/sdk zod
npm i -D typescript tsx @types/node
export ANTHROPIC_API_KEY=sk-ant-...

共通:ツール定義

比較を公平にするため、両パターンで同じツールを使います。題材は当社の実務に合わせて「ログファイルを調べて、失敗しているジョブを特定し、レポートを書く」です。

tools.ts
import { readFile, readdir, writeFile } from "node:fs/promises";
import type Anthropic from "@anthropic-ai/sdk";

export const LOG_DIR = process.env.LOG_DIR ?? "./logs";

export const toolDefs: Anthropic.Tool[] = [
  {
    name: "list_logs",
    description: "ログディレクトリ内のファイル名一覧を返す",
    input_schema: { type: "object", properties: {} },
  },
  {
    name: "read_log",
    description: "ログファイルの末尾N行を返す",
    input_schema: {
      type: "object",
      properties: {
        file: { type: "string" },
        lines: { type: "number", default: 50 },
      },
      required: ["file"],
    },
  },
  {
    name: "write_report",
    description: "Markdownレポートを reports/ に書き出す(副作用あり)",
    input_schema: {
      type: "object",
      properties: { name: { type: "string" }, body: { type: "string" } },
      required: ["name", "body"],
    },
  },
];

export async function runTool(name: string, input: any): Promise<string> {
  switch (name) {
    case "list_logs":
      return (await readdir(LOG_DIR)).join("\n");
    case "read_log": {
      // パストラバーサル対策:ファイル名だけを受け付ける
      if (input.file.includes("/")) return "ERROR: file名のみ指定してください";
      const text = await readFile(`${LOG_DIR}/${input.file}`, "utf8");
      return text.split("\n").slice(-(input.lines ?? 50)).join("\n");
    }
    case "write_report":
      await writeFile(`reports/${input.name.replace(/[^\w.-]/g, "_")}`, input.body);
      return "OK";
    default:
      return `ERROR: unknown tool ${name}`;
  }
}

read_log の / チェックは省略しないでください。ReAct はモデルが自由に引数を組み立てるので、../../.env を読みに行く可能性が普通にあります。当社でも「絶対禁止リスト」を作るきっかけになったのは、AIが git push --force を自分で選んで実行した事故でした。ツール側でガードするのが鉄則です。

実装1:ReAct

ReAct は Anthropic の tool use がそのまま対応しています。stop_reason が tool_use の間ループし、観察結果を tool_result として返すだけです。

react.ts
import Anthropic from "@anthropic-ai/sdk";
import { toolDefs, runTool } from "./tools";

const client = new Anthropic();
const MODEL = "claude-sonnet-5-5";
const MAX_STEPS = 15; // 無限ループ防止。これがないと必ずいつか事故る

export async function react(task: string) {
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: task }];
  const stats = { calls: 0, inputTokens: 0, outputTokens: 0 };

  for (let step = 0; step < MAX_STEPS; step++) {
    const res = await client.messages.create({
      model: MODEL,
      max_tokens: 2048,
      system: "あなたは運用エンジニアです。ツールで事実を確認してから結論を出してください。",
      tools: toolDefs,
      messages,
    });
    stats.calls++;
    stats.inputTokens += res.usage.input_tokens;
    stats.outputTokens += res.usage.output_tokens;

    messages.push({ role: "assistant", content: res.content });
    if (res.stop_reason !== "tool_use") return { stats, final: res.content };

    const results: Anthropic.ToolResultBlockParam[] = [];
    for (const block of res.content) {
      if (block.type !== "tool_use") continue;
      console.log(`[react:${step}] ${block.name}`, JSON.stringify(block.input));
      results.push({
        type: "tool_result",
        tool_use_id: block.id,
        content: await runTool(block.name, block.input),
      });
    }
    messages.push({ role: "user", content: results });
  }
  throw new Error(`MAX_STEPS(${MAX_STEPS}) に到達。stats=${JSON.stringify(stats)}`);
}

ポイントは2つです。

  1. MAX_STEPS は必須。 ログが巨大だと「もう少し読む」を繰り返します
  2. 入力トークンはステップごとに累積します。 会話履歴を毎回全部送るので、ステップ数 n に対しておおむね n² で効いてきます

実装2:Plan-Execute

Plan-Execute は「計画を構造化データで出させる」のがキモです。自由文の計画は実行側でパースできず、結局 ReAct に戻ってしまいます。zod でスキーマを固定します。

plan-execute.ts
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { toolDefs, runTool } from "./tools";

const client = new Anthropic();
const MODEL = "claude-sonnet-5-5";

const Plan = z.object({
  steps: z.array(z.object({
    tool: z.enum(["list_logs", "read_log", "write_report"]),
    input: z.record(z.any()),
    why: z.string(),
  })).max(10),
});
type Plan = z.infer<typeof Plan>;

async function ask(system: string, user: string, stats: any) {
  const res = await client.messages.create({
    model: MODEL, max_tokens: 4096, system,
    messages: [{ role: "user", content: user }],
  });
  stats.calls++;
  stats.inputTokens += res.usage.input_tokens;
  stats.outputTokens += res.usage.output_tokens;
  const text = res.content.find((b) => b.type === "text")?.text ?? "";
  return text.replace(/^```json\n?|```$/gm, "").trim();
}

async function makePlan(task: string, context: string, stats: any): Promise<Plan> {
  const toolsDesc = toolDefs.map((t) => `- ${t.name}: ${t.description}`).join("\n");
  const json = await ask(
    `次のツールだけを使う実行計画をJSONで出力してください。説明文は不要。\n${toolsDesc}\n` +
    `形式: {"steps":[{"tool":"...","input":{...},"why":"..."}]}`,
    `タスク: ${task}\n\nこれまでの観察:\n${context || "(なし)"}`,
    stats,
  );
  return Plan.parse(JSON.parse(json)); // スキーマ違反はここで例外にする
}

export async function planExecute(task: string, maxReplans = 2) {
  const stats = { calls: 0, inputTokens: 0, outputTokens: 0, replans: 0 };
  let observations = "";

  for (let round = 0; round <= maxReplans; round++) {
    const plan = await makePlan(task, observations, stats);
    console.log(`[plan:${round}]`, plan.steps.map((s) => s.tool).join(" -> "));

    let needReplan = false;
    for (const step of plan.steps) {
      const out = await runTool(step.tool, step.input);
      observations += `\n## ${step.tool} ${JSON.stringify(step.input)}\n${out.slice(0, 2000)}`;
      if (out.startsWith("ERROR")) { needReplan = true; break; } // 失敗時だけ再計画
    }
    if (!needReplan) return { stats, observations };
    stats.replans++;
  }
  throw new Error(`再計画上限に到達。stats=${JSON.stringify(stats)}`);
}

ここで落とし穴があります。「ログを読んでから、内容に応じてレポートを書く」というタスクは、計画時点でレポート本文が決められません。 素直にやると、モデルは write_report の body に「(ログ確認後に記載)」のようなプレースホルダを入れてきます。

対策は2通りあります。

  • 計画を「収集フェーズ」と「生成フェーズ」に分け、最後に観察結果を渡して本文だけ生成させる
  • write_report を計画から外し、Execute 完了後に1回だけ要約コールを挟む

当社は後者で運用しています。「副作用のあるツールは計画に入れず、最後に1回だけ人間が見える形で呼ぶ」 という形にすると、draft→承認→実行のパイプラインにそのまま乗せられるからです。

比較用ハーネス

bench.ts
import { react } from "./react";
import { planExecute } from "./plan-execute";

const tasks = {
  known: "auto-tasks.log の末尾50行を読み、ERROR行の件数を数えてください",
  unknown: "logs/ 配下で直近に失敗しているジョブを特定し、原因の仮説を述べてください",
};

for (const [label, task] of Object.entries(tasks)) {
  for (const [name, fn] of [["react", react], ["plan", planExecute]] as const) {
    const t0 = Date.now();
    try {
      const { stats } = await fn(task);
      console.table({ label, name, ms: Date.now() - t0, ...stats });
    } catch (e) {
      console.log(label, name, "FAILED", (e as Error).message);
    }
  }
}
mkdir -p logs reports
cp ~/your-project/logs/*.log logs/   # 手元の任意のログでOK
npx tsx bench.ts

結果の読み方(筆者の環境での傾向)

数値は使うログの量や中身で大きく変わるので、絶対値ではなく傾向として書きます。自分のログで必ず計測してください。

手順が既知のタスク(known)

  • Plan-Execute は計画1回+(必要なら)集計1回で終わり、APIコール数・入力トークンともに少ない
  • ReAct も正解にたどり着くが、「念のため別のファイルも見る」といった寄り道が入ることがある

手順が未知のタスク(unknown)

  • ReAct は「一覧→怪しいログを読む→関連ログを追加で読む」と自然に掘り進める
  • Plan-Execute は最初の計画で「全ファイルを読む」か「当て推量で数ファイル読む」のどちらかになりがちで、再計画が発生しやすい。再計画を重ねると、結局 ReAct と同程度のコールになる

検証時の確認ポイント

  • console.log に出るツール呼び出し列を見て、同じツール・同じ引数の繰り返しがないか(ReAct のループ兆候)
  • Plan-Execute の replans が毎回 1 以上なら、そのタスクは計画に向いていない
  • inputTokens がステップ数に対して急増していないか(ReAct で長いログをそのまま返していると起きる。ツール側で slice すること)

当社の実スクリプトはどちらの型か

実際に毎晩動いている auto-tasks.sh は、完全に Plan-Execute 型(というより、計画を人間が書いた固定パイプライン)です。

.company/scripts/auto-tasks.sh(Stepコメント追記・一部省略)
#!/bin/bash
# タスク自動実行スクリプト
# pendingタスクからAI実行可能なものを自動実行する
source "$(dirname "$0")/common.sh"

LOG_FILE="$LOG_DIR/auto-tasks.log"

main() {
  log_info "=== タスク自動実行開始 ===" | tee -a "$LOG_FILE"

  check_cost_limit || exit 0   # Step1: 予算ガード
  ensure_dashboard || exit 1   # Step2: 依存サービス確認

  local result
  result=$(api_post "tasks/auto-execute" '{}')   # Step3: 実行
  if [ $? -ne 0 ] || [ -z "$result" ]; then
    log_error "API呼び出し失敗" | tee -a "$LOG_FILE"
    exit 1
  fi

  local executed_count
  executed_count=$(echo "$result" | jq -r '.executed | length')
  if [ "$executed_count" -gt 0 ]; then
    summary=$(echo "$result" | jq -r '.executed[] | "\(.id): \(if .success then "SUCCESS" else "FAILED" end) - \(.result)"')
    notify_slack ":robot_face: [AI-CEO] タスク自動実行完了\n${summary}" || true  # Step4: 通知
    track_cost "auto-tasks" "0.05" || true
  fi

  git_auto_commit "auto: タスク自動実行 (${executed_count}件)"   # Step5: 記録
}

main "$@"

手順は「予算確認→依存確認→実行→通知→コミット」で毎日同じです。ここに ReAct を入れる理由はありません。毎晩無人で動くものは、計画が事前にレビュー可能であることが最優先だからです。

一方で失敗したのが Zenn の公開でした。1日に複数コンテンツを push してレートリミットに当たり、デプロイが失敗。固定パイプラインには「失敗を観察して手を変える」ステップがないので、当時は回復用に使い捨てスクリプト(retry-zenn-deploy.sh)を手で書いて launchd に1回だけ登録する、という対処をしました。

.company/scripts/retry-zenn-deploy.sh(抜粋)
# 記事末尾に空行を追加して変更を作る → push でZennのデプロイを再トリガー
echo "" >> "$ARTICLE"
git add "$ARTICLE"
git commit -m "fix: レートリミットで失敗した記事を再デプロイ"
git push origin main

# ロックファイル作成(同日の通常auto-publishをスキップさせる)
touch "/tmp/zenn-publish-$(date +%Y%m%d).lock"

# 自分自身のlaunchdジョブを削除(1回限りのタスク)
launchctl unload ~/Library/LaunchAgents/com.joinclass.retry-zenn-deploy.plist 2>/dev/null
rm -f ~/Library/LaunchAgents/com.joinclass.retry-zenn-deploy.plist

これは本質的に「人間がReActの1ステップを代行した」状態です。現在は本流は Plan-Execute のまま、失敗時の分岐だけ ReAct 的に観察→再計画する構成(この記事の planExecute の needReplan と同じ発想)+ロックファイルによる1日1件ルールで再発を防いでいます。

判断フローチャート

僕が新しい自動化を作るときに使っている基準です。

  1. 外部に副作用がある(メール送信・公開・課金)? → 副作用部分は計画から外し、draft→承認に回す。ReAct に直接持たせない
  2. 手順を人間が箇条書きできる? → Plan-Execute(または LLM すら不要な固定スクリプト)
  3. 途中の観察結果で次の手が変わる? → ReAct。ただし MAX_STEPS とツール側ガード必須
  4. ほぼ定型だが、たまに失敗する? → Plan-Execute + 失敗時のみ再計画(ハイブリッド)

実運用ではハイブリッドが一番多くなります。純粋な ReAct を無人で回しているのは、ログ調査やデバッグのような「読むだけ」のタスクに限っています。

まとめ

  • ReAct は柔軟だがコールとトークンが積み上がり、監査しにくい。読み取り専用の探索タスク向き
  • Plan-Execute は安く・事前レビューできるが、想定外に弱い。定型バッチ向き
  • 実務では「Plan-Execute を本流に、失敗時だけ再計画」のハイブリッドが扱いやすい
  • どちらでも、副作用はツール側でガードし、計画からは外すのが事故を防ぐ一番の手

1人会社でAIに仕事を任せるとき、性能より先に問われるのは「何をさせて、何をさせないか」です。エージェントの型選びは、その線引きをコードで表現する作業だと考えています。

参考:さらに深く知りたい方へ

本記事のような「複数のエージェントに役割を分け、計画役・実行役・レビュー役を組み合わせる」設計は、Claude Code のサブエージェント機能で実装すると管理がずっと楽になります。当社の10部門エージェント構成の作り方を、設計から運用までまとめた書籍がこちらです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?