Claude Media
Compactionの一時停止で合計トークン予算を強制する実装パターン

Compactionの一時停止で合計トークン予算を強制する実装パターン

pause_after_compactionはCompactionの直後にAPIを止めるオプションです。人間承認を挟むパターンとエージェントループ全体のハード上限を作るパターンの2通りを実装します。

pause_after_compactionとは何か

pause_after_compaction は、Messages APIのCompaction機能(compact_20260112)が要約を生成した直後にレスポンスを一度止めるブール値のオプションです。既定は false で、有効にすると要約が終わった時点でAPIが stop_reason: "compaction" のメッセージを返します。会話の続きに手を加えてから再開できるのが、このオプションの価値です。

要約が生成されてから会話が続くまでの間に、任意の制御ロジックを挟めます。代表的な用途は2つです。要約のたびに人間の承認を挟む運用と、圧縮の回数からトークン消費を見積もってエージェントループ全体にハード上限をかける運用です。

しきい値型とオンデマンド型のどちらで書くか

Compactionのドキュメントは現在、2つの方式を並べています。この記事の pause_after_compaction はしきい値型の機能です。ドキュメントはオンデマンド型を「使える場所ではオンデマンド型を使う」方式として先に置いており、選ぶときの軸は次のとおりです。

くらべる

圧縮の2方式

この記事の対象

しきい値型

ベータヘッダーは compact-2026-01-12。入力トークンが trigger に達した時点でAPIが要約を書きます。通常のリクエストに1つパラメータを足すだけで済みますが、要約はそのリクエストの中で走るので、バックグラウンド実行はできません。直近のターンを原文のまま残すには、一時停止して自分で差し込み直します。

要約のタイミングを自分で決める

オンデマンド型

ベータヘッダーは compact-2026-09-04、トップレベルの compaction パラメータで使います。要約を頼むリクエストを自分で送り、返ってきたブロックを messages の先頭に差し替えます。バックグラウンド実行や、直近のターンを原文のまま残す構成が専用のページで用意されています。

承認フローや予算判定を挟みたいだけなら、しきい値型と一時停止の組み合わせで足ります。要約のタイミング自体を呼び出し側で握りたい、要約の間も会話を止めたくない、という要件ならオンデマンド型が候補になります。

一時停止後に何が起きているか

pause_after_compaction: true を付けたリクエストが要約を生成すると、stop_reason が "compaction" になって応答が返ります。呼び出し側はこの応答を assistant メッセージとしてメッセージ配列に追加し、必要な変更を加えてから再度リクエストを送ります。

response = client.beta.messages.create(
    betas=["compact-2026-01-12"],
    model="claude-opus-5",
    max_tokens=4096,
    messages=messages,
    context_management={
        "edits": [{"type": "compact_20260112", "pause_after_compaction": True}]
    },
)
 
if response.stop_reason == "compaction":
    messages.append({"role": "assistant", "content": response.content})
    # ここで会話に手を加えてから再開する
手順

一時停止から再開までの1周

  1. 1

    しきい値に達する

    入力トークンが trigger.value に届くと、APIが要約を書き始めます。

  2. 2

    要約で止まる

    stop_reason: "compaction" の応答が返ります。ここから先は呼び出し側の番です。

  3. 3

    応答を追加して手を加える

    応答を assistant として追加し、直近のメッセージの再挿入、指示的なメッセージの差し込み、会話の打ち切りのいずれかを行います。

  4. 4

    同じ設定で再リクエスト

    更新した messages を送ると、要約より前のブロックは無視されて会話が続きます。

この間の処理は同期的なAPI呼び出しの合間に挟まるだけなので、追加のインフラを用意しなくても実装できます。

APIが compaction ブロックを受け取ると、それより前のコンテンツブロックはすべて無視されます。呼び出し側の選択肢は2つです。元のメッセージ配列をそのまま持ち続けて除外をAPI側に任せるか、圧縮済みのメッセージを手元で削除して compaction ブロック以降だけを残すかです。前者は実装が単純ですが配列が膨らみ続け、後者はメモリを節約できる代わりに「どこまでが圧縮済みか」を自前で追跡することになります。先にどちらかを決めておくと、承認フローや予算カウンタのロジックがシンプルになります。

Pythonで client.messages を使い、ブロックを自分でシリアライズする場合は注意が要ります。素の model_dump() は compaction ブロックに text: null と citations: null を足し、APIが Extra inputs are not permitted の400で拒否します。サンプルのとおり client.beta.messages を使うか、to_dict() や model_dump(exclude_none=True) で書き出します。

