エージェントを作り始めてすぐにぶつかる壁は、「昨日話したことを今日また説明しないといけない」という問題です。APIコールのたびにコンテキストがリセットされるのは仕様どおりですが、実用的なエージェントを作るには、何らかの形で「記憶」を設計する必要があります。
ここでは、実際に自分が実装してきたメモリアーキテクチャを4つのパターンに整理してお伝えします。どれが正解という話ではなく、ユースケースに応じて選択するものです。
パターン1: セッション内圧縮(短期メモリ)
最もシンプルなアプローチで、会話が長くなってきたときにコンテキストウィンドウの使用量を抑える手法です。
考え方はシンプルです。会話が一定のターン数(または一定のトークン数)を超えたとき、古いやり取りを要約して1つのブロックに圧縮し、詳細な履歴の代わりに要約を先頭に置きます。
def compress_history(messages: list[dict], keep_recent: int = 6) -> list[dict]:
if len(messages) <= keep_recent:
return messages
old_messages = messages[:-keep_recent]
recent_messages = messages[-keep_recent:]
# 古いメッセージを要約
summary_prompt = [
{"role": "user", "content": f"以下の会話を3〜5文で要約してください:\n{format_messages(old_messages)}"}
]
summary = call_llm(summary_prompt)
# 要約を先頭に挿入して返す
compressed = [
{"role": "system", "content": f"これまでの会話の要約:{summary}"}
] + recent_messages
return compressedこのパターンの限界は、セッションをまたいだ記憶の保持ができない点です。「昨日の続き」には対応できません。ただし、外部ストレージが不要で実装が軽いため、プロトタイプや単一セッション完結のユースケースには十分機能します。
パターン2: キー・バリューストアによる事実の永続化
ユーザーが教えてくれた情報(名前、好み、設定値、プロジェクトの詳細)を永続的に保存するパターンです。
重要なのは、何を保存するかをエージェント自身に判断させることです。すべての発言を保存するのではなく、後で参照価値がある「事実」だけを抽出します。
def extract_and_store_facts(user_message: str, agent_response: str, user_id: str):
extraction_prompt = f"""
以下の会話から、後で参照すべき永続的な事実を抽出してください。
一時的な内容(「今日は疲れた」など)は除外し、
ユーザーについての固定情報や設定のみを抽出してください。
ユーザー: {user_message}
アシスタント: {agent_response}
抽出すべき事実があれば JSON 形式で返してください。なければ空配列を返してください。
形式: [{"key": "事実の種類", "value": "内容"}]
"""
facts = call_llm_json(extraction_prompt)
for fact in facts:
key = f"user:{user_id}:{fact['key']}"
redis_client.set(key, fact['value'], ex=86400*365) # 1年このパターンで注意が必要なのは「古くなった事実」の処理です。「東京に住んでいる」という事実が3ヶ月前に記録されていても、今は違うかもしれません。TTLを設定するか、事実の更新日を記録して、古すぎる情報は再確認するロジックを組み込むことをお勧めします。
パターン3: ベクトル検索による意味的な記憶想起
最もよく耳にするパターンで、過去の会話や文書をベクトルDBに保存し、現在の質問と意味的に近い情報を検索して文脈に挿入する手法です。
実装自体はシンプルです。問題は「何をインデックスするか」と「どのタイミングで検索するか」の設計です。
from openai import OpenAI
import chromadb
client = OpenAI()
chroma = chromadb.Client()
collection = chroma.get_or_create_collection("agent_memory")
def store_exchange(user_msg: str, agent_msg: str, session_id: str):
# 会話ペアをチャンクとして保存
text = f"ユーザー: {user_msg}\nアシスタント: {agent_msg}"
embedding = client.embeddings.create(
model="text-embedding-3-small",
input=text
).data[0].embedding
collection.add(
documents=[text],
embeddings=[embedding],
ids=[f"{session_id}_{int(time.time())}"],
metadatas=[{"session_id": session_id, "timestamp": time.time()}]
)
def recall_relevant_memory(query: str, n_results: int = 3) -> str:
query_embedding = client.embeddings.create(
model="text-embedding-3-small",
input=query
).data[0].embedding
results = collection.query(
query_embeddings=[query_embedding],
n_results=n_results
)
if not results["documents"][0]:
return ""
return "関連する過去の会話:\n" + "\n---\n".join(results["documents"][0])実際に運用してみて気づいた落とし穴は2つです。
落とし穴1: 関係のない情報が召喚される
類似度スコアに閾値を設けないと、「なんとなく近い」過去の会話が毎回挿入されて、むしろ回答の精度が下がります。collection.query の結果に distances が含まれるので、しきい値(例:コサイン類似度 0.75以上)を下回るものは除外する処理が必須です。
落とし穴2: 矛盾する情報が複数ヒットする
同じトピックについて時系列で異なる情報が記録されていると、古い情報と新しい情報が同時に検索にヒットします。タイムスタンプで降順ソートして、最新の情報を優先する設計が必要です。
パターン4: エピソード記憶としての構造化ログ
最も設計コストが高いですが、「何をいつ誰と話したか」という構造を持ったメモリです。単なるテキスト検索ではなく、「先週のプロジェクトXに関する議論を要約して」のような時間軸や文脈軸での検索を可能にします。
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Episode:
session_id: str
timestamp: datetime
topic: str # LLMで自動抽出
participants: list # ユーザーID等
summary: str # LLMで自動生成
key_decisions: list # 決定事項の抽出
follow_ups: list # 次回に持ち越す事項
def create_episode(messages: list[dict], session_id: str) -> Episode:
# セッション終了時に自動生成
analysis_prompt = f"""
以下の会話を分析し、JSON形式で構造化してください:
- topic: 主なトピック(1文)
- summary: 会話全体の要約(3〜5文)
- key_decisions: 決定したこと(リスト)
- follow_ups: 次回に確認・継続すべきこと(リスト)
会話:
{format_messages(messages)}
"""
structured = call_llm_json(analysis_prompt)
return Episode(
session_id=session_id,
timestamp=datetime.now(),
topic=structured["topic"],
participants=["user"],
summary=structured["summary"],
key_decisions=structured.get("key_decisions", []),
follow_ups=structured.get("follow_ups", [])
)このパターンは、定期的に長期的な関係を持つユーザーへの対応や、プロジェクト管理系エージェントに特に有効です。「先月決めたアーキテクチャの方針は何でしたっけ」という問いに、ベクトル検索だけでは正確に答えられない場合でも、エピソード構造があれば検索精度が上がります。
4パターンの使い分けガイド
実際には、これらを組み合わせて使うことが多いです。私が個人開発のエージェントで使っているのは「パターン2(事実の永続化)+パターン3(ベクトル検索)」の組み合わせです。ユーザーの基本情報はKey-Valueに、会話の流れはベクトルDBに保存する構成です。
| パターン | セッション持続 | 長期記憶 | 構造的な検索 | 実装コスト |
|---|---|---|---|---|
| 1. セッション内圧縮 | ◎ | ✗ | ✗ | 低 |
| 2. KVストア事実保存 | ◎ | ◎ | △ | 低〜中 |
| 3. ベクトル検索 | ◎ | ◎ | ◎ | 中 |
| 4. エピソード記憶 | ◎ | ◎ | ◎ | 高 |
判断の出発点として: 「ユーザーが明示的に教えた情報を覚えたい」ならパターン2から始めてください。「過去の会話の流れを参照したい」ならパターン3を追加する、という順序が最もスムーズに進みます。パターン4は「このエージェントを1年以上継続的に使う予定がある」場合に初めて検討するくらいの設計コストです。
メモリ設計で最初に決めること
どのパターンを選ぶにしても、最初に決めておくべきことが1つあります。それは「何を忘れていいか」です。
すべてを記憶しようとすると、関係のない情報がノイズになります。「このエージェントが覚えているべきことは何か」を先に定義し、それ以外は積極的に捨てる設計にすることで、メモリの精度と維持コストの両方が改善されます。
次のセッションでは、ユーザーが「覚えていてほしい」と感じる情報の1つを選んで、パターン2の実装から試してみてください。動くものができてから、必要に応じてパターン3を重ねるのがスムーズな進め方だと思います。