エージェントメモリシステム:AIへの永続的なコンテキストの付与
すべてのAIエージェントにはメモリの問題があります。モデルへの各呼び出しはステートレスです。モデルは前のリクエストを覚えていません。ユーザーの名前、好み、以前の決定も覚えていません。外部メモリがなければ、エージェントは毎ターン最初から始めます。
このガイドでは、4つのメモリ戦略(最もシンプルなものから最も強力なものまで)とそれぞれをいつ使うかを説明します。すべての例はTypeScriptとAnthropic SDKを使用します。
エージェントがメモリを必要とする理由
コードレビューエージェントを考えてみましょう。最初の実行時、ユーザーはチームの規約を説明します:any型は使わない、エラーは必ず明示的に処理する、関数型スタイルを好む。エージェントは良いレビューを生成します。
2回目の実行時、ユーザーは別のファイルを提出します。エージェントはすべてを忘れています。昨日フラグを立てた同じ規約違反を見逃します。
これが核心的な問題です: エージェントはステートレスですが、有用なエージェントには継続性が必要です。
メモリはエージェントに3つの能力を与えます:
- 想起 — 会話の前の部分や以前のセッションからの事実を取り出す
- パーソナライゼーション — 時間とともにユーザーの好みに適応する
- 調整 — マルチエージェントシステムで、エージェント間で状態を共有する
4つのメモリタイプ
コードを書く前に、戦略を明確に命名しておくと役立ちます。エージェントのメモリは4つのパターンに分類されます:
| タイプ | 保存場所 | スコープ | 最適な用途 |
|---|---|---|---|
| バッファ | コンテキスト内(メッセージ配列) | 現在のセッション | 短い会話 |
| サマリー | コンテキスト内(圧縮) | 現在のセッション | 長い会話 |
| セマンティック | 外部(ベクターDB) | セッション横断 | 知識の想起 |
| エピソード | 外部(キーバリューストア) | セッション横断 | ユーザーの事実、好み |
各戦略はシンプルさ、コスト、能力の間で異なるトレードオフがあります。問題を解決する最小限のものを選んでください。
戦略1: 会話バッファ
最もシンプルな戦略は、すべてのモデル呼び出しに完全な会話履歴を渡すことです。モデルはすべての以前のメッセージを見て、完全なコンテキストで応答します。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface Message { role: "user" | "assistant"; content: string;}
// バッファは完全な会話履歴を保持するconst buffer: Message[] = [];
async function chat(userMessage: string): Promise<string> { // 新しいユーザーメッセージをバッファに追加 buffer.push({ role: "user", content: userMessage });
const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, // すべての呼び出しで完全なバッファを渡す messages: buffer, });
const assistantMessage = response.content[0].type === "text" ? response.content[0].text : "";
// アシスタントの返答をバッファに追加 buffer.push({ role: "assistant", content: assistantMessage });
return assistantMessage;}
// 使用例await chat("My name is Alex and I prefer Python over TypeScript.");await chat("What language should I use for my next project?");// モデルは最初のメッセージで述べられた好みを覚えている使用するタイミング: 20ターン未満の会話。シンプルなチャットボット。プロトタイプ。
制限: コンテキストウィンドウは有限です。長いセッションは最終的にトークン制限を超え、最も古いメッセージを削除しなければなりません。モデルは暗黙的に初期コンテキストを失い、一貫性のない動作を引き起こします。
戦略2: ローリングサマリー
会話が長くなると、古いターンを削除する代わりに要約します。最近のメッセージのスライディングウィンドウの前に何が起こったかの圧縮されたサマリーを維持します。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface ConversationState { summary: string; // 古いターンの圧縮された履歴 recentMessages: Array<{ role: "user" | "assistant"; content: string }>; maxRecentTurns: number; // 最後のNターンを逐語的に保持する}
const state: ConversationState = { summary: "", recentMessages: [], maxRecentTurns: 6, // ユーザー3ターン + アシスタント3ターン};
// 古いターンをランニングサマリーに圧縮するasync function compressSummary( currentSummary: string, messagesToCompress: Array<{ role: string; content: string }>): Promise<string> { const formatted = messagesToCompress .map((m) => `${m.role.toUpperCase()}: ${m.content}`) .join("\n");
const response = await client.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 512, messages: [ { role: "user", content: `Update the conversation summary by adding the new exchanges below.Return only the updated summary. Keep it under 200 words.
Current summary:${currentSummary || "(none)"}
New exchanges:${formatted}`, }, ], });
return response.content[0].type === "text" ? response.content[0].text : currentSummary;}
async function chat(userMessage: string): Promise<string> { // バッファが制限を超えたら、最も古い半分を圧縮する if (state.recentMessages.length >= state.maxRecentTurns * 2) { const toCompress = state.recentMessages.splice(0, state.maxRecentTurns); state.summary = await compressSummary(state.summary, toCompress); }
state.recentMessages.push({ role: "user", content: userMessage });
// メッセージ配列を構築: システムサマリー + 最近の逐語的なターン const messages = [ ...(state.summary ? [ { role: "user" as const, content: `Context from earlier in this conversation:\n${state.summary}`, }, { role: "assistant" as const, content: "Understood. I will use that context.", }, ] : []), ...state.recentMessages, ];
const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, messages, });
const assistantMessage = response.content[0].type === "text" ? response.content[0].text : "";
state.recentMessages.push({ role: "assistant", content: assistantMessage }); return assistantMessage;}サマリー呼び出しは高速で安価なモデル(Haiku)を使用します。メインの会話は有能なモデル(Sonnet)を使用します。この分割により、品質を保ちながらコストを低く抑えます。
使用するタイミング: 20ターン以上のセッション。サポートエージェント。長期間のタスクエージェント。
制限: サマリーは詳細を失います。会話の早い段階で述べられた具体的な事実が曖昧な表現に圧縮される可能性があります。具体的な事実の正確な想起には、エピソードメモリを使用してください。
戦略3: エピソードメモリ
エピソードメモリは、ユーザーやセッションに関する具体的な事実をキーバリューストアに保存します。エージェントは会話中に事実を抽出し、将来のセッションで取り出します。
import Anthropic from "@anthropic-ai/sdk";import fs from "fs/promises";import path from "path";
const client = new Anthropic();
// 本番環境では、このファイルストアをRedisやデータベースに置き換えるconst MEMORY_PATH = "./memory.json";
interface EpisodicStore { [userId: string]: Record<string, string>;}
async function loadMemory(): Promise<EpisodicStore> { try { const data = await fs.readFile(MEMORY_PATH, "utf-8"); return JSON.parse(data); } catch { return {}; }}
async function saveMemory(store: EpisodicStore): Promise<void> { await fs.writeFile(MEMORY_PATH, JSON.stringify(store, null, 2));}
// 最後のやり取りから構造化された事実を抽出するasync function extractFacts( userMessage: string, assistantReply: string): Promise<Record<string, string>> { const response = await client.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 256, messages: [ { role: "user", content: `Extract any personal facts, preferences, or important decisions from this exchange.Return a JSON object of key-value pairs. Return {} if there is nothing worth remembering.
User: ${userMessage}Assistant: ${assistantReply}`, }, ], });
try { const text = response.content[0].type === "text" ? response.content[0].text : "{}"; const match = text.match(/\{[\s\S]*\}/); return match ? JSON.parse(match[0]) : {}; } catch { return {}; }}
// 保存された事実をシステムプロンプトの注入としてフォーマットするfunction formatMemory(facts: Record<string, string>): string { const entries = Object.entries(facts); if (entries.length === 0) return ""; return ( "What you know about this user:\n" + entries.map(([k, v]) => `- ${k}: ${v}`).join("\n") );}
async function chat(userId: string, userMessage: string): Promise<string> { const store = await loadMemory(); const userFacts = store[userId] ?? {};
const systemPrompt = formatMemory(userFacts);
const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, system: systemPrompt || undefined, messages: [{ role: "user", content: userMessage }], });
const assistantMessage = response.content[0].type === "text" ? response.content[0].text : "";
// 事実を抽出して将来のセッションのために永続化する const newFacts = await extractFacts(userMessage, assistantMessage); if (Object.keys(newFacts).length > 0) { store[userId] = { ...userFacts, ...newFacts }; await saveMemory(store); }
return assistantMessage;}
// セッション1await chat("user-123", "I prefer concise answers. No bullet points unless necessary.");// セッション2(異なるプロセス、数日後)await chat("user-123", "Explain how TCP handshakes work.");// エージェントはセッション1からのフォーマットの好みを覚えている使用するタイミング: 複数のセッションにわたって実行されるユーザー向けエージェント。パーソナライゼーション。好みの追跡。
制限: 事実は時間とともに蓄積されます。古い事実は誤った動作を生む可能性があります(ユーザーが好みの言語を変える。古い好みがまだストアに残っている)。事実を更新または期限切れにするためのメカニズムを追加してください。
戦略4: セマンティックメモリ(ベクター検索)
エピソードメモリは離散的な事実を保存します。セマンティックメモリは、意味によってインデックス化されたドキュメント、コード、または会話のチャンクを保存します。エージェントが情報を必要とするとき、クエリを使用してインデックスを検索します。
これはRetrieval-Augmented Generation(RAG)の基盤です。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface Document { id: string; content: string; embedding?: number[];}
// テキスト文字列の埋め込みベクトルを生成するasync function embed(text: string): Promise<number[]> { // Claudeは直接embeddingsエンドポイントを公開していません。 // 専用の埋め込みモデルを使用してください。この例は説明のためのスタブを使用します。 // 本番環境では: text-embedding-3-small(OpenAI)、embed-english-v3(Cohere)、 // またはnomic-embed-textのようなセルフホストモデルを使用します。 throw new Error("Replace this stub with a real embedding call");}
// 2つのベクトル間のコサイン類似度を計算するfunction cosineSimilarity(a: number[], b: number[]): number { const dot = a.reduce((sum, val, i) => sum + val * b[i], 0); const magA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0)); const magB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0)); return dot / (magA * magB);}
class SemanticMemory { private documents: Document[] = [];
// 後で取り出すためにドキュメントをインデックス化する async add(id: string, content: string): Promise<void> { const embedding = await embed(content); this.documents.push({ id, content, embedding }); }
// クエリに対して最も関連性の高いtop-kドキュメントを取り出す async search(query: string, topK = 3): Promise<Document[]> { const queryEmbedding = await embed(query); return this.documents .filter((doc) => doc.embedding) .map((doc) => ({ doc, score: cosineSimilarity(queryEmbedding, doc.embedding!), })) .sort((a, b) => b.score - a.score) .slice(0, topK) .map(({ doc }) => doc); }}
const memory = new SemanticMemory();
async function answerWithContext(question: string): Promise<string> { // 関連するドキュメントを取り出す const relevant = await memory.search(question); const context = relevant.map((d) => d.content).join("\n\n---\n\n");
const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, system: context ? `Answer the question using the following context:\n\n${context}` : undefined, messages: [{ role: "user", content: question }], });
return response.content[0].type === "text" ? response.content[0].text : "";}
// 例: ナレッジベースをインデックス化してクエリするawait memory.add("doc-1", "The team uses React 18 with the app router pattern.");await memory.add("doc-2", "All API routes must validate input with Zod schemas.");await memory.add("doc-3", "Database queries use Drizzle ORM. Avoid raw SQL.");
const answer = await answerWithContext("How should I write a new API endpoint?");// エージェントはdoc-2とdoc-3を取り出して根拠のある回答を生成する本番使用には、メモリ内ストアを専用のベクターデータベースに置き換えます。すでにPostgresを使用している場合はPgvector(PostgreSQL拡張)が最もシンプルです。ChromaとQdrantは良いスタンドアロンオプションです。
使用するタイミング: ドキュメントアシスタント。コード検索エージェント。ナレッジベースQ&A。大規模なコーパスからの想起が必要なもの。
メモリをMCPに接続する
MCPサーバーガイドに従っている場合、メモリをMCPリソースとして公開できます。これにより、Claude Codeを含む任意のMCP互換クライアントからメモリレイヤーにアクセスできるようになります。
import { Server } from "@modelcontextprotocol/sdk/server/index.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server( { name: "memory-server", version: "1.0.0" }, { capabilities: { tools: {}, resources: {} } });
// ツール: 事実を保存するserver.setRequestHandler("tools/call", async (request) => { if (request.params.name === "remember") { const { key, value, userId } = request.params.arguments as { key: string; value: string; userId: string; }; await storeEpisodicFact(userId, key, value); return { content: [{ type: "text", text: `Stored: ${key} = ${value}` }] }; }
if (request.params.name === "recall") { const { userId } = request.params.arguments as { userId: string }; const facts = await loadEpisodicFacts(userId); return { content: [{ type: "text", text: JSON.stringify(facts, null, 2) }], }; }});
const transport = new StdioServerTransport();await server.connect(transport);MCPを話す任意のエージェントがrememberとrecallを標準ツールとして呼び出せるようになります。メモリはエージェントごとの実装ではなく、共有サービスになります。
適切な戦略の選択
この決定表を出発点として使用してください:
| 状況 | 戦略 |
|---|---|
| 単一セッション、20ターン未満 | バッファ |
| 単一セッション、長期実行 | サマリー |
| マルチセッション、ユーザーの好み | エピソード |
| クエリする大規模なナレッジベース | セマンティック |
| 上記すべて | 組み合わせる:セッションにサマリー + セッション間でエピソード/セマンティック |
バッファから始めてください。会話が長くなったらサマリーレイヤーを追加します。ユーザーがセッションをまたいで戻ってきたらエピソードメモリを追加します。取り出すべきドキュメントのコーパスがあったらセマンティックメモリを追加します。
必要になる前に複雑さを追加しないでください。会話バッファはほとんどのプロトタイプに対して正解です。メモリを早期に過剰設計すると、比例した利益なしにコスト、レイテンシ、保守の表面積が増加します。
結論
メモリは一つのものではありません。異なるスコープで異なる問題に対処する戦略のスタックです。バッファは現在のセッションを処理します。サマリーはそのセッションを拡張します。エピソードメモリはセッションをまたぎます。セマンティックメモリは回答をナレッジベースに根拠付けます。
今日抱えている問題を解決するレイヤーを選んでください。後で実装を交換できるように、クリーンなインターフェースの背後にラップしてください。そしてエージェントがMCPを通じて通信する場合、メモリを共有ツールとして公開してください。各エージェントをシンプルに保ちながら、システム全体に永続的な脳を与えます。