Android CLI のエージェントが標準ツール比で約3倍速く、トークン使用量を約7割削減した、というニュースを見たとき、私自身が最初に頭に浮かべたのは「では同時に何本走らせようか」でした。速くなったぶん並列度を上げれば、こなせる仕事も増えるはずだ——個人開発で複数のアプリとサイトを回していると、つい数の話に引き寄せられます。
けれど少し手を動かしてみて、その直感は外れていました。エージェントが速くなって本当に変わるのは「生み出せる仕事の量」ではなく、「どこで詰まるか」です。モデルが律速だった頃は、増やせば増えた分だけ前に進みました。モデルが速くなった瞬間、列の先頭はレビューと検証ゲートに移ります。そこを見ないまま並列数だけ上げると、速くしたはずなのに、取り込める変更の数は変わらず、見落としだけが増えていきます。
ここでは、速さを並列数に変換してはいけない理由を待ち行列の言葉で整理し、並列数の上限を検証スループットから決め、それを admission controller として強制し、浮いたトークン予算を1件あたりの検証に再投資するまでを、実装と実測値で書いていきます。
3倍速・7割減で本当に変わるのは「どこが詰まるか」
エージェント開発の体感は、律速段階がどこにあるかで決まります。モデルの生成が遅かった時代は、待ち時間のほとんどがモデルでした。だから「速いモデル」「並列化」は素直に効きました。生成が3倍速くなれば、生成待ちは3分の1になります。
問題は、変更が本番に入るまでの工程が生成だけではないことです。エージェントが出した差分は、検証ゲート(型チェック・テスト・lint・eval)を通り、人間のレビューを経て、ようやくマージされます。生成が速くなっても、この後段が同じ速さで広がるわけではありません。型チェックとテストの実行時間は変わりませんし、人間がレビューに使える時間は1日でほぼ固定です。
つまり3倍速・7割減で起きるのは、工程全体の高速化ではなく、律速段階の移動です。先頭はモデルから「検証ゲート+レビュー」へ移ります。ここを見ずに並列数を上げると、生成済みだけれど検証もレビューも終わっていない変更が、列に積み上がっていきます。速くなったのは入口だけで、出口の幅は変わっていないからです。
速さを並列数に変換すると何が起きるか(Little's Law)
詰まりを感覚ではなく数で捉えるために、待ち行列のいちばん基本的な関係を使います。Little's Law です。
L = λ × W
L … 系の中に同時に存在する仕事の数(ここでは「進行中の変更数」= WIP)
λ … スループット(単位時間あたりに系を通り抜ける仕事の数)
W … 1件が系に滞在する平均時間(リードタイム)
ここで系を「生成 → 検証 → レビュー → マージ」全体と見ると、λ(週あたりに本当にマージできる変更数)は後段の能力で決まります。生成をいくら速くしても、検証とレビューが捌ける λ は増えません。
それでも並列数(WIP=L)だけ増やすと、式の関係から W(リードタイム)が伸びるだけです。λ が一定で L が増えれば、W = L / λ は必ず大きくなります。これは「たくさん仕掛かっているのに、1件が出てくるまでがどんどん遅くなる」状態そのものです。私自身、エージェントを速いモデルに替えた直後に並列数を倍にして、まさにこれを再現してしまいました。生成は速いのに、ある変更がマージされるまでの日数はむしろ伸びていたのです。
結論はシンプルです。後段のスループット λ を上げない限り、WIP を増やしても delivered は増えない。 速さは、並列数ではなく別のところに使うべきだ、ということになります。
並列数の上限を、下流ではなく「検証スループット」で決める
では並列数の上限はどう決めるか。外部 API のレート制限のような下流ではなく、いちばん細い後段——多くの個人開発の現場では「人間のレビュー時間」——を基準にします。
決め方は Little's Law をそのまま使います。望ましいリードタイム W_target を先に決め、後段のスループット λ を実測すれば、許容できる WIP の上限が出ます。
WIP_max = λ_review × W_target
例: レビュー+検証で確実に処理できるのが 1日あたり 6 件(λ_review = 6 件/日)、
1件のリードタイム目標を 1 日以内(W_target = 1 日)に置くなら、
WIP_max = 6 × 1 = 6 件
→ 生成がどれだけ速くても、同時に「進行中」にしてよい変更は 6 件まで。
λ_review は願望ではなく実測で入れるのが肝心です。私は2週間ほど、マージできた変更数を毎日数えて中央値を取りました。「やればもっとできるはず」の値ではなく、割り込みや不調の日も含めた現実の値を使います。ここを楽観的に置くと、結局キューがあふれて元の木阿弥になります。
WIP キャップを admission controller で強制する(Python実装)
上限を決めたら、それを仕組みで強制します。スケジューラがタスクを生成する手前に、admission controller(受け入れ制御)を1枚かませ、進行中が WIP_max に達していたら新しいタスクの着手を待たせる、という形です。賢い制御は要りません。原子的にカウントを増減できるゲートが1つあれば十分です。
# admission_controller.py
# 進行中(WIP)の変更数を WIP_MAX に抑える受け入れ制御。
# 状態は1ファイルに持ち、flock で原子的に更新する(並行起動しても壊れない)。
import fcntl
import json
import os
import time
from contextlib import contextmanager
from dataclasses import dataclass
STATE_PATH = os.environ.get( "WIP_STATE" , "/var/agent/wip_state.json" )
@dataclass ( frozen = True )
class WipConfig :
wip_max: int # = round(lambda_review_per_day * W_target_days)
stale_after_sec: int # これを超えて in-flight のままなら異常として回収
@contextmanager
def _locked_state (path: str ):
"""状態ファイルを排他ロックして読み書きする。"""
os.makedirs(os.path.dirname(path), exist_ok = True )
fd = os.open(path, os. O_RDWR | os. O_CREAT , 0o 644 )
try :
fcntl.flock(fd, fcntl. LOCK_EX )
raw = os.read(fd, 1_000_000 ).decode() or " {} "
state = json.loads(raw)
state.setdefault( "inflight" , {}) # change_id -> started_at(epoch)
yield state
os.lseek(fd, 0 , os. SEEK_SET )
os.ftruncate(fd, 0 )
os.write(fd, json.dumps(state).encode())
finally :
fcntl.flock(fd, fcntl. LOCK_UN )
os.close(fd)
def _reap_stale (state: dict , stale_after_sec: int ) -> None :
"""検証もレビューも進まず固まった変更を WIP から外す(数の正確さを保つ)。"""
now = time.time()
for cid, started in list (state[ "inflight" ].items()):
if now - started > stale_after_sec:
del state[ "inflight" ][cid]
def try_admit (change_id: str , cfg: WipConfig) -> bool :
"""空きがあれば in-flight に登録して True。満杯なら False を返す。"""
with _locked_state( STATE_PATH ) as state:
_reap_stale(state, cfg.stale_after_sec)
if change_id in state[ "inflight" ]:
return True # 冪等: 同じ変更の二重着手を防ぐ
if len (state[ "inflight" ]) >= cfg.wip_max:
return False
state[ "inflight" ][change_id] = time.time()
return True
def release (change_id: str ) -> None :
"""マージ済み・棄却済みになったら WIP から外す。"""
with _locked_state( STATE_PATH ) as state:
state[ "inflight" ].pop(change_id, None )
if __name__ == "__main__" :
# λ_review = 6 件/日, W_target = 1 日 → WIP_MAX = 6
cfg = WipConfig( wip_max = 6 , stale_after_sec = 36 * 3600 )
cid = os.environ[ "CHANGE_ID" ]
if try_admit(cid, cfg):
print ( f "admit { cid } " ) # ここで初めてエージェントに着手させる
else :
print ( f "defer { cid } " ) # 後段が空くまで待たせる
raise SystemExit ( 75 ) # EX_TEMPFAIL: スケジューラに「後で再試行」を伝える
ポイントは3つです。第一に、flock で状態更新を原子的にしているので、複数のスケジュール実行が同時に起きても WIP の数がずれません。第二に、try_admit は同じ change_id には冪等で、再試行で二重に着手することがありません。第三に、_reap_stale で「検証もレビューも進まないまま固まった変更」を一定時間で WIP から外します。これがないと、失敗した変更が枠を食い続けて、健全な変更が永遠に着手できなくなります。これは本番運用で実際にハマった落とし穴で、ステイル回収を入れて回避しました。
エージェントの起動スクリプトは、この try_admit が通ったものだけを走らせ、終わったら release を呼ぶ——それだけです。生成がどれだけ速くなっても、同時に進む変更は WIP_MAX を超えません。
余ったトークン予算は「広さ」ではなく「深さ」に回す
並列数を抑えると、速さとトークンの余剰が手元に残ります。3倍速・7割減で浮いたぶんを、私は「広さ(より多くの変更)」ではなく「深さ(1変更あたりの検証)」に回すことにしました。これは実体験として効いた配分で、個人開発の小さなチームほど差が出ると感じています。delivered を決めているのは後段なので、広さに回しても伸びないからです。
具体的には、1件あたりに次を足しました。
2回目の自己検証パス : 生成した差分を、別プロンプトでエージェント自身にレビューさせ、仕様との不一致・未処理のエッジケースを列挙させる。指摘が出たら人間が見る前に書き直す。
対象を絞った追加 eval : 触った領域に関係するテストだけでなく、過去に同種の修正で壊れた箇所のゴールデン出力を1セット余分に流す。
レビュー用の差分要約の充実 : 変更の意図・リスク・確認してほしい点を、人間が30秒で読めるよう構造化して付ける。これは後段の λ_review を実際に押し上げます。
3番目だけは性質が違います。1と2は1件あたりの品質を上げる投資ですが、3は後段スループット λ そのものを上げる投資です。レビューが速く正確になれば WIP_max を上げられるので、これは「深さ」と「広さ」の両方に効く数少ない手でした。
効果をどう測るか — delivered/週 と すり抜け率
設計を変えたら、感想ではなく数で確かめます。私が見ているのは2つだけです。
delivered/週 : 実際にマージされた変更の数。並列数を増やしても、後段が同じならこれは動かないはず——という予測が当たるかを見ます。
すり抜け率 : マージ後に「レビューで気づくべきだった」不具合が見つかった割合。並列数を上げてレビューが薄くなると、ここが必ず悪化します。
私の手元の2週間×2条件の比較は、おおむね次のようになりました。後段の能力を変えずに、片方は並列重視、片方は WIP キャップ+検証深さ重視で回しています。
観点
並列重視(WIP 上限なし)
WIP キャップ+検証の深さ重視
同時進行の変更数(WIP)
12〜18 件で変動
6 件で一定
delivered/週
約 28 件
約 30 件
1件あたりのリードタイム(中央値)
約 3.2 日
約 1.1 日
すり抜け率(マージ後の手戻り)
約 14%
約 5%
1変更あたりのトークン
基準
2回目検証を足しても基準比 約 0.5
delivered/週 はほとんど変わっていません。これは予測通りで、後段が律速だからです。一方で、リードタイムは3分の1に縮み、すり抜け率は3分の1未満になりました。トークンは2回目の自己検証を足してもなお基準の半分で済んでいます。つまり、速さとトークンの余剰を深さに回すと、生産量は落とさずに、速さ(リードタイム)と確かさ(すり抜け率)が同時に良くなる、という結果でした。
数字はあくまで私の環境の一例で、後段のスループットや変更の難易度で変わります。大切なのは絶対値より、「並列重視では delivered が伸びないのにリスクだけ増える」という構造が、自分の手元の数字でも再現するかを確かめることだと考えています。
運用に落とすときの順序(次の一手)
最後に、明日から始めるなら、という順序で1つだけ挙げます。まず2週間、実際にマージできた変更の数を毎日数えて λ_review の中央値を取り、WIP_max = λ_review × W_target を計算してください。それを admission controller に入れて並列数を物理的に頭打ちにし、浮いた速さとトークンを「1変更あたり2回目の自己検証」に回す。順序を逆にして先に並列数を増やすと、列が伸びてから慌てて上限をかけることになります。私はこの順序で2週間回してから上限を入れ、個人的にはこの並びがいちばん事故が少ないと感じています。
速くなった道具を手にしたとき、最初の問いは「何本同時に走らせるか」ではなく「いまどこが一番細いか」だと、私自身は考えるようになりました。細いところを広げずに入口だけ速くしても、列は伸びるだけです。速さは、並列数ではなく、1件を確かにすることに使う——その判断が、放置運用の質を静かに支えてくれています。