3つのClaude Maxアカウントを使い分け、rate limitの影響を最小化する仕組みを実装する。CLAUDE_CONFIG_DIR + キーチェーンによるアカウント分離は技術的に実証済み。
bot/account_pool.py(〜120行)を作成。claude_runner.py は30行追加のみpool_status.json で永続化。再起動しても安全run_claude() のインターフェースは変更なし。内部でアカウント選択を注入@dataclass
class AccountInfo:
name: str # 識別名(例: "account-a")
config_dir: str # CLAUDE_CONFIG_DIR のパス
active_count: int = 0 # 現在実行中のプロセス数
cooldown_until: float = 0 # rate limit クールダウン終了時刻(time.time()ベース、0=制限なし)
total_runs: int = 0 # 累計実行回数(統計用)
last_rate_limit: float = 0 # 最後にrate limit検知した時刻
class AccountPool:
"""Claude Maxアカウントのプール管理
アクティブプロセス数ベースのスコアリングで負荷分散し、
rate limit発生時は該当アカウントをクールダウンして別アカウントに振り替える。
"""
def __init__(self, accounts: list[dict]) -> None:
"""アカウント一覧から初期化
accounts: [{"name": "account-a", "config_dir": "/path/to/.claude-a"}, ...]
"""
def acquire(self, *, exclude: str | None = None) -> AccountInfo | None:
"""最小スコアのアカウントを取得し、active_count += 1
exclude: 除外するアカウント名(rate limit後のリトライ時)
Returns: AccountInfo(利用可能なアカウントがなければ None)
"""
def release(self, name: str) -> None:
"""プロセス完了時に active_count -= 1"""
def mark_rate_limited(self, name: str, cooldown_seconds: int = 300) -> None:
"""rate limit検知時にクールダウン期間を設定(デフォルト5分)"""
def _score(self, account: AccountInfo) -> float:
"""アカウントのスコアを計算(低い = 優先)
クールダウン中 → float('inf')
それ以外 → active_count
"""
def status(self) -> list[dict]:
"""全アカウントの現在状態を返す(ダッシュボード / AI Ops用)"""
def persist(self) -> None:
"""pool_status.json にアトミック書出し"""
def all_limited(self) -> bool:
"""全アカウントがクールダウン中かどうかを返す"""
| 条件 | スコア | 説明 |
|---|---|---|
| クールダウン中 | float('inf') | rate limit中のアカウントは選択されない |
| 通常 | active_count | 実行中プロセスが少ないアカウントを優先 |
| 同スコア時 | total_runs で破壊 | 累計実行数が少ない方を選択(均等分散) |
min(available_accounts, key=lambda a: (a.active_count, a.total_runs))
{
"accounts": [
{
"name": "account-a",
"config_dir": "/Users/mnmladmin/.claude-a",
"active_count": 2,
"cooldown_until": null,
"total_runs": 145,
"last_rate_limit": null
},
{
"name": "account-b",
"config_dir": "/Users/mnmladmin/.claude-b",
"active_count": 1,
"cooldown_until": 1743984000.0,
"total_runs": 132,
"last_rate_limit": 1743983700.0
},
{
"name": "account-c",
"config_dir": "/Users/mnmladmin/.claude-c",
"active_count": 0,
"cooldown_until": null,
"total_runs": 128,
"last_rate_limit": null
}
],
"updated_at": 1743984001.0
}
保存先: logs/pool_status.json(既存の rate_limit_status.json と同階層)
_run_claude_inner() 内で、subprocess起動直前にアカウントを取得し、完了時(正常/異常問わず)にリリースする。rate limit検知時は別アカウントでリトライする。
# _run_claude_inner() の変更イメージ(約30行追加)
from bot.account_pool import pool # グローバルインスタンス
async def _run_claude_inner(..., *, _account_name: str | None = None, ...):
# ① アカウント取得
account = pool.acquire(exclude=_account_name) if pool.enabled else None
# ② env構築時に CLAUDE_CONFIG_DIR を注入
env = {k: v for k, v in os.environ.items() if k not in _env_exclude}
if account:
env["CLAUDE_CONFIG_DIR"] = account.config_dir
try:
# ... 既存のsubprocess実行ロジック(変更なし) ...
pass
finally:
# ③ 必ずリリース
if account:
pool.release(account.name)
# ④ RateLimitError 検知時: 別アカウントでリトライ
# (既存のRateLimitError送出箇所を変更)
if is_rate_issue and not result_text.strip():
if account:
pool.mark_rate_limited(account.name, cooldown_seconds=wait_seconds)
# 別アカウントで即リトライ(1回のみ)
if not _account_name: # 初回のみリトライ
return await _run_claude_inner(
..., _account_name=account.name, ...
)
raise RateLimitError(wait_seconds=wait_seconds)
run_claude() の引数・戻り値は一切変わらない。アカウント選択は内部で透過的に行われる。
内部関数 _run_claude_inner() に _account_name: str | None 引数を追加(プライベート、リトライ時の除外用)。
| ステップ | 処理 | 担当 |
|---|---|---|
| 1 | Claude CLI が rate_limit_event を出力 | Claude CLI |
| 2 | 既存パースロジックで rate_limited=True を検知 | claude_runner.py(既存) |
| 3 | pool.mark_rate_limited(name, wait_seconds) でクールダウン設定 | claude_runner.py(追加) |
| 4 | pool.release(name) で現アカウントを解放 | claude_runner.py(追加) |
| 5 | pool.acquire(exclude=name) で別アカウントを取得 | claude_runner.py(追加) |
| 6a | 別アカウント取得成功 → _run_claude_inner() を再帰呼出 | claude_runner.py(追加) |
| 6b | 全アカウント制限中 → 既存の RateLimitError をそのまま送出 | claude_runner.py(既存) |
| パラメータ | 値 | 根拠 |
|---|---|---|
| デフォルトクールダウン | 300秒(5分) | Claude Max の rate limit ウィンドウは通常5時間。5分で十分に回復する実績あり |
| CLI提供の待機秒数 | 優先使用 | rate_limit_info に retry_after / resetsAt がある場合はそれを使用 |
| クールダウン上限 | 1800秒(30分) | 過大な値を防ぐ安全弁 |
| リトライ回数 | 1回(別アカウントで即リトライ) | 2回以上のリトライは全アカウント制限のリスク。既存の Task.retry_count と組み合わせて上限管理 |
_run_claude_inner 内で1回だけ。同一タスク実行内で即座に別アカウントに切り替えTask.retry_count で管理。タスク全体を再実行。exponential backoffRateLimitError が送出され、既存のタスクリトライが発動AI Ops Monitor(agents/ai-ops/monitor/watcher.py)に _check_pool_status() メソッドを追加。
async def _check_pool_status(self) -> None:
"""アカウントプールの状態を監視
チェック項目:
1. 全アカウントがクールダウン中 → CRITICAL通知
2. 1アカウントが長時間(15分超)クールダウン中 → WARNING通知
3. 特定アカウントのrate limit頻度が高い → INFO通知(偏り検出)
"""
| 条件 | レベル | 通知先 | クールダウン |
|---|---|---|---|
| 全アカウントが同時にクールダウン中 | CRITICAL | #bot-info | 5分 |
| 1アカウントのクールダウンが15分超 | WARNING | #bot-info | 30分 |
| 1アカウントが1時間に3回以上rate limit | INFO | #bot-info | 1時間 |
既存の reader.py に read_pool_status() メソッドを追加。logs/pool_status.json を読取専用でパースする。
# reader.py に追加
@dataclass(frozen=True)
class PoolAccountRecord:
name: str
config_dir: str
active_count: int
cooldown_until: float | None
total_runs: int
last_rate_limit: float | None
def read_pool_status(self) -> list[PoolAccountRecord]:
"""pool_status.json からアカウント状態を読取"""
class Settings(BaseSettings):
# ... 既存設定 ...
# アカウントプール設定
# 形式: "name1:/path1,name2:/path2,name3:/path3"
claude_accounts: str = ""
# クールダウン秒数(デフォルト5分)
pool_cooldown_seconds: int = 300
# クールダウン上限(デフォルト30分)
pool_cooldown_max: int = 1800
# アカウントプール(カンマ区切り、各 "名前:config_dirパス")
CLAUDE_ACCOUNTS=account-a:/Users/mnmladmin/.claude-a,account-b:/Users/mnmladmin/.claude-b,account-c:/Users/mnmladmin/.claude-c
# クールダウン秒数(任意、デフォルト300)
# POOL_COOLDOWN_SECONDS=300
claude_accounts が空文字の場合、AccountPool.enabled = False となり、従来どおり単一アカウントで動作する(後方互換性)。
| ファイル | 操作 | 変更内容 | 行数目安 |
|---|---|---|---|
bot/account_pool.py |
新規 | AccountPool クラス、pool_status.json 永続化、グローバルインスタンス初期化 | 〜120行 |
bot/claude_runner.py |
改修 | _run_claude_inner() にアカウント取得/解放/切替リトライを追加 |
+30行 |
bot/config.py |
改修 | claude_accounts, pool_cooldown_seconds, pool_cooldown_max の3設定を追加 |
+6行 |
agents/ai-ops/monitor/watcher.py |
改修 | _check_pool_status() メソッドを追加。メインループの asyncio.gather に追加 |
+40行 |
agents/ai-ops/monitor/reader.py |
改修 | PoolAccountRecord データクラス + read_pool_status() メソッド追加 |
+20行 |
.env |
改修 | CLAUDE_ACCOUNTS 環境変数を追加 |
+2行 |
| 項目 | リスク | 対策 |
|---|---|---|
| Rate limitの粒度 | 高 |
Anthropicの公開情報にrate limitがアカウント単位かIP/組織単位かの明記なし。 Phase 0で実測検証が必要。もしIP単位なら3アカウントでも効果なし。 → 実測結果に基づき、Phase 1でIPローテーション等の代替策を検討 |
| CLAUDE_CONFIG_DIRの互換性 | 中 |
Claude CLI のバージョンアップで挙動が変わる可能性あり。 → 技術実証済み(Issue #329確認事項)。CLI更新時に動作確認を入れる |
| セッション継続(--resume) | 中 |
セッションIDはアカウント固有。rate limitリトライで別アカウントに切り替えると --resume が使えない。→ リトライ時は新規セッションで実行(one-shot)。プロンプトに必要なコンテキストは含まれているため問題なし |
| active_countの不整合 | 低 |
プロセスkill等で release() が呼ばれないケース。→ try/finally で確実にリリース。加えて ProcessRegistry との突き合わせで定期補正(AI Ops)
|
| キーチェーンアクセス | 低 |
3アカウント分のOAuthトークンがキーチェーンに格納されている前提。 → 各アカウントの claude auth status で事前確認するスクリプトを用意
|
bot/account_pool.py 新規作成bot/claude_runner.py にアカウント選択ロジック組込みbot/config.py に設定追加.env に3アカウント設定AccountPool の acquire/release/mark_rate_limitedpool_status.json で確認)CLAUDE_ACCOUNTS 未設定)でも既存動作に影響なしCLAUDE_ACCOUNTS のパース: 不正な形式(区切り文字不正、パス不存在)は起動時にログ警告し、該当アカウントをスキップconfig_dir のパス: 絶対パスのみ許可。相対パスは拒否cooldown_seconds: 0〜pool_cooldown_max の範囲にクランプpool_status.json にはトークン情報を含めない(config_dir パスのみ).env は .gitignore 済みpool_status.json は既存の rate_limit_status.json と同じパターン(tmpfile → os.replace)で書出し| 案 | 概要 | 採用/却下 | 理由 |
|---|---|---|---|
| A. ラウンドロビン | 順番にアカウントを割り当て | 却下 | 負荷の偏りを考慮できない。長時間タスクがあると特定アカウントに偏る |
| B. スコアベース(採用) | active_count最小のアカウントを選択 | 採用 | 実装がシンプルかつ負荷均等化に効果的。active_countは既にProcessRegistryで追跡中 |
| C. 重み付きスコア | active_count + rate_limit_history でスコア計算 | 却下 | Phase 0では過剰。rate limitの実測データが不足している段階で重みを調整するのは時期尚早 |
| D. 外部プロキシ | HAProxy等でCLI実行をプロキシ | 却下 | Claude CLIはsubprocess実行のため、HTTPプロキシは不適。アーキテクチャの複雑化 |