TL;DR: Codex CLI を複数インスタンス並列実行するには、タスクを独立ユニットに分割し、rate limit を考慮したワーカープール設計が不可欠。本記事では Node.js と Python の実装例を交えながら、状態共有・エラー伝播・結果マージまで一貫した設計パターンを解説する。
はじめに
Codex CLI(公式ドキュメント)は、単一のコーディングタスクを自律的に実行できるが、大規模なコードベースのリファクタリングやマルチファイル変換を単一インスタンスで処理しようとすると、すぐに限界が見える。そこで複数の Codex インスタンスをオーケストレーションする「マルチエージェント」アーキテクチャが必要になる。
OpenAI は 2025 年に Codex のマルチエージェント機能(Codex in ChatGPT)を正式リリースし、並列タスク実行を公式サポートした。一方、ローカル環境では独自にオーケストレーションを構築する必要がある場面も多い。
本記事では、Codex CLI を並列実行する際の設計上の意思決定と実装パターンを体系的に解説する。Claude Code のマルチエージェント実装と比較しながら、Codex 特有の考慮点も示す。
マルチエージェントが必要になる場面
単一 Codex インスタンスで対処しきれないシナリオは主に3つある。
大規模一括変換: 数百ファイルに渡る API バージョン移行、テストコード一括生成、ドキュメントの多言語翻訳。これらは相互依存が少なく、ファイル単位での並列化が自然にできる。
並列コードレビュー: Codex を使った自動コードレビューを複数ファイルで同時実行し、レビュー完了時間を短縮するケース。
パイプライン型タスク: 「リサーチ → 設計 → 実装 → テスト生成」を専門エージェントに分担させる形。各フェーズが前フェーズの出力を受け取る逐次型だが、設計と実装を別エージェントに担当させることで並列化できる部分もある。
タスク分割戦略
独立性によるタスク分類
並列化可能かどうかを判断するには、タスク間の依存関係を整理する(経験則)。
依存関係なし → 完全並列実行
直列依存 → パイプライン(前タスク完了待ち)
部分依存 → ファンアウト/ファンイン(依存部だけシリアル)
実際のコードベースでは、以下のように分類する。
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional
class TaskType(Enum):
INDEPENDENT = "independent" # 完全並列化可能
SEQUENTIAL = "sequential" # 直列実行
DEPENDENT = "dependent" # 他タスクの完了を待つ
@dataclass
class CodexTask:
id: str
prompt: str
target_files: List[str]
task_type: TaskType
depends_on: List[str] = field(default_factory=list)
max_retries: int = 3
def classify_tasks(tasks: List[CodexTask]) -> dict:
"""依存グラフを構築してタスクを分類する"""
dependency_map = {t.id: t.depends_on for t in tasks}
independent = [t for t in tasks if not t.depends_on]
dependent = [t for t in tasks if t.depends_on]
return {"independent": independent, "dependent": dependent}
ファイルベース分割
最もシンプルな分割戦略は、処理対象ファイルをチャンクに分けて各ワーカーに割り当てる方法だ。
import math
from pathlib import Path
from typing import List
def split_files_into_chunks(
file_paths: List[Path],
worker_count: int,
max_tokens_per_chunk: int = 8000
) -> List[List[Path]]:
"""
ファイルをトークン数を考慮してチャンクに分割する。
大きなファイルは単独チャンクに。
"""
chunks: List[List[Path]] = [[] for _ in range(worker_count)]
chunk_sizes = [0] * worker_count
# ファイルサイズでソートし、大きいものを先に割り当て(bin packing)
sorted_files = sorted(file_paths, key=lambda p: p.stat().st_size, reverse=True)
for file_path in sorted_files:
# 最も空きのあるチャンクに割り当て
min_idx = chunk_sizes.index(min(chunk_sizes))
chunks[min_idx].append(file_path)
chunk_sizes[min_idx] += file_path.stat().st_size
return [chunk for chunk in chunks if chunk]
ワーカープールの実装
Node.js による並列実行
Node.js の worker_threads を使ったワーカープール実装を示す。Codex CLI をサブプロセスとして起動し、標準出力をパースする。
// orchestrator.js
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
const { spawn } = require('child_process');
const path = require('path');
const MAX_WORKERS = 4;
const RATE_LIMIT_RPM = 60; // requests per minute (公式値: Codex API tier による)
class TokenBucket {
constructor(capacity, refillRate) {
this.capacity = capacity;
this.tokens = capacity;
this.refillRate = refillRate; // tokens per second
this.lastRefill = Date.now();
}
async consume(tokens = 1) {
this.refill();
if (this.tokens < tokens) {
const waitMs = ((tokens - this.tokens) / this.refillRate) * 1000;
await new Promise(resolve => setTimeout(resolve, waitMs));
this.refill();
}
this.tokens -= tokens;
}
refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
}
class CodexOrchestrator {
constructor(maxWorkers = MAX_WORKERS) {
this.maxWorkers = maxWorkers;
this.activeWorkers = 0;
this.queue = [];
this.results = new Map();
this.errors = new Map();
// RATE_LIMIT_RPM を秒換算してバケット設定
this.rateLimiter = new TokenBucket(RATE_LIMIT_RPM, RATE_LIMIT_RPM / 60);
}
async executeTask(task) {
await this.rateLimiter.consume();
return new Promise((resolve, reject) => {
const proc = spawn('codex', [
'--model', 'codex-1',
'--quiet',
task.prompt
], {
cwd: task.workDir,
env: { ...process.env }
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', data => { stdout += data.toString(); });
proc.stderr.on('data', data => { stderr += data.toString(); });
proc.on('close', code => {
if (code === 0) {
resolve({ taskId: task.id, output: stdout });
} else {
reject(new Error(`Task ${task.id} failed (exit ${code}): ${stderr}`));
}
});
});
}
async runAll(tasks) {
const results = await Promise.allSettled(
tasks.map(task => this.executeWithRetry(task))
);
return this.mergeResults(results, tasks);
}
async executeWithRetry(task, attempt = 0) {
try {
return await this.executeTask(task);
} catch (err) {
if (attempt < task.maxRetries && this.isRetryable(err)) {
const backoffMs = Math.pow(2, attempt) * 1000;
await new Promise(r => setTimeout(r, backoffMs));
return this.executeWithRetry(task, attempt + 1);
}
throw err;
}
}
isRetryable(error) {
// rate limit エラーと一時的なネットワークエラーのみリトライ
return error.message.includes('rate_limit') ||
error.message.includes('ECONNRESET') ||
error.message.includes('529');
}
mergeResults(settledResults, tasks) {
const succeeded = [];
const failed = [];
settledResults.forEach((result, idx) => {
if (result.status === 'fulfilled') {
succeeded.push(result.value);
} else {
failed.push({ taskId: tasks[idx].id, error: result.reason.message });
}
});
return { succeeded, failed, total: tasks.length };
}
}
module.exports = { CodexOrchestrator };
Rate Limit 制御
OpenAI API のレート制限の実態
OpenAI の Codex API には、tier によって異なるレート制限が設定されている(公式料金・制限ドキュメント)。
| Tier | RPM | TPM |
|---|---|---|
| Tier 1 | 60 | 40,000 |
| Tier 2 | 500 | 160,000 |
| Tier 3 | 3,000 | 1,000,000 |
マルチエージェントでは複数インスタンスが同一 API キーを共有するため、ワーカー数 × 1タスクあたりの API 呼び出し数が RPM を超えないよう制御が必要(公式値)。
指数バックオフ付き retry 実装
import asyncio
import time
import random
from typing import Callable, TypeVar, Awaitable
T = TypeVar('T')
async def retry_with_backoff(
func: Callable[[], Awaitable[T]],
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
jitter: bool = True
) -> T:
"""
指数バックオフ + jitter でリトライする汎用ラッパー。
429 (rate limit) と 529 (overloaded) のみリトライ対象。
"""
for attempt in range(max_retries + 1):
try:
return await func()
except Exception as e:
error_code = getattr(e, 'status_code', None)
if attempt == max_retries or error_code not in (429, 529):
raise
delay = min(base_delay * (2 ** attempt), max_delay)
if jitter:
delay *= (0.5 + random.random() * 0.5)
print(f"Attempt {attempt + 1} failed (code={error_code}). "
f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
共有レートリミッターによるワーカー間調整
複数ワーカーが同一プロセス内で動く場合は、共有の asyncio.Semaphore で同時実行数を制御する。
import asyncio
from collections import deque
class SlidingWindowRateLimiter:
"""1分間のスライディングウィンドウでリクエスト数を制限する"""
def __init__(self, max_requests: int, window_seconds: float = 60.0):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.request_times: deque = deque()
self.lock = asyncio.Lock()
async def acquire(self):
async with self.lock:
now = time.monotonic()
# 古いリクエスト記録を削除
while self.request_times and \
now - self.request_times[0] > self.window_seconds:
self.request_times.popleft()
if len(self.request_times) >= self.max_requests:
# 最も古いリクエストがウィンドウ外になるまで待機
wait_time = self.window_seconds - (now - self.request_times[0])
await asyncio.sleep(wait_time + 0.01)
self.request_times.append(time.monotonic())
ワーカー間の状態共有
ファイルシステムを介した共有
最もシンプルな状態共有は、JSON/YAML ファイルへの書き込みだ。ただし、複数ワーカーが同時に書き込む場合はロック必須(経験則)。
import json
import fcntl
from pathlib import Path
class SharedStateManager:
"""
ファイルロックを使ってワーカー間で状態を安全に共有する。
大規模並列(>10ワーカー)では Redis や SQLite を推奨。
"""
def __init__(self, state_file: Path):
self.state_file = state_file
self.lock_file = state_file.with_suffix('.lock')
self.state_file.parent.mkdir(parents=True, exist_ok=True)
if not self.state_file.exists():
self.state_file.write_text('{}')
def update(self, task_id: str, result: dict):
with open(self.lock_file, 'w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
try:
state = json.loads(self.state_file.read_text())
state[task_id] = result
self.state_file.write_text(json.dumps(state, indent=2))
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
def read(self) -> dict:
return json.loads(self.state_file.read_text())
進捗トラッキング
大量タスクを処理する際は、中断・再開できる進捗管理が重要だ。
from enum import Enum
class TaskStatus(Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
class ProgressTracker:
def __init__(self, state_manager: SharedStateManager):
self.state = state_manager
def is_completed(self, task_id: str) -> bool:
"""冪等実行のため、完了済みタスクをスキップできる"""
state = self.state.read()
return state.get(task_id, {}).get('status') == TaskStatus.COMPLETED.value
def mark_running(self, task_id: str):
self.state.update(task_id, {'status': TaskStatus.RUNNING.value,
'started_at': time.time()})
def mark_completed(self, task_id: str, output: str):
self.state.update(task_id, {
'status': TaskStatus.COMPLETED.value,
'output': output,
'completed_at': time.time()
})
def mark_failed(self, task_id: str, error: str):
self.state.update(task_id, {
'status': TaskStatus.FAILED.value,
'error': error,
'failed_at': time.time()
})
エラー伝播の設計
エラーの種類と対応方針
エラーを種類で分類し、対応を自動化する(経験則)。
| エラー種別 | 原因 | 対応 |
|---|---|---|
rate_limit_error | API リクエスト過多 | 指数バックオフでリトライ |
context_length_exceeded | プロンプトが長すぎる | タスクを分割して再実行 |
timeout | 処理時間超過 | 一定時間後にキャンセル・再実行 |
invalid_api_key | 認証エラー | 即時中止・人間エスカレーション |
connection_error | ネットワーク断 | 短いバックオフでリトライ |
Dead Letter Queue パターン
リトライ上限を超えたタスクを Dead Letter Queue に移し、後から手動確認できるようにする。
from typing import List
class TaskQueue:
def __init__(self, state_manager: SharedStateManager):
self.state = state_manager
self.dlq: List[dict] = []
async def process_with_dlq(self, tasks: List[CodexTask], runner):
results = []
for task in tasks:
try:
result = await retry_with_backoff(
lambda: runner.execute(task),
max_retries=task.max_retries
)
results.append(result)
except Exception as e:
# リトライ上限を超えたら DLQ へ
self.dlq.append({
'task_id': task.id,
'error': str(e),
'timestamp': time.time(),
'task': task
})
return results, self.dlq
結果のマージ
diff ベースのマージ
複数ワーカーが同一ファイルを変更した場合、git の 3-way merge ロジックを活用する。
import subprocess
from pathlib import Path
class ResultMerger:
"""
ワーカーが生成したファイル変更を安全にマージする。
競合が発生した場合はマーカーを残してレビューを促す。
"""
def merge_file_changes(
self,
base_file: Path,
changes: list[dict]
) -> tuple[str, list[str]]:
"""
複数ワーカーの変更を順にパッチ適用する。
競合検出時は conflict_files に追記。
"""
current_content = base_file.read_text()
conflict_files = []
for change in changes:
try:
result = subprocess.run(
['patch', '--merge', str(base_file)],
input=change['patch'],
capture_output=True,
text=True
)
if result.returncode != 0:
conflict_files.append(base_file.name)
except Exception as e:
conflict_files.append(f"{base_file.name}: {e}")
return base_file.read_text(), conflict_files
def generate_summary(self, results: list[dict]) -> str:
succeeded = [r for r in results if r.get('status') == 'completed']
failed = [r for r in results if r.get('status') == 'failed']
return (
f"処理完了: {len(succeeded)}/{len(results)} タスク成功, "
f"{len(failed)} タスク失敗"
)
Claude Code マルチエージェントとの比較
Claude Code のマルチエージェント実装と Codex CLI を比較すると、それぞれ異なる強みがある。
| 比較軸 | Codex CLI | Claude Code |
|---|---|---|
| オーケストレーション | 自前実装が必要 | Task ツールで組み込み |
| ツールアクセス | コード実行・ファイル操作中心 | 幅広い外部ツール |
| 状態共有 | ファイル/DB で自前管理 | コンテキスト継承あり |
| rate limit 制御 | API tier に依存 | モデルごとに異なる |
| コスト効率 | タスク単位の課金 | トークン課金 |
| AI駆動開発計画との統合 | CLI 連携 | ネイティブ統合 |
Codex が優位なのは「コード変換」に特化した大量バッチ処理。Claude Code が優位なのは複雑なコンテキストを必要とする設計・意思決定タスク。両者を組み合わせる場合は、Codex を機械的な変換ワーカーとして使い、Claude Code をオーケストレーターにするパターンが実用的(経験則)。
詳しくはCodex と Claude Code の比較を参照。
実装チェックリスト
マルチエージェント実装を本番導入する前に確認すべき項目(経験則)。
- タスクの冪等性を確認(再実行しても安全か)
- 進捗状態を永続化し、途中再開できるか
- rate limit を余裕を持って設定(実使用 RPM ≤ 制限の 80%)
- DLQ(Dead Letter Queue)を実装し、失敗タスクを追跡できるか
- 結果マージ時の競合検出を実装しているか
- ワーカー数をタスク量に応じて動的に調整できるか
👉 シリーズ全体像: Codex vs Claude Code 使い分け2026
まとめ
Codex CLI のマルチエージェント実装の要点は3つだ。タスク独立性の確保(並列化できないタスクを無理に並列化しない)、rate limit のバジェット管理(スライディングウィンドウで全ワーカーの合算を制御)、障害耐性の設計(DLQ + 冪等実行で安全な再試行)。
大規模な一括コード変換や並列レビューには Codex マルチエージェントが有効だが、タスクの性質や規模に応じて Claude Code との使い分けを検討してほしい。
FAQ
Q. ワーカー数は何個まで増やせますか?
使用している API キーの tier によります。Tier 1(RPM=60)であれば、1タスクあたり平均2〜3リクエストとすると、安全に並列実行できるのは約10〜15ワーカーが上限です(公式値 + 経験則)。Tier 3 では理論上100以上も可能ですが、コンテキスト汚染を避けるため30〜50程度が実用的です。
Q. ワーカー間でコードの状態(変数値など)を共有できますか?
Codex CLI はステートレスなので、実行時のコンテキストはプロセス間で自動共有されません。Redis や SQLite を使った外部状態ストアか、ファイルシステム上の JSON ファイルで明示的に共有する必要があります。
Q. あるワーカーが失敗したとき、他のワーカーを止めるべきですか?
タスクが独立している場合(ファイル単位の並列変換など)は止める必要はありません。ただし「前段の結果に依存する下流タスク」が失敗した場合は、依存タスクをキャンセルして DLQ に移す「fail-fast」戦略が有効です。
Q. Codex CLI と OpenAI Codex API(直接呼び出し)のどちらを使うべきですか?
大量バッチ処理では API 直接呼び出し(OpenAI API リファレンス)の方がオーバーヘッドが少なく、リクエストパラメーターを細かく制御できます。CLI はローカル開発・テスト用途、API は本番バッチ処理向けと使い分けるのが一般的(経験則)。
Q. Claude Code の Task ツールとの違いは何ですか?
Claude Code の Task ツール(公式ドキュメント)はモデルネイティブの並列実行機能で、コンテキストの引き継ぎや中断・再開が組み込まれています。Codex CLI は自前でオーケストレーション層を実装する必要がある代わりに、コード特化タスクで高い費用対効果が見込めます。
References
- OpenAI Codex 公式ドキュメント — Codex CLI の基本仕様・セットアップ
- OpenAI API Rate Limits — Tier 別の RPM/TPM 制限(公式値)
- OpenAI API リファレンス — Completions・Responses API の詳細仕様
- Introducing Codex (OpenAI Blog) — Codex マルチエージェント機能の公式発表
- Anthropic Claude Code ドキュメント — Claude Code の Tool Use 実装詳細
