コンテンツにスキップ

コンテキストの経済学

メインスレッドのコンテキストは、一度のlong-horizon な実行のなかで唯一最初から最後まで貫き通るものだ。そこに何を積み、何を積まないかが、その実行がどこまで走れるかを決める。flower の形 —— コーディネーターは手を動かさない、長い成果物はスピルする、hook がその場で刈る —— はすべてこの一点から導かれている。 このページはその理由を書く。

何を解決するのか

compact はコンテキストが満杯になってから振り返って要約するもので、対症療法だ。本当の問題はこうだ: 些末なものは最初からメインスレッドに入るべきではない。

差はタイミングにある。一度の pytest の出力は平気で数万文字になる。モデルはそれを一目見て結論を一つ取り出すが、残りの文字はその後も毎ターン送り直される。窓が埋まった時点で、compact はそれを隣にある意思決定ごと一段落の要約にまとめる —— 節約できたのは体積、失われたのは「当初なぜそう決めたか」だ。auto-compact の発火閾値はウィンドウ − 33k (core/agent.py)。 その時点では、捨ててよいものと捨ててはいけないものが既に並んで横たわっている。

flower は四つの層で解決する。並び順がそのまま優先順位 —— 削減量の大きい順だ:

何をするか どこ
一、分業 手を動かす作業は subagent に委譲し、試行錯誤はその自前の transcript に入る core/roles.py
二、ワークベンチ スクリプト / 長い成果物 / 意思決定をスピルし、インデックスを system prompt に注入する core/workbench.py
三、その場でスピル PostToolUse hook が閾値超えのツール結果をスピルし、コンテキストにはパス一行だけ残す core/guard.py
四、トリムとプルーン resume の前にセッションを書き換える:期限切れの結果、拒否された呼び出し、切断の残骸はもう食わせない stores/trim.pystores/prune.py

前の二層はそもそも入れるかどうかを、後ろの二層は入ってしまったものを残すかどうかを扱う。順序は入れ替えられない:第四層がどれだけ強力でも、 第一層から漏れ込んだ量を取り戻すことはできない。

どう使うか(最小コード)

from flower import Runtime, coordinator, worker

分析员 = worker("分析文件:统计、查找、比对。要真读文件、跑命令的活派给它。",
               "你负责文本分析。用命令行完成,不要手工估算。",
               tools=["Read", "Write", "Bash", "Glob", "Grep"])   # model のデフォルトは "inherit"

主控 = coordinator("主控", "目标:摸清 data/ 的规模。", {"分析员": 分析员})
rt = Runtime(workspace="repo", workbench=True)

この数行で前の三層が入る:coordinator() は常に delegate_only=True を設定する(第一層)。workbench=Trueワークベンチを作り、インデックスをコーディネーターの system prompt に注入し(第二層)、 同時に Runtimespill_guard を装着する(第三層)。第四層はデフォルトで既にある —— RuntimeセッションストアPruningSessionStore にハードコードされており、コンストラクタ引数に差し替え口はない。

workbench=True はオプションではない

コーディネーターが手を動かすのを止める delegate_guardworkbench_hooks の中に掛かっており、workbench_hooksRuntime にワークベンチがあるときにしか装着されない。さらに whitelist_guarddelegate_only=True のせいでスキップされる。 結論:Runtime(workbench=False)coordinator() を組み合わせると、メインスレッドの Bash / Write / Edit には 一枚の壁もない。

実際に何をしているのか

第一層:分業(最も削減量が大きい)

コーディネーターは「Claude Code を使える人間」を演じる:分解し、割り振り、レポートを読み、決める。Bash / Write / Edit は手に入らない —— ツールは AgentTodoWriteRead だけだ (glance=True のときは制限付きの Bash が一つ追加される。後述)。手を動かす作業はすべて ワーカーに委譲する。

