第9章 溢れさせない — 要約(summary)+ログのハイブリッド
📖 この章のゴール:第8章で出た コンテキストの上限(入れられる量の天井) への王道の対処——古い会話をLLM自身に要約させて短くし、毎回渡すのは「設定+これまでの要約+直近のN件」だけにする方法を、実装できるようになる。 ← 目次・はじめにへもどる
📱 ChatGPTではこう見える
ChatGPTと 何時間も、何十往復も 話し続けても、あるところで急に「もう無理です」と止まったりはしません。会話がどれだけ伸びても、破綻せずに話が続いていきます。
でも第8章で学んだとおり、LLMに一度に入れられる量には 天井(コンテキスト上限) があります。会話をぜんぶ積み続ければ、いつか必ずあふれるはずです。なのに、なぜ続くのでしょう? 答えは「裏でうまく圧縮している」からです。
🤔 全部は送れない。だから「要点メモ+最近の発言」だけ送る(🟢 基礎)
第8章の結論は「会話が伸びると、毎回の messages がコンテキスト上限を超えてあふれる」でした。第4章でやった「毎回ぜんぶ送る」を、いつまでも続けられないのです。
そこで王道の対処はこうです。
古いやりとりは、LLM自身に「要約」させて短くまとめ、毎回渡すのは「設定(system)+これまでの要約+直近のN件」だけにする。
要約(summary)とは、長い会話を「ここまでの要点だけ」に縮めた短い文章のこと。LLMは文章を縮めるのが得意なので、要約づくりも LLMにお願い します。
🗂 たとえ話:分厚い議事録を持ち歩かない 100ページの会議録を毎回まるごと持ち歩く人はいません。ふつうは「ここまでの要点メモ(A4一枚)+ 最近の数発言」だけ手元に置きます。要点メモさえあれば話の流れは分かるし、最近の発言があれば「いま何の話か」も分かる。LLMに渡すのも、これと同じ「要点メモ+最近のやりとり」です。
大事なのは、会話の全文(ログ)は捨てないことです。
- 要約:LLMに毎回渡す、短い「要点メモ」。あふれないための圧縮版。
- ログ(全文):DBにそのまま残す、消さない記録。画面で会話を表示するのに使う。
この 「要約はLLMへ/全文はDBへ」を両方持つやり方 を、ハイブリッド(hybrid=組み合わせ)と呼びます。
🧠 背骨①そのもの:会話が続いて見えるのは、やはりLLMが覚えているからではありません。開発者が「要約+直近」という形で記憶を作り直して渡しているから。じつはこの教材を動かしている MulmoClaude 自身 も、まさにこの「要約+直近ログ」方式で長い会話を覚えています。
🛠 こう作る — messagesを「要約+直近N件」で組み立てる(🟢 基礎)
これまで(第5章)は、DBの発言を 古い順に全部 並べて messages を作っていました。これを 「要約を1個 + 直近N件だけ」 に差し替えます。
ステップ1:要約をconversationに保存できるようにする
-- conversations 表に「これまでの要約」を入れる列を足す
alter table conversations add column summary text not null default '';
1行ずつ読むと:
alter table conversations add column summary text:会話の表(第5章のお弁当箱)にsummary(要約)の列 を1つ追加する。not null default '':最初はまだ要約が無いので、空っぽ(空文字) を初期値にしておく。会話が伸びてから中身が入る。
ステップ2:渡すmessagesを「要約+直近N件」で組み立てる
const RECENT_COUNT = 10; // 直近で何件そのまま渡すか
// 1) 直近N件だけ、新しい順にとって、古い順に並べ直す
const { data: recent } = await supabase
.from("messages")
.select("role, content")
.eq("conversation_id", conversationId)
.order("created_at", { ascending: false })
.limit(RECENT_COUNT);
const recentMessages = recent.reverse();
// 2) 要約を「system発言」として、直近の前に差し込む
const messages = [
{ role: "system", content: "あなたは親切なアシスタントです。" },
{ role: "system", content: `これまでの会話の要約:\n${summary}` },
...recentMessages,
{ role: "user", content: userText },
];
1行ずつ読むと:
const RECENT_COUNT = 10:直近で 何件そのまま渡すか を名前付きの定数にする(あとで調整する数。マジックナンバーにしない)。.order("created_at", { ascending: false }).limit(RECENT_COUNT):新しい順 に並べて先頭からRECENT_COUNT件——つまり 直近のN件だけ を取る(全部は取らない=あふれ対策)。recent.reverse():取った直近N件を 古い順に並べ直す。LLMには会話の進んだ順で渡す必要があるため。{ role: "system", content: ... }(1個目):第4章と同じ、いちばん最初の 設定(性格・役割)。{ role: "system", content: 'これまでの会話の要約:\n${summary}' }:これまでの要約を system 発言として差し込む。「ここまでの話はこうだった」とLLMに前提を渡す役割。...recentMessages:その後ろに 直近N件 を並べる(要点メモの後に最近の発言、という順)。{ role: "user", content: userText }:最後に今回の新しい発言。これでLLMは「要約+直近+今の質問」を見て答えられる。
ステップ3:会話が伸びたら、要約を「差分だけ」更新する
要約は毎回作る必要はありません。会話が一定以上たまったときだけ、しかも 前回の要約+今回あふれた分だけ をまとめ直します。
// 「前回の要約」+「直近から外れて古くなった分」だけを、LLMにまとめ直させる
async function updateSummary(oldSummary: string, overflowed: Message[]): Promise<string> {
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "次の要約と新しい発言を、短い要約にまとめ直して。固有名詞・決定事項は必ず残して。" },
{ role: "user", content: `これまでの要約:\n${oldSummary}\n\n新しい発言:\n${formatLog(overflowed)}` },
],
});
return completion.choices[0].message.content;
}
1行ずつ読むと:
async function updateSummary(oldSummary, overflowed):前回の要約と、直近N件から押し出されて古くなった発言の2つを受け取る関数。model: "gpt-4o-mini":要約づくりも、いつもの安いモデルでLLMに頼む。{ role: "system", content: "...短い要約にまとめ直して。固有名詞・決定事項は必ず残して。" }:消えると困る情報(名前・決めたこと)は残す よう指示する(後述のハマりどころ対策)。これまでの要約:...\n\n新しい発言:...:ゼロからではなく「前回要約+あふれた分」だけ を渡す。会話全体を毎回要約しないので 安く済む(差分だけ)。return completion.choices[0].message.content:まとめ直した新しい要約の文章を返す。これを次のステップでDBに保存する。
// 返ってきた要約を conversation に保存し直す
await supabase
.from("conversations")
.update({ summary: newSummary })
.eq("id", conversationId);
1行ずつ読むと:
.update({ summary: newSummary }):会話のsummary列を、まとめ直した新しい要約で 上書き保存 する。.eq("id", conversationId):その会話の行だけ を更新する(取り違え防止)。次回ステップ2は、この更新後の要約を読んで使う。
⚠️ ハマりどころ
- 要約しすぎて大事な情報が消える → 名前・日付・決めたこと(決定事項) まで縮めると、後の会話がちぐはぐに。要約のプロンプトで「固有名詞・決定事項は必ず残す」と明示する。
- 要約のコストも課金される → 要約づくりも LLMの呼び出し=お金がかかる(第8章・付録D)。毎回ゼロから全文を要約すると高いので、前回要約+あふれた分の差分だけ にする。
- 全文(ログ)まで消してしまう → 要約は「LLMに渡す圧縮版」であって、会話の記録ではありません。
messagesの全文はDBに残し、画面表示はそちらを使う。 - 🔧 直近を何件残すか・いつ要約するかの調整 →
RECENT_COUNTを増やすと文脈は濃いがあふれやすく・高くなる。要約のタイミング(何件たまったらか)も含め、会話の長さと予算を見て決める チューニングどころ(応用)。
🤖 AIに頼むなら(Vibe codingのコツ)
🗣 プロンプト例: 「いまの会話を、古い会話を要約して
conversations.summaryに保存し、LLMに毎回渡すのは『system設定+これまでの要約+直近10件+今回の発言』だけにする形へ変えて。会話の全文(messages)はDBに残したままにして。要約の更新は 前回の要約+直近から外れた分の差分だけ をLLMにまとめ直させ、固有名詞・決定事項は残す ように指示して」
出てきたものの確認チェックリスト:
- LLMに渡すのが 「要約+直近N件」だけ になっているか(古い発言を全部は積んでいないか)
- 会話の 全文(messages)はDBに残っている か(要約のために消していないか)
- 要約の更新が 差分だけ(前回要約+あふれた分)で、毎回ゼロから全文を要約していないか
- 要約プロンプトに 「固有名詞・決定事項は残す」 が入っているか
- 直近の件数が
RECENT_COUNTのような 名前付き定数 で、後から調整できるか
📝 ことばメモ
- 要約(summary):長い会話を「ここまでの要点」に縮めた短い文章。LLMに作らせ、毎回渡す圧縮版
- ハイブリッド(hybrid):「要約はLLMへ/全文はDBへ」を両方持つやり方。あふれずに記録も残せる
- 直近ウィンドウ(recent window):そのまま渡す「最近のN件」のかたまり。
RECENT_COUNTで大きさを決める - 差分要約:会話全体ではなく「前回の要約+新しくあふれた分」だけをまとめ直すこと。安く済む
➡️ 次の章へ
これで、会話がどれだけ長くなっても あふれずに続けられる ようになりました。でもこの要約は、あくまで 「その1本の会話の中」 の記憶です。別の会話を開けば、また何も知らない状態に戻ります。
第10章では 会話をまたいで覚える=メモリ を扱います。「前に名前を伝えたら、別の会話でも覚えていてくれる」——そんな、本物のアシスタントらしい記憶のしくみです。