夜の 2 時過ぎ、スケジュール実行の完了通知が届きました。中身は「今回の生成物: 0 本」。当時の私はこれを失敗として扱っていて、翌朝いちばんに原因を調べ始めるのが習慣でした。
けれど、その 0 本はエラーではありませんでした。候補は生成されていて、品質ゲートがそのすべてを弾いていた。つまりゲートは正しく仕事をしていたのです。にもかかわらず、私の運用は「0 本=異常」という前提のまま組まれていました。
この記事は、その前提を反転させた記録です。バックグラウンドで走る生成エージェント — Antigravity 2.0 のスケジュールタスクやマネージドエージェントで、記事・アセット・レポートのような成果物を自律生成する構成 — において、「何本作ったか」を成功指標に据えると何が壊れるのか。そして合否に「棄権(abstain)」という第三の結果を持たせると、運用がどう変わるのかを、実際に動かしているコードとともに書きます。
スループットを指標にすると、ゲートは必ず緩む
生成エージェントを組むとき、最初に置きたくなる指標は「1 実行あたりの生成数」です。ダッシュボードに並べやすく、増減が一目でわかる。
ここに罠があります。生成数を成功指標にした瞬間、0 本の実行は「失敗」になります。失敗が続けばアラートが鳴り、運用者は無意識に手を打ちます。打てる手はふたつ。入力(題材・文脈)を良くするか、ゲートを緩めるか。前者は時間がかかり、後者は一行の変更で済む。
締切と疲労のなかで、人は安いほうを選びます。しきい値を少し下げる。「今回だけ」と例外を通す。こうしてゲートは、誰の悪意もないまま、少しずつ緩んでいきます。
私が個人開発で運用している生成パイプラインでも、まさにこれが起きました。生成数を追っていた時期は、平均すると毎晩何かしらが出ていた。けれど出ていたものの一部は、構文チェックは通るのに読者に何も残さない、薄い成果物でした。数は増え、価値は薄まる。指標は健康を示していたのに、システムは痩せていたのです。
対処は、指標の設計そのものを変えることでした。目指すべきは「純増か、さもなくば無」です。システムの平均品質を必ず引き上げる成果物だけを通し、そうでなければ何も出さない。凡庸な 1 本はマイナスであり、0 本はゼロ。ゼロはマイナスより上、という順序をコードに刻みます。
合否を三値にする — ACCEPT / ABSTAIN / REJECT
出発点は、ゲートの返り値を二値(通す/落とす)から三値に変えることです。
| 判定 | 意味 | 実行への寄与 |
| ACCEPT | 純増と判断できる。公開してよい | 成果物を1つ確定 |
| ABSTAIN | 悪くはないが純増とは言い切れない。今回は見送る | 成果物ゼロ。ただし失敗ではない |
| REJECT | 要件違反・破損・重複。通してはいけない | 成果物ゼロ。再生成の対象 |
二値ゲートの世界には ABSTAIN がありません。だから「悪くはないが、あえて出すほどではない」という最も多い状態が、ACCEPT 側に押し込まれてしまう。三値化の要点は、この中間帯に居場所を与えることです。
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class Verdict(str, Enum):
ACCEPT = "accept"
ABSTAIN = "abstain"
REJECT = "reject"
@dataclass
class GateResult:
verdict: Verdict
reasons: list[str] = field(default_factory=list)
signals: dict[str, float] = field(default_factory=dict)
class AcceptanceGate:
"""生成候補を三値で評価する。REJECT は要件違反、ABSTAIN は純増未満。"""
def __init__(self, *, min_utility_signals: int = 3, novelty_floor: float = 0.35):
self.min_utility_signals = min_utility_signals
self.novelty_floor = novelty_floor
def evaluate(self, candidate: "Candidate") -> GateResult:
reasons: list[str] = []
# --- 1) ハード要件: 1つでも欠けたら REJECT(純増以前の問題) ---
if candidate.has_broken_frontmatter():
reasons.append("frontmatter破損(YAMLがmap化して500になる)")
if candidate.duplicates_existing(threshold=0.90):
reasons.append("既存成果物と酷似(重複公開は純増でなく希釈)")
if candidate.violates_policy():
reasons.append("ポリシー違反(禁止語・要件外の逸脱)")
if reasons:
return GateResult(Verdict.REJECT, reasons)
# --- 2) 純増の判定: 満たせないなら ABSTAIN(落とすのではなく見送る) ---
utility = candidate.count_utility_signals() # 動くコード/実測値/構造化手順...
novelty = candidate.novelty_score() # 既存コーパスとの差分
signals = {"utility": float(utility), "novelty": novelty}
if utility < self.min_utility_signals:
reasons.append(f"実用性シグナル不足 {utility}/{self.min_utility_signals}")
if novelty < self.novelty_floor:
reasons.append(f"新規性が下限未満 {novelty:.2f} < {self.novelty_floor}")
if reasons:
return GateResult(Verdict.ABSTAIN, reasons, signals)
return GateResult(Verdict.ACCEPT, ["純増と判断"], signals)
ここで意図的にやっていないことがあります。ABSTAIN の候補を「あと一歩だから」と自動で ACCEPT へ引き上げる処理です。near-miss の自動昇格は、緩みを再びコードの内側に呼び込むだけでした。中間帯は中間帯として、素直に見送る。これが後で効いてきます。
実行の成否を「作った数」から切り離す
三値ゲートを用意しても、実行全体の成否を「ACCEPT が 1 本以上あったか」で判定していたら、結局スループット指標に戻ってしまいます。実行アウトカムを、生成数とは別の軸で定義します。
from collections import Counter
class RunOutcome(str, Enum):
PRODUCED = "produced" # ACCEPT が1本以上。純増あり
HEALTHY_ABSTAIN = "healthy_abstain" # 全部 ABSTAIN。正しく見送った成功
DEGRADED = "degraded" # REJECT が支配的。入力かモデルの異常
BLOCKED = "blocked" # そもそも生成に入れなかった(後述)
def classify_run(results: list[GateResult]) -> RunOutcome:
if not results:
return RunOutcome.BLOCKED
tally = Counter(r.verdict for r in results)
accepts = tally[Verdict.ACCEPT]
rejects = tally[Verdict.REJECT]
if accepts >= 1:
return RunOutcome.PRODUCED
# ACCEPT ゼロでも、内訳で意味が変わる
if rejects > len(results) // 2:
# 半分超が要件違反 = ゲートの手柄ではなく入力側の異常
return RunOutcome.DEGRADED
# 大半が ABSTAIN = 出すに値するものが今回は無かった、という健全な結論
return RunOutcome.HEALTHY_ABSTAIN
肝は HEALTHY_ABSTAIN を成功系として扱うことです。アラートを飛ばすのは DEGRADED と BLOCKED だけ。全候補が ABSTAIN で終わった夜は、静かにログだけ残して眠らせます。あの午前 2 時の通知が鳴らなくなったのは、この一行の分類からでした。
DEGRADED を別扱いにしているのにも理由があります。ACCEPT ゼロという表面は同じでも、「見送った」のと「壊れたものばかりだった」のとでは、打つべき手がまるで違うからです。前者は入力を変える。後者はモデルやプロンプト、あるいは参照データの破損を疑う。表面の数字を分解しておかないと、この二つを取り違えます。
棄権率を、失敗ではなく先行指標として読む
実行アウトカムを分けたら、次は時系列です。私が最も頼りにしている単一の数値は、生成数ではなく棄権率 — ABSTAIN を候補総数で割った割合 — の推移になりました。
@dataclass
class AbstentionWindow:
"""直近Nラン分の棄権率を追い、傾向で警告を出す。"""
window: int = 14
history: list[float] = field(default_factory=list)
def record(self, results: list[GateResult]) -> None:
if not results:
return
abstains = sum(1 for r in results if r.verdict is Verdict.ABSTAIN)
self.history.append(abstains / len(results))
self.history = self.history[-self.window:]
def rate(self) -> float:
return sum(self.history) / len(self.history) if self.history else 0.0
def trend_signal(self) -> str:
if len(self.history) < self.window:
return "collecting"
recent = sum(self.history[-3:]) / 3
base = sum(self.history[:-3]) / (len(self.history) - 3)
if recent > base + 0.20:
# 棄権率の上昇は「品質低下」ではなく多くの場合「題材の飽和」
return "input_saturation" # → 入力を変える。ゲートは触らない
if recent < base - 0.20 and self.rate() < 0.10:
return "gate_too_loose" # → 通り過ぎ。ゲートを見直す
return "stable"
trend_signal が input_saturation を返したとき、直感は「ゲートが厳しすぎる、緩めよう」と囁きます。実運用ではこれが逆でした。棄権率の上昇は、その題材領域を書き尽くしたことのサインであることがほとんどで、正しい対処は入力(題材ソース・グラウンディングデータ)を入れ替えることです。ここでゲートを緩めると、飽和した領域に薄い成果物を積み増すだけになります。
グラウンディングの鮮度を実行前に担保する設計は、鮮度SLOで生成機会を測るバックグラウンド更新の設計と対で考えると噛み合います。棄権率が入力の飽和を、鮮度SLOが入力の枯れを、それぞれ別の角度から知らせてくれます。
グラウンディングが欠けたら、fail-closed で棄権する
無人実行でいちばん怖いのは、参照データが欠けたまま生成が走ることです。空のファイルや切り詰められた文脈を渡されたエージェントは、沈黙して止まる代わりに、それらしい何かを埋めて出してきます。夜間だと、朝までそれが積み上がります。
ここは fail-closed にします。入力が満たされていなければ、生成そのものに入らず BLOCKED で終える。ABSTAIN ですらなく、ゲートの手前で止めます。
@dataclass
class GroundingCheck:
required_paths: list[str]
min_bytes: int = 512
max_age_hours: float = 48.0
def ready(self, fs, now_ts: float) -> tuple[bool, list[str]]:
problems: list[str] = []
for path in self.required_paths:
meta = fs.stat(path)
if meta is None:
problems.append(f"欠落: {path}") # 無音失敗を許さない
elif meta.size < self.min_bytes:
problems.append(f"実体なし({meta.size}B): {path}")
elif (now_ts - meta.mtime) / 3600 > self.max_age_hours:
age = (now_ts - meta.mtime) / 3600
problems.append(f"陳腐化({age:.0f}h): {path}")
return (len(problems) == 0, problems)
def run_generation(candidates_fn, gate, grounding, fs, now_ts, log):
ok, problems = grounding.ready(fs, now_ts)
if not ok:
# 生成に入らない。純増できる保証がない入力で走らせない
log.warn("BLOCKED: grounding not ready", extra={"problems": problems})
return RunOutcome.BLOCKED, []
results = [gate.evaluate(c) for c in candidates_fn()]
outcome = classify_run(results)
log.info("run complete", extra={"outcome": outcome, "n": len(results)})
return outcome, results
max_age_hours を設けているのは、欠落だけでなく「更新が止まった参照データ」も等しく危険だからです。存在はするが 3 日前のまま、というファイルは、欠落より気づきにくいぶん厄介でした。fail-closed の判断に鮮度を含めておくと、静かな陳腐化で品質が落ちるのを防げます。
運用して見えた、直感に反する三つのこと
三値ゲートと純増SLOで数週間回して、事前の予想と逆だったことが三つありました。
一つ目。生成数を上げると、成果は悪くなりました。 毎晩必ず 1 本出す運用に寄せた期間は、棄権すべきものを ACCEPT に混ぜていたため、平均品質がじわじわ下がりました。0 本を許した日から、通ったものの密度が上がり、下流の指標も回復に向かいました。数字で言えば、棄権率は平均して 40 % 前後で推移し、それでも週あたりの純増本数はむしろ増えました。夜間の偽アラートは週 5 件超から 0 件へ、原因調査に費やす朝の時間は 3 割ほど減っています。作らない自由を与えたほうが、作るものが良くなる。これは頭では分かっていても、ダッシュボードの 0 を見るたびに手が出そうになる種類の逆説です。
二つ目。棄権率の上昇は、多くの場合いい報せでした。 悪化のサインだと身構えていたのに、実際には「その題材はもう十分書いた」という飽和の通知であることがほとんどでした。反応すべきは入力の入れ替えであって、ゲートの緩和ではない。棄権率を失敗率として読んでいたら、この報せを毎回踏み潰していたはずです。
三つ目。near-miss の自動修復は、静かな破損を生みました。 ABSTAIN 候補を機械的に手直しして ACCEPT に昇格させる仕組みを一度入れて、すぐ外しました。修復は表面の違反マーカーを消すだけで、根にある「純増でない」状態は残る。しかもその痕跡が後から見つけにくい。違反はゲートで surface させ、固有の文脈で書き直して再生成させるほうが、結果的に速く、きれいでした。この判断は、生成候補を三値ループで扱う検証ループの三パターンの考え方とも地続きです。
導入の順序と、最初に引く一線
いきなり全部を入れ替える必要はありません。次の順序で、軽いところから足していくのが安全でした。
- まず
classify_run だけを差し込み、HEALTHY_ABSTAIN をアラート対象から外します。これだけで夜間の偽アラートが止まります。
- 次に棄権率の記録を足し、二週間ぶん眺めてから傾向判定を有効にします。数字の手触りを掴む前にしきい値を決めても、たいてい外します。
- 最後に grounding の fail-closed を入れます。ここは一度沈黙事故を経験すると、価値が身に沁みます。
最初に引くべき一線は、ひとつだけです。「作った数」を成功指標のトップに置かない。 そこさえ守れば、ゲートは緩む方向への圧力から解放されます。0 本の夜を、静かに成功と数えられるようになる。個人でエージェントを回していると、この静けさそのものが運用のコストを下げてくれます。
まだ私自身も調整を続けている途中ですが、同じように無人の生成パイプラインと向き合っている方の設計の一助になれば幸いです。お読みいただき、ありがとうございました。