いつ発火するか:メインスレッドが Bash|Write|Edit|NotebookEdit を呼ぶたびに、PreToolUse hook delegate_guard がその場で deny し、同時に道を示す —— 「Agent ツールで subagent を出して実行させ、タスクには目標と受け入れ基準を明記し、 長い成果物は .flower/artifacts/ に書き、返答はパスと結論だけにさせろ」。subagent は一律通す。 判定基準は hook データに agent_id があるかどうか:無いものがメインスレッドだ

どれだけ削れるか:subagent のツール呼び出しと試行錯誤はその自前の transcript に入る(セッションストアでは subpath で 区別される)。メインスレッドに残るのはその一度の Agent 呼び出しと最終レポートだけだ。試行錯誤の過程は compact で消されたのではなく、 最初からメインスレッドに入っていない

  • 実測(大量のツール出力を生むタスク):transcript の 83% が subagent 側に落ち、 メインスレッドは 13 件・21K 文字、subagent は 105K 文字。
  • 実測(実スケール、10.4 時間の実行。HT001 参照):subagent が 97.7% のターン、94.8% の本文文字を担った。手を動かすツール呼び出しは 1,893 回 vs メインスレッド 32 回(59:1)。 早期の compact はもはや主戦場ではない。

この二行は別々の二回の計測だ:上は初期の小規模テスト、下は実スケールでの再測定。同じ仕組みで、 規模が大きいほど削減量も大きくなる。

削るのはコンテキストであってモデルのグレードではない:worker() のデフォルトは model="inherit" —— ワーカーを格下げすべきではない。

分業の唯一の逆コストはタスクブリーフ —— コーディネーターが委譲時に書くあの文章だ。 それはメインスレッドに入り、しかも永久に残る。実測では 8/8 件のタスクブリーフが相手の既知の規律を復唱しており、最短の 521 文字のうち タスク固有なのは約 120 文字だけ、一ターンあたり約 4.8k の永久コンテキストを無駄に占めていた。だから COORDINATOR_RULES には 一条がハードコードされている:タスクブリーフには今回のタスク固有のことだけを書く。それでも伝える必要がある唯一の規律は「ワークベンチの場所 + 長い成果物は artifacts/ に書く + 返答はパスと結論だけ」だ —— ワークベンチのインデックスは subagent に届かないので、タスクブリーフが唯一の経路になる。

第二層:ワークベンチ(「毎回書き直し」を治す)

.flower/ 配下の三つのディレクトリがワークスペースに付いて回る:

ディレクトリ 何を置くか 何を解決するか
scripts/ 二度目も走る検証 / 再現スクリプト。先頭行に # desc: 一句话 を書く 一度書けば以後はそのまま走る。「compact 後に失われ、毎回書き直し」が無くなる
artifacts/ 2000 文字を超える長い成果物:ログ、データ、レポート、diff 会話にはパスと結論しか現れない
notes/ 重要な意思決定とその理由。一決定一ファイル compact されても、再起動しても、マシンを変えても結論は残る

いつ発火するか:INDEX.md は自動生成される(デフォルト最大 40 件)。refresh()PostToolUseindex_guardWrite / Edit をワークベンチ内に対して検出したときに呼ばれ、各ステップの開始前にも一度更新される。上の三つの規律は prompt_block() によってコーディネーターの system prompt に注入される —— 開始時点でどんな既製スクリプトがあるかを知っているので、 発見のためにツール呼び出しを一回費やす必要がない。

どれだけ削れるか:実測、10.4 時間の実行で 61 個のスクリプトが 95 回書かれ、331 回実行された。92% は一度以上実行され、 書いたのに走らせなかったものは 0 個。定性的には audit-fake-ai-server.py が 7 個のスクリプトから再利用された。

この層が効くのは一つの差のおかげだ:compact はコンテキストを消せるが、ディスクは消せないし、system prompt 内の インデックスも消せない

インデックスは subagent には継承されない

インデックスはセッションレベルの system_prompt.append を通る。subagent は自前の system prompt を持つため、 継承されない(実測 $0.2461、tests/prelude_live.py)。だから「ワークベンチの場所 + 長い成果物は artifacts/ へ」はコーディネーターがタスクブリーフで伝え直すしかない —— それが唯一の経路であって、冗長ではない。

