木曜の夜、同じ依頼を三度書き直していました。個人開発の作業は、詰まった時間をそのまま自分で払うことになります。記事一覧ページの改修を Antigravity に頼み、返ってきた差分を見て、チェックポイントまで巻き戻す。文言を丁寧にし直して、また巻き戻す。三度目にようやく通ったころには、自分で書いたほうが早かったと分かる時間になっていました。
翌朝、直近の依頼ログを CSV に落として眺めました。すると、巻き戻した依頼にはひとつの共通点がありました。プロンプトの丁寧さとは無関係だったのです。巻き戻した依頼は、例外なく「触るファイル数」か「含まれる意思決定の数」のどちらかが多かった。
これは主観の話ではなく、送信前に数えられる量です。数えられるなら、送信前に判定できます。この記事は、その判定器を作って自分の依頼ログ80件に当ててみた記録です。
差し戻し率という一本の指標
「エージェントの出力品質」は曖昧です。良い・悪いを人が採点すると、その日の疲労で揺れます。そこで指標を一本に絞りました。
差し戻し率(rework rate)= その依頼が一発で採用されず、巻き戻し・大幅な書き直し・追加指示による軌道修正を要した割合。
計測は単純です。依頼ごとに accepted / reworked の二値を記録するだけ。Antigravity のチェックポイントを使っていれば、ロールバックの有無がそのまま reworked の判定になります。私はエディタのセッションログから抽出しましたが、手動でメモしても構いません。重要なのは、主観の五段階評価ではなく二値にすることです。二値なら、あとから相関が取れます。
三週間ぶんの依頼、80件。差し戻し率は全体で 41% でした。半分近くをやり直していたわけです。この数字を初めて見たとき、胸のあたりが少し重くなりました。
送信前に採点する — preflight スコアラー
依頼文からスコアを出します。目的は「大きすぎる依頼を送信前に止める」ことだけなので、構文解析まではしません。以下の6因子を数えます。
因子 数え方 重み 根拠
推定変更ファイル数 依頼文中のファイル名・コンポーネント名・「〜も」の列挙数 ×2.0 6ファイル以上で後半の整合性が崩れる
未決定の意思決定点 「どうする」「最適な」「いい感じに」等の判断委譲表現 ×3.0 最も差し戻しに効く因子
実装とテストの同居 「テストも」「テストまで」の有無 ×2.5 思考モードが異なり片方が雑になる
接続詞による抱き合わせ 「ついでに」「あわせて」「ついでに言うと」 ×2.0 抱き合わせは意思決定の追加を隠す
恒久ルールの混入 「型は any を使わない」等プロジェクト全体の規約 ×1.0 注意が本題から分散する
自己完結性の欠如 「あとで繋ぐ」「一旦」「仮で」 ×1.5 中間成果物は後工程で辻褄合わせが増える
重みは天下りではありません。最初は全て 1.0 で始め、80件のログでロジスティック回帰を掛けて係数の大小関係だけを見て、扱いやすい数字に丸めました。厳密な回帰係数を使うほどのデータ量ではないので、順序が保たれていれば十分だと判断しています。
# preflight_score.py — 依頼文をエージェントに送る前に採点する
import re
from dataclasses import dataclass
DECISION_MARKERS = [
"どうする" , "どうすべき" , "最適な" , "いい感じ" , "よしなに" ,
"適切に" , "任せる" , "決めて" , "選んで" ,
]
TEST_MARKERS = [ "テストも" , "テストまで" , "テストも書いて" , "テストを含め" ]
BUNDLE_MARKERS = [ "ついでに" , "あわせて" , "同時に" , "その流れで" ]
POLICY_MARKERS = [ "any を使わない" , "コメントは日本語" , "命名規則" , "フォーマッタ" ]
DEFERRAL_MARKERS = [ "あとで繋" , "一旦" , "仮で" , "とりあえず形だけ" ]
# ファイル・モジュールらしき語のパターン(拡張子つき / PascalCase コンポーネント)
FILE_PAT = re.compile( r " [\w ./- ] + \. ( tsx ?| jsx ?| py | go | rs | mdx ?| css )\b " )
COMPONENT_PAT = re.compile( r " \b[ A-Z ][ a-zA-Z0-9 ] {2,} \b " )
WEIGHTS = {
"files" : 2.0 , "decisions" : 3.0 , "tests" : 2.5 ,
"bundles" : 2.0 , "policies" : 1.0 , "deferrals" : 1.5 ,
}
@dataclass
class Score :
total: float
factors: dict
@ property
def band (self) -> str :
if self .total < 6 :
return "GREEN"
if self .total < 12 :
return "AMBER"
return "RED"
def _count (text: str , markers: list[ str ]) -> int :
return sum (text.count(m) for m in markers)
def score_request (text: str ) -> Score:
files = len ( set ( FILE_PAT .findall(text))) + len ( set ( COMPONENT_PAT .findall(text)))
factors = {
# ファイル数は 3 までは無害。超過分だけを罰する
"files" : max ( 0 , files - 3 ),
"decisions" : _count(text, DECISION_MARKERS ),
"tests" : _count(text, TEST_MARKERS ),
"bundles" : _count(text, BUNDLE_MARKERS ),
"policies" : _count(text, POLICY_MARKERS ),
"deferrals" : _count(text, DEFERRAL_MARKERS ),
}
total = sum ( WEIGHTS [k] * v for k, v in factors.items())
return Score( total = total, factors = factors)
if __name__ == "__main__" :
import sys
body = sys.stdin.read()
s = score_request(body)
print ( f "score= { s.total :.1f } band= { s.band } " )
for k, v in s.factors.items():
if v:
print ( f " { k } : { v } (+ { WEIGHTS [k] * v :.1f } )" )
sys.exit( 0 if s.band != "RED" else 1 )
ファイル数を「3 を超えた分だけ罰する」形にしたのは、実装上の細かい判断ですが効きます。素直に本数へ比例させると、単一ファイルの小さな依頼まで軽くスコアが乗ってしまい、閾値の意味が薄れました。無害な領域を 0 点に潰しておくと、閾値が「危険の始まる点」を素直に表します。
意思決定マーカーの重みが最も大きいのも、実測から来ています。「認証を追加して」という一文には、セッション管理・トークン保存先・失敗時の UX という三つの判断が畳み込まれています。エージェントはそれを尋ね返さず、最も一般的な選択肢を黙って選びます。その選択が既存コードの流儀と衝突したとき、差分は静かに壊れます。
80件を後ろ向きに採点する
過去の依頼文をそのままスコアラーに通し、記録済みの accepted / reworked と突き合わせました。
# requests.jsonl: {"text": "...", "outcome": "accepted"|"reworked"}
python3 - << 'PY'
import json, collections
from preflight_score import score_request
bands = collections.defaultdict(lambda: {"accepted": 0, "reworked": 0})
for line in open("requests.jsonl"):
r = json.loads(line)
bands[score_request(r["text"]).band][r["outcome"]] += 1
for band in ("GREEN", "AMBER", "RED"):
a, w = bands[band]["accepted"], bands[band]["reworked"]
n = a + w
if n:
print(f"{band:6} n={n:3d} rework={w / n:.0%}")
PY
出力は次のとおりでした。
スコア帯 件数 一発通過 差し戻し率
GREEN(< 6.0) 34 30 12%
AMBER(6.0〜11.9) 27 16 41%
RED(≧ 12.0) 19 1 95%
RED 帯 19 件のうち、一発で通ったのは 1 件だけでした。その 1 件は「依存パッケージのバージョンを一括更新して」という、意思決定が実質ゼロで機械的な横断変更です。スコアラーはファイル数だけを見て RED を出しましたが、判断の余地がなかったため通った。ここに、この指標の限界がはっきり出ています。
ファイル数は「意思決定が多いこと」の代理変数にすぎません。 代理変数が本体と乖離する領域では外れます。運用では、機械的な一括変更に --mechanical フラグを与えてファイル因子を無効化する回避口を作りました。回避口を持たない閾値は、いずれ無視されるようになります。無視される閾値は、無いのと同じです。
本番運用に持ち込んでから気づいた落とし穴がもうひとつあります。スコアラーを CI に置いて依頼テンプレートを検査させたところ、テンプレート中の変数プレースホルダがコンポーネント名として数えられ、常時 RED になりました。プレースホルダを小文字のスネークケースに統一するだけで回避できましたが、ヒューリスティクスは「人が読む文章」を前提に書かれている、という当たり前のことを忘れていたのです。
スコアを下げる三つの分割軸
RED や AMBER が出たとき、闇雲に短くしても意味がありません。因子ごとに、効く切り方が違います。
軸1: 意思決定を前段に切り出す
decisions が乗っているなら、実装依頼の前に設計相談だけを独立させます。「セッションは Cookie と KV のどちらが妥当ですか。既存の Worker 構成を前提に」と尋ねて、返答を採用してから実装を頼む。私の記録では、この一手を挟んだ依頼の差し戻し率は 41% から 15% に落ちました。設計相談は 5 分で終わり、書き直しは 1 時間かかります。
軸2: 依存を含めて一塊にする
deferrals が乗っているとき、分割は逆効果です。「戻り値を Promise にして」だけを頼むと呼び出し元が壊れます。呼び出し元 3 箇所の async/await 対応まで含めて一塊にしたほうが、ファイル数は増えてもスコアは下がり、結果も安定しました。1 タスクの終わりで動く状態になる 、という切り方だけは譲らないほうがよいです。
軸3: テストを後続に回す
tests は無条件に分離します。実装差分が確定してから「この差分に対するテストを」と頼むほうが、テストの網羅性も上がります。実装と同時に書かれたテストは、実装の誤りをそのまま固定化しがちでした。
恒久ルールは AGENTS.md に逃がす
policies 因子が乗る依頼は、粒度の問題ではなくチャンネルの選択ミスです。「型は any を使わない」「コメントは日本語で」は、毎回のプロンプトではなく AGENTS.md に置くべき情報です。
ここには実用上のこつがあります。ルールを書くだけでは守られないことがあるので、守られているかを検査する側も同じファイルから読む ようにしておきます。
# agents_rules.py — AGENTS.md の規約を CI からも読めるようにする
import re, pathlib
def load_rules (path = "AGENTS.md" ) -> dict[ str , str ]:
"""`- rule_id: 説明` 形式の箇条書きだけを抽出する"""
text = pathlib.Path(path).read_text( encoding = "utf-8" )
return dict (re.findall( r " ^ - \s + ([ a-z0-9_ ] + ) : \s * (. + )$ " , text, re.M))
def assert_known (rule_id: str ) -> str :
rules = load_rules()
if rule_id not in rules:
raise KeyError ( f "AGENTS.md に { rule_id } の定義がありません" )
return rules[rule_id]
規約の実体を一箇所に置き、プロンプトからも CI からも同じ ID で参照する。こうすると「AGENTS.md には書いてあるが誰も見ていない」という状態を、CI が失敗することで検出できます。私は no_any・ja_comments・test_framework の三つから始めました。三つで十分でした。増やすほど守られなくなります。最初に置く規約は三つまでに絞ることをお勧めします。
送信前に強制する
スコアラーは、思い出したときだけ使うと使わなくなります。エディタから叩くシェル関数にして、RED のときだけ確認を挟むようにしました。
# ~/.zshrc
ag-ask () {
local body
body = "$( cat )" # 依頼文を標準入力から受ける
if ! printf '%s' " $body " | python3 ~/bin/preflight_score.py ; then
printf '\nRED です。分割してから送りますか? [y/N] ' >&2
read -r ans
[ " $ans " = "y" ] || return 1
fi
printf '%s' " $body " | pbcopy # 通過したらクリップボードへ
echo "→ クリップボードに入れました" >&2
}
止めるのではなく、一拍おくだけです。「本当に送りますか」と一度尋ねられると、半分くらいは自分で分割し直します。強制ブロックにすると回避策を探し始めるので、摩擦は小さく、しかし確実に置く。この加減が続けるためのすべてでした。
導入から三週間後、全体の差し戻し率は 41% から 19% に下がりました。スコアラーが賢くなったからではありません。RED を送らなくなっただけです。指標が変えたのは、エージェントではなく私の手でした。私自身、閾値を守るために依頼を分割する時間が、巻き戻しに費やしていた時間より短いことに、しばらく気づけずにいました。
三週間の運用で見えたこと
スコアが低いのに差し戻された依頼を、あとから読み返しました。共通していたのは、依頼文の外に情報があったことです。「昨日話した件の続きで」と書かれた依頼は、スコアラーからは 2.0 点の無害な依頼に見えます。しかしエージェントには昨日の文脈がありません。
スコアラーは依頼文の中しか見られません。 依頼文の外に依存した瞬間、この指標は沈黙します。会話の継続を前提とする依頼には、継続そのものを明示的に書き直す — 面倒ですが、それが唯一の対処でした。今は deferrals の隣に「暗黙の文脈参照」を数える因子を足そうか迷っています。「昨日」「さっき」「例の」を数えるだけの、また別のヒューリスティクスです。
もうひとつ。スコアが低く一発で通った依頼のうち、いくつかはそもそもエージェントに頼むべきではない小ささ でした。if (x > 0) を if (x >= 0) に変える依頼は、GREEN で通り、そして自分で編集したほうが速い。差し戻し率は下限側の無駄を検出しません。指標は片側しか照らさないのだと、当たり前のことを実装してから知りました。
計測を始めるまで、私は自分がプロンプトを下手に書いていると思っていました。実際には、書き方は問題ではなかった。何を一度に頼むかという、送信前に数えられる量の話でした。数えられるものは減らせます。減らせるものから減らせばいい。
自分の依頼ログを 20 件でよいので二値でラベル付けし、上のスコアラーに通してみてください。相関が出るなら、閾値はあなたの手元の数字で決められます。実装の参考になれば幸いです。