$ cat ./blog/claude-context-compaction.md
Claude API の Context Compaction(beta)で長時間対話を継続する — 設定を間違えると会話が壊れる落とし穴
Claude API に Context Compaction ベータが追加されました。長時間の multi-turn 会話が 1M context を超えそうになる前に、サーバー側が履歴を自動要約して継続できるようにする機能です。beta header、設定方法、そして最大の footgun(response.content を text だけ拾ってしまう)を実装込みでまとめます。
- author
- r43
- date
- (2026年4月23日)
- reading
- 3 min
- commit
- 7e719b7
Claude API に Context Compaction(beta、compact-2026-01-12)が追加されました。長時間の multi-turn 会話が 1M context に近づいた時点で、サーバー側が早期履歴を自動要約して、そのまま会話を続けられるようにする機能です。自作で tail-truncation や summarization を書く必要が(当面)無くなる、運用上は地味に重要な更新です。
本記事では、有効化の手順と、ここで外すと機能全体が静かに壊れる 1 行 を含めて整理します。
到達点
- Context Compaction が効く対象モデルと beta header の正体を知る
context_management.edits の書き方
- 最大の footgun: 応答の
response.content を丸ごと次のターンに append する(text だけ拾うと compaction ブロックが失われる)
- どれくらい長い会話で発動するか、コスト影響の見方
検証環境: 2026-04 時点、@anthropic-ai/sdk v0.90 系。
Context Compaction は現時点で以下 3 モデルの beta サーフェスです。
- Claude Opus 4.7 (
claude-opus-4-7)
- Claude Opus 4.6 (
claude-opus-4-6)
- Claude Sonnet 4.6 (
claude-sonnet-4-6)
beta header は compact-2026-01-12。SDK からは client.beta.messages.create({ betas: ["compact-2026-01-12"], ... }) を呼び出すのが素直です。
2. 仕組み — trigger と edit
有効化には context_management.edits に compact_20260112 エントリを入れます。デフォルトの trigger は 150,000 トークン(これを超えかけると自動要約が入る)。
const response = await client.beta.messages.create({
betas: ["compact-2026-01-12"],
model: "claude-opus-4-7",
max_tokens: 16_000,
thinking: { type: "adaptive" },
context_management: {
edits: [{ type: "compact_20260112" }],
},
messages,
});
要約処理は Claude 側で走ります。クライアントから「ここを要約して」と指示する必要はなく、context の消費量が閾値に近づいた時点で自動発火します。
3. ここで外すと全部壊れる — response.content を丸ごと append する
Compaction は レスポンスの content 配列に “compaction ブロック” を含めて返します。API はこのブロックを次のリクエストで読んで、すでに要約された履歴を上書きします。
そのため、応答を次のターンへ引き渡すときは、text だけを拾うのではなく、response.content を丸ごと messages に append しなければなりません。
// ❌ これは壊れる — compaction ブロックが失われ、次ターンで context が再膨張する
const text = response.content
.filter((b) => b.type === "text")
.map((b) => (b as { text: string }).text)
.join("\n");
messages.push({ role: "assistant", content: text });
// ✅ これが正解 — content 配列をそのまま持ち回す
messages.push({ role: "assistant", content: response.content });
UI に text だけ表示するのと、API に content を戻すのは別の話です。UI 表示用に text を抽出するのは OK ですが、API 側には必ず response.content をそのまま送り直すという原則を守ります。
4. multi-turn の最小実装
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const messages: Anthropic.Beta.BetaMessageParam[] = [];
async function chat(userMessage: string) {
messages.push({ role: "user", content: userMessage });
const response = await client.beta.messages.create({
betas: ["compact-2026-01-12"],
model: "claude-opus-4-7",
max_tokens: 16_000,
thinking: { type: "adaptive" },
context_management: {
edits: [{ type: "compact_20260112" }],
},
messages,
});
// ✅ content 配列を丸ごと保持 (compaction ブロックが含まれる可能性)
messages.push({ role: "assistant", content: response.content });
// UI 用には text だけ拾う
const text = response.content
.filter((b): b is Anthropic.Beta.BetaTextBlock => b.type === "text")
.map((b) => b.text)
.join("");
return { text, usage: response.usage };
}
// 長尺の対話
console.log(await chat("Python web scraper を設計して"));
console.log(await chat("JavaScript レンダリングも対応させて"));
console.log(await chat("rate limit と error handling を追加"));
// …会話がどれだけ続いても 1M を超える前に自動 compaction
5. いつ使うか
- 会話型エージェントで、ユーザーが 1 つのセッションで 100 ターン以上回す UI(コーディング伴走、調査アシスタント等)
- 長時間の tool-use ループ で、入出力が累積して 150K トークンを超えそうな場合
- Managed Agents ではない自作ループ(Managed Agents 側はコンテキスト管理が built-in)
短時間・短文の Q&A 主体なら compaction は発動しないので、beta header を付ける必要もありません。
6. usage から発動を見る
レスポンスの usage を毎ターン保存しておくと、どのターンで compaction が走ったかが粒度で見えます。特に input_tokens が急に下がるターンが、まさに要約が入って過去履歴が圧縮された瞬間です。
Prompt Caching 記事 (本ブログ既出) と同様、input_tokens + cache_creation_input_tokens + cache_read_input_tokens の合計で見るのが正解で、1 フィールド単体の推移を追うと誤解します。
7. 落とし穴
content を text 化して push する — compaction ブロックが失われ、次ターンで context が再膨張して効果ゼロ。本記事の最重要事項
- beta header を忘れる —
betas: ["compact-2026-01-12"] を指定していないと context_management が無視される
- 対応モデルと組み合わせてない — Sonnet 4.5 以前 / Haiku 系では使えない。移行前にモデル ID を確認
- trigger しきい値を低く期待しすぎる — デフォルトは 150K トークン。短いセッションでは発動しないので「効いていない」と誤認しやすい
- streaming 時の取り扱い — streaming でも原則は同じで、最終メッセージ再構築時に
response.content 全体を取り出す。ここを finalMessage() 等の SDK helper に寄せる
まとめ
Context Compaction は、自作ループで 1M 文脈を超えないための運用コードを肩代わりしてくれる beta です。使う上で重要なのは 1 点だけで、毎ターン response.content を丸ごと messages に戻す。ここさえ守れば beta 卒業まで特別な運用コードは要りません。
次回の release notes は Structured Outputs の新 output_config.format と client.messages.parse() を取り上げる予定です。
参考
$ tree ./repo/
記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。
┌─claude-context-compaction──────────files08──────────────────────●●●─┐$cat./package.jsonjson
# .env にコピーして値を入れる
# https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=
# claude-context-compaction-starter
Claude API の **Context Compaction**(beta `compact-2026-01-12`)を最小構成で動かすテンプレート。
[記事本文](https://r43lab.com/blog/claude-context-compaction) と対になるコピペ用リポジトリです。
## Quick start
```bash
bun install
cp .env.example .env # ANTHROPIC_API_KEY を入れる
# 1) 最小の有効化例 (1 request)
bun run src/basic.ts
# 2) 複数ターンを正しく積み上げる (compaction が効く形)
bun run src/multi-turn.ts
# 3) 代表的な footgun を再現する (text だけ push する誤り)
bun run src/common-footgun.ts
# 4) usage の内訳を観察
bun run src/inspect-usage.ts
```
## 鉄則
- beta header は **`compact-2026-01-12`** を `betas: [...]` に渡す
- `context_management.edits` に `{ type: "compact_20260112" }` を入れる
- 応答を次ターンに引き渡すとき、**`response.content` を丸ごと `messages` に append**。`text` だけ拾うと compaction ブロックが失われて機能しない
- UI 表示用には `text` だけ抽出して良い(API への再送とは別物)
## 対象モデル
- Claude Opus 4.7 (`claude-opus-4-7`)
- Claude Opus 4.6 (`claude-opus-4-6`)
- Claude Sonnet 4.6 (`claude-sonnet-4-6`)
それ以前のモデルでは無効。
## 依存バージョン
`package.json` は **2026-04 時点のスナップショット** です。
```bash
bun outdated
bun update
```
{
"name": "claude-context-compaction-starter",
"private": true,
"type": "module",
"scripts": {
"basic": "bun src/basic.ts",
"multi": "bun src/multi-turn.ts",
"footgun": "bun src/common-footgun.ts",
"usage": "bun src/inspect-usage.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0"
},
"devDependencies": {
"@types/bun": "^1.3.0",
"typescript": "^6.0.3"
}
}
/**
* basic.ts — Context Compaction を有効にした最小 1 リクエスト
*
* 単発の呼び出しでは compaction は発動しない(150K tokens 近くで初めて起きる)。
* ここでは「有効化する呼び方の形」だけを確認する。
*/
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function main() {
const response = await client.beta.messages.create({
betas: ["compact-2026-01-12"], // ← beta header を明示
model: "claude-opus-4-7",
max_tokens: 4_000,
thinking: { type: "adaptive" },
context_management: {
edits: [{ type: "compact_20260112" }], // ← 自動要約を有効化
},
messages: [
{ role: "user", content: "Context Compaction について 1 段落で説明して。" },
],
});
for (const block of response.content) {
if (block.type === "text") console.log(block.text);
}
console.log("\nusage:", response.usage);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
/**
* common-footgun.ts — "text だけ push" という代表的な誤りを再現する
*
* 実際の運用でこの書き方をすると、compaction ブロックが失われ、
* 長時間セッションで context が再膨張して API が 1M を超えるエラーを返す。
*
* ⚠️ このファイルは "やってはいけない書き方" を示すためのアンチパターン。
* 本番コードにコピペしないこと。
*/
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const messages: Anthropic.Beta.BetaMessageParam[] = [];
async function badTurn(userMessage: string) {
messages.push({ role: "user", content: userMessage });
const response = await client.beta.messages.create({
betas: ["compact-2026-01-12"],
model: "claude-opus-4-7",
max_tokens: 4_000,
thinking: { type: "adaptive" },
context_management: {
edits: [{ type: "compact_20260112" }],
},
messages,
});
// ❌ ここが問題 — text だけ拾って string として push している。
// compaction ブロック / thinking ブロックが失われる。
const text = response.content
.filter((b): b is Anthropic.Beta.BetaTextBlock => b.type === "text")
.map((b) => b.text)
.join("");
messages.push({ role: "assistant", content: text });
return text;
}
// ─── 正しい版 (比較用) ───
async function goodTurn(userMessage: string) {
messages.push({ role: "user", content: userMessage });
const response = await client.beta.messages.create({
betas: ["compact-2026-01-12"],
model: "claude-opus-4-7",
max_tokens: 4_000,
thinking: { type: "adaptive" },
context_management: {
edits: [{ type: "compact_20260112" }],
},
messages,
});
// ✅ こちらが正解 — content 配列を丸ごと保持
messages.push({ role: "assistant", content: response.content });
return response.content
.filter((b): b is Anthropic.Beta.BetaTextBlock => b.type === "text")
.map((b) => b.text)
.join("");
}
// 試したい方だけコメントを入れ替えて実行する
async function main() {
console.log("(using GOOD turn by default; flip to badTurn to see the failure pattern)");
await goodTurn("Hello, who are you?");
// await badTurn("Hello, who are you?");
}
main().catch(console.error);
/**
* inspect-usage.ts — usage をターン毎にダンプして、compaction の発動位置を見やすくする
*
* compaction が走ったターンでは input_tokens が急に下がる。
* 総トークン量は input_tokens + cache_creation_input_tokens +
* cache_read_input_tokens の合計で見るのが正解。
*/
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const messages: Anthropic.Beta.BetaMessageParam[] = [];
async function turn(userMessage: string, idx: number) {
messages.push({ role: "user", content: userMessage });
const response = await client.beta.messages.create({
betas: ["compact-2026-01-12"],
model: "claude-opus-4-7",
max_tokens: 2_000,
thinking: { type: "adaptive" },
context_management: {
edits: [{ type: "compact_20260112" }],
},
messages,
});
messages.push({ role: "assistant", content: response.content });
const u = response.usage;
const total =
(u.input_tokens ?? 0) +
(u.cache_creation_input_tokens ?? 0) +
(u.cache_read_input_tokens ?? 0);
console.log(
`turn #${idx.toString().padStart(2, "0")} ` +
`in=${u.input_tokens} ` +
`cache_w=${u.cache_creation_input_tokens ?? 0} ` +
`cache_r=${u.cache_read_input_tokens ?? 0} ` +
`out=${u.output_tokens} ` +
`total_in=${total}`
);
}
async function main() {
// 短い質問を複数ターン回してトークンを積み上げる
const prompts = [
"Cloudflare Workers の最小構成を一言で。",
"D1 と KV の使い分けを 3 行で。",
"R2 の特徴を 3 行で。",
"Durable Objects は何に使う?",
"Workers AI の概要を 3 行で。",
"Hyperdrive の役割は?",
"Queues と Durable Objects の違い。",
"Pages と Workers の選び分け方。",
];
for (let i = 0; i < prompts.length; i++) {
await turn(prompts[i]!, i + 1);
}
}
main().catch(console.error);
/**
* multi-turn.ts — 複数ターンを正しく積み上げる
*
* 重要: 毎ターン、応答の content 配列を "丸ごと" messages に push する。
* text だけ抽出して push すると compaction ブロックが失われる。
*/
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const messages: Anthropic.Beta.BetaMessageParam[] = [];
async function turn(userMessage: string) {
messages.push({ role: "user", content: userMessage });
const response = await client.beta.messages.create({
betas: ["compact-2026-01-12"],
model: "claude-opus-4-7",
max_tokens: 4_000,
thinking: { type: "adaptive" },
context_management: {
edits: [{ type: "compact_20260112" }],
},
messages,
});
// ✅ 正しい — content 配列を丸ごと持ち回す
messages.push({ role: "assistant", content: response.content });
// UI 用に text だけ抽出(API への再送とは別物)
const text = response.content
.filter((b): b is Anthropic.Beta.BetaTextBlock => b.type === "text")
.map((b) => b.text)
.join("");
return { text, usage: response.usage };
}
async function main() {
const r1 = await turn("Python で Web scraper を作る設計案を 3 つ挙げて");
console.log("\n--- turn 1 ---\n" + r1.text);
console.log("usage:", r1.usage);
const r2 = await turn("それぞれ JavaScript レンダリングへの対応しやすさを比較して");
console.log("\n--- turn 2 ---\n" + r2.text);
console.log("usage:", r2.usage);
const r3 = await turn("rate limit と error handling を追加する時の注意点は?");
console.log("\n--- turn 3 ---\n" + r3.text);
console.log("usage:", r3.usage);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*"]
}