LLMを使ったチャットアプリでは、会話が長くなるほど入力トークンが増え、応答速度やコストに影響します。特に、毎回すべての履歴を送信する実装では、ユーザーが数十回会話しただけでコンテキストが大きく膨らみます。
今回、会話履歴の古い部分を要約し、直近のメッセージだけを残す方式をPythonで試しました。結論としては、「古い履歴の要約」と「直近数ターンの原文」を組み合わせると、会話の流れを保ちながら入力トークンを抑えられます。
会話履歴が増えると何が問題になるのか
一般的なチャット処理では、次のように毎回すべてのメッセージをLLMへ渡します。
messages = [
{"role": "system", "content": "あなたは親切なアシスタントです。"},
{"role": "user", "content": "東京のおすすめ観光地を教えてください。"},
{"role": "assistant", "content": "浅草や上野がおすすめです。"},
{"role": "user", "content": "浅草では何を食べるべきですか?"},
]
この方法は簡単ですが、会話が続くほど messages の要素が増えます。LLMは現在の質問だけでなく、過去の入力と過去の回答も毎回読み込むためです。
ここで重要なのは、会話履歴のすべてが常に必要とは限らないことです。
例えば、10ターン前に話した「ユーザーは辛い食べ物が苦手」という情報は重要かもしれません。一方で、その直前に行った挨拶や、すでに解決した質問と回答は、原文のまま保持しなくても問題ない場合があります。
要約方式の基本設計
会話履歴を要約してLLMへ渡すまでの流れを図にすると次の通りです。
今回の実装では、会話を次の3つに分けました。
- システムメッセージ
- 古い会話をまとめた要約
- 直近数ターンの原文
最終的にLLMへ渡すデータは、次のような構造です。
[システムメッセージ]
[これまでの会話の要約]
[直近のユーザーメッセージとアシスタントの回答]
[今回のユーザー入力]
要約には、単なる短縮ではなく、後続の応答に必要な情報を残すよう指示します。
次の会話を要約してください。
以下の情報を優先して残してください。
- ユーザーの目的
- ユーザーの好みや制約
- すでに決まったこと
- 未解決の質問
- 数値、日付、固有名詞
- 次の応答で参照すべき前提
推測や新しい情報は追加せず、事実だけを簡潔にまとめてください。
「短くしてください」だけでは、重要な制約条件まで消える可能性があります。何を残すべきかを明示することがポイントです。
Pythonで実装する
まず、トークン数を概算するために tiktoken を使います。
python -m venv .venv
.venv\Scripts\activate
pip install tiktoken
次に、履歴のトークン数を数える関数を用意します。
import tiktoken
encoder = tiktoken.get_encoding("cl100k_base")
def count_tokens(messages: list[dict]) -> int:
total = 0
for message in messages:
total += 4 # roleやメッセージ構造の概算
total += len(encoder.encode(message["content"]))
return total
実際のAPIでは、モデルやSDKによってメッセージ構造の計算方法が異なるため、厳密な請求トークン数ではなく「要約を実行する目安」として使います。
履歴を要約対象と直近履歴に分ける処理は次のように書けます。
def split_history(
history: list[dict],
keep_messages: int = 6,
) -> tuple[list[dict], list[dict]]:
if len(history) <= keep_messages:
return [], history
old_messages = history[:-keep_messages]
recent_messages = history[-keep_messages:]
return old_messages, recent_messages
要約結果を保持するための簡単なクラスも作成します。
class ConversationMemory:
def __init__(self, summary: str = ""):
self.summary = summary
self.history: list[dict] = []
def add(self, role: str, content: str) -> None:
self.history.append({
"role": role,
"content": content,
})
def build_messages(self, user_input: str) -> list[dict]:
messages = [
{
"role": "system",
"content": "あなたは親切で正確なアシスタントです。",
}
]
if self.summary:
messages.append({
"role": "system",
"content": f"これまでの会話の要約:\n{self.summary}",
})
messages.extend(self.history)
messages.append({
"role": "user",
"content": user_input,
})
return messages
ここで summary は、要約用のLLM呼び出しで生成した文字列を保存します。要約処理の部分は、利用するLLMのSDKに合わせて実装します。
def create_summary(
previous_summary: str,
old_messages: list[dict],
) -> str:
conversation_text = "\n".join(
f'{message["role"]}: {message["content"]}'
for message in old_messages
)
prompt = f"""
既存の要約:
{previous_summary or "なし"}
追加で要約する会話:
{conversation_text}
ユーザーの目的、好み、制約、決定事項、未解決事項、
日付や数値などの重要情報を残して、簡潔に要約してください。
事実にない内容は追加しないでください。
"""
# ここでLLM APIを呼び出し、応答テキストを返す
return call_llm(prompt)
実際には、履歴を追加するたびに要約するのではなく、一定のトークン数を超えたときだけ実行します。
def compact_memory(memory: ConversationMemory) -> None:
messages = memory.build_messages("")
if count_tokens(messages) < 3000:
return
old_messages, recent_messages = split_history(
memory.history,
keep_messages=6,
)
if not old_messages:
return
memory.summary = create_summary(
memory.summary,
old_messages,
)
memory.history = recent_messages
この方式では、要約後も直近6メッセージは原文で残ります。直近の文脈は細かいニュアンスが重要なことが多いため、すぐに要約へ置き換えないようにしました。
要約で情報を失わないための工夫
実際に試してみると、単純な要約にはいくつか注意点がありました。
まず、ユーザーの制約条件を必ず残すことです。例えば「予算は5,000円以内」「子ども連れ」「専門用語は避ける」といった情報は、文章量が少なくても重要です。
また、要約を毎回作り直すのではなく、既存の要約に新しい古い履歴を統合する形にします。
既存の要約 + 今回要約する履歴
これにより、前回の要約対象を再度すべて送る必要がなくなります。
ただし、要約を何度も繰り返すと、少しずつ情報が欠落する可能性があります。重要な情報を別管理する方法も有効です。
memory = {
"summary": "...",
"user_preferences": [
"辛い食べ物は苦手",
"予算は5000円以内",
],
"decisions": [
"浅草を午後に訪問する",
],
"history": [],
}
ユーザーの好みや決定事項を構造化して保存し、LLMへ渡す際に要約とは別枠で追加します。特に、予約条件、契約条件、コードの要件など、間違えると問題になる情報は原文または構造化データで保持する方が安全です。
どのタイミングで要約するか
要約の実行タイミングには、主に次の選択肢があります。
1つ目は、メッセージ数で決める方法です。例えば20メッセージを超えたら要約します。実装は簡単ですが、長いメッセージが多い場合には不十分です。
2つ目は、トークン数で決める方法です。今回はこちらを採用しました。メッセージの長さに応じて判定できるため、より安定します。
3つ目は、モデルのコンテキスト上限に近づいたときに要約する方法です。ただし、上限ぎりぎりまで使うと、要約処理自体の入力や今回の質問を追加できない場合があります。
実運用では、上限の70〜80%程度を目安に早めに要約すると扱いやすくなります。要約用のAPI呼び出しにもトークンが必要なので、要約すれば必ずコストが下がるわけではありません。短い会話で何度も要約すると、かえって呼び出し回数が増えます。
まとめ
会話履歴の要約では、古い履歴をすべて削除するのではなく、次の構成にするのが実用的でした。
- 古い履歴は、目的・制約・決定事項を中心に要約する
- 直近の数ターンは原文のまま残す
- トークン数を基準に要約タイミングを決める
- 重要な好みや条件は構造化データとして別管理する
- 要約APIのコストも含めて効果を測定する
最初から完璧な要約を目指すより、まずは「要約 + 直近履歴」の構成を実装し、実際の会話ログで情報の欠落を確認するのがおすすめです。特に業務用途では、削減できたトークン数だけでなく、要約後に回答品質が維持されているかをセットで検証することが重要です。