第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つのこと(🟢 基礎)

  1. 上限に当たる(あふれる):AIが一度に見られる量には天井があります(後述、モデルにより数十万〜100万トークン規模)。超えるとエラーになったり、古い情報が押し出されたりします。
  2. 入力トークン=コストが積む: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

➡️ 次の章へ

これで、長い作業でもコンテキストを膨らませすぎず、コストも抑えて走り続けられるようになりました。道具・ループ・安全・システムプロンプト・スキル・ストリーミング、そして記憶——エージェントを作る部品が、ひととおり出そろいました。

最終章は、ここまで作ってきた小さなエージェントが、現実のコーディングエージェントへとスケールしていく物語を、コードを離れて読み物として味わいます。あなたが手で組んだ部品が、世界のどこへつながっているのか——肩の力を抜いて読んでください。

第15章 スケールの物語(読み物)

← 目次・はじめにへもどる