はじめに: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-...
共通:ツール定義
比較を公平にするため、両パターンで同じツールを使います。題材は当社の実務に合わせて「ログファイルを調べて、失敗しているジョブを特定し、レポートを書く」です。
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 として返すだけです。
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つです。
-
MAX_STEPSは必須。 ログが巨大だと「もう少し読む」を繰り返します - 入力トークンはステップごとに累積します。 会話履歴を毎回全部送るので、ステップ数 n に対しておおむね n² で効いてきます
実装2:Plan-Execute
Plan-Execute は「計画を構造化データで出させる」のがキモです。自由文の計画は実行側でパースできず、結局 ReAct に戻ってしまいます。zod でスキーマを固定します。
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→承認→実行のパイプラインにそのまま乗せられるからです。
比較用ハーネス
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 型(というより、計画を人間が書いた固定パイプライン)です。
#!/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回だけ登録する、という対処をしました。
# 記事末尾に空行を追加して変更を作る → 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件ルールで再発を防いでいます。
判断フローチャート
僕が新しい自動化を作るときに使っている基準です。
- 外部に副作用がある(メール送信・公開・課金)? → 副作用部分は計画から外し、draft→承認に回す。ReAct に直接持たせない
- 手順を人間が箇条書きできる? → Plan-Execute(または LLM すら不要な固定スクリプト)
-
途中の観察結果で次の手が変わる? → ReAct。ただし
MAX_STEPSとツール側ガード必須 - ほぼ定型だが、たまに失敗する? → Plan-Execute + 失敗時のみ再計画(ハイブリッド)
実運用ではハイブリッドが一番多くなります。純粋な ReAct を無人で回しているのは、ログ調査やデバッグのような「読むだけ」のタスクに限っています。
まとめ
- ReAct は柔軟だがコールとトークンが積み上がり、監査しにくい。読み取り専用の探索タスク向き
- Plan-Execute は安く・事前レビューできるが、想定外に弱い。定型バッチ向き
- 実務では「Plan-Execute を本流に、失敗時だけ再計画」のハイブリッドが扱いやすい
- どちらでも、副作用はツール側でガードし、計画からは外すのが事故を防ぐ一番の手
1人会社でAIに仕事を任せるとき、性能より先に問われるのは「何をさせて、何をさせないか」です。エージェントの型選びは、その線引きをコードで表現する作業だと考えています。
参考:さらに深く知りたい方へ
本記事のような「複数のエージェントに役割を分け、計画役・実行役・レビュー役を組み合わせる」設計は、Claude Code のサブエージェント機能で実装すると管理がずっと楽になります。当社の10部門エージェント構成の作り方を、設計から運用までまとめた書籍がこちらです。
- 📘 『Claude Codeマルチエージェント開発』
- 書籍一覧: https://zenn.dev/joinclass?tab=books