いつ止めるかはtriggerが決める

pause_after_compaction は「止めた後どう振る舞うか」を決めるだけで、「いつ止めるか」は trigger が決めます。trigger.type は input_tokens のみで、trigger.value が入力トークン数の閾値です。既定は150,000トークン、最小値は50,000トークンです。閾値を小さくすると圧縮と一時停止の頻度が上がり、承認の回数やカウンタの更新も増えます。

人間承認パターンでは、閾値が大きすぎるとレビューの間隔が空いて1回あたりの差分が大きくなり、承認判断が難しくなります。小さすぎると承認依頼が頻発します。トークン予算パターンでは、閾値は「圧縮回数 × 閾値」という見積もり式の乗数そのものなので、閾値を変えると同じ TOTAL_TOKEN_BUDGET でも許容されるツール呼び出しの回数が変わります。

パターン1: 要約のたびに人間承認を挟む

長時間タスクの節目ごとに、要約内容を人間がレビューしてから続行するかどうかを判断させる実装です。本番環境への変更を伴うタスクや、コストが読みにくいタスクに向いています。

def run_with_human_approval(client, messages, model="claude-opus-5"):
    while True:
        response = client.beta.messages.create(
            betas=["compact-2026-01-12"],
            model=model,
            max_tokens=4096,
            messages=messages,
            context_management={
                "edits": [
                    {
                        "type": "compact_20260112",
                        "trigger": {"type": "input_tokens", "value": 100000},
                        "pause_after_compaction": True,
                    }
                ]
            },
        )
 
        if response.stop_reason != "compaction":
            return response
 
        messages.append({"role": "assistant", "content": response.content})
 
        # compactionブロックの中身を人間に提示して承認を取る
        summary_block = next(
            b for b in response.content if b.type == "compaction"
        )
        if summary_block.content is None:
            raise RuntimeError("summary was not written (content is null)")
        approved = request_human_approval(summary_block.content)
 
        if not approved:
            messages.append(
                {
                    "role": "user",
                    "content": "Approval denied. Stop here and report the current state.",
                }
            )
            # 次のリクエストは通常どおり送るが、以降のcompactionは無効にしてもよい

request_human_approval は要約テキストをSlack通知やレビューUIに渡し、承認・却下を待つ関数として実装します。却下時は「停止して現状報告せよ」という user メッセージを追加し、Claudeにタスクを畳ませます。承認が続く限り、ループは自動で先に進みます。

コードの content is None の分岐には理由があります。リクエストに tools が含まれていると、要約を書く内部の処理でモデルがツールを呼んでしまうことがあり、そのときは content: null の compaction ブロックが返ります。ドキュメントは対策として、instructions でツールを呼ばないよう明示する方法を挙げています。

Summarize the transcript inside <summary></summary> tags. Include relevant information in the summary for continuing the task in the next context window. Do not call any tools while writing this summary; respond with text only.

instructions はデフォルトの要約プロンプトを丸ごと置き換えます。Claude 5.1以降のモデルで独自の instructions を渡すと、要約は見えている会話だけから作られ、過去のthinkingブロックは要約の入力に入りません。承認画面に空の要約が出る事故は、ここで防げます。

パターン2: エージェントループ全体のハード上限を作る

ツール呼び出しを繰り返す長時間タスクでは、圧縮の回数からおおよその累計トークン消費を見積もれます。ドキュメントは「圧縮回数 × トリガー閾値」で見積もり、予算に達したら畳ませる例を示しています。

TRIGGER_THRESHOLD = 100_000
TOTAL_TOKEN_BUDGET = 3_000_000
n_compactions = 0
 
response = client.beta.messages.create(
    betas=["compact-2026-01-12"],
    model="claude-opus-5",
    max_tokens=4096,
    messages=messages,
    context_management={
        "edits": [
            {
                "type": "compact_20260112",
                "trigger": {"type": "input_tokens", "value": TRIGGER_THRESHOLD},
                "pause_after_compaction": True,
            }
        ]
    },
)
 
if response.stop_reason == "compaction":
    n_compactions += 1
    messages.append({"role": "assistant", "content": response.content})
 
    if n_compactions * TRIGGER_THRESHOLD >= TOTAL_TOKEN_BUDGET:
        messages.append(
            {
                "role": "user",
                "content": "Please wrap up your current work and summarize the final state.",
            }
        )

これは「予算超過を伝えて畳ませる」段階です。ハード上限にするには、畳ませるメッセージを送った後の次の一時停止で会話を強制終了する分岐を足します。

WRAP_UP_SENT = False
 
