LLM アプリ開発 #9 会話メモリとコンテキスト管理

読了 5分

第2回で見たとおり、API は状態を保存しないので、会話を続けるには履歴を毎回まるごと送ります。ところが会話が長くなると、この履歴が際限なくたまっていきます。二つの問題が生じます。毎回送るトークンが増えて費用が上がり、やがてモデルが一度に受け取れるコンテキストの限界にぶつかります。今回はこの履歴を扱う方法を整理します。

たまっていく履歴の問題 #

会話の各ターンごとに、messages にユーザーメッセージと答えが足されます。10回やり取りすれば20個、100回なら200個になります。毎回の呼び出しはこの全体を送り直すので、100番目の質問を一つ送るために、先の99回の会話を全部一緒に送ることになります。

ここで二つが問題です。

  • トークン費用 — 入力トークンは履歴が長いほど比例して増えます。長い会話ほど、一度答える費用がどんどん大きくなります。
  • コンテキストの限界 — モデルごとに、一度に受け取れるトークンの上限があります。履歴がその限界を超えると、もう送れません。

ですから長くなる会話では、履歴をそのままにせず何らかの形で減らす必要があります。方法は大きく、切り捨てと要約です。

スライディングウィンドウ — 最近のものだけ残す #

最も単純な方法は、最近のメッセージをいくつか残し、古いものは捨てることです。窓を滑らせるように常に最近の区間だけを見るので、スライディングウィンドウと呼びます。

sliding_window.py
MAX_TURNS = 10  # 最近の10ターン(20メッセージ)だけ保持

def trim(messages):
    # system は別パラメータなので messages には user/assistant のみ
    if len(messages) > MAX_TURNS * 2:
        return messages[-MAX_TURNS * 2:]
    return messages

実装が簡単で、費用も安定します。短所は、古い会話をまるごと忘れることです。前に「私の名前はミンス」と言っていても、窓の外へ押し出されればその事実は消えます。ですから最近の文脈だけが重要な軽いチャットボットによく合います。

要約で圧縮する #

古い会話を捨てるのが惜しければ、捨てる代わりに要約します。前半の会話を Claude に短く要約させて一かたまりにし、その要約を履歴の前に置く方式です。

summarize_history.py
def summarize(old_messages) -> str:
    text = "\n".join(f"{m['role']}: {m['content']}" for m in old_messages)
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=512,
        messages=[{
            "role": "user",
            "content": f"次の会話を、以降の文脈維持に必要な要点だけ短く要約してください:\n\n{text}",
        }],
    )
    return next(b.text for b in response.content if b.type == "text")

# 古い前半は要約し、最近の会話はそのまま残す
summary = summarize(messages[:-10])
messages = [
    {"role": "user", "content": f"[これまでの会話の要約] {summary}"},
] + messages[-10:]

こうすれば「ミンス」という名前のような古い情報も、要約に含まれて生き残ります。トークンも減ります。代わりに要約のための呼び出しが一度多くかかり、要約の過程で細部が一部消えることがあります。長い相談や作業を続けるアプリによく合います。

サーバーに任せて圧縮する #

この圧縮を自分で書く代わりに、Claude API がサーバーで自動で処理するようにもできます。コンテキストが限界に近づくと、以前の内容を自動で要約してくれるコンパクション(compaction)機能です。現在ベータで提供されています。

注記
コンパクションを有効にすると、コンテキストが一定の大きさに達したとき API が前半を自動で要約します。注意点が一つあります。応答に要約(compaction)ブロックが含まれ、次の呼び出しのときにこのブロックを必ず送り直す必要があります。そのためには、答えのテキストだけを取り出して積むのではなく、response.content 全体を履歴へ足します。テキストだけ保存すると、要約の状態を失います。

自分で管理するか、サーバーに任せるかはアプリ次第です。動作を細かく制御したければスライディングウィンドウや要約を自分で書き、長い会話を手軽に続けたければコンパクションを使います。

RAG とは何が違うのか #

第8回の RAG と混同しやすいので押さえておきます。RAG は外部の文書から関連内容を見つけて入れることで、ここで扱うメモリはこれまでの会話を管理することです。二つは一緒に使われます。社内文書を RAG で取り込みながら、長くなる会話履歴は要約で圧縮する、という具合です。

よくつまずくところ #

  • 履歴を無制限にためる — 減らす仕掛けなしにため続けると、費用がどんどん上がり、いつかコンテキストの限界で呼び出しが失敗します。長くなりうる会話なら、最初から減らす戦略を入れます。
  • system を切り捨てるsystem は別パラメータなので、messages を切るときに影響を受けません。役割の指示を messages の中に入れておくと切られることがあるので、指示は system に置きます。
  • コンパクションでテキストだけ保存する — コンパクションを使うとき、response.content 全体ではなくテキストだけを履歴に入れると、要約の状態が消えます。

まとめ #

今回は、長くなる会話の履歴を扱う方法を整理しました。

  • 履歴はトークン費用とコンテキストの限界のため、ため続けることはできません。
  • スライディングウィンドウは最近のものだけ残して単純で、要約は古い情報を圧縮して生かします。
  • 自分で管理する代わりに、サーバーのコンパクション機能に任せることもできます。

ここまで、単一のツール呼び出し、検索、メモリを扱いました。次回の「LLM アプリ開発 #10 AI エージェントを作る」では、これらのかけらを束ねて、Claude が自分でツールを選び、複数の段階を踏んで仕事を処理するエージェントを作ります。

X