レッスン6: 実践: ハーネスにコンテキスト管理を組み込む
学習目標:
- stop_reason 駆動のハーネスループにトークン使用量の追跡を組み込む:
response.usage で累積し、コンテキストがウィンドウの上限に近づいているかを判定する
- レッスン4の
compact() を閾値で発動する仕組みに変える: トリガー比率を決め、発動後に messages と使用量カウンターがどうリセットされるべきかを考え抜く
- 構造化ノートをこのコンパクションの流れに組み込み、新しいウィンドウが再起動するたびに
NOTES.md を読み戻すようにして、1つのウィンドウの容量を超えるタスクを走らせる
前提: コンパクションとノートを扱ったレッスン4、サブエージェントの分離を扱ったレッスン5を修了し、本シリーズのコース7「Agent Harness Fundamentals: Loops and Control」のハーネスループを手元に置いていること | 前: レッスン5 <<
まずは動いているところを見る
最初の5レッスンはすべて原理でした。なぜコンテキストが有限の資源なのか、コンパクションはどう動くのか、ノートはどう働くのか、サブエージェントはどう分離するのか。このレッスンでは、最初の2つ — コンパクションとノート — を、本シリーズのコース7で書いたハーネスループに溶接します。まず動いているところを見て、それからコードを解きほぐしていきましょう。
以下は実際の実行ログです(複数ターンのモデル応答をシミュレートするスタブクライアントを使い、長時間タスクを数行のログに収めています。スタブクライアントの実装はこのレッスンの最後に出てきます)。タスクはレッスン4でおなじみのあの場面、注文サービスの並行競合バグの修正です。数ターンのうちにコンパクションを発動させるため、デモでは意図的にごく小さなコンテキストウィンドウを設定しています。
1行ずつ読んでいきましょう。最初の3回の呼び出しが tokensUsed を 400、1050、1850 と押し上げます。3ラウンド目のツール結果が履歴に追加されたあと、累積値が設定した閾値を越えるので、ハーネスはウィンドウが実際に破裂するのを待たず、自発的にコンパクション呼び出しを撃ちます(呼び出し 4。この応答はメインループに入らず、その出力が messages の再起動に直接使われるので、stop_reason はもう関係ありません)。tokensUsed はゼロにリセットされ、続く2ラウンドは新しいウィンドウで新たに数えられ、モデルが締めくくります。この一連の流れでユーザーの目に見える成果物はたった1つ、あの最終応答だけです。コンパクションとノートは舞台裏で起きています。このレッスンの残りは、あのログの背後にあるコードを1行ずつ組み立てる作業です。
I. ループにトークン使用量の追跡を組み込む
第1歩は素直です。これまでに何トークン使ったかを知ること。コンパクションするかどうかを判断する前提がそれだからです。このフィールドは、本シリーズのコース7で予算バルブ(バルブ2)を書いたときにすでに使っています。response.usage はその呼び出しの input_tokens と output_tokens を持っており、応答を受け取るたびにそれらをアキュムレーターに足します。
コース7の TOKEN_BUDGET バルブは、この累積値を1つの目的に使っていました。上限に達したら止まる、です。このレッスンがするのは別のことです。ずっと手前の比率で自発的にコンパクションし、止まらずに作業を続ける。使っているアキュムレーターは同じで、発動後に起きることがまったく違います。片方はブレーキ、もう片方は一息つくことです。
ウィンドウ自体の大きさはどうでしょうか。それはあなた自身のエンジニアリング判断で定める定数です。
COMPACT_RATIO をいくつにすべきかに標準の答えはありません。これは仕様条項ではなくエンジニアリング判断です。高くしすぎると、「そろそろコンパクションだ」と気づいたときにはウィンドウが逼迫していて次のリクエストすら送れないかもしれません。低くしすぎると、コンパクションが必要以上に早く頻繁にタスクを中断し、モデル呼び出しを無駄にします。経験則としては、30% ほどの余裕を残す(つまり閾値 0.7)とだいたいうまくいきます。具体的な数値は、実際に使うモデルのウィンドウサイズと、1ターンあたりのツール出力の量をもとに調整してください。
II. 閾値でのコンパクション発動: レッスン4の compact() をループに組み込む
仕組みが決まったら、次はループ本体への配線です。レッスン4の結論を思い出してください。コンパクションはサマリーを古い会話に押し戻して絞り続けるのではなく、サマリーで新しいウィンドウを再起動し、古い messages を丸ごと捨てます1。ループに組み込むなら、それは適切な瞬間に messages を丸ごと差し替えるということです。
このコードが正しいかどうかは、3つの位置で決まります。
- チェックが座る場所: このラウンドのツール結果が
messages に追加された直後、次の client.messages.create の前です。早すぎる(追加の前にチェックする)と、たった今生まれたツール出力を見落とします。遅すぎる(チェックの後に追加する)と、すでに閾値を超えた内容でリクエストを1回余分に送ることになります。
- コンパクション後、
messages は追記ではなく丸ごと差し替える: compact() の戻り値を messages に直接代入し、数十回のツール往復を抱えた古い配列は捨てられます。それが「再起動」と「古い会話に積み増し続ける」の境界線です。
tokensUsed は必ずゼロにリセットする: 新しいウィンドウはサマリーから始まるので、使用量もそのサマリー以降で数えるべきで、古いウィンドウの累積値を持ち越してはいけません。この一手を忘れるのはよくある罠で、このレッスンの演習でまさにそれを診断します。
compact() 自体はレッスン4の実装をそのまま再利用します。COMPACT_INSTRUCTION と取捨の原則(アーキテクチャ上の決定、未解決のバグ、重要な実装詳細を残し、冗長なツール出力を捨てる)も同じです1。次の節では、そこに新しい能力を1つ足します。再起動時にサマリーだけでなく NOTES.md も読むことです。
III. 保険としての構造化ノート: コンパクション時に NOTES.md を読み戻す
コンパクションは受動的で事後的です。要約するのは「発動した瞬間にウィンドウに残っているもの」だけです。レッスン4ですでに説明したとおり、ノートは能動的で書きながら残す保険です。エージェントは決定や問題が生まれたその瞬間に、ウィンドウの外の NOTES.md に書き込みます1。2つを組み合わせる方法は素直です。新しいウィンドウが再起動するとき、サマリーを読むのに加えて NOTES.md も読み戻す。こうすれば、そのラウンドのサマリーの取捨が間違っていても、ノートに独立した控えが残ります。
まず、ノートを書くためのツールをエージェントに与えます。
update_notes の input は保存したいノートの完全な内容で、実装はそれを丸ごと書き込みます。これが最も単純なセマンティクスです。エージェントは1つの完全なノート本体を維持し、更新は常に「これが現在の状態だ」を意味するので、差分のマージを扱う必要がありません。システムプロンプトには要件を1つ配線します。「重要な決定を下したとき、新しい問題を見つけたとき、1つの段階を終えたときは、続行する前に update_notes を呼んでノートを更新すること」。レッスン4と同じです。
そしてこのレッスンの新しい一歩です。compact() がサマリーを生成したあと、NOTES.md も読んで再起動メッセージに含めます。
新しいウィンドウが目を覚ますとき、手元には2つの材料があります。モデル自身が書いたサマリーと、エージェントが自分の手で書いたノートです。前者は取捨によって細部を失いうるもので、後者は無損失です。これがレッスン4の「ノートを丁寧に書くほど、コンパクションが何かを落としたときの被害は軽くなる」をコードにした姿です。
IV. まとめ上げる: 1つのウィンドウの容量を超えるタスク
3つの部品 — 使用量の追跡、閾値でのコンパクション発動、NOTES.md の読み書き — を同じ runAgent に配線すると、冒頭のログの背後にある完全なコードになります。
これが冒頭のログの完全な出どころです。3回のツール呼び出しが tokensUsed を 400、1050、1850 と押し上げ、2000 * 0.7 = 1400 という閾値の線を越えます。compact() が呼ばれ、messages が丸ごと差し替えられ、カウンターがゼロにリセットされます。そのあと新しいウィンドウで2ラウンド進み、モデルが締めくくります。この実行の間にコンパクションが起きたのは1回だけですが、タスクが続いて再び閾値に達すれば、同じロジックが2回目、3回目を発動します。shouldCompact はこれが何番目のウィンドウかを気にせず、現在のウィンドウの使用量だけを見ます。「1つのウィンドウの容量を超えるタスクを走らせる」とはこういう意味です。タスクの総長はどの1つのウィンドウの容量にも縛られず、縛られるのは「中断のない連続した1回の推論」だけです。
完全な検証を走らせるには、複数ターンのモデル応答をシミュレートするスタブクライアントをつなぎます(実際の呼び出しでは new Anthropic() に差し替えます。runAgent のコードは一文字も変わりません)。
こうしたスタブクライアントで検証する価値は、「このターンでモデルはツールを呼ぶのか、何トークン使ったのか」を既知の値として固定できることにあります。おかげで、コンパクションがどのターンで発動するか、tokensUsed がゼロにリセットされるか、NOTES.md が書かれてから読み戻されるか — そのすべてを、実際の呼び出し出力を睨んで推測するのではなく、アサーションで確認できます。
分量の感覚: すべてのタスクにこの仕掛けが要るわけではない
ここまで配線すると、ある誤った印象を抱きがちです。これからエージェントを書くときは、使用量の追跡、閾値でのコンパクション、構造化ノートを一式で入れるのがデフォルトだ、という印象です。レッスン2で確立した分量の感覚に戻りましょう。複雑さの追加は、それが結果を実証的に改善しうるときにのみ検討します2。十数ターンで終わるタスクにとって、コンパクションもノートも余計な部品です。本シリーズのコース7の素のループと基本の制御バルブから始め、実際にウィンドウの上限にぶつかるか、「新しいウィンドウが古いウィンドウのしたことを知らない」という記憶喪失の症状を見てから、この層を足してください。
ここまでで、このコースがレッスン1からレッスン6まで教えてきたすべて — アテンション予算、システムプロンプトの高度、ジャストインタイムの取得、コンパクションとノート、サブエージェントの分離 — が同じ結論に収束します。毎ターンモデルに何を見せるべきかは、一度設定して忘れる構成ではなく、常に見直し続けるエンジニアリング判断だということです。
まとめ
- ハーネスに使用量の追跡を組み込むのに必要なのはアキュムレーター1つだけです。応答を受け取るたびに
response.usage.input_tokens + response.usage.output_tokens を足します。コース7の TOKEN_BUDGET バルブとデータは同じですが、発動後の動作が違います。予算バルブは上限に達したら止まり、このレッスンの閾値はコンパクションして作業を続けます。
- 閾値でのコンパクション発動は、レッスン4の
compact() をループ本体に組み込みます。チェックが座るのは「このラウンドのツール結果が完全に追加されたあと、次のリクエストが出る前」です。発動後は messages をコンパクションの結果で丸ごと差し替えます。追記ではなく再起動です1。tokensUsed も同期してリセットしなければ、コンパクションが繰り返される嵐に落ちます。
- ノートとコンパクションはこのレッスンで配線されます。
update_notes ツールが書きながら残す — これがコンテキストウィンドウの外にノートを永続化することです1 — そして compact() はサマリーを生成するのに加えて NOTES.md を再起動メッセージに読み戻します。サマリーは取捨で内容を失いうる一方、ノートは無損失の写しとして読み戻されます。「ウィンドウの再起動のような重要な瞬間にだけ一度読み、毎ターンのシステムプロンプトには詰め込まない」は、アテンション予算の原理(新しいトークンはすべてその予算を削る1)に基づくこのレッスンのエンジニアリング上のトレードオフです。
- 実際のエンドツーエンドの検証が示すのは次のことです。3回のツール呼び出しが使用量を 400 から 1850 へ押し上げ、閾値を越えてコンパクションを1回発動させ、カウンターがリセットされ、そのあと2ラウンドで締めくくる。タスクの総長はもはや1つのウィンドウの容量に縛られず、縛られるのは「中断のない連続した1回の推論」だけです。
- この仕掛けをデフォルト構成と思わないでください。追加の複雑さが結果を実証的に改善しうるときにだけ足します2。数ターンで終わるタスクなら、本シリーズのコース7の素のループと基本の制御バルブで十分です。