夜中に回している記事生成のパイプラインが、朝になってもまだ動いていました。
ログを開くと、ツール呼び出しは一つも失敗していません。read_file が通り、edit_file が通り、run_tests が通り、また read_file に戻る。全て 200 で、エラーは一行もない。それなのに、成果物は六時間前とほとんど変わっていませんでした。
エラーで止まるループには、私たちはたいてい備えています。リトライ上限、サーキットブレーカー、give-up 予算。けれど「成功し続ける停滞」には、止める手がかりそのものがありません。例外も非ゼロ終了コードも出ないからです。
無人で長く回す立場からすると、これが一番たちが悪い失敗だと感じています。失敗してくれれば気づける。静かに前進をやめて予算だけを溶かす方が、ずっと高くつきます。
ここでは、その「成功したまま止まらないループ」を構造的に検知して、診断情報を残しながら安全にハルトさせる設計をまとめます。個人開発で複数のサイトを無人で回している私自身、この失敗で何度も予算を溶かしてきました。
エラー予算では停滞は捕まえられない
まず、なぜ既存のガードレールがすり抜けられるのかを整理します。
典型的なエージェントループの安全装置は、ほぼ全て「失敗の回数」を数えています。
| ガードレール | 数えているもの | 成功し続ける停滞で発火するか |
| リトライ上限 | 連続した例外の回数 | しない(例外が出ない) |
| サーキットブレーカー | エラー率 | しない(エラー率は0) |
| give-up 予算 | 自己修復の試行回数 | しない(修復対象のエラーがない) |
| ターン上限 | 総ターン数 | 遅れて発火する(予算を使い切ってから) |
ターン上限だけは最後に効きます。ただしそれは「停滞の検知」ではなく「全予算の消尽」です。50 ターンの上限なら、49 ターン無駄にしてから止まる。検知が遅すぎて、コストの抑制になっていません。
停滞は、エラーの不在ではなく 進捗の不在 として捉える必要があります。そして進捗は、明示的に定義しない限り測れません。ここが設計の出発点になります。
「停滞」は進捗の定義なしには存在しない
停滞とは「進捗が一定期間ないこと」です。逆に言えば、進捗を定義していなければ停滞も定義できません。多くのエージェント実装が停滞を検知できないのは、そもそも進捗オラクルを持っていないからだと考えています。
進捗オラクルは、タスクのゴールに紐づいた単調に近い数値を返す関数です。値が改善し続けている限り、エージェントは前進しているとみなします。
from typing import Protocol
class ProgressOracle(Protocol):
def score(self) -> float:
"""大きいほどゴールに近い、を返す。副作用なしで現状を観測する。"""
...
# 例: コード修正タスクのオラクル
class TestPassProgress:
def __init__(self, run_tests):
self._run_tests = run_tests # () -> (passed:int, total:int)
def score(self) -> float:
passed, total = self._run_tests()
if total == 0:
return 0.0
# 通過率を主成分にしつつ、全通過に強いボーナスを与える
return passed / total + (1.0 if passed == total else 0.0)
タスクによって、進捗の自然な指標は変わります。
| タスク種別 | 進捗オラクルの候補 |
| コード修正 | 通過したテスト数 / 残ったリンタ違反の減少 |
| リサーチ収集 | 未回答の小問が埋まった数 / 新規に得た一次情報の件数 |
| ファイル整理 | 未分類ファイルの残数(減少が進捗) |
| 記事執筆 | 満たした品質ゲート項目数 |
進捗オラクルを書けないタスクもあります。そのときは、後述する 行動の新規性 を進捗の代理指標として使います。代理は本物より弱い信号ですが、何もないよりはるかに早く停滞を捕まえられます。
ここで一つ、運用してわかった勘所があります。進捗オラクルは「副作用なしで安く呼べる」ことが必須です。毎ターン全テストを回すと、停滞検知のためのコストが本体を上回ります。テストならキャッシュ済みの結果を使う、リサーチならカウンタを覗くだけ、というように、観測コストを一定に保つ設計にしておきます。
行動フィンガープリントで「同じ手」を捉える
進捗が止まったことに加えて、「エージェントが何を繰り返しているか」を見ると、停滞のパターンを切り分けられます。そのために、各ステップを正規化したハッシュ=行動フィンガープリントに落とします。
肝は、揮発する要素を取り除いてから比較することです。タイムスタンプやリクエスト ID をそのまま含めると、毎回違うハッシュになり、繰り返しを見抜けません。
import hashlib, json
from dataclasses import dataclass, field
from collections import deque
VOLATILE_KEYS = {"timestamp", "request_id", "trace_id", "elapsed_ms", "nonce"}
def _strip_volatile(obj):
if isinstance(obj, dict):
return {k: _strip_volatile(v) for k, v in sorted(obj.items())
if k not in VOLATILE_KEYS}
if isinstance(obj, list):
return [_strip_volatile(v) for v in obj]
return obj
def action_fingerprint(tool_name: str, tool_input: dict) -> str:
canonical = json.dumps(
{"tool": tool_name, "input": _strip_volatile(tool_input)},
sort_keys=True, ensure_ascii=False,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
def result_digest(tool_result: dict) -> str:
# 結果のうち「意味のある変化」だけを残す。本文が長い場合は要約ハッシュにする
salient = _strip_volatile(tool_result)
blob = json.dumps(salient, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
action_fingerprint が同じステップが何度も並ぶなら、エージェントは文字どおり同じ操作を繰り返しています。さらに result_digest まで一致するなら、その操作は世界を全く変えていません。読み取り系なら無害ですが、書き込み系で結果ダイジェストが変わらないのは「効いていない編集」を投げ続けている強いサインです。
完全一致・振動・新規性の低下を切り分ける
停滞には実務上いくつかの顔があります。それぞれ対処が違うので、判定を分けておくと診断が楽になります。
@dataclass
class Step:
fp: str # action_fingerprint
digest: str # result_digest
score: float # その時点の進捗オラクル値
@dataclass
class StagnationGuard:
window: int = 8 # 直近何ステップを見るか
max_exact_repeats: int = 3 # 同一(行動+結果)の許容回数
novelty_floor: float = 0.34 # 新規フィンガープリント比率の下限
progress_patience: int = 6 # 進捗が伸びないまま許すステップ数
_hist: deque = field(default_factory=lambda: deque(maxlen=64))
_best_score: float = float("-inf")
_since_improved: int = 0
def observe(self, step: Step) -> None:
self._hist.append(step)
if step.score > self._best_score + 1e-9:
self._best_score = step.score
self._since_improved = 0
else:
self._since_improved += 1
def _exact_repeats(self) -> int:
if not self._hist:
return 0
last = self._hist[-1]
key = (last.fp, last.digest)
return sum(1 for s in self._hist if (s.fp, s.digest) == key)
def _novelty_ratio(self) -> float:
recent = list(self._hist)[-self.window:]
if len(recent) < self.window:
return 1.0 # サンプル不足のうちは判定しない
uniq = len({s.fp for s in recent})
return uniq / len(recent)
def _oscillating(self) -> bool:
# A-B-A-B のような短周期サイクルを検出する
recent = [s.fp for s in list(self._hist)[-self.window:]]
for period in (2, 3):
if len(recent) >= period * 2:
tail = recent[-period * 2:]
if tail[:period] == tail[period:]:
return True
return False
def verdict(self) -> str | None:
if self._exact_repeats() >= self.max_exact_repeats:
return "exact_repeat"
if self._oscillating():
return "oscillation"
if self._novelty_ratio() < self.novelty_floor:
return "low_novelty"
if self._since_improved >= self.progress_patience:
return "no_progress"
return None
四つの判定の意味は次のとおりです。
| 判定 | 状況 | 典型的な原因 |
| exact_repeat | 同一の行動と結果が許容回数を超えた | 効いていない編集の投げ直し、固定したエラーの握りつぶし |
| oscillation | 2〜3手の短周期で行き来している | 2つの修正案の間で揺れる、相反する制約の板挟み |
| low_novelty | 窓内の行動の種類が乏しい | 探索が狭まり同じ数手をこね回している |
| no_progress | 進捗オラクルが規定ステップ伸びない | 多彩に動いてはいるが本質的に前進していない |
no_progress は進捗オラクルがある場合の本命です。exact_repeat と oscillation と low_novelty は、オラクルが書けないタスクでも効く構造的な代理信号です。両方を併用すると、検知の取りこぼしが目に見えて減ります。
停滞予算を使い切ったら、診断を残してハルトする
検知できても、ただ例外を投げるだけでは無人運用で困ります。「なぜ止めたのか」を後から追えるように、停滞の証拠を構造化して残します。
class StagnationHalt(Exception):
def __init__(self, reason: str, evidence: dict):
super().__init__(f"stagnation halt: {reason}")
self.reason = reason
self.evidence = evidence
def build_evidence(guard: StagnationGuard, reason: str) -> dict:
recent = list(guard._hist)[-guard.window:]
return {
"reason": reason,
"best_score": guard._best_score,
"steps_since_improved": guard._since_improved,
"novelty_ratio": round(guard._novelty_ratio(), 3),
"recent_fingerprints": [s.fp for s in recent],
"recent_scores": [round(s.score, 3) for s in recent],
}
ここで意識しているのは、ハルトを「失敗」ではなく「観測された結論」として扱うことです。証拠ログがあれば、翌朝にログを一目見て「振動で止まったのか、進捗が伸びずに止まったのか」が分かります。原因が分かれば、窓幅や progress_patience の調整、あるいはプロンプト側の制約の見直しに、迷わず進めます。
エージェントループへの組み込み
ここまでの部品を、実際のツール実行ループに差し込みます。観測はツール実行の直後、判定はターンの末尾で行うのが扱いやすい形です。
def run_agent_loop(client, messages, tools, oracle,
guard: StagnationGuard, max_turns: int = 50):
for turn in range(max_turns):
resp = client.run_turn(messages, tools) # モデル呼び出し(簡略化)
messages.append(resp.assistant_message)
if not resp.tool_calls: # ツールを呼ばない=完了
return {"status": "done", "turns": turn + 1}
for call in resp.tool_calls:
result = call.execute()
messages.append(result.as_tool_message())
guard.observe(Step(
fp=action_fingerprint(call.name, call.input),
digest=result_digest(result.payload),
score=oracle.score(),
))
reason = guard.verdict()
if reason:
evidence = build_evidence(guard, reason)
raise StagnationHalt(reason, evidence)
raise StagnationHalt("turn_limit", {"turns": max_turns})
呼び出し側は、停滞ハルトを通常の例外と区別して扱えます。
try:
out = run_agent_loop(client, msgs, tools, oracle, StagnationGuard())
except StagnationHalt as halt:
log.warning("agent halted: %s", halt.evidence)
# 無人運用なら: 証拠を残して当該ジョブはクリーンに失敗扱い。
# 次回の再実行に向けて、窓幅・patience・プロンプト制約の見直し対象として記録する。
record_failure(reason=halt.reason, evidence=halt.evidence)
導入してから、私のパイプラインでは「朝まで空回り」が実質的に消えました。50 ターンを丸ごと無駄にしていたジョブが、6〜10 ターンあたりで停滞判定に引っかかって止まるようになり、無駄に消費していたトークンは月あたりでおよそ80%減りました。止まること自体は失敗ですが、早く失敗してくれることの価値は、無人運用ではとても大きいと感じています。
公式の手引きには出てこない運用の勘所
実際に回してみて、ドキュメントの設計論だけでは埋まらない部分がいくつかありました。
揮発フィールドの正規化は、想像より丁寧にやる必要があります。timestamp や request_id だけでなく、ツールが返す「経過時間」「カーソル位置」「一時ファイル名」なども、毎回変わるのに意味は持たない場合があります。これらを残すと新規性が常に高く見え、低新規性の判定がまったく発火しません。最初は控えめに除外し、誤検知の傾向を見ながら除外リストを育てるのが現実的です。
窓幅と progress_patience は、タスクの「一手の重さ」に合わせます。一手で大きく状況が動くタスク(大規模リファクタなど)は patience を短く、一手が小さく積み上げ型のタスク(リサーチ収集など)は長めにします。短すぎると正当な試行錯誤を停滞と誤認し、長すぎると検知が遅れます。私は新しいタスク種別ではまず patience を 6、窓幅を 8 から始め、二、三回観察して寄せています。
それから、書き込み系ツールで result_digest が変わらないのは、最優先で疑うべきサインです。読み取りの繰り返しは無害なことが多いのですが、編集を投げているのに結果ダイジェストが動かないのは、エージェントが「効かない同じパッチ」を出し続けている可能性が高い。判定とは別に、この一点だけを警告ログに出しておくと、原因の切り分けが速くなります。
最後に、停滞ガードはあくまで安全網であって、停滞そのものを減らす仕組みではありません。頻繁に振動で止まるなら、相反する制約をプロンプトが同時に課している、という上流の設計問題を疑うのが筋だと考えています。ガードが教えてくれるのは「どこで詰まったか」であって、「なぜ詰まる設計なのか」は人間が読み解く領域です。
状況別の最初の一歩
無人運用に組み込むなら、次の順序が扱いやすいです。
- まず進捗オラクルを書けるか検討し、書けるなら no_progress 判定から入れます。
- 次に行動フィンガープリントを足し、exact_repeat と oscillation で構造的な裏取りを加えます。
- 最後に証拠ログを整え、ハルトを「観測された結論」として翌朝追えるようにします。
細かな当てはめは、次の表を目安にしてください。
| あなたの状況 | 最初に入れるもの |
| 進捗を数値化できるタスク(テスト・チェックリスト等) | 進捗オラクル + no_progress 判定から始める |
| 進捗を数値化しにくい自由形式のタスク | 行動フィンガープリント + exact_repeat / oscillation から始める |
| 既にターン上限だけで運用している | まず低新規性判定を足し、検知を前倒しする |
| 書き込み系ツールを多用する | result_digest 不変の警告ログを最優先で入れる |
無人で長く回すほど、「失敗を検知できること」は「失敗しないこと」と同じくらい大切になってきます。停滞を進捗の不在として定義し、構造的な代理信号で裏を取る。私はこの二段構えを強く推奨します。静かに溶けていく予算を、きっと守ってくれるはずです。
同じように夜通しのパイプラインと向き合っている方の参考になれば幸いです。