第三層:その場でスピル

spill_guardPostToolUse hook で、ツール結果がモデルに入る前に一目見る。threshold (デフォルト 4000 文字)を超えたものはワークベンチの spill/ ディレクトリにスピルし、 コンテキストではポインタ一行 + 先頭 400 文字に置き換える。内容は失われず、常駐しなくなるだけだ。

いつ発火するか:matcher は Bash|Read|Grep|Glob|WebFetch|WebSearch。デフォルトは main_only=False なので、 subagent の結果もスピルされる。置き換えるのはツール出力構造のなかで長すぎる文字列フィールドだけで、list には一切触れない (中に画像ブロックが入っている可能性があるため)。updatedToolOutput は元のツールの出力構造を保たなければならないからだ。

スピルファイル自体の読み取りは通し、再スピルしない。 そうしないと、あの一行の案内に書かれた「全文が必要なら Read で読め」が空文句になる: 読み返すとまた閾値を超え、またスピルされ、またポインタ一行が返る、という無限ループだ。実測でぶつかった(tests/handoff_live.py の初回の実走)。モデルは五通りの書き方を試して回避しようとし、自分で "The spill read loops back on itself" と言い、 最後は 40 行ずつ力技で読み進め、七、八ターンを無駄に燃やした。スピルの意味は「大きいものを自動ではコンテキストに詰め込まない」ことだ。 モデル自身が全文を見ると決めたなら、それはモデルの選択である。

Runtime(workspace="repo", workbench=True, spill_threshold=4000)   # None または 0 = この hook を装着しない

どれだけ削れるか:HT001 のあの実行では、103 回のスピルで 791.4K 文字がパスのポインタに置き換わり、 コンテキストに常駐しなかった。

第四層:トリムとプルーン

この層はセッションストアの中にある。Runtime の store は常に PruningSessionStore(継承チェーンは SqliteSessionStoreTrimmingSessionStorePruningSessionStore)で、load() —— つまり resume の直前 —— に、食わせ直す履歴を書き換える。 SQLite 内の原文は一文字も動かさない。やることは四つ:

① 時効切れ(ephemeral、デフォルト有効)。git statuslscat のような 一過性コマンドの結果は、数ターン後に本文を一文の説明に置き換え、 直近 6 件を保持する。期限切れの内容はスピルしない —— 古い git status を一部アーカイブしても意味はなく、走らせ直せば手に入るからだ:

[`git status -s` 的结果已过期(第 7 轮前),当前状态可能已变。需要请重新执行]

ライブ resume の実測で expired: 2。実 transcript 上で keep_recent を 2 に下げると 5 件が期限切れになった。

トリム(trimデフォルト無効)。>= 2000 文字の tool_result の 本文を <workspace>/.flower/spill/ にスピルし、ブロックの内容をファイルポインタに置き換え、直近 20 件は原文のまま残す。このディレクトリは 第三層 spill_guard のスピル先と同じではない:後者はワークベンチのルート配下に書くが、こちらはワークスペース内でなければならない。 そうでないと agent の Read が届かないからだ。

from flower import Runtime, TrimPolicy

Runtime(workspace="repo", trim=TrimPolicy(keep_recent=20, min_chars=2000))   # True でもよい

③ 拒否された呼び出しのプルーン(keep_denials、デフォルト 1)。止めるというその動作自体も コンテキストを汚す:拒否メッセージは一件の tool_result であり、一度も実行されていないコマンドと一緒に永久に 残る。実測で一回 273 文字(拒否文 93 文字 + 死んだコマンド 180 文字)、死んだコマンドのほうが拒否文より高くつく。

