コンテンツにスキップ

世代交代

コンテキストが埋まりかけたら、いま走っているそのセッション自身に、人間が読めて書き換えられる引き継ぎ書を書かせ、 新しいセッションを立てて引き継ぐ。compact は使わない。プロセス全体は端末から見えるし、文書はディスクに落ちる。 書き換えたければいつでも書き換えていい —— 引き継ぐセッションが読むのは、まさにそのファイルだ。

世代交代は継続ではない

世代交代同じ実行の内部で新しいセッションに切り替えること。 継続プロセスをまたいで前回の実行につなぐこと。 継続を参照。

両者は自動的に噛み合い、追加の配線はいらない。リネージが記録するのは、そのステップの 最後の session_id であり、それがまさに後継者だ —— だから次回のウェイクは後継者につながり、燃やされた世代にはつながらない。

何を解決するのか

SDK 付属の auto-compact はウィンドウ −33k の地点で発火し(実測:ウィンドウが 200000 のとき閾値は 167000。 圧縮アルゴリズム自体は harness のバイナリの中にあり変更できない。変えられるのは発火させるかどうかだけだ)、 やることは履歴を一段落に要約することだ。これはこのフレームワークの他の部分とねじれている:

いつ決めるか 何が残るか
spill_guard ツールが返ったその瞬間 大きな結果はスピル、コンテキストにはパス一行だけ
凍結物(ブリーフ / ゴール) そのステップが終わった時点 一つの文書。次のステップはそれだけを読む
auto-compact コンテキストが埋まってから振り返る モデル自身が書いた要約一段落

flower が最初から最後までやっているのは、その場で何を残すかを決めることだ。compact だけが唯一の「事後の埋め合わせ」で、 しかもその産物には四つの欠陥がある:

  • モデルが生成したもの —— 要約に何を書くかはそのときのモデルの判断次第で、あなたは関与していない
  • 読めない —— 次のラウンドのモデルのために書かれていて、人間のために書かれていない
  • 書き換えられない —— harness の内側にあり、開けるファイルが一つもない
  • 何が失われたか分からない —— 何が落ちたのかも見えないし、落ちる前に「それは落とすな」と言うこともできない

世代交代はこれを同じ流儀に引き戻す。すなわちもう一つの凍結物であり、ブリーフやゴールと同じ形をしている —— 構造化され、ディスクに落ち、開いて一行書き換えてから走らせ直せる。そしてこれはこのプロジェクト自身のやり方でもある —— リポジトリルートの HANDOFF.md は、人間が書いた同じ種類のものだ。

使い方(最小コード)

コマンドラインはデフォルトで世代交代が入っている:

flower                       # 默认就带换代
flower --window 200000       # 判错了才需要给(默认 100 万)
flower --no-handoff          # 关掉 —— 退回 SDK 自带的 auto-compact

自分でコードを書くときは、世代交代は Runtime(handoff=…) で制御する。デフォルトは True:

from flower import HandoffPolicy, Runtime

# 既定値:HandoffPolicy(enabled=True, window=default_window(), headroom=50_000, max_generations=8)
rt = Runtime(workspace=".", run_dir="runs", workbench=True)

# ウィンドウを明示的に設定(ゲートウェイやモデルを変えたとき、まず調整すべきはここ)
rt = Runtime(
    workspace=".",
    run_dir="runs",
    workbench=True,
    handoff=HandoffPolicy(window=200_000, headroom=50_000, max_generations=8),
)

rt = Runtime(workspace=".", run_dir="runs", handoff=False)   # 无効化し、auto-compact に戻す

Runtime.__init__ はすべて keyword-only で、workspace は必須。handoffHandoffPolicy のインスタンスか bool を受け取り、bool を渡した場合は HandoffPolicy(enabled=…) と等価だ。

世代交代を有効にする = auto-compact が強制的に切られる

Runtime(handoff=True)デフォルト値であり、各試行を組み立てるたびにこうする: handoff.enabled かつ spec.compact is None であれば、spec を CompactPolicy(mode="no_summary") に差し替える —— つまり子プロセスに DISABLE_AUTO_COMPACT=1 を注入する。

理由は、二つの仕組みが同時に走ると、あるときコンテキストが落ちたのはどちらの仕業なのか説明できなくなるからだ。 代償はセーフティネットがないこと。引き継ぎ書を書くラウンドが失敗したときに止まることはできないし、 何事もない顔でハード上限まで粘ることもできない。だから必ず縮退パスが要る(後述)。

auto-compact を保険として残したいなら、明示的に AgentSpec(compact=CompactPolicy(mode="auto")) を渡すこと —— spec 自身が指定していればそれを尊重し、上書きしない。ただしこれは世代交代側の前提を黙って上書きして勝つことに注意。

実際に何をしているか

発火タイミング:世代交代に入る二つの経路

一、水位が閾値に達した。 判定は _handoff_due:handoff.enabled かつ引き継ぎ書を書いているラウンドではない かつ _ctx >= handoff.at かつそのステップがすでに session_id を取得している。_ctxメインスレッドが最後のラウンドで 実際に見たコンテキスト規模だ —— メインスレッドしか見ない。subagent のコンテキストはそれ自身の transcript の話で、走り終われば消える。メインスレッドに世代交代を強いる筋合いはない。

warn_at の地点でまず接近の通知を一度出す。世代ごとに一度だけで、画面を埋め尽くさない。

二、API が直接「入りきらない」と返した。 下の is_overflow の節を参照。

どちらの経路も max_attempts の制約を受けず、しかもリトライ枠を消費しない(内部で attempt -= 1)—— 世代交代は失敗ではない。

引き継ぎ書:五つの節、必須は二つだけ

各節は「引き継いだ側が犯す種類のミス」をそれぞれ一つ防ぐ:

フィールド 何を防ぐか
今やっていること doing 必須 自分がどこに立っているか分からない
すでに決まったこと decided すでに決着した事柄を議論し直す(なぜを添えること)
通らなかった道 deadends 最も高価な節 —— 下記参照
次の一手 next 必須 まず三十分かけて何をするか決めることになる
現場 scene 重要なファイルと成果物のパス。中身ではなくポインタ

Handoff.missing() がチェックするのは REQUIRED = ("doing", "next") の二つだけで、complete() はその否定だ。 「通らなかった道」を空でないよう強制すると捏造を招く —— タスクの冒頭ではそもそも空であるべきだ。 しかも complete() の判定には結果が伴う:必須の節が欠けていれば、この引き継ぎ書はまるごと機械的に組み立てた縮退版に差し替えられる (後述)。それは一節欠けただけの本物の引き継ぎ書よりずっと悪い。だから残り三節はオプションだ —— 書けば役に立つし、書かなくても世代交代は止まらない。

to_markdown() では空の節に (空) と書く。prompt_block() の冒頭は引き継ぐ側に「あなたは引き継いでいる」と明示し、 背景を人に聞き返しに戻ることを防ぐ。step フィールドは文書の見出しに使うだけで、パースには関与しない。

「通らなかった道」がなぜ最も高価か

それは引き継いだ側が最も高い金を払って再発見するものであり、かつ書く側が最も落としやすいものだからだ。

ワーカーには体系的な楽観バイアスがある(ゴールガードが同じことを論証している)。自分が何を成し遂げたかは書くが、 何を試してダメだったかは書き忘れる。そして本当に高価なのは後者だ —— HT002 ではあるコンパイル問題で一時間回り道した。 その一時間の結論が書き残されなければ、引き継いだ側は同じ道をそのままもう一周する。

だから HANDOFF_PROMPT の中では、この点を一段落を割いて指摘し、その実測コストまで添えてある。

本物の引き継ぎ書はどんな見た目か

# 上下文 130.0K/200K · 还有约 20K 到换代

# 上下文 152.0K/200K —— 写交接准备换代
  - 现在在做  在给 Makefile 加 macOS 垫片头,让 sigemptyset 宏不再展开成语法错误。
  - 已定的事  不改业务源码 —— 用户明确说过边界,所以走 Makefile 生成 shim 这条路。
  - 走不通的  -D_ANSI_SOURCE 会把别的宏一起关掉;改 include 顺序无效。
  - 下一步    在干净 clone 上跑一次 make 验证 shim 成立。
<- 交接写在 ~/proj/.flower/notes/交接-干活.md
<- 新会话接手,上下文从 152.0K 重新开始

全自動で、あなたを待って止まったりしない —— 長期の実行が、人が飯を食いに行ったせいで詰まるべきではない。

イベントは Event("handoff")payload["phase"]三つの値を取る:near(接近)、writing(執筆中。 引き継ぎ書を書くのに十数秒かかるので、これを出さないと画面が固まったように見える)、done(交代完了)。done の payload には contextwindowdegradedpathsections も入る。

閾値の計算

at      = max(10_000, window - headroom)   # 世代交代ライン。下限 10k
warn_at = max(1_000, at - 20_000)          # 接近通知ライン

at の下限 10k は必須だ —— それより低いと引き継ぎ書すら書けない。

下の目盛り図は --window 200000 を例にしている。デフォルトのウィンドウは 100 万だ:

  0--------------------------------------|-----|--------------|
                                       130K  150K           200K
                                       通知  交代        ハード上限

window を渡さない場合は default_window()モデル名の文字列で判定する。見るのは ANTHROPIC_MODELANTHROPIC_DEFAULT_OPUS_MODEL の二つの環境変数だけだ:

モデル名 判定
名前に独立した 1m という語がある 1_000_000
名前に haiku を含む 200_000
それ以外、および両方の変数が未設定 1_000_000

順序に注意:1m が先にマッチするので、claude-haiku[1m] は 20 万ではなく 100 万と判定される。

headroom がなぜ 50_000 なのか:auto-compact はウィンドウ −33k で発火するので、世代交代はその前に間に合わせる必要がある。 さらに「引き継ぎ書を書く」こと自体にもう一ラウンド要る。50k はこの二つを同時に満たす。

--window はモデルやゲートウェイを変えたとき、まず回すべきつまみだ。 SDK 側からは信頼できるウィンドウサイズが取れず、名前で推測するしかない。 実際のウィンドウがもっと大きい → 世代交代が早すぎる(無駄なだけで壊れはしない)。もっと小さい → 間に合わないので必ず調整が要る。実測値として挙げておく: 開発マシンのゲートウェイに設定されているのは claude-opus-5[1m] だった。以前のように 20 万で計算すると 15 万ごとに世代交代することになるが、 実際には 95 万まで走れる —— 5 倍の差だ。長期の作業は細切れにされる。

flower -v を使えば、走り出す前に現在有効な認証設定(エンドポイント、モデル名。token はマスクされ先頭 4 桁のみ)が見られる。

is_overflow:ハードエラーをその場の世代交代に変える

これが default_window() のデフォルトを 100 万に取る勇気の前提だ。

ウィンドウを大きく見積もりすぎた場合、閾値には永遠に届かず、しかも auto-compact は切られている —— そのまま API に激突する。 is_overflow(*texts) はこのシグナルを認識する:prompt is too longcontext length exceededmaximum context lengthtoo many total text bytesinput length and max_tokens exceed など。

認識した後は同じ世代交代のパスを通る。ただしこの世代の引き継ぎ書は必然的に縮退版になる —— そのセッションはもう 「もう一ラウンド引き継ぎ書を書く」ことができないので、機械的に組み立てた縮退版をそのまま使い、通常どおり新しいセッションに交代して作業を続ける。このステップは失敗しない。

こうして、見積もり過大の代償は「このステップが失敗する」から「この世代の引き継ぎ書が縮退版になる」まで下がる。

is_overflowHandoff のメソッドではなくモジュールレベルの関数で、しかも可変長引数を取る。

引き継ぎ書が書けなかったとき:止まらずに縮退する

引き継ぎ書を書くラウンドも失敗しうる —— ネットワークが切れた、モデルが暴れた、パースしたら必須の節が欠けていた。 auto-compact はすでに切られているので、保険はない。ここで止まればウィンドウに激突するのと同じだ。

やり方:手元にある既知の情報から機械的に欠損した引き継ぎ書を組み立て、doing[降级:交接没写成] の印を付け (定数 DEGRADED)、scene に元タスクの先頭 1200 文字を詰め、そのまま世代交代する。引き継ぐ側には、 受け取ったものが不完全であり、自分で現場を見に行くべきだと明示的に伝わる。同時に StepResult.errors に「交接降级(…)」が一件増え、 manifest.json から理由を追える。

対応するのはモジュールレベルの関数 degraded(step, prompt, *, why="")Handoff.degraded は読み取り専用の property で、 doing にその印があるかどうかを判定する。

欠損した引き継ぎ書のほうが、ウィンドウへの激突よりはるかにましだ。

引き継ぎ書を書くラウンドには、意図的な仕掛けがもう二つある。max_budget_usd=None で走らせること —— 引き継ぎ書は必ず書けなければならず、予算で詰まってはいけない。そして on_event=None —— このラウンドは UI に流さない。

一つの地雷:引き継ぎ書を書くラウンドは閾値を免除しなければならない

引き継ぎ書は線を越えた後に走る —— そのとき水位はもともと閾値の上に張り付いている。免除しなければ、 引き継ぎ書のラウンドの最初のメッセージでまた「世代交代すべき」と判定され、一文字も書けないまま中断される。どの世代も縮退版を出すうえに、 見た目には何も問題がないように見える(縮退パスがよく機能しているので)。

実際にやらかした:tests/handoff_live.py を初めて本当に走らせたとき、二世代とも縮退版の引き継ぎ書だった。オフラインテストでは捕まらなかった —— そこでは _attempt をまるごと差し替えていたので、偽物はこの判定を通っていなかった。いまは判定を Runtime._handoff_due() に切り出し、オフラインで直接検証している。

暴走を止める閘門

max_generations=8

window を小さく設定すると無限に世代交代して金を燃やす

危険はこうだ:閾値がそのロールの起動フロアを下回る(コーディネーターは実測で約 34k。 システムプロンプトとワークベンチのインデックスだけで占められる)。すると新しいセッションは口を開いた瞬間に線を越え → 引き継ぎ書を書き、世代交代し、また線を越える。永遠に止まらない。しかも世代交代はリトライ枠を食わない。それは意図的だ。だから唯一の閘門が max_generations=8 になる。

通常の長期実行で 8 世代に達することはない。本当に当たったなら、ほぼ確実に window が小さすぎる —— 上限に達したときのエラーメッセージがそう明言する (「閾値がこのロールの起動フロアを下回っている可能性が高い。window を大きくするか、--no-handoff」)。

一度の世代交代の全過程

作業(session A)
  |  メインスレッドのコンテキストが閾値を越える  <- 見るのはメインスレッドだけ。subagent のコンテキストは
  |                            それ自身の transcript の話で、走り終われば消える。メインに交代を強いる筋合いはない
  |- メッセージ境界で切る       <- Ctrl-C の中断と同じ理屈:きれいに切り、状態を引き裂かない
  |                            (代償も同じ:飛行中の subagent は失われる。50k の余裕はそのために取ってある)
  |- 同じ session でもう一ラウンド走らせる:引き継ぎ書を書く
  |     なぜ自分自身に書かせるのか —— そのコンテキストを持っているのはそれだけだから。誰に書かせても
  |     まず一度読む必要があり、それでは交代する意味がない
  |- <ワークベンチ>/notes/交接-<ステップ名>.md に凍結。前の世代は notes/archive/交接/ へ退避
  |- 新しいセッション(resume=None、fork=False)、prompt は引き継ぎ書の prompt_block()
作業(session B)が続きをやる

HANDOFF_PROMPT は現在のセッションに引き継ぎ書を書かせるプロンプトで、{used}{window} の二つのプレースホルダを含む。 これは新しいロールではない —— そのコンテキストを持っているのは、いま走っているこのセッションだけだ。

世代交代はリトライに数えない。帳簿はどうつけるか

フィールド 世代交代でどう変わるか
attempts 増えない —— これが数えるのは失敗した試行
retired[] このステップで燃やした session_id が順に記録される
session_id 常に最後に引き継いだもの。燃やされたものではない
context 最後のラウンドでメインスレッドが実際に見たコンテキスト規模
cost_usd / num_turns リトライと世代交代をまたいで累積

これらのフィールドはすべて manifest.json に入るので、事後に「このステップは何世代燃やし、各世代でいくらかかったか」を完全に復元できる。

引き継ぎ書はどこに落ちるか

<ワークベンチ>/notes/交接-<ステップ名から不正文字を除いたもの>.md。すでに存在する前の世代は notes/archive/交接/<ステップ名>-<タイムスタンプ>.md へ移される。

ワークベンチがなければ落ちない —— そのとき _handoff_pathNone を返す。文書は変わらず prompt で引き継ぐ側に渡され、 世代交代も通常どおり進む。ただ人が事後にそのファイルを見返せないだけだ。事後に見返したければワークベンチを開くこと (Runtime(workbench=True)、あるいはワークフロー自身が一つ用意する)。

使うべきでないとき

  • compact をそのまま使いたい。 flower --no-handoff、または Runtime(handoff=False)。 世代交代は auto-compact を巻き添えで切る。その副作用が嫌なら有効にしないこと。
  • 両方同時に動かしたい。 AgentSpec(compact=CompactPolicy(mode="auto")) を明示すれば auto-compact は保てるが、 その後は「あるときコンテキストが落ちたのはどちらの仕業か」を説明できなくなり、調査が難しくなる。世代交代を信じるか compact を信じるか、どちらか一つにすること。
  • 短いタスク、単発の作業。 世代交代は決して発火しないので設定する意味がない —— ただし Runtime はデフォルトで handoff=True であり、それでも auto-compact は切られることを忘れないこと。
  • ワークベンチがないのに事後に引き継ぎ書を読みたい。 先にワークベンチを開くこと。さもないと文書はその一回の実行のコンテキストにしか現れない。
  • window を合わせないまま長期実行を始める。 実際のウィンドウがデフォルトより小さいと、最初の数世代の引き継ぎ書はすべて縮退版になる。 そして縮退版こそ、最も役に立たない種類の引き継ぎ書だ。先に --window を合わせるか、まず短いラウンドを走らせて -v でモデル名を見ること。
  • 世代交代をコンテキスト管理のすべてだと考える。 これは最後の一線だ。その場で刈り取る層 (スピル、トリムプルーン)のほうが安い。 コンテキストの経済学を参照。

症状別:どのつまみを回すか

症状 回すつまみ
世代交代が頻繁すぎて作業がいつも中断される --window をモデルの実ウィンドウに合わせる(-v で有効なモデル名が見える)
開始直後に世代交代し、「起動フロア」と出る 同上。window が小さすぎる
引き継ぎ書がいつも縮退版になる runs/manifest.jsonerrors を見る。縮退の理由が書いてある
引き継いだ側が前の世代と同じ作業を繰り返す 引き継ぎ書の「通らなかった道」が薄い。そのファイルを直接書き換えてよい
事後に引き継ぎ書を読みたいのにファイルが見つからない ワークベンチを開いていない。引き継ぎ書は落ちず、prompt だけを通った
とにかく compact を使いたい --no-handoff

次に読むもの