第14章 長い作業の記憶 — コンテキストが膨らむ問題・要約・コスト
📖 この章のゴール:長い作業をしていると、AIが一度に見ている情報(コンテキスト=視野)がどんどん膨らむ問題が分かる。なぜ膨らむのか、放っておくと何が起きるのか(上限に当たる+コストが積む)、そして刈り込み・要約・直近優先でどう抑えるかを、実装できるようになる。第1章で予告した「視野」の深掘りです。 ← 目次・はじめにへもどる
📱 Claude Code ではこう見える
Claude Code で長い作業(たくさんのファイルを読ませて、何度も直して、何度もテストを走らせる…)をしていると、ときどき画面の下に
compacting conversation…(会話を圧縮中)
という表示が出て、少し待たされることがあります。これは Claude Code が「これまでのやりとりが長くなりすぎたので、いったん短くまとめ直しています」という合図です。
なぜこんなことをするのでしょう? じつは、エージェントは作業が長くなるほど、AIに毎回渡す情報のかたまりが膨らんでいきます。放っておくとあふれてしまうので、Claude Code は裏でこっそり「畳んで(compact)」小さくしているのです。
この章では、その「膨らむ情報のかたまり」=コンテキストを、自分のミニ・エージェントでどう扱うかを学びます。
🤔 なぜコンテキストは膨らむ?/放っておくとどうなる
まず「コンテキスト」とは(🟢 基礎)
第1章でちらっと出てきた言葉を、ここで深掘りします。
コンテキスト(context=文脈・前後関係)とは、作業中にAIが一度に見ている情報のかたまりのこと。具体的には、これまでの全やりとり(あなたの指示・AIの返事)と、道具を使った結果(読んだファイルの中身・コマンドの出力)の全部です。
第1章のたとえで言えば、コンテキスト=いま見えている視野でした。体(道具とループ)が同じでも、視野しだいで仕事の出来は変わります。そして視野には広さの限界がある——ここが今回の主役です。
👀 たとえ話:机の上に広げられる紙の量 AIは、机の上に広げた紙だけを見て考える人だと思ってください。指示書、これまでの会話メモ、読んだファイルを印刷した紙——ぜんぶ机に広げます。でも机の広さには限りがある。紙が増えすぎるともう乗らなくなり、しかも「机いっぱいの紙を毎回ぜんぶ読み直す」ので読むのに時間とお金がかかる。これがコンテキストの膨張です。
なぜ「毎回」膨らむのか(🟢 基礎)
ここで第4章(エージェントループ)を思い出してください。LLMはステートレス(毎回まっさら)で、前回の記憶を持っていません。だから私たちのコードは、毎ターン、これまでの履歴(messages)を丸ごと再送していました。
const res = await anthropic.messages.create({
model: MODEL, max_tokens: 1024, system: SYSTEM_PROMPT, messages, tools,
});
1行ずつ読むと:
messagesには、これまでの全やりとりが積み上がっている(第4章でpushし続けた配列)。- それを毎回まるごと
messages.createに渡している。LLMは前回を覚えていないので、毎回これが必要。 - つまり往復が増えるほど
messagesは長くなり、毎回それを送り直す。1往復ごとに机の紙が増え、その机を毎回ぜんぶ読み直しているのです。
放っておくと起きる2つのこと(🟢 基礎)
- 上限に当たる(あふれる):AIが一度に見られる量には天井があります(後述、モデルにより数十万〜100万トークン規模)。超えるとエラーになったり、古い情報が押し出されたりします。
- 入力トークン=コストが積む:LLMの料金は「入れたトークン(かけら)+出したトークン」で決まります(付録F)。毎回ぜんぶ送り直すので、往復が10回・20回と増えるほど、同じ古い履歴を何度も再送して課金される。長い作業ほどジワジワ高くなります。
⚠️ とくに効いてくるのが、道具の結果が大きいケースです。
read_fileで巨大なファイルを丸ごと読んだり、run_commandで長いログを吐いたりすると、その全文がmessagesに積まれて、以後ずっと毎回再送されます。会話の文章より、道具の結果のほうが膨らみの主犯になりがちです。
解決の発想は、前作とまったく同じ(🟢 基礎)
ここで朗報です。この問題、前作の ChatGPTクローンで一度解いています。
🧠 前作「記憶」とのつながり:ChatGPTクローン第9章では、長い会話があふれないように「古いやりとりはLLM自身に要約させて短くし、毎回渡すのは『設定+これまでの要約+直近のN件』だけ」にしました。本作のコンテキスト対策も、まったく同じ発想です。違いは、本作では会話だけでなく道具の結果(ファイル全文・コマンド出力)も膨らみの対象になる、という点だけ。
やることは3つです。①大きな道具の結果を刈り込む、②履歴が伸びたら古い往復を要約する、③ときどきサイズを見積もる。順に作ります。
🛠 こう作る
① 大きな道具の結果は、push する前に刈り込む(🟢 基礎)
いちばん効くのがこれです。道具の結果を messages に積む前に、長すぎるものを先頭N文字だけに切り詰めます。第5〜7章で作った executeTool の出口に、ひと手間足すだけ。
const MAX_RESULT_CHARS = 4000; // 道具の結果として残す最大の文字数
function clipResult(text: string): string {
if (text.length <= MAX_RESULT_CHARS) return text;
const head = text.slice(0, MAX_RESULT_CHARS);
const omitted = text.length - MAX_RESULT_CHARS;
return `${head}\n…(長すぎるので省略:あと ${omitted} 文字)`;
}
1行ずつ読むと:
const MAX_RESULT_CHARS = 4000:1つの道具結果として残す最大の文字数を、名前付きの定数にする(あとで調整する数。マジックナンバーにしない)。if (text.length <= MAX_RESULT_CHARS) return text;:短ければそのまま返す。ふつうのファイルやコマンド出力は手を加えない。const head = text.slice(0, MAX_RESULT_CHARS);:長い場合は先頭からMAX_RESULT_CHARS文字だけを切り出す(=先頭N文字で刈り込む)。const omitted = text.length - MAX_RESULT_CHARS;:何文字を捨てたかを計算する。- 末尾に「省略しました」と残り文字数を書き添える:AIに「ここで切れている。全部見たければ別の手で読み直して」と気づかせるため。黙って切ると、AIは「これで全部だ」と勘違いします。
そして、結果を messages に積む前にこれを通します(第4章のループの中)。
const result = await executeTool(block.name, block.input);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: clipResult(result), // ← 積む前に刈り込む
});
1行ずつ読むと:
const result = await executeTool(...):これまで通り、道具を実行して結果(文字列)を受け取る。content: clipResult(result):その結果をそのまま積まず、clipResultを通してから積む。これで巨大な結果が以後ずっと再送される事故を防げる。- 残りは第4章のループと同じ。
tool_use_idで「どの道具呼び出しへの返事か」を必ず対応づける。
② 履歴が伸びたら、古い往復を要約して入れ替える(🔧 応用)
刈り込みでも messages が長くなってきたら、次は古い往復をLLMにまとめさせて、「要約+直近数往復」だけを渡す形にします。前作第9章とまったく同じ作戦です。
const KEEP_RECENT = 6; // 要約せずにそのまま残す直近のメッセージ数
async function summarizeOld(old: Anthropic.MessageParam[]): Promise<string> {
const res = await anthropic.messages.create({
model: MODEL,
max_tokens: 1024,
system: "次のやりとりを箇条書きで短く要約して。決めたこと・ファイル名・エラー内容は必ず残して。",
messages: [{ role: "user", content: JSON.stringify(old) }],
});
const block = res.content.find((b) => b.type === "text");
return block && block.type === "text" ? block.text : "";
}
1行ずつ読むと:
const KEEP_RECENT = 6:そのまま残す直近メッセージ数を名前付き定数にする(増やすほど文脈は濃いが、あふれやすく・高くなる調整どころ)。async function summarizeOld(old):要約したい古いメッセージの配列を受け取る関数。system: "…決めたこと・ファイル名・エラー内容は必ず残して。":消えると困る情報を残せとLLMに指示する(後述のハマりどころ対策)。messages: [{ role: "user", content: JSON.stringify(old) }]:古いやりとりを文字列にして渡し、「これを短くして」と頼む。要約づくりもLLMの仕事。res.content.find((b) => b.type === "text"):返事の中からテキストのブロックを取り出す(第2章でやった、ブロック配列からテキストを拾うやり方)。return … block.text : "":要約の文章を返す。これを次で履歴の先頭に差し込みます。
次に、履歴が長くなったら古い部分だけ要約に入れ替える関数です。
const SUMMARIZE_THRESHOLD = 20; // 何件たまったら要約に畳むか
async function compactIfNeeded(messages: Anthropic.MessageParam[]): Promise<Anthropic.MessageParam[]> {
if (messages.length <= SUMMARIZE_THRESHOLD) return messages;
const old = messages.slice(0, messages.length - KEEP_RECENT);
const recent = messages.slice(messages.length - KEEP_RECENT);
const summary = await summarizeOld(old);
return [
{ role: "user", content: `これまでの作業の要約:\n${summary}` },
...recent,
];
}
1行ずつ読むと:
const SUMMARIZE_THRESHOLD = 20:何件たまったら畳むかの目安(毎回畳むと高いので、たまってからにする)。if (messages.length <= SUMMARIZE_THRESHOLD) return messages;:まだ短ければ何もしない。短いうちから要約すると、かえって情報を失うだけ。const old = messages.slice(0, messages.length - KEEP_RECENT);:直近KEEP_RECENT件を除いた古い部分を切り出す(ここを畳む)。const recent = messages.slice(messages.length - KEEP_RECENT);:直近KEEP_RECENT件はそのまま残す(「いま何の話か」を保つため)。const summary = await summarizeOld(old);:古い部分をLLMに要約させる(①で作った関数)。return [{ role: "user", content: 'これまでの作業の要約:…' }, ...recent]:「要約1個+直近数往復」だけの新しい履歴を返す。これが「要点メモ+最近の発言」の形(前作第9章と同じ)。
ループの先頭でこれを呼べば、長くなった履歴が自動で畳まれます。
messages = await compactIfNeeded(messages); // ループの頭で履歴を畳む
1行ずつ読むと:
- 毎ターンの最初に
compactIfNeededを通す。短いうちは素通り、長くなったら自動で要約に入れ替わる。これが📱で見た「compacting conversation…」の手作り版です。
③ いまどれくらい? 送る前にトークン数を見積もる(🔧 応用)
「膨らんでいる」を数字で確かめる方法もあります。公式SDKに、送る前のトークン数を教えてくれる関数があります。
const count = await anthropic.messages.countTokens({
model: MODEL,
system: SYSTEM_PROMPT,
messages,
});
console.log(`いまの入力トークン: ${count.input_tokens}`);
1行ずつ読むと:
anthropic.messages.countTokens({...}):実際には送らずに、この内容が何トークンになるかを見積もる専用の関数。お金はかからない。model / system / messages:実際の呼び出しと同じ材料を渡す(モデルによって数え方が違うので、使うMODELを渡すのが大事)。count.input_tokens:見積もられた入力トークン数。これが上限に近づいていないか、コストがどれくらい積むかの目安になる。- ループの節目でこれを表示すれば、「いま机の紙がどれくらい」が見えて、②を畳むタイミングの判断材料になります。
🔧 もっと先:仕組みに任せる手もある(名前だけ)
ここまでは「自分のコードで」コンテキストを抑える話でした。じつは、もっと楽をする道もあります。いまは名前だけ知っておけば十分です。
- プロンプトキャッシュ:①②で抑えても、毎回「前文を再送している」事実は変わらず、その入力トークンは課金されます。Anthropicには、変わらない前の部分をキャッシュ(使い回し)して安くする仕組みがあり、ブロックに
cache_control: { type: "ephemeral" }を付けて使います。再送ぶんの料金をぐっと下げられます(くわしくは付録F)。 - サーバー側の自動圧縮(compaction):②の「古い往復を要約して畳む」を、API側がほぼ自動でやってくれるベータ機能もあります(📱で見た Claude Code の「compacting…」は、これの仲間)。自分で要約ロジックを書かずに済みます。
どちらも「まず自分で①②③を理解してから、必要になったら使う」のが順番です。名前を覚えておけば、あとで「あれだ」とたどり着けます。
⚠️ ハマりどころ
- 履歴を無制限に積む → いちばんやってはいけないこと。長い作業でいつか上限に当たってクラッシュし、しかもそこに至るまで毎回ぜんぶ再送してコストが爆発します。本作のループ(第4章)はそのまま放置すると無制限に積むので、①②のどちらかは必ず入れる。
- 巨大なファイルを丸ごとコンテキストに入れる →
read_fileで大きなファイルを全文渡すと、それが以後ずっと再送されます。①の刈り込みを通すか、「必要な部分だけ読む」道具を用意する。机に全ページ印刷を広げない。 - 要約で大事な情報を落とす → 名前・ファイル名・決めたこと・エラー内容まで縮めると、後の作業がちぐはぐに。要約のプロンプトで「これらは必ず残す」と明示する(②でやった通り)。
- コンテキスト ≠ セッションをまたぐ記憶 → ここが最重要の勘違いポイント。コンテキストは、あくまで「いまのこの作業の中」の視野です。プログラムを終了して別の作業を始めれば、また何も知らない状態に戻ります。「前に教えたことを次回も覚えていてほしい」なら、ファイルやDBへの別の保存が必要(前作のメモリの話と同じ。本作では扱いません)。
- コンテキスト上限を決め打ちで書く → 「ちょうど◯◯トークンまで」とコードに直書きしないこと。上限はモデルによって数十万〜100万トークン規模と幅があり、変わります。③の
countTokensで見て、余裕をもって畳むのが安全。
🤖 AIに頼むなら(Vibe codingのコツ)
🗣 プロンプト例: 「いまのエージェントに、コンテキストが膨らみすぎない対策を足して。①道具の結果は
messagesに積む前に先頭N文字で刈り込む(残り文字数を書き添える)、②履歴が一定数を超えたら古い往復をLLMに要約させて『要約+直近数往復』に入れ替える(要約プロンプトには『ファイル名・決めたこと・エラー内容は必ず残す』を入れる)、③節目でanthropic.messages.countTokensを呼んで入力トークン数をログに出す。N件・直近数・しきい値は名前付き定数にして、あとで調整できるようにして。」
出てきたものの確認チェックリスト:
- 道具の結果を
messagesに積む前に刈り込んでいるか(巨大な結果がそのまま積まれていないか) - 刈り込み時に「省略しました/残りN文字」を書き添えているか(黙って切っていないか)
- 履歴が伸びたとき、「要約+直近数往復」だけを渡しているか(古い往復を全部は積んでいないか)
- 要約プロンプトに「ファイル名・決めたこと・エラー内容は残す」が入っているか
- 刈り込み量・直近数・要約のしきい値が 名前付き定数で、後から調整できるか
- ループ回数の上限(第4章)はそのまま残っているか(暴走=無限に積む の二重の歯止め)
- 「セッションをまたぐ記憶」と混同していないか(それが必要なら別途ファイル/DBに保存、とコメントしてあるか)
📝 ことばメモ
- コンテキスト(context):作業中にAIが一度に見ている情報のかたまり(これまでのやりとり+道具の結果)。第1章の「視野」の正体。広さに上限がある
- コンテキストウィンドウ(context window):そのコンテキストに入れられる量の天井。モデルにより数十万〜100万トークン規模(具体値は変わるので決め打ちしない)
- 要約(summary):長い履歴を「ここまでの要点」に縮めた短い文章。LLMに作らせ、毎回渡す圧縮版。刈り込みは結果を先頭N文字で切ること、要約は意味を残して縮めること
- トークン:文章を細かく刻んだ“かけら”。料金と上限の単位。
countTokensで送る前に見積もれる - (参考)compaction(コンパクション):古い履歴を要約して畳むこと。Claude Code の「compacting…」や、API側の自動圧縮(ベータ)がこれ
- (参考)プロンプトキャッシュ:変わらない前文を使い回して再送ぶんを安くする仕組み(
cache_control: { type: "ephemeral" })。くわしくは付録F
➡️ 次の章へ
これで、長い作業でもコンテキストを膨らませすぎず、コストも抑えて走り続けられるようになりました。道具・ループ・安全・システムプロンプト・スキル・ストリーミング、そして記憶——エージェントを作る部品が、ひととおり出そろいました。
最終章は、ここまで作ってきた小さなエージェントが、現実のコーディングエージェントへとスケールしていく物語を、コードを離れて読み物として味わいます。あなたが手で組んだ部品が、世界のどこへつながっているのか——肩の力を抜いて読んでください。