この記事は note / Zenn にも同じ内容で公開しています(筆者本人による転載)。
前の記事で、AIエージェントを17時間回した実測を書きました。その最後に「見張りスクリプト、状態ファイルの設計、判断待ちの出し方は別の記事に書く」と約束したので、それを書きます。
動くコードごと出します。
まず、なぜ書くことになったか
長時間かかるジョブ(データ収集、学習、バッチ)をAIエージェントに走らせていました。私は別の作業をしながら、定期的に状態を見る監視をひとつ立てていました。
途中で、その監視が不要になったと判断して止めました。
その34分後にジョブが落ちました。誰も気づきませんでした。
次に気づいたのは57分後です。
原因は単純でした。
監視を止めた本人が、「見る責任」を誰にも渡していなかった。
**人が見張りを持つと、その人が止めた瞬間に、見る目そのものが消えます。**しかも消えたことに誰も気づきません。監視は「止めました」というログを残して、静かに終わるからです。
**だから見張りは機械に持たせます。**以下がその実装です。
① 「動いている」と「進んでいる」を分ける
最初に作った版は、プロセスが生きているかだけを見ていました。それでは足りません。
プロセスは生きている + 進んでいる → 正常
プロセスは生きている + 進んでいない → **固まっている**(これを見逃す)
プロセスは死んでいる + ロックは残る → **後続が永久に待つ**
プロセスは動いていない + 動くべき → **落ちたことに誰も気づいていない**
4つ目が57分の空白の正体です。「動いていない」だけでは異常か正常か決まりません。「動くべきなのに動いていない」を判定できないと、落ちたことに気づけません。
状態をこう分けました。
if holds and alive is False:
state = "DEAD_LOCK_STALE" # ロックはあるがプロセスは死んでいる
elif holds and stalled:
state = "STALLED" # 生きているが進んでいない
elif holds:
state = "RUNNING"
else:
state = "NOT_RUNNING"
## 「動いていない」だけでは足りない。**動くべきかを別に判定する**
sr, why = _should_run(job, lock)
if state == "NOT_RUNNING" and sr:
state = "SHOULD_BE_RUNNING"
SHOULD_BE_RUNNING を足したことが、この実装でいちばん効きました。
② 進捗の出所を、1つに決めない
これはこの記事を書いている当日に見つけた欠陥です。
進捗の古さを、こう測っていました。
## 悪い実装
return file_age_sec(job.get("progress_file") or job.get("log"))
or が問題でした。progress_file があるとき、log は「予備」ではなく**「使わない」**になります。
実際に何が起きたか。
progress_file 処理が1件終わるごとに追記される。平均26秒間隔
→ ただし**大きい入力を処理中は5分を超える**
log 30秒ごとに件数を出力
→ **明確に生きていた**
停止とみなす閾値は300秒でした。進捗の古さは303秒と出ました。
健全なジョブが「停止」と判定される寸前でした。
直した版:
def _progress_age(job):
"""**進捗の出所は1つに決めない。生きている証拠が1つでもあれば生きている。**
粒度の粗い出所だけを見ると、偽の停止が出る。
利用できる出所すべてのうち、**最も新しいもの**を採る。
"""
ages = []
if job.get("progress_table"):
ages.append(_db_progress_age(job["progress_table"]))
for key in ("progress_file", "log"):
if job.get(key):
ages.append(file_age_sec(job[key]))
ages = [a for a in ages if a is not None]
return min(ages) if ages else None
影響が非対称なことに注意してください。
このジョブは自動再起動を禁止していたので、機械が誤って再起動することはありませんでした。しかし「停止」の赤が出ると、見張りは新しい作業を投入しなくなります。
健全なジョブについた偽の赤が、無関係な作業まで止めます。
③ プロセスの生死を、正しく測る
Windows で os.kill(pid, 0) を生存確認に使ってはいけません。
Unix では signal 0 は「何もしない」ですが、Windows の os.kill は TerminateProcess を呼びうるため、確認するつもりで殺します。
def _pid_alive(pid):
"""Windows で pid の生存を見る。**os.kill は使わない**"""
if not pid:
return None
try:
import ctypes
k = ctypes.windll.kernel32
h = k.OpenProcess(0x1000, False, int(pid)) # QUERY_LIMITED_INFORMATION
if not h:
return False
code = ctypes.c_ulong()
ok = k.GetExitCodeProcess(h, ctypes.byref(code))
k.CloseHandle(h)
return bool(ok) and code.value == 259 # STILL_ACTIVE
except Exception:
return None
戻り値を3値にしています。True / False / None(判定できなかった)。
**「判定できなかった」を False に丸めないでください。**丸めると、確認に失敗しただけのジョブを「死んだ」と扱い、再起動して二重に走らせます。
④ 状態ファイル ― 何を宣言させるか
**ジョブごとに「進んでいる証拠がどこに出るか」が違います。**それを見張り側にハードコードすると、ジョブが増えるたびに見張りを直すことになります。
宣言させます。
jobs:
- id: collect_raw
name: 生データの一括取得
owner: data-team
log: /var/log/collect.log
progress_file: /data/raw/_manifest.jsonl
stall_sec: 300
expected_total_files: 5121
# **機械が再開してよいか。既定は false**
auto_restart: false
auto_restart_blocked_reason: >-
サーバ側エラーの原因が未特定。原因を見ずに再開すると、
配信側の異常を「順調」と読み続けることになる。
# **「走るべきなのに走っていない」を機械が判定するための条件。**
# 機械が評価できるものだけを書く
should_run_when:
- not_complete # 進捗の行数 < 期待総数
- resource_free # 排他資源を生きたプロセスが握っていない
設計の要点は1つです。
should_run_whenを書けないジョブは、見張りが「落ちた」と判定できない。
だから**書けないジョブは登録させません。**登録の条件にすることで、「見ているつもり」の状態がなくなります。
⑤ 機械が再開してよい条件、いけない条件
ここが最も間違えやすいところでした。
私は一度、サーバ側エラーで落ちたジョブを**自動で再開する設計にしていました。**壊れた入力を飛ばして続行する、という実装です。
もし自動で再開していたら、飛ばした期間は永久に欠測になっていました。
原因を調べる過程で、使っていたSDKに**「壊れたキャッシュを削除する関数」**があることが分かりました。**削除すれば次回に再取得されます。**つまり正しい復旧は「飛ばす」ではなく「消して取り直す」で、欠測は出ませんでした。
人を挟んで遅れる 数十分〜半日
挟まずに間違える **復旧不能な欠測**
配信側の異常を「順調」と読み続ける
後者のほうが高くつくので、迷ったら人を挟みます。
**「早く再開する」ことと「正しく再開する」ことは別です。**自動再開で得られたのは数十分の短縮、失うところだったのは復旧可能なデータそのものでした。
規則にしました。
機械が再開してよいのは
・**原因が特定済みで、対処が手順として確定している**
・かつ設定に auto_restart: true と、その根拠が書かれている
・かつ 1日の再開回数に上限がある(既定6回)
それ以外は、見張りは「再開が必要」と表示して**止まる。**
「繰り返し落ちている」は、手順ではなく原因を見るべき合図です。
⑥ 判断待ちの出し方
見張りが見つけたものを、全部その場で人に投げると、人が見なくなります。
指示の順番を固定しました。
RESTART → RED → STALE → DISPATCH
赤(検査の失敗)があるうちは、新しい作業を投入しません。
**検査を書くこと自体は事故を防ぎません。赤を放置しないことだけが防ぎます。**赤があるまま次を積むと、赤は「いつもの表示」になって消えます。
人に上げるものも絞りました。
即時に上げる(これだけ)
① 人の操作でしか進まないもの
② 不可逆な操作の直前(削除・上書き・公開・送信・課金)
③ 守るべき規則が破れている状態を見つけたとき
④ 止まっていて、機械では再開できないとき
それ以外 ファイルに溜めて、1日1回まとめて出す
判断待ちを随時投げると、人の時間が細切れになって、結局どれも決まりません。
まとめ
① 「動いている」と「進んでいる」を分ける
さらに「動くべきなのに動いていない」を別に判定する
② 進捗の出所は1つに決めない。**最も新しいもの**を採る
③ 生死は3値(生・死・不明)。不明を死に丸めない
④ 「進んでいる証拠がどこに出るか」はジョブに宣言させる
宣言できないジョブは登録させない
⑤ 機械が再開してよいのは、原因が分かっていて手順が確定している場合だけ
⑥ 赤があるうちは新しい作業を投入しない
**この6つは、全部いちど失敗してから足したものです。**最初から思いついたものは1つもありません。
そして、いちばん大事なのはこれでした。
見張りは機械が持つ。人が持つと、その人が止めた瞬間に消える。
**人が持つべきなのは判断だけです。**生成はAIに、検証と見張りは機械に、判断を人に。
この連載
AIエージェントを実際に回して、かかった費用と壊れた箇所を測って書いています。推測は書きません。実測値だけです。
- Claude Code のトークン使用量を実測したら1ターン23万 ― 節約に効いたのは「セッションを切る」だった
https://qiita.com/manabu49-ai/items/2598a30d5140e4445ab6 - 本記事 ― 57分間、死んだジョブを誰も見ていなかった ― AIエージェントの見張りを機械に渡す
- AIに100通り試させたら、効果ゼロなのに5個が「有意」だった
https://qiita.com/manabu49-ai/items/397a018948e1ab7ef29e - 仕様書は2日前から手元にあった ― 読まずに実装した対処が、データを永久に失う設計だった
https://qiita.com/manabu49-ai/items/9b947604d3dc4958fa36 - 次 ― AI初心者が3日でエージェントを24時間回すまで(未公開)
番外 ― Claude のモデル提供終了、猶予は告知から約60日 ― 過去9回を数えて、手元を検査するスクリプトを書いた
https://qiita.com/manabu49-ai/items/bc2a7be2a6ee4ceb6185
**無料記事は結論まで全部書きます。**出し惜しみはしません。
この記事は note / Zenn にも同じ内容で公開しています(筆者本人による転載)。