if n_compactions * TRIGGER_THRESHOLD >= TOTAL_TOKEN_BUDGET:
    if WRAP_UP_SENT:
        # 畳ませる指示を送った後でもまだ予算超過が続いている = 強制終了
        raise BudgetExceededError(
            f"Stopped after {n_compactions} compactions "
            f"(~{n_compactions * TRIGGER_THRESHOLD:,} tokens estimated)."
        )
    messages.append(
        {
            "role": "user",
            "content": "Please wrap up your current work and summarize the final state.",
        }
    )
    WRAP_UP_SENT = True
手順

ハード上限の判定

  1. 1

    圧縮のたびに数える

    一時停止するごとに n_compactions を1つ増やし、n_compactions × 閾値 を見積もりとします。

  2. 2

    超えたら1回だけ猶予

    予算を超えた最初の一時停止で、畳ませるメッセージを送って WRAP_UP_SENT を立てます。

  3. 3

    それでも超えていたら例外

    次の一時停止でまだ超過なら BudgetExceededError を投げ、呼び出し元のオーケストレーターが失敗の記録か人へのエスカレーションを決めます。

見積もりを実測値に置き換える

「圧縮回数 × 閾値」は圧縮が起きた回数しか見ていません。実際の消費は usage.iterations から得られます。圧縮が起きたリクエストでは compaction の反復と message の反復が並び、トップレベルの input_tokens と output_tokens は圧縮の反復を含まない値です。請求される合計は、iterations の全要素を足した値になります。

JSONのキーは input_tokens と output_tokens です。次のコードの属性アクセスは、使っているSDKの型定義で名前を確かめてから使います。

def billed_tokens(usage):
    # トップレベルのinput/outputには圧縮の反復が入らないため、iterationsを合算する
    return sum(it.input_tokens + it.output_tokens for it in usage.iterations)
 
total_tokens += billed_tokens(response.usage)
if total_tokens >= TOTAL_TOKEN_BUDGET:
    ...  # 畳ませる / 強制終了の分岐へ

圧縮ベータを有効にしていると、圧縮が起きなかったリクエストにも usage.iterations が付きます。そのため、一時停止のときだけでなく毎回のレスポンスで加算できます。トップレベルの値だけで集計していた既存のコスト計測は、圧縮を有効にした時点で過少になります。

猶予の回数と畳ませ方

猶予を1回にするか複数回にするかは、タスクの性質で決まります。ファイル編集のようにいつ止めても状態が壊れにくいタスクなら1回で足ります。外部APIへの書き込みを何段階も重ねるタスクでは、強制終了で中途半端な状態が残りえます。その場合は「畳んでください」の代わりに「ここまでの変更をロールバック可能な状態でまとめてください」と指示し、ロールバック手順を最後の要約に残させる設計という選択肢があります。例外を投げた後の後始末は、呼び出し元のオーケストレーター設計に依存します。

エージェントループとの関係

実際に使う場面の大半は、ツール呼び出しを繰り返すエージェントループの中です。ループの1往復ごとに入力トークンが積み上がるので、trigger に達するタイミングはタスクの複雑さで変わります。単純な質問応答なら圧縮が一度も起きないこともあり、リポジトリ全体を横断編集するタスクでは何度も起きて、そのたびに一時停止が呼ばれます。どのターンで挟み込むかを考えるときは、エージェントループの内部モデルが手がかりになります。

サーバーツール(Web検索など)を使う場合、圧縮のトリガーは各サンプリング反復の始めに判定されます。1回のリクエストの中で複数回の圧縮が起きることもあります。

2つのパターンの使い分け

観点パターン1(人間承認)パターン2(トークン予算)
止める基準パターン1(人間承認)要約が生成されるたび全部パターン2(トークン予算)累計トークンが閾値を超えたときだけ
人手の関与パターン1(人間承認)毎回必要パターン2(トークン予算)超過時のみ(または不要)
向くタスクパターン1(人間承認)本番影響のある変更、コスト読みにくいタスクパターン2(トークン予算)バッチ処理、無人実行するエージェント
実装の複雑さパターン1(人間承認)承認UI・通知経路が別途必要パターン2(トークン予算)カウンタと閾値だけで完結
失敗時の挙動パターン1(人間承認)却下メッセージを送って安全に畳ませるパターン2(トークン予算)猶予後に例外で強制終了

両者は排他ではありません。同じ pause_after_compaction のフックに別々の判断ロジックを載せているだけなので、「通常は自動続行、累計トークンが閾値を超えたときだけ人間承認に切り替える」実装も作れます。

