深夜のスケジュール実行が、いつもより 40 分長く回り続けていました。ログを追うと、記事生成を任せたサブエージェントが、その下に検証用の孫エージェントを起こし、孫がさらにひ孫を起こして、4 段目で同じ整合性チェックを延々と繰り返していました。誰も止め役を持っていなかったのです。
サブエージェントの入れ子が解禁されてすぐ、個人開発で回している 4 サイトのブログ自動運用を、親 1 段の扇形から木構造の委譲へ作り変えました。深くできるのは確かに便利でした。ただ、深くした瞬間に品質メトリクスが悪化したのも事実です。要約が一段ごとに痩せ、再実行が増え、トークン消費が膨らみました。
原因は階層を増やしたことそのものではなく、階層の「あいだ」に契約が無かったことでした。私自身がこの 3 週間で入れた 4 つの契約 — トークン予算、受け渡しスキーマ、失敗隔離、独立グレーダー — と、その効果の実測値を書き残します。
「深くできる」と「深くしてよい」は別の問題
木構造の委譲には、フラットな並列には無かった固有の減衰が 2 つあります。
ひとつは要約減衰です。子が親へ返すとき、子は自分の作業ログを要約します。孫から子へも要約され、その要約をさらに子が要約して親へ返す。一段ごとに情報が圧縮され、4 段重ねると親が受け取る頃には「成功しました」という一行だけが残る、という事故が起きました。
もうひとつは制御の喪失です。親は孫やひ孫を直接見ていません。途中の階層が暴走しても、親に届くのは「処理中」という中間報告だけで、止める判断材料がありません。冒頭の 40 分暴走はこれでした。
ですから設計の問いは「何階層まで深くできるか」ではなく、「各階層が上下とどんな契約を結ぶか」になります。深さは結果であって、目標ではありません。
階層ごとにコンテキスト予算を配る — トークン契約の設計
最初に入れたのがトークン予算の配分です。親が全体予算を持ち、子へ委譲するたびに、その子が使ってよいトークン量を明示的に渡します。子は自分の取り分の中から、さらに孫へ分け与えます。
配分は均等割りではうまくいきませんでした。末端ほど具体的な作業をするのに予算が枯れていく、いわゆる先細りが起きるためです。私は深さに応じて逓減させつつ、末端に下限を保証する配分にしています。
def allocate_budget(total: int, depth: int, max_depth: int = 3,
leaf_floor: int = 8000) -> dict:
"""親の残予算 total を、この階層と子へ配分する。
decay: 階層を下るほど一度に使える割合を絞り、暴走の上限を作る。
leaf_floor: 末端が具体作業をやり切れる最低トークンを保証する。
"""
if depth >= max_depth:
return {"self": max(total, leaf_floor), "children_pool": 0}
decay = 0.55 ** depth # 0階層=1.0, 1階層=0.55, 2階層=0.30
self_share = int(total * 0.35 * decay)
children_pool = total - self_share
# 子に渡せる総量が末端下限を割るなら、これ以上深くしない
if children_pool < leaf_floor:
return {"self": total, "children_pool": 0, "force_leaf": True}
return {"self": self_share, "children_pool": children_pool}
force_leaf が返ったら、その階層はもう委譲せず自分で処理を終えます。これが「予算による深さの自動制限」になりました。最大 5 階層を許可した環境でも、私の記事生成ワークフローでは予算が 3 階層で末端下限に達するため、実質 3 階層で頭打ちになります。深さを定数でハードコードするより、予算が自然に天井を作るほうが、タスクの重さに応じて伸縮して扱いやすいと感じています。
受け渡しスキーマで要約忠実度を守る
要約減衰への対処は、返却フォーマットを自由文から構造化スキーマへ変えることでした。「いい感じに要約して」と頼むと、階層をまたぐたびに語彙が抽象化していきます。返すべき欄をあらかじめ決めておけば、各階層は欄を埋めるだけになり、圧縮されても残すべき事実が残ります。
{
"status": "ok | degraded | failed",
"artifact_refs": ["content/articles/ja/claude-code/xxx.mdx"],
"metrics": { "gate_violations": 0, "chars": 6200, "h2": 7 },
"decisions": ["カテゴリは claude-code を選択", "重複回避で旧 slug を除外"],
"unresolved": ["en 版のコード注釈が未訳"],
"tokens_used": 14200
}
肝は unresolved 欄です。空文字を返してはいけない欄を 1 つ作っておくと、各階層が「未解決事項は無いか」を毎回点検する習慣になります。以前は子が握りつぶしていた小さな取りこぼし — 英語版の未訳箇所や、件数不一致の予兆 — が、親まで明示的に上がってくるようになりました。
status を 3 値にしたのも実用上効きました。degraded という中間状態があると、親は「完全成功ではないが捨てるほどでもない」成果物を受け取り、再実行ではなく軽い補修で済ませられます。二値の成功・失敗だけだと、惜しい成果物を全部捨てて再実行してしまい、トークンを浪費していました。
ひとつ落とし穴がありました。unresolved を必須にした当初、末端の階層が反射的に「特になし」と埋めて欄を形骸化させていたのです。Dolice Labs の 4 サイトを横断で見ていると、同じ「特になし」が並んだ日に限って後から件数不一致が見つかる、という相関に気づきました。対処として、unresolved に「特になし」を入れる場合は必ず点検した観点名(日英件数・内部リンク・コード注釈)を併記させるルールにしました。空欄を禁じるだけでは足りず、「何を見て無いと言ったか」まで書かせて初めて、握りつぶしが減りました。
失敗を階層内に閉じ込める — サーキットブレーカと縮退
冒頭の暴走を二度と起こさないために、各階層に同じ作業の反復を検知するブレーカを入れました。子が同種の委譲を規定回数以上繰り返したら、その階層で打ち切って degraded を返します。
class LayerBreaker:
def __init__(self, max_retries=2, max_same_task=3):
self.retries = 0
self.task_counts = {}
def allow(self, task_signature: str) -> bool:
self.task_counts[task_signature] = self.task_counts.get(task_signature, 0) + 1
if self.task_counts[task_signature] > 3:
return False # 同一作業の反復 → この階層で打ち切り
return True
def on_child_fail(self) -> str:
self.retries += 1
if self.retries > 2:
return "degrade" # 親へ縮退報告。再委譲しない
return "retry"
ポイントは、失敗を親へそのまま投げ上げないことです。階層 N で起きた失敗は、まず階層 N が吸収を試みます。吸収できなければ degraded または failed という構造化された報告に変換して上げます。親が受け取るのは生の例外ではなく、判断可能な状態です。
本番運用で効いたのは縮退の使い分けでした。整合性チェックの孫が落ちても、生成済みの記事そのものは無事なことが多いので、子は「記事はあるが検証は未完」という degraded を返します。親はそれを見て、検証だけを別系統で再実行できます。全部を巻き戻さない、という選択肢を持てたことが再実行率の低下に直結しました。
末端に独立グレーダーを置く — 親が自分の成果物を採点しない
最後の契約が、品質判定を生成系統から切り離すことです。生成を担った階層に「自分の成果物は合格か」を聞くと、ほぼ合格と答えます。自分の文脈に染まっているからです。そこで、生成ツリーとは別に、ルーブリックだけを渡した独立グレーダーを末端に呼びます。
GRADER_RUBRIC = {
"intro_is_concrete": "導入が具体的な場面から始まり、テンプレ導入文でないか",
"has_working_code": "コピペで動く完全なコード例が1つ以上あるか",
"has_own_insight": "公式ドキュメントに無い運用知見が含まれるか",
"no_jotai": "敬体が崩れて常体が混入していないか",
"premium_signals": "実用性シグナルが3つ以上あるか",
}
# グレーダーは生成文脈を一切持たず、本文とルーブリックだけを受け取る
# 各項目 pass/fail と1行の根拠を返す。1つでも fail なら status=degraded
独立グレーダーの判定は、私が後から走らせる article_gate.py の機械チェックと突き合わせて精度を確認しています。グレーダーが pass と言ったのに機械ゲートが弾いた回数を毎週数え、食い違いが多い項目はルーブリックの文言を直す、という運用にしています。人の目に当たる役割を、生成と同じ文脈に置かないことが肝心でした。
実測 — 3階層委譲で要約減衰と再実行率がどう変わったか
4 つの契約を入れる前後で、2 週間ずつ同じ記事生成ワークフローを回して比較しました。
再実行率は 23% から 7% へ下がりました。degraded 縮退で部分再実行ができるようになったのが最大の要因です。以前は検証の失敗だけで記事ごと作り直していました。
要約忠実度は、親が受け取った報告に末端の重要事実が残っているかを人手で採点した指標で、0.62 から 0.88 へ上がりました。構造化スキーマの decisions と unresolved 欄が効いています。
一方でトークン消費は 1.4 倍に増えました。グレーダーを独立で呼ぶ分と、スキーマを律儀に埋める分のコストです。ここは正直に書いておきます。再実行が減った分とおおよそ相殺され、総コストはほぼ横ばいでしたが、無料で品質が上がったわけではありません。 内訳を見ると増加分のおよそ 6 割が独立グレーダー、残りがスキーマの記述で、グレーダーへ渡す本文を見出し単位に絞るだけで、増加幅は 1.4 倍から 1.25 倍程度まで抑えられました。
暴走による異常終了は、ブレーカ導入後の 2 週間でゼロになりました。冒頭の 40 分のような事故は、force_leaf と LayerBreaker の二段で物理的に起こせなくなっています。
導入の順序 — どこから契約を入れるか
4 つを一度に入れる必要はありませんでした。私が効果対コストで推奨する順序は、失敗隔離 → 受け渡しスキーマ → トークン予算 → 独立グレーダーです。
最初にブレーカを入れる理由は、暴走の停止が最も損失の大きい問題を潰すからです。コードも小さく、既存の委譲構造を変えずに差し込めます。次にスキーマを入れると要約減衰が止まり、親のログが読めるようになります。ここまでで運用の安定は大きく改善します。
トークン予算は、複数サイトを同時に回すなど並行度が上がってから効いてきます。並行度が低いうちは予算が余るので、後回しでも実害は小さいです。独立グレーダーは最もコストが高いので、品質のばらつきが課金読者に届く段になってから入れるのが、私の判断です。
深い委譲は、深さ自体を目的にすると必ず痩せます。階層の「あいだ」に契約を 1 つずつ置いていくと、結果として深さが自分の重さに見合うところで止まる。そういう設計に落ち着けるのが、いまの私の役割だと考えています。