朝になると判断が雑になっていた
自動運用しているエージェントを夜通し走らせていて、最初に気づいた異変は「朝のコミットメッセージが急にそっけなくなる」ことでした。深夜0時の出力は丁寧に文脈をたどっているのに、朝6時の同じ種類のタスクでは、指示の前半を無視したような短い応答が返ってくる。モデルを変えたわけでも、プロンプトを変えたわけでもありません。変わっていたのは、セッションに積み上がった文脈の量だけでした。
これは珍しい話ではありません。単発の会話では完璧に動くプロンプトが、長く走らせると期待どおりに振る舞わなくなる。その多くは、モデルの能力ではなく、コンテキストの設計に原因があります。とりわけツールを何十回も呼ぶエージェントでは、過去のツール結果と会話履歴が静かに積み上がり、本当に効かせたい指示を希釈していきます。
ここでは、長時間走るエージェントの文脈を「健全なまま」保つための設計を扱います。コンテキストを予算として捉えて配分し、劣化を計測し、閾値で圧縮を発火させる——この3つを動くコードとともに整理していきます。広い意味でのコンテキストエンジニアリングの話ではありますが、焦点は「走り続けるエージェント特有の腐り方」に絞ります。
なぜ文脈は腐るのか — 蓄積・希釈・位置効果
長いセッションで起きていることは、ざっくり3つに分けられます。
ひとつめは蓄積です。エージェントがツールを呼ぶたび、その入力と出力が履歴に残ります。1回のファイル読み込みが数千トークン、検索結果が1万トークン——これが何十ターンも続けば、本来の指示が占める割合はどんどん小さくなります。
ふたつめは希釈です。コンテキストウィンドウは広くても、モデルが一度に強く参照できる「実効的な注意」は無限ではありません。重要な制約が大量の中間ログに埋もれると、相対的な重みが下がります。個人開発で複数のエージェントを自動運用している私の観測でも、同じシステムプロンプトのまま履歴が15万トークンを超えたあたりから、冒頭の制約を破る応答が目に見えて増えました。これは本番運用で実際にぶつかった注意点で、回避するには文脈量そのものを抑えるしかありません。
みっつめは位置効果です。長い文脈では、中盤に置かれた情報ほど見落とされやすい傾向があります。これは「lost in the middle」として知られる現象で、長いツールログの真ん中に紛れた重要事実は、想像以上に効きません。
結論はシンプルです。コンテキストは「入れば入るほど良い」ものではなく、配分を設計すべき有限の資源として扱う必要があります。
コンテキストを予算として配分する
まず、コンテキストを役割ごとの「層」に分け、それぞれに上限トークンを割り当てます。総量ではなく配分を決めるのがこの設計の肝です。
| 層 | 役割 | 目安配分 | 圧縮可否 |
|---|---|---|---|
| 不変層(システム) | 役割・制約・出力規約 | 5〜10% | 不可(常に全文) |
| 知識層(取得結果) | RAG・参照ドキュメント | 20〜30% | 可(毎ターン入替) |
| 履歴層(会話・ツール) | 過去のやりとり | 40〜50% | 可(要約・破棄) |
| 作業層(直近) | 現在のタスクと直近数ターン | 15〜25% | 不可(鮮度優先) |
ポイントは、圧縮できる層とできない層を最初に分けておくことです。システムプロンプトと直近の作業文脈は鮮度と完全性が命なので削らない。一方で、知識層と履歴層は入れ替え・要約の対象として設計します。
予算管理を最小限のクラスで表すと、こうなります。
from dataclasses import dataclass, field
@dataclass
class ContextBudget:
total: int = 200_000 # モデルのウィンドウ
reserve_output: int = 16_000 # 出力用に確保する分
# 入力に使える上限から各層へ配分
def allocation(self) -> dict[str, int]:
usable = self.total - self.reserve_output
return {
"system": int(usable * 0.08),
"work": int(usable * 0.22),
"knowledge": int(usable * 0.30),
"history": int(usable * 0.40),
}
def count_tokens(client, model, blocks) -> int:
# 概算ではなく公式のトークンカウントAPIで実測する
return client.messages.count_tokens(model=model, messages=blocks).input_tokens
トークン数を「文字数 ÷ 4」のような概算で済ませないことが大切です。日本語と英語、コードとプローズでトークン効率は大きく変わるので、count_tokens で実測した値を予算判定に使います。概算で運用すると、ある日いきなりウィンドウ超過で落ちます。
劣化を計測する — 観測なしに圧縮しない
圧縮をいつ発火させるかは、勘ではなく数値で決めます。私が常時見ているのは次の3つです。
履歴占有率は、履歴層が割当予算の何割を使っているかです。これが1.0に近づくほど、新しいツール結果を入れる余地がなくなり、古い情報が新しい指示を圧迫します。0.8を超えたら圧縮の検討に入ります。
システム制約の残存距離は、システムプロンプトの末尾から現在の生成位置までの実トークン距離です。距離が伸びるほど冒頭の制約が効きにくくなります。距離が15万トークンを超えるなら、制約を作業層へ再注入することを考えます。
取得命中率は、知識層に入れたドキュメントのうち、実際に応答で参照された割合です。これが低いまま知識層を厚くしているなら、それは予算の浪費です。詰め込みではなく絞り込みへ舵を切る合図になります。
def context_health(usage: dict, alloc: dict) -> dict:
history_ratio = usage["history"] / alloc["history"]
system_distance = usage["history"] + usage["knowledge"] + usage["work"]
return {
"history_ratio": round(history_ratio, 2),
"system_distance": system_distance,
"compact_now": history_ratio > 0.8 or system_distance > 150_000,
}
compact_now が真になったら、次のターンの前に履歴層を圧縮します。閾値は運用しながら調整しますが、「落ちてから対処」ではなく「劣化の兆候で先回り」する姿勢が、長時間運用では効いてきます。
圧縮の実装 — いつ・何を・どう捨てるか
圧縮には大きく2つの手段があります。要約と選択的破棄です。
要約は、古い会話ターンをまとめて1つの簡潔なメモに置き換える方法です。判断の根拠は残しつつ、冗長なやりとりを畳みます。直近のターンは要約せず、鮮度を保ちます。
SUMMARIZE_SYSTEM = (
"以下はエージェントの過去ログです。後続の判断に必要な"
"決定事項・制約・未解決の課題だけを、箇条書きで簡潔に残してください。"
"完了済みの冗長なやりとりは省いて構いません。"
)
def compact_history(client, model, old_turns, keep_recent=6):
archived = old_turns[:-keep_recent] # 古い部分だけ要約対象
recent = old_turns[-keep_recent:] # 直近は手をつけない
if not archived:
return old_turns
summary = client.messages.create(
model=model,
max_tokens=1024,
system=SUMMARIZE_SYSTEM,
messages=[{"role": "user",
"content": _render(archived)}],
).content[0].text
memo = {"role": "user",
"content": f"[これまでの要約]\n{summary}"}
return [memo] + recent
選択的破棄は、もう参照しないツール結果を文脈から外す方法です。Claude API の Context Editing を使うと、古いツール結果を自動でクリアできます。大量のファイル読み込みや検索を繰り返すエージェントでは、これが最も効きます。
resp = client.beta.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
messages=conversation,
tools=tools,
betas=["context-management-2025-06-27"],
context_management={
"edits": [{
"type": "clear_tool_uses",
# 直近のツール結果は残し、古いものから消す
"keep": {"type": "tool_uses", "value": 8},
"clear_at_least": {"type": "tokens", "value": 20_000},
}]
},
)
本番運用で陥りやすい落とし穴は、要約と破棄を同じものとして扱ってしまうことです。ここで意識したいのは、要約と破棄は役割が違うことです。要約は「判断の文脈」を残すため、破棄は「もう不要な生データ」を消すため。検索結果やファイル本文のような大きな生データは破棄し、判断の経緯は要約で残す——この使い分けが、文脈を軽くしつつ賢さを保つ近道です。
取得は詰め込みではなく絞り込み
知識層でやりがちな失敗が、関連しそうなドキュメントを上位20件まとめて入れることです。トークンは食うのに、命中率は上がりません。先ほどの取得命中率が低い状態は、たいていこれが原因です。
効くのは、検索で広めに集めてから、再ランク付けで本当に必要な数件まで絞る二段構えです。
def retrieve(query, store, k_search=40, k_final=5):
candidates = store.search(query, top_k=k_search) # まず広く
reranked = rerank(query, candidates) # 関連度で並べ直す
picked = reranked[:k_final] # 数件に絞る
# 知識層の予算内に収める。溢れたら末尾から落とす
return trim_to_budget(picked, budget="knowledge")
「念のため入れておく」を捨てるのが本質です。入れた分だけ希釈と位置効果のリスクが増えるので、確信のないドキュメントは入れないほうが応答は安定します。私の運用では、上位20件の素朴な詰め込みから再ランク後5件へ絞ったところ、トークン消費が減ったうえで、参照の的確さがむしろ上がりました。
プロンプトキャッシュと予算の関係
長時間エージェントでは、プロンプトキャッシュの設計が予算とコストの両方に効きます。鍵は変わらないものを前に、変わるものを後ろに置くことです。
system = [
{"type": "text", "text": ROLE_AND_RULES,
"cache_control": {"type": "ephemeral"}}, # 不変層をキャッシュ
]
# 知識層・履歴層はキャッシュの後ろ。入れ替わってもキャッシュは無効化されない
ここで圧縮設計とキャッシュ設計がぶつかる点に注意が必要です。履歴を要約で書き換えると、その地点より後ろのキャッシュは無効化されます。だから圧縮は毎ターンではなく、閾値を超えたときにまとめて行うほうが、キャッシュのヒット率を保てます。劣化指標で先回りしつつ、頻度は抑える——このバランスが運用の腕の見せどころです。
コストの観点も2026年は無視できません。2026-06-15 に発効した課金変更で、Agent SDK や headless 実行はサブスクの上限から外れ、月次クレジットの実 API レート課金に切り替わりました。つまり、無駄に膨らんだ文脈は、そのままトークン課金として跳ね返ります。文脈を軽く保つことは、品質だけでなく運用コストの問題にもなったわけです。実コストは必ず実測し、圧縮の閾値はコストの観測値も見ながら調整することをおすすめします。
次の一歩
長時間走るエージェントを抱えているなら、私は次の順序で着手することを推奨します。
- 1セッション分のトークン内訳を
count_tokens で実測し、どの層が予算を食っているかを把握する
- 履歴占有率と制約残存距離を計測し、0.8 と15万トークンという閾値で圧縮を発火させる
- 大きな生データは Context Editing で破棄し、判断の経緯だけを要約で残す
どの層が予算を食い、どこで履歴占有率が0.8を超えるかが見えれば、圧縮をどこに入れるべきかは自ずと決まります。観測なしの圧縮は当て推量になりますが、内訳が一度見えれば、設計はぐっと具体的になります。個人的には、この順序で進めるのが遠回りに見えて最短だと感じています。
私自身まだ調整を続けている最中ですが、文脈を有限の予算として扱い始めてから、朝のエージェントが夜と同じ丁寧さで働くようになりました。同じように個人開発で長時間運用に悩んでいる方の参考になれば幸いです。