直近のターンを残すときの注意

一時停止の用途として、ドキュメントは直近のメッセージを原文のまま残す使い方も示しています。圧縮直後に、直前のやり取りと現在のユーザーメッセージを含む3メッセージを差し込み直す例です。

差し込む assistant ターンに思考ブロックが含まれる場合、Claude Fable 5.1・Claude Opus 5.5・Claude Sonnet 5.5では扱いが変わります。そのブロックは全履歴があった状態で作られたため、圧縮後の会話の検査を通りません。差し込む前に thinking と redacted_thinking のブロックを取り除くか、thinking-binding-controls-2026-08-01 ベータヘッダーと thinking.block_binding.prefix_mismatch_behavior: "drop_block" を送ります。承認や予算判定だけで直近のターンを触らない構成なら、この処理は要りません。

ストリーミングで実装する場合の注意

stream: true でリクエストしている場合、compaction ブロックは他のコンテンツブロックと違うイベント順で届きます。content_block_start の後に、要約全文を含む単一の content_block_delta が1回だけ届き、最後に content_block_stop で終わります。テキストブロックのように少しずつは届きません。

人間承認やトークン予算をストリーミング実装に組み込むなら、要約の中身を読むタイミングはこの content_block_delta を受け取った瞬間です。逐次的に届くテキストと同じ扱いでバッファリングすると、要約が1チャンクで来ることを前提にしていないコードで不具合の原因になります。

承認が滞留したときの扱い

人間承認を本番で運用すると、承認依頼を出したまま誰も応答しない時間帯が発生します。request_human_approval をブロッキングな待受にすると、その間プロセスが保持され続けて無駄が生じます。承認依頼を出した時点でいったんプロセスを終了し、結果を非同期のキューやWebhookで受け取ってから再開する設計のほうが扱いやすくなります。

この場合、再開に必要な状態(それまでの messages 配列と、承認待ちの compaction ブロック)を外部ストレージに保存します。JSONで保存するときは、先に述べた text: null と citations: null の混入に注意が要ります。会話を止めている間はタスクの状態が変わらないので、保存は承認依頼を出した直後の1回で足ります。

TypeScriptで実装する場合

パターン2(トークン予算)をTypeScriptで書くと、判定ロジックはPythonとほぼ同じ形になります。

const TRIGGER_THRESHOLD = 100_000;
const TOTAL_TOKEN_BUDGET = 3_000_000;
let compactionCount = 0;
let wrapUpSent = false;
 
const response = await client.beta.messages.create({
  betas: ["compact-2026-01-12"],
  model: "claude-opus-5",
  max_tokens: 4096,
  messages,
  context_management: {
    edits: [
      {
        type: "compact_20260112",
        trigger: { type: "input_tokens", value: TRIGGER_THRESHOLD },
        pause_after_compaction: true,
      },
    ],
  },
});
 
if (response.stop_reason === "compaction") {
  compactionCount += 1;
  messages.push({ role: "assistant", content: response.content });
 
  if (compactionCount * TRIGGER_THRESHOLD >= TOTAL_TOKEN_BUDGET) {
    if (wrapUpSent) {
      throw new Error(
        `Stopped after ${compactionCount} compactions (~${compactionCount * TRIGGER_THRESHOLD} tokens estimated).`
      );
    }
    messages.push({
      role: "user",
      content: "Please wrap up your current work and summarize the final state."
    });
    wrapUpSent = true;
  }
}

compactionCount と wrapUpSent は、リクエストをまたいで保持する必要があります。エージェントループを関数で包むなら、ループの外側(クロージャやクラスのインスタンス変数)に置いて使い回します。

複数セッションを並行して走らせる場合(Webサービスの裏で複数ユーザーのタスクを同時に処理する場合など)は、この2つの変数をセッションIDに紐づけます。グローバル変数のまま共有すると、あるユーザーの圧縮回数が別のユーザーの予算判定に混ざります。置き場所はメモリ内のマップでもデータベースの1レコードでもよく、リクエストの間で2値が正しく引き継がれる場所を1つに決めることが要点です。

まとめ

承認を挟むか予算で止めるかは、タスクの影響範囲と無人で回すかどうかで決まります。実装に入る前に、system へ cache_control を付けておくと圧縮をまたいで長いsystemプロンプトがキャッシュされたままになる点も押さえておくと、コストの見通しが立てやすくなります。仕組みはPrompt Cachingの仕組み、長時間エージェント全体の文脈管理は長時間稼働エージェントのハーネス設計に詳しくあります。

この記事を共有:XはてブLinkedIn