AI エージェント開発実践 #4 長い作業を支えるコンテキスト管理

読了 6分

第3回で複数段階の作業を計画し検証するエージェントを作りました。ところが段階が数十回を超えると、新しい敵が現れます。ループが回るたびに messages が増え、やがてコンテキストウィンドウの限界にぶつかります。限界の手前でも問題です。入力が長いほど呼び出しごとの費用が増え、古い情報がたまると判断も鈍ります。今回は長い作業を支えるコンテキスト管理を扱います。

LLM アプリ開発 第9回でチャットボットの会話メモリを扱いました。あちらが「ユーザーとの会話を覚える」問題だったとすれば、今回は「一つの作業の中でたまるツール結果をさばく」問題です。同じコンテキスト管理でも、対象が違います。

何がコンテキストを占めているかを見る #

エージェントの会話で容量の大部分は、ユーザーメッセージでも Claude の答えでもなくツール結果です。検索結果数十件、ファイル内容の全体、API 応答の JSON が毎段階たまっていきます。ですからコンテキスト管理の第一原則は単純です。ツール結果を管理すれば、大部分は解決します。

ツール結果のダイエット — 入ってくるときに減らす #

最も効果が大きい方法は、そもそも大きな結果を作らないことです。ツールを作るとき、返す大きさに上限を設けます。

tool_result_limit.py
MAX_RESULT_CHARS = 4000

def search_docs(query: str) -> str:
    results = do_search(query)
    text = format_results(results[:10])   # 上位10件のみ
    if len(text) > MAX_RESULT_CHARS:
        text = text[:MAX_RESULT_CHARS] + "\n…(結果が切り捨てられました。より狭い検索語で再試行してください。)"
    return text

切り捨てた事実と対処法を結果に書いておくことが重要です。第2回のエラーメッセージの原則と同じです。Claude はその案内を読んで、検索語を絞って再試行します。ファイル読み取りツールなら全体の代わりに範囲を受け取らせ、一覧ツールならページネーションを入れるという具合に、ツールごとに「少しずつ取ってくる道」を開けておきます。

古いツール結果を空にする #

入ってくるときに減らしても、段階が積み重なれば結局増えます。二つ目の方法は、すでに役目を終えたツール結果を、痕跡だけ残して空にすることです。検索結果は、Claude がそれを読んで次の行動を決めた瞬間に役割が終わっていることが多いです。

prune_old_results.py
def prune_tool_results(messages: list, keep_recent: int = 3) -> list:
    """直近 N ターンを除き、ツール結果の本文をプレースホルダーに置き換える。"""
    pruned = []
    for i, msg in enumerate(messages):
        old = i < len(messages) - keep_recent * 2
        if old and msg["role"] == "user" and isinstance(msg["content"], list):
            new_content = []
            for block in msg["content"]:
                if isinstance(block, dict) and block.get("type") == "tool_result":
                    block = {**block, "content": "(古いツール結果は整理されました)"}
                new_content.append(block)
            msg = {**msg, "content": new_content}
        pruned.append(msg)
    return pruned

会話の構造(どのツールを呼び、結果があったという事実)は残して、本文だけ空にするのがポイントです。構造まで消すと、API が tool_usetool_result のペアを検証するときにエラーになります。毎ループで呼び出しの前にこの整理を回せば、コンテキストは直近の作業ぶん程度に保たれます。

要約による圧縮 — これまでの過程を一かたまりに #

空にするだけでは足りないほど長い作業なら、これまでの過程をまるごと要約して置き換えます。古い区間を切り出して Claude に「ここまでの進行状況を要約してください」と別の呼び出しで頼み、その要約一かたまりで該当区間を置き換える方式です。進行状況の文脈は残り、容量は大きく減ります。

自分で実装しない道もあります。API のコンパクション機能(ベータ)を有効にすると、コンテキストが限界に近づいたとき、サーバーが自動で以前の内容を要約してくれます。

server_compaction.py
response = client.beta.messages.create(
    betas=["compact-2026-01-12"],
    model="claude-opus-4-8",
    max_tokens=16000,
    tools=tools,
    messages=messages,
    context_management={"edits": [{"type": "compact_20260112"}]},
)
messages.append({"role": "assistant", "content": response.content})

一つだけ注意してください。応答には要約を担うコンパクションブロックが混ざってきますが、これは response.contentまるごと会話へ戻す必要があります。テキストだけ取り出して積むと、要約の状態が静かに消えて圧縮が解けてしまいます。

ファイルのスクラッチパッド — コンテキストの外に書いておく #

最後の方法は発想を変えます。重要な情報をコンテキストに積む代わりに、コンテキストの外のファイルに書かせるのです。エージェントにメモファイルを読み書きするツールを与え、システムプロンプトに使用ルールを入れます。

scratchpad_rule.py
SYSTEM = """...
- 作業中に分かった重要な事実と残りのタスクは notes.md に記録する。
- 長い作業の続きを行うときは、まず notes.md を読んでから始める。
"""

こうすれば、ツール結果を積極的に空にしたり要約したりしても、核心の情報はファイルに生き残ります。コンテキストは「いま見ている画面」で、ファイルは「ノート」というわけです。プロセスが再起動してもファイルは残るので、セッションをまたぐ長い作業の土台にもなります。

どれから適用するか #

四つすべてを作る必要はありません。効果対費用の順で、次のようにおすすめします。

  1. ツール結果の上限 — ツールのコード数行で済み、効果が最も大きいです。常に適用します。
  2. 古い結果を空にする — 数十段階の作業が日常的なら追加します。
  3. コンパクション — コンテキストの限界そのものに届く作業なら有効にします。
  4. スクラッチパッド — セッションをまたいだり数日がかりの作業なら導入します。

よくつまずくところ #

  • テキストだけ取り出して積む — コンパクションでも通常の応答でも、response.content にはテキスト以外のブロックが混ざることがあります。会話には content をまるごと保存します。
  • tool_result のペアを壊す — 整理のつもりで tool_result ブロック自体を消すと、残っている tool_use とペアが合わず 400 エラーになります。本文だけ空にしてブロックは残します。
  • 切り捨てて案内しない — 結果を黙って切ると、Claude はそれが全部だと信じて進めます。切り捨てた事実と、続きを取ってくる方法を結果の中に書きます。

まとめ #

今回は、長い作業でコンテキストをさばく四つの方法を扱いました。

  • 容量の主犯はツール結果です。ツールが最初から少なく返すようにするのが最も安上がりです。
  • 役目を終えた結果は本文だけ空にし、さらに長くなるなら要約で置き換えるか、サーバー側のコンパクションを有効にします。
  • 重要な事実はファイルのスクラッチパッドに書かせて、コンテキストの外に保存します。

ここまでは、一つのエージェントを育てる話でした。次回の「AI エージェント開発実践 #5 サブエージェントで仕事を分ける」では、仕事を複数のエージェントに分けて任せる方法を扱います。コンテキスト問題のもう一つの解法でもあります。

X