コンピュータアクセスシステム

エージェントメモリシステム: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;
}
// セッション1
await 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拡張)が最もシンプルです。ChromaQdrantは良いスタンドアロンオプションです。

使用するタイミング: ドキュメントアシスタント。コード検索エージェント。ナレッジベース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を話す任意のエージェントがrememberrecallを標準ツールとして呼び出せるようになります。メモリはエージェントごとの実装ではなく、共有サービスになります。

適切な戦略の選択

この決定表を出発点として使用してください:

状況戦略
単一セッション、20ターン未満バッファ
単一セッション、長期実行サマリー
マルチセッション、ユーザーの好みエピソード
クエリする大規模なナレッジベースセマンティック
上記すべて組み合わせる:セッションにサマリー + セッション間でエピソード/セマンティック

バッファから始めてください。会話が長くなったらサマリーレイヤーを追加します。ユーザーがセッションをまたいで戻ってきたらエピソードメモリを追加します。取り出すべきドキュメントのコーパスがあったらセマンティックメモリを追加します。

必要になる前に複雑さを追加しないでください。会話バッファはほとんどのプロトタイプに対して正解です。メモリを早期に過剰設計すると、比例した利益なしにコスト、レイテンシ、保守の表面積が増加します。

結論

メモリは一つのものではありません。異なるスコープで異なる問題に対処する戦略のスタックです。バッファは現在のセッションを処理します。サマリーはそのセッションを拡張します。エピソードメモリはセッションをまたぎます。セマンティックメモリは回答をナレッジベースに根拠付けます。

今日抱えている問題を解決するレイヤーを選んでください。後で実装を交換できるように、クリーンなインターフェースの背後にラップしてください。そしてエージェントがMCPを通じて通信する場合、メモリを共有ツールとして公開してください。各エージェントをシンプルに保ちながら、システム全体に永続的な脳を与えます。


関連記事