AIを長時間動かしていて、いちばん無駄なのは待っている時間です。
実行役のAIが長い作業をしている間、司令塔のAIは「終わった?」「まだ?」と見に行きます。 見に行くたびにトークンを使う。何もしていないのに減る。
これをやめる仕組みを作りました。 司令塔は一切ポーリングしません。終わった側から叩き起こされます。
作ったのは常駐スクリプト1本、280行です。 ただ、そこで踏んだ罠2つのほうが、たぶん役に立ちます。先に書いておきます。
⚠ を print した瞬間に落ちるwait --for tui-idle は、TUIでない相手だと指定時間まるごと待つどちらも動かしてみるまで分からない種類のもので、 どちらも常駐スクリプトでは致命的でした。
| 役 | 担当 | なぜそこか |
|---|---|---|
| 計画 | 高価だが賢いAI | 手順を書くのは一度きり。ここは質で選ぶ |
| 実行 | 安価で長時間動くAI | 同じことを何十回もやる。ここは単価で選ぶ |
| 司令塔 | 判断するAI | 結果と実物を突き合わせ、次を決める。判断のときだけ動く |
実際の並びはこうなります。
計画役がsteps/STEP01.mdを置く
→ 実行役が実行 →STEP01.result.mdとSTEP01.doneを置く
→ ★常駐スクリプトが検知 → 司令塔のペインへ通知を投げ込む
→ 司令塔が result と実物を突き合わせる
定型どおり → 司令塔が自分で STEP02 を書く(計画役を呼ばない=安い)
想定と違う → 計画役に要約を渡して直させる
判断が要る → 人に聞く(ここだけ止まる)
肝は★の行です。司令塔は見張りません。
STEP01.done という空ファイルが置かれた瞬間、常駐スクリプトが司令塔の端末に
プロンプトを打ち込みます。司令塔は、呼ばれるまで完全に停止しています。
「長く寝ているローカルプロセスに仕事をさせ、AIは呼ばれた時だけ動く」形自体は、 前に作ったローカルハブと同じ考え方です。新しい発想ではありません。 新しいのは、それをAIどうしの受け渡しに使ったところです。
AIどうしを直接つながない。間にファイルを置きます。
実行役は STEP01.done を置くだけ。司令塔はそれを見るだけ。
お互いの存在を知らなくていいので、片方を別のAIに差し替えても壊れません。
STEP01.done を置くのは実行役だけ。誰も改名・編集しません。
「処理済みかどうか」は、常駐スクリプトが自分の状態ファイルで持ちます。
人が目視で追う印が要るなら STEP01.reviewed という別のファイルを置きます。
2人が同じファイルを触った瞬間、どちらが最後に書いたかで結果が変わります。 分散システムの難しい話ではなく、単に事故ります。
常駐スクリプトはメモリに何も溜めません。 だから落ちてもいい。再起動するとフォルダを全部見直し、 死んでいた間に置かれたマーカーも拾い直します。
このスクリプトは「たまたま起きている便利屋」であって、いなくても状態は壊れない。
引き継ぎもここで効きます。別のAIに渡すとき、渡すのはファイルの場所だけでいい。 会話の履歴を持っていく必要がありません。
⚠ を print したら落ちたWindowsの既定コンソールは cp932。UTF-8ではありません。
print("⚠ 司令塔のペインが見つかりません")
# UnicodeEncodeError: 'cp932' codec can't encode character '⚠'
⚠ も ★ も cp932 にありません。その行に到達した瞬間にスクリプトが死にます。
普通のスクリプトなら「文字化けした、直そう」で済みます。 しかし常駐スクリプトでは意味が変わります。
落ちるのは正常系ではなく、エラーを報告しようとした行です。 つまり「何かおかしいことが起きた」→「それを知らせようとして死ぬ」→ 誰も気づかないまま監視が止まる。いちばん困る壊れ方をします。
対策は冒頭に3行。
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
stderr も一緒に直します。トレースバックのほうが先に出る場面があるからです。
errors="replace" を付けておくのは、リダイレクト先によっては
reconfigure が効かない場合の保険。ここで落ちるくらいなら化けたほうがいい。
司令塔の端末に文字を打ち込むので、相手が作業中だと入力が混ざります。 だから送る前に「手が空くまで待つ」ようにしました。使ったのはこれです。
terminal wait --for tui-idle --timeout-ms 120000
TUI(画面を描き替えるタイプの端末)が落ち着くまで待つ、という指示です。
相手がTUIでないと、この条件は永遠に成立しません。 素のシェルに対して投げたところ、120秒まるごと待たされました(実測)。 タイムアウトするまで、ただ止まっています。
5秒間隔で見に行く常駐スクリプトが、1回の通知で2分固まる。
その間に次の .done が置かれても気づきません。
直し方は2つあります。
2つめが本題です。「待てなかった」は「送るな」ではありません。
w = orca("terminal", "wait", ...)
if not (w and w.get("ok")):
log("(相手が手を止めるのを待てなかったのでそのまま送ります)")
# ← ここで return せず、そのまま送信へ進む
d = orca("terminal", "send", ...)
待ちは best-effort。成立すれば嬉しいが、成立しなくても仕事は進める。 これを「失敗したら中止」で書いていたら、通知が1件も届かない仕組みになっていました。
送信に失敗しても「処理済み」にする。
一見おかしい。失敗したのだから、次の周期でもう一度試すべきに見えます。 だが5秒間隔で回るループでは、それは永久リトライになります。 相手が見つからない状態が10分続けば、120回叩きに行く。
なので、こうしました。
取りこぼしは、リトライではなくキューで拾う。 自動で消えるのは仕組みの信頼を壊しますが、 別の場所に残っていれば、あとから人が拾えます。
言えること。作って、使い捨ての端末を立てて、通しで検証しました。
STEP01.done / STEP02.blocked を検知し、.md や無関係なファイルは無視する言えないこと。
効果を書きたい気持ちはありますが、書けるのは数字を取ってからです。 この判断自体、以前に測っていない数字を公開した失敗から来ています。 あのとき決めた「公開する数字には標本数を書く」を、ここでも守ります。
stdout/stderr を UTF-8 に。⚠ で死ぬ仕組みの話より、後半2つのほうが明日から使えると思います。
※ この記事の内容は、記事公開日に作成・検証したものです。実運用の記録ではありません。 効果(トークン消費の増減など)は測定していないため、数値は書いていません。