token より厄介なのは、それがミスリードすることだ。実測では、コーディネーターが「Bash を直接使うな」を数件読んだあと、 通るはずの git status すら試さなくなり、いきなり「Bash は制限されているから agent を出して見に行かせる」と言い出した —— 学習性無力感を身につけ、 かえって subagent の起動コストを一回余分に払った。デフォルトを 0 ではなく 1 にしているのは、最新の拒否は有効なシグナルであり、 モデルが同じターン内で同じ止められたコマンドを繰り返しリトライするのを防げるからだ。識別は harness 自身が付ける構造的マーカー toolDenialKind: "permission-rule" に頼っており、 文面のマッチではない —— 文面はいつでも変わるが、マーカーは変わらない。ライブ実測:2 回の拒否 → 1 件を摘出し 1 件を残す。チェーンは切れず、resume は正常で、 モデルも何が起きたかを依然として把握していた。

④ 切断の残骸のプルーン。ネットワーク切断のリトライ中に生じた合成 API エラーメッセージは食わせ直さない。中断によって残された tool_result は 中立的な一文([上一轮在此处被中断,该工具结果未产生])に置き換え、項目自体は残す。

摘出時のレッドライン:tool_use とその tool_result一緒に摘まなければならない(片方欠けると Missing Tool Result Block)。同じ assistant メッセージ内の他の呼び出しを巻き込んではならず、parentUuid チェーンは 繋ぎ直さなければならない。

Runtime(trim=False)(デフォルト)は何も掃除しないという意味ではない:大きな結果のトリムを切るだけで、期限切れ・拒否された呼び出し・ 切断の残骸の処理は行われる。

反例:一目見るだけの作業は自分でやる

前の三層はどれも「外に出せ」と言っているが、反例がある:git statuslscat のようなコマンドは結果が数十文字なのに、 subagent を一つ出すだけで起動に約 4.3k のコンテキストが要る(実測、償却不能)。一回の ls にこの代金を払うのは純損だ。

そこでコーディネーターは制限付きの Bash を取り戻す(coordinator(..., glance=True)、デフォルト有効)。判定基準は「コマンドが短いか」ではなく、 結果が期限切れになるかどうかであり、しかも「通す」と「期限切れ」は同じ関数 is_ephemeral() が決める:

自分で走らせて通す 結果が期限切れとマークされる
git status / ls / cat
git commit / pytest / pip install ✗ 委譲

両者は同じ一枚の表でなければならない。どちらか片方だけが成り立つと有害だからだ:通すのにトリムしないなら、期限切れの git status が永久に コンテキストを占め、しかも現状として意思決定をミスリードする。トリムするのに通さないなら、コーディネーターは一回の ls に 4.3k を払う羽目になる。 tests/glance.py はこの不変条件をアサーションとして固定している —— 実測 46 件のコマンドで両側の判定が完全に一致、うち 10 件は敵対的サンプルだ。

落とし穴(二度踏んだ):モデルは単一コマンドを書かない。書くのは git status -s && echo "--- LOG ---" && git log --oneline -10 だ。最初の版は && / | / 2>&1 を含むコマンドを一律拒否したところ、 glance が完全に機能しなくなった —— 実測でコーディネーターの三回の試行がすべて止められ、結局 subagent を出しに戻った。 現在は区切って一段ずつ検査する:すべての段がホワイトリストにあるときだけ通し、git status && rm -rf x はきちんと止まる (後半が表にないため)。

置き換えではなく append

system_prompt = {"type": "preset", "preset": "claude_code", "append": spec.instructions}

build_options()AgentSpec を SDK オプションにコンパイルするとき、instructionsappend を通る —— Claude Code のネイティブなシステムプロンプトの後ろに追加されるのであって、 置き換えではない。だから上に挙げた規律テキスト(COORDINATOR_RULESWORKER_RULES など)は加算だ: 専門化は汎用能力の犠牲の上に成り立たない。

ワークベンチのインデックスもこの経路を通る。毎ターン存在するが、system prompt の一部なので会話履歴を食わず、 compact でも消えない —— その代償が上に書いたあれだ:コーディネーターまでしか届かない

disallowed_tools でコーディネーターの手を止めようとしないこと

disallowed_toolsセッションレベルで、subagent まで一緒に禁止してしまう。実測のエラー原文:

