2026 年 1 月、深夜 2 時 47 分。スマホがクラッシュレポートではなく Stripe からのチャージ通知で震えました。普段の 200 倍の課金額。Anthropic コンソールを開くと、1 つの壁紙分類ワーカーが約 3 時間で 12,000 リクエストを発火していました。原因はリトライがリトライを呼ぶ静かな暴走です。「分類失敗 → 再生成 → 再失敗 → 再生成」のループが、何の警告もなく一晩中走っていたのです。
2014 年から個人で AdMob 系のアプリ事業を続けて 11 年。累計 5,000 万ダウンロードを超えるあいだ、サーバー側の事故は何度も経験してきました。けれど、Claude エージェントを本番に組み込み始めてから感じたのは、確率的に失敗する処理を確率的に再試行する仕組みは、油断するとあっという間に金銭的に焼ける ということです。アプリのクラッシュは観察できますが、エージェントの「ただ走り続けている」状態は静かで、気付くまでに時間がかかります。
その一晩の損失は約 $32。金額そのものよりも、自分の運用が「燃えていることに気付かない構造」だった事実のほうがこたえました。翌週から、私は手元の Claude エージェントすべてに三層のキルスイッチ を入れ直しました。本記事はその設計の記録です。プロセス内ガード、プラットフォーム停止フラグ、観察層──この三層がそれぞれ独立して機能するように作ったところ、その後 4 ヶ月で暴走インシデントはゼロ件です。
なぜ個人運営のエージェントこそ「キルスイッチ」が要るのか
クラウドベンダーが用意する自動スケーリングや「予算アラート」は、企業規模で運用する人を主な対象にしています。私のように 1 人で複数のエージェントを動かす立場だと、警告メールが届く頃にはもう手遅れということが少なくありません。Anthropic コンソールの予算アラートはおおむね 24 時間遅延で集計されます。Cloudflare Workers の請求アラートも翌月以降に効きます。気付いた時点で課金は確定している のが個人運営の現実です。
加えて、Claude エージェントは「失敗してもとりあえず動き続けてくれる」性格を持っています。これは長所でもありますが、無限ループ的なリトライや、入力が想定外でも黙々と回り続ける振る舞いと隣り合わせです。手を動かしながら気付いたのは、エージェントの安全弁は機能としてではなく、構造として持っておく必要がある ということでした。1 ヶ所のフラグや try/catch では足りません。プロセスの中、外、そしてそれらを観察する層──三つの異なる場所で独立に止められなければ、結局どこかで漏れます。
宮大工だった祖父はよく「直すための余白を最初から作っておけ」と言っていました。古い社寺の柱に少しだけ遊びがあるのも、揺れに耐えるための余白だと教わりました。キルスイッチも同じで、正常系の隙間に意図的に作る余白 だと思っています。
三層キルスイッチの全体像
設計は次の三層で構成します。各層は独立してエージェントを止められ、上位層が反応しなくても下位層で確実に切れる作りです。
層 場所 反応速度 守るもの
Layer 1: プロセス内ガード エージェントのコード内 即時(1 リクエスト単位) 暴走の総量(イテレーション・トークン・時間)
Layer 2: プラットフォーム停止フラグ KV / 設定ストア 数秒〜30 秒 外から「いま止めて」を伝える
Layer 3: 観察層 別ワーカー / ログ収集 3 分以内 予兆を検出して通知する
Layer 1 だけだと、コードのバグでガードがすり抜けたときに止まりません。Layer 2 だけだと、観察が遅れて手が遅れます。Layer 3 だけだと、止める実装がそもそもありません。三層をセットで持つのが要点です。
第 1 層:プロセス内ガード — エージェントが自分で自分を止める
最初の層はエージェント本体の中に置きます。Cloudflare Workers + TypeScript で実装している前提で、再利用しやすい SafetyValve クラスを書いておきます。
// safety-valve.ts — Layer 1: プロセス内ガード
export type ValveLimits = {
maxIterations : number ; // 1 回の起動で何回までリトライ・反復を許すか
maxOutputTokens : number ; // 1 回の起動で消費していい合計トークン
maxWallClockMs : number ; // 1 回の起動が最長で何ミリ秒走っていいか
perRequestTokenCap : number ; // 1 リクエスト単位の max_tokens 上限
};
export class SafetyValve {
private iterations = 0 ;
private tokensSpent = 0 ;
private readonly startedAt = Date. now ();
constructor ( private readonly limits : ValveLimits ) {}
beginIteration () : void {
this .iterations += 1 ;
if ( this .iterations > this .limits.maxIterations) {
throw new SafetyTripped ( "iteration_cap" , this .iterations);
}
if (Date. now () - this .startedAt > this .limits.maxWallClockMs) {
throw new SafetyTripped ( "wall_clock_cap" , Date. now () - this .startedAt);
}
}
spendTokens ( input : number , output : number ) : void {
this .tokensSpent += input + output;
if ( this .tokensSpent > this .limits.maxOutputTokens) {
throw new SafetyTripped ( "token_cap" , this .tokensSpent);
}
}
capRequestTokens ( requested : number ) : number {
return Math. min (requested, this .limits.perRequestTokenCap);
}
}
export class SafetyTripped extends Error {
constructor ( public readonly reason : string , public readonly value : number ) {
super ( `SafetyValve tripped: ${ reason }=${ value }` );
}
}
このクラスを、エージェントの「1 回の起動」のスコープでインスタンス化します。重要なのはイテレーション・トークン・時間の三つを同時に 見ることです。私が一晩で焼いたケースは、リトライ回数自体は 1 回ごとに小さく見えていました。けれど合計トークンが想定の 200 倍に積み上がっていました。個別の指標だけでは暴走を捕まえられない のが、運用してみて分かった生の感覚です。
そして毎ループの先頭で beginIteration() を呼びます。
// wallpaper-classifier.ts — エージェントの実装側
const valve = new SafetyValve ({
maxIterations: 8 , // 1 枚あたり最大 8 回まで再生成
maxOutputTokens: 6_000 , // 合計 6,000 トークンで打ち切り
maxWallClockMs: 60_000 , // 60 秒で強制終了
perRequestTokenCap: 512 ,
});
while ( true ) {
valve. beginIteration ();
const res = await client.messages. create ({
model: "claude-haiku-4-5-20251001" ,
max_tokens: valve. capRequestTokens ( 800 ),
system: SYSTEM_PROMPT ,
messages,
});
valve. spendTokens (res.usage.input_tokens, res.usage.output_tokens);
const parsed = trySafeParse (res);
if (parsed.ok) return parsed.data;
messages. push ({ role: "assistant" , content: res.content });
messages. push ({ role: "user" , content: parsed.repairHint });
}
ここで valve.capRequestTokens(800) が地味に効きます。私のリクエスト暴走の引き金は、エージェントが「次は長く書けばいいのかも」と判断して max_tokens を上げ続けた挙動でした。個別リクエストにも上限を被せる ことで、ループ単位の指数的肥大化を防げます。
第 2 層:プラットフォーム停止フラグ — 外から強制的に止められる道を残す
Layer 1 がコードのバグでうまく動かないことを前提にする層が Layer 2 です。エージェントとは別の場所、私は Cloudflare KV を使っています、に「いま止めろ」を意味するキーを置いておき、各リクエストの直前で読み込みます。
// kill-switch.ts — Layer 2: KV ベースの停止フラグ
export type KillSwitchEnv = {
KILL_SWITCH : KVNamespace ;
};
const KEY = "agent:wallpaper-classifier:kill" ;
export async function isKilled ( env : KillSwitchEnv ) : Promise < boolean > {
// cacheTtl で過剰な KV 読み込みを避ける(数秒は古くてよい)
const v = await env. KILL_SWITCH . get ( KEY , { cacheTtl: 10 });
return v === "1" ;
}
export async function killAgent ( env : KillSwitchEnv , ttlSeconds = 3600 ) {
await env. KILL_SWITCH . put ( KEY , "1" , { expirationTtl: ttlSeconds });
}
export async function reviveAgent ( env : KillSwitchEnv ) {
await env. KILL_SWITCH . delete ( KEY );
}
cacheTtl: 10 は意図的にゆるめています。毎リクエストで強整合性を求めると KV 読み込みが Layer 1 より高くつく ことが分かったからです。10 秒の遅延を許せば、エージェントが暴走を始めても最悪 10 秒以内には止まる計算で、KV 読み出しコストは 1/数十まで下がります。
そして、エージェント本体のループ先頭で isKilled を確認します。
while ( true ) {
if ( await isKilled (env)) {
throw new SafetyTripped ( "external_kill" , 1 );
}
valve. beginIteration ();
// ... 通常処理
}
外から止める方法は、私の場合はワンライナーの管理用 Cloudflare Workers 経由にしました。スマホからもブラウザからも https://admin.example.com/kill/wallpaper-classifier を叩けば 10 秒以内に静まります。電車の中で「あれ、また課金通知が変だな」と思ったら、その場でスマホから止められる構造にしておくのは、個人運営では本当に効きます。
私が一度この Layer 2 で救われたのは、Cloudflare Queues の retry が裏で増幅していた事故です。Queue → Worker → Anthropic API の経路で、Worker 側のバグが Queue の dead-letter を呼ばずに再キューに入れ続けていました。停止フラグを置いた瞬間、Worker は処理を全部スキップして dead-letter に流れるようになり、根本原因の調査に集中できました。Layer 1 だけだと、エージェントは『正しく走り続ける』ために止まれない んです。
第 3 層:観察層 — 暴走の予兆を 3 分以内に拾う
最後の層は「止める」ではなく「気付く」ためのものです。Layer 1 と Layer 2 で止められる構造にしたとしても、止めるべきだと判断できなければ意味がありません。
観察層は、エージェント本体とは別のワーカーで動かすのが基本だと考えています。同じプロセスに乗せると、本体が固まったときに観察も止まる、という嬉しくない結合が起きます。
私が実際に運用しているのは次のような最小構成です。
// observer.ts — Layer 3: 暴走の予兆検出
type WindowStat = { count : number ; tokens : number ; failures : number };
export async function checkWindow ( env : ObserverEnv ) : Promise < void > {
const now = Math. floor (Date. now () / 60_000 );
const recent : WindowStat [] = await Promise . all (
[ 0 , 1 , 2 , 3 , 4 ]. map (( m ) => readMinute (env, now - m))
);
const totalCount = recent. reduce (( s , w ) => s + w.count, 0 );
const totalTokens = recent. reduce (( s , w ) => s + w.tokens, 0 );
const failureRate =
recent. reduce (( s , w ) => s + w.failures, 0 ) / Math. max (totalCount, 1 );
// 5 分間の閾値: 1,000 リクエスト / 50,000 トークン / 失敗率 30%
if (totalCount > 1_000 || totalTokens > 50_000 || failureRate > 0.3 ) {
await notifySlack (env, {
count: totalCount,
tokens: totalTokens,
failureRate,
});
if (totalCount > 2_000 || failureRate > 0.5 ) {
await killAgent (env, 1800 ); // 自動でも止める
}
}
}
ポイントは三つあります。一つ目は、1 分単位のバケットで 5 分のウィンドウを見る こと。1 時間バケットだと反応が遅すぎ、リアルタイムだとノイズが多すぎます。5 分は経験的に良いバランスでした。二つ目は、通知と自動停止の閾値を分ける ことです。通知は早めに、自動停止は確信度が高くなってから。誤動作で勝手にエージェントが止まり続けると、それはそれで業務に支障が出ます。三つ目は、観察ワーカー自身は 1 分おきに Cloudflare Cron Triggers で起動する こと。常駐型にすると、観察ワーカーが固まったときに気付けません。Cron で 1 分おきに「起き直す」のは、健全性そのものを観察する仕組みにもなります。
通知は私の場合 Slack で十分でしたが、stand.fm のラジオ収録中に画面が見えないことも多いので、スマホの通知音にしっかり差を付けています。
実際の運用で見えた失敗パターン 3 つ
設計を入れ替えてから 4 ヶ月。三層の正しさは概ね保てましたが、その間に設計の穴 を 3 つ踏みました。共有しておきます。
パターン A: リトライがリトライを呼ぶ多段増幅
最初の事故は、Layer 1 を入れた後でも起きました。エージェント本体には iteration cap を入れていたのですが、それを呼んでいた 上位のキューワーカー側にリトライがあり、エージェントが SafetyTripped で落ちると、キューがまた同じジョブを投入 していました。Layer 1 の iteration_cap ヒットを「失敗」と扱った結果、再投入で実質無限ループになっていたわけです。
教訓は、SafetyTripped はリトライ可能なエラーとして扱わない ことです。SafetyTripped をキャッチしたら、デッドレターに送って人手のレビュー対象にします。直近の私のコードでは、エラー型に retryable: false フラグを明示しています。
パターン B: 停止フラグが届かない(キャッシュの古さ)
Layer 2 を最初に作ったとき、cacheTtl: 60 にしていました。1 分は許容範囲だと思ったのです。けれど運用してみると、暴走時の 1 分は永遠です。1 分間に 200 リクエスト走るペースだったら、止まるまでに $0.4 走ってしまいます。cacheTtl は『止まる速度』そのもの なのだと、運用してから気付きました。今は 10 秒に調整しています。
ただし、cacheTtl を 0 にして毎回 KV から読むと、KV の読み込みコストが効いてきます。私の規模だと月 $3 ほど追加でした。「許容できる最悪の遅延」と「許容できる KV コスト」のトレードオフ で 10 秒に着地した、というのが正直なところです。
パターン C: 観察ワーカーの『健康そうな沈黙』
3 つ目の失敗は、観察ワーカー自体がエラーで黙っていたケースです。Cron が正常に起きているのに、内部でテレメトリ収集が失敗していて、checkWindow が空のデータで動いていました。閾値も超えないので、通知も飛びません。何も起きていないように見えるけれど、実は何も観察していない、という最悪の状態です。
そこから、観察ワーカーから 5 分に 1 回『生存確認ハートビート』を Slack の別チャンネルに流す ようにしました。私はそのチャンネルを通常はミュートしていますが、ハートビートが途絶えたら「最近静かすぎないか?」と気付けるようにしています。沈黙そのものを観察するメタ層、と呼んでいます。
段階的に導入する:最小構成から始める順序
「全部いきなり入れるのは無理」というのは正直な感想だと思います。私もすべてを最初の週末に組み込んだわけではなく、段階的に積み上げました。これから始めるなら、次の順序が無理なく続くと感じています。
まず Layer 1 の maxIterations と maxWallClockMs だけ入れる : 半日で導入できて、最大の事故の 80% は防げます
次に Layer 2 の KV 停止フラグを足す : 管理用の URL を 1 つ作るだけ。スマホから止められる安心感は大きい
続いて Layer 1 にトークン上限を足す : ここまでで個人運営の 95% のリスクが消える
最後に Layer 3 を組む : Cron + Slack。観察は後でいい、それより止められる構造を先に作るのが先決
私の経験では、Layer 2 の停止フラグまで来た時点で夜にぐっすり眠れるようになりました 。Layer 3 が無くても、最悪の場合は朝起きて気付いてスマホから止めれば、$30 を超える事故にはつながりません。Layer 3 は『気付くまでの時間』を短くするための投資で、優先度は最後でよいというのが私の判断です。
これから始めるなら
個人運営の Claude エージェントは、企業の本番系より構造的にデリケートだと感じます。観察する人が 1 人しかいなくて、その人が寝ている時間も走り続けるからです。だから、機能ではなく構造 として安全弁を持っておく必要があります。三層キルスイッチは、その構造の最小単位として、いまの私の運用を支えてくれています。
「今日のエージェントは、自分が寝ている間に独りでも止まれるだろうか」──この問いに「はい」と即答できるかどうかが、私にとっての健全性の指標になりました。同じように個人で AI 運用を続けている方の参考になれば嬉しいです。お読みいただきありがとうございました。