夜の 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が入力の枯れを、それぞれ別の角度から知らせてくれます。
しきい値は、指標とセットでしか意味を持ちません
上のゲートには novelty_floor: float = 0.35 という既定値が置いてあります。書いた当時の私は、この 0.35 を「だいたいこのくらい」で決めていました。いま思えば、この一行がいちばん危なかったのです。
novelty_score() を「既存コーパスとの類似度の裏返し」として実装するとき、素直に書ける方法が少なくともふたつあります。どちらも数行で済みます。
- Python 標準の
difflib.SequenceMatcher で全件と突き合わせ、最大の quick_ratio() を類似度とする方法
- 本文を 3-gram のシングルに割り、転置インデックスで候補を絞ってから Jaccard 係数の最大値を類似度とする方法
このふたつを、手元の Linux 環境(Python 3.10)で、Antigravity Lab の日本語記事 1,059 本に対して実際に走らせて測りました。コードブロックと HTML タグを落とした本文のみ、中央値は 3,372 字です。1 本を既存コーパス全体と突き合わせる処理を、乱数シードを固定して繰り返した結果が次の表になります。
| 実装 | 1 本あたりの所要 | 事前準備 |
| difflib 総当たり(各 4,000 字に切って比較) | 2,336 ms | なし |
| 3-gram シングル+転置インデックス | 64 ms | シングル生成 1.0 秒+索引 2.8 秒 |
速さの差は 36 倍でした。ここだけ見れば、転置インデックス版に差し替えれば済む話に見えます。ところが同じ計測のなかで、速さよりもずっと効く事実が出てきました。ふたつの実装は、最近傍スコアの分布がまったく重なっていません。
| 類似度の測り方 | 最近傍スコアの中央値 | 観測レンジ | 0.35 との関係 |
| 3-gram Jaccard(120 本) | 0.091 | 0.057 – 0.150 | 全件が 0.35 未満 |
| difflib quick_ratio(12 本) | 0.730 | 0.668 – 0.747 | 全件が 0.35 以上 |
novelty を 1 - similarity と定義して novelty < 0.35 で棄権させると、挙動はこうなります。Jaccard 版では novelty が 0.9 前後に張り付きますので、この条件は一度も成立しません。新規性ゲートは存在するだけで、実際には何も判定していない状態です。quick_ratio 版では novelty が 0.27 前後に収まりますので、今度は全候補が無条件に棄権します。
同じ 0.35 が、実装を差し替えただけで「絶対に発火しない」から「必ず発火する」へ反転しました。厄介なのは前者のほうです——ゲートは通っているように見えて、条件がひとつ死んでいるだけ。この形の故障は、何にも一致しないつもりの 1 行が全コマンドを承認していた記録で見たものと同じ構造でした。
ついでに、速い実装で遅い実装を近似できるかも確かめました。difflib の最近傍が、転置インデックスの上位 50 候補に含まれた割合は 15 本中 6 本です。片方をもう片方の高速版だと思って差し替えると、判定の意味そのものが変わります。どちらを選んでもよいのですが、選び直したらしきい値も同じコミットで引き直す必要があるのだと思います。
そこで、しきい値を指標 ID と実測列に束ね、観測レンジの外に置かれたものは起動時に落とすようにしました。
from __future__ import annotations
from dataclasses import dataclass
import statistics
class VacuousThreshold(RuntimeError):
"""観測分布の外にあるしきい値。起動時に落とす。"""
@dataclass(frozen=True)
class CalibratedFloor:
"""しきい値を「どの指標で測ったか」と束ねて持つ。
metric_id が変われば floor も必ず引き直す。samples は自分のコーパスで
測った最近傍スコアの実測列(novelty = 1 - similarity に変換済み)。
"""
metric_id: str
floor: float
samples: tuple[float, ...]
def __post_init__(self) -> None:
if len(self.samples) < 30:
raise VacuousThreshold(
f"{self.metric_id}: 実測サンプルが {len(self.samples)} 本。30 本以上で校正する"
)
lo, hi = min(self.samples), max(self.samples)
if not (lo <= self.floor <= hi):
side = "全候補が常に通過" if self.floor < lo else "全候補が常に棄権"
raise VacuousThreshold(
f"{self.metric_id}: floor={self.floor} が観測レンジ "
f"[{lo:.3f}, {hi:.3f}] の外です({side})"
)
def fires_on(self) -> float:
"""このしきい値が実測サンプルのうち何割を棄権させるか。"""
return sum(1 for v in self.samples if v < self.floor) / len(self.samples)
def describe(self) -> str:
return (
f"{self.metric_id}: floor={self.floor:.3f} "
f"median={statistics.median(self.samples):.3f} "
f"棄権率見込み={self.fires_on():.0%}"
)
def load_novelty_samples(path: str) -> tuple[float, ...]:
"""校正用の実測列を読む。similarity を 1 から引いて novelty にする。"""
with open(path, encoding="utf-8") as fp:
sims = [float(line) for line in fp if line.strip()]
return tuple(1.0 - s for s in sims)
先ほどの 120 本の実測列をそのまま食わせると、出力はこうなりました。
samples: 120
OK : jaccard3gram-novelty-v1: floor=0.900 median=0.909 棄権率見込み=25%
BLOCK: jaccard3gram-novelty-v1: floor=0.35 が観測レンジ [0.850, 0.943] の外です(全候補が常に通過)
OK : jaccard3gram-novelty-v1: floor=0.860 median=0.909 棄権率見込み=1%
fires_on() を添えているのは、しきい値を決めた瞬間に「これは何割を棄権させる設定なのか」を言葉にしておきたかったからです。0.900 と 0.860 の差は数字の上では 0.04 ですが、棄権率の見込みは 25 % と 1 % に分かれます。この幅を見ないままコミットしていた頃の自分に、いちばん見せたい 3 行かもしれません。
グラウンディングが欠けたら、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 に昇格させる仕組みを一度入れて、すぐ外しました。修復は表面の違反マーカーを消すだけで、根にある「純増でない」状態は残ります。しかもその痕跡は、後から見つけにくいのです。違反はゲートの側で表に出し、固有の文脈で書き直して再生成させるほうが、結果的に速く、きれいでした。この判断は、生成候補を三値ループで扱う検証ループの三パターンの考え方とも地続きです。
導入の順序と、最初に引く一線
いきなり全部を入れ替える必要はありません。次の順序で、軽いところから足していくのが安全でした。
- まず
classify_run だけを差し込み、HEALTHY_ABSTAIN をアラート対象から外します。これだけで夜間の偽アラートが止まります。
- 次に棄権率の記録を足し、二週間ぶん眺めてから傾向判定を有効にします。数字の手触りを掴む前にしきい値を決めても、たいてい外します。
- 最後に grounding の fail-closed を入れます。ここは一度沈黙事故を経験すると、価値が身に沁みます。
最初に引くべき一線は、ひとつだけです。成功指標のトップに「作った数」を据えません。 そこさえ守れば、ゲートは緩む方向への圧力から解放されます。0 本の夜を、成功として数えられるようになります。個人でエージェントを回していると、この静けさそのものが運用のコストを下げてくれるのかもしれません。
まだ私自身も調整を続けている途中ですが、同じように無人の生成パイプラインと向き合っている方の設計の一助になれば幸いです。お読みいただき、ありがとうございました。