Bash is disabled for this session, in subagents as well as here

正しいやり方は二段構えだ:allowed_tools に入れない、そのうえで PreToolUse hook が agent_id を見てメインスレッドだけを止める。 coordinator() は既にそうしている —— delegate_only=True を設定し、delegate_guard がメインスレッドを止め、 subagent は通す。

allowed_tools だけでも足りない:これは承認不要リストであって、排他的ホワイトリストではない。実測ではモデルはそこに無いツールも呼べる —— $0.1 のプローブで、allowed_tools=["Read"] の agent が Write / Bash を平然と呼べた。 本当に止めているのは hook だ。

allowed_tools もセッションレベルであり、同じ授業を二度受けた。 このリストに無いツールは、subagent が 呼ぶときも同じく権限承認を通る必要がある。無人運用では承認する者がいないので、エラーにも停止にもならず、モデルは同じ呼び出しを繰り返しリトライする (toolDenialKind=user-rejected)。実測:ワーカーに WebFetch/WebSearch を足したのに AgentDefinition.tools にしか書かなかったせいで、 その実行は二十数回の user-rejected を出し、一文字も産出しなかった(roles.py:513-518)。 症状は disallowed_tools より追いにくい —— 後者はその場でエラーになるが、前者は画面上どこもエラーらしく見えない。 そのため coordinator() は現在、配下のワーカーの読み取り専用 web ツールを自分の allowed_tools にマージする (roles.py:523-526)。Write/Edit/Bash意図的にマージしない —— マージすれば上の hook を外すのと同じことになるからだ。

使うべきでないとき

この四層が節約するのはすべて現場だ。以下の問題は解決しないし、いくつかはこの四層のせいでかえって見えにくくなる:

  1. 目標の理解を誤った場合 —— この四層はそれを悪化させる。 現場が捨てられた後に残るのは、まさに誤った前提の上に建った 意思決定であり、しかもそれは正しい意思決定と見た目が全く同じだ。long-horizon はそれを最悪の形に増幅する:誤った前提のまま数時間走り、十数個の subagent を出し、ディスクに成果物を積み上げ、そのあとで露見する。その時点で高くつくのは token ではなく、あらゆる成果物が誤った要求に沿って 建てられているという事実だ。これを止めるのは事前確認であって、このページのどの層でもない。
  2. メインスレッドは依然として単調増加する。 四層が抑えるのは傾きであって方向ではない。実測:メインスレッドは 70 ターンで 28.7K から 185.9K へ増え、 傾きは 2.2K/ターン、全期間 compact なしで 1M ウィンドウの 18.6% を消費、外挿すると約 440 ターンで壁に当たる。その壁を越えるのは ハンドオフだ。
  3. 全量 compact を切った後にセーフティネットは無い。 ハンドオフが有効なとき、Runtime は spec に強制的に CompactPolicy(mode="no_summary") を設定し、auto-compact はそこで無効になる(spec 自身が明示的に compact を与えていればそちらを 尊重する)。上限に当たるのはハードエラーなので、この四層はハンドオフとセットで使わなければならず、単に compact を切って終わりにはできない。
  4. 第四層は resume 時にしか効かない。 トリムもプルーンも load() で起こる。連続して走っているセッションはこれによって小さくならない。 上の分業をやったあとなら、この層はたいてい出番がない —— メインスレッドにはもともとツール結果がそれほど入らないからだ。
  5. 一目見るだけの作業を外に出すのは純損。 subagent の起動は約 4.3k、上の glance の節を参照。
  6. コンテキストを組み替える前にキャッシュの勘定をすること。 実測、ある実行の入力は 299.4M token で、96.1% がキャッシュヒットした。 $171 で済んだのはこれのおかげだ。履歴を書き換える最適化はすべて、まずこの勘定をしなければならない。
  7. 画像やドキュメント類のツール結果はスピルしない。 spill_guard は出力構造の文字列フィールドしか変更せず、list には一切触れない。

パラメータの完全なデフォルト値とシグネチャは Python API を、用語は用語集を参照。