なぜエージェントに「記憶」が必要か
Claude API を使ってチャットボットやエージェントを構築していると、必ずある壁にぶつかります。「ユーザーが昨日話したことを今日のセッションでは覚えていない」という問題です。
デフォルトの Claude API はステートレスです。各リクエストは独立しており、過去の会話は messages 配列に手動で含めない限り参照できません。この設計は API としてはシンプルで優れていますが、実用的なエージェントを構築するためには、独自の記憶システムを設計する必要があります。
PostgreSQL + pgvector :長期記憶・知識ベース(ベクトル検索)
Redis :短期記憶・セッションコンテキスト(高速アクセス)
Claude API :推論エンジン・ツール呼び出しによる記憶の活用
完成したシステムは、ユーザーごとの長期的な好みや過去の会話を記憶し、文脈に応じた応答を生成できるようになります。ソロ開発者から本番サービスまで対応できる、実践的なアーキテクチャを学んでいきましょう。
アーキテクチャ概観:階層型メモリシステム
人間の記憶は一枚岩ではありません。瞬間的な作業記憶、数時間後も残るエピソード記憶、そして何年も続く長期記憶が階層を成しています。AIエージェントの記憶設計も、同じ階層構造で考えると整理しやすくなります。
本ガイドのシステムは以下の3層で構成されます。
第1層:短期記憶(Redis)
現在の会話セッション中のやり取りを保持します。TTL(Time To Live)を設定して、セッション終了後に自動削除される設計です。アクセス速度が重要なため、インメモリDBのRedisを採用します。
第2層:長期記憶(PostgreSQL + pgvector)
会話の中で重要な情報(ユーザーの好み、学んだ知識、過去の決定事項など)をベクトル化して保存します。検索時は類似度検索を使い、現在のコンテキストに関連する記憶を効率よく取り出します。
第3層:エピソード記憶(PostgreSQL)
過去の会話のサマリーをタイムスタンプ付きで保存します。「先月話したプロジェクトの進捗」のような時系列に基づく記憶の検索に使います。
この3層構造により、応答速度・検索精度・ストレージコストのバランスをとりながら、実用的な記憶システムを実現できます。
環境構築:Docker Compose で依存関係をまとめる
まずはローカル開発環境を整えます。Docker Compose を使えば PostgreSQL + pgvector と Redis を一度に起動できます。
# docker-compose.yml
version : "3.9"
services :
postgres :
image : pgvector/pgvector:pg16
environment :
POSTGRES_USER : agent_user
POSTGRES_PASSWORD : your_secure_password
POSTGRES_DB : agent_memory
ports :
- "5432:5432"
volumes :
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
redis :
image : redis:7-alpine
ports :
- "6379:6379"
command : redis-server --appendonly yes
volumes :
- redis_data:/data
volumes :
postgres_data :
redis_data :
次に、データベースの初期化スクリプトを作成します。
-- init.sql
CREATE EXTENSION IF NOT EXISTS vector ;
-- 長期記憶テーブル
CREATE TABLE long_term_memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL ,
content TEXT NOT NULL ,
content_embedding vector ( 1536 ), -- text-embedding-3-small の次元数
memory_type TEXT NOT NULL , -- 'fact', 'preference', 'skill', 'event'
importance_score FLOAT DEFAULT 0 . 5 ,
access_count INTEGER DEFAULT 0 ,
last_accessed_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW (),
expires_at TIMESTAMPTZ -- NULL = 永続
);
-- ベクトル検索用インデックス(HNSW)
CREATE INDEX ON long_term_memories
USING hnsw (content_embedding vector_cosine_ops)
WITH (m = 16 , ef_construction = 64 );
-- ユーザー別の検索最適化
CREATE INDEX ON long_term_memories (user_id, created_at DESC );
-- エピソード記憶テーブル
CREATE TABLE episode_memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL ,
session_id TEXT NOT NULL ,
summary TEXT NOT NULL ,
key_topics TEXT [],
sentiment TEXT ,
created_at TIMESTAMPTZ DEFAULT NOW ()
);
CREATE INDEX ON episode_memories (user_id, created_at DESC );
docker compose up -d で起動後、接続を確認してください。
PostgreSQL + pgvector の実装:長期記憶の保存と検索
次に、Node.js(TypeScript)で長期記憶を操作するクラスを実装します。埋め込みベクトルの生成には OpenAI の text-embedding-3-small を使いますが、Claude API 単体で完結させたい場合は Claude の embeddings 機能(提供状況を確認してください)も選択肢です。
// src/memory/LongTermMemory.ts
import { Pool } from "pg" ;
import OpenAI from "openai" ;
interface Memory {
id : string ;
content : string ;
memoryType : "fact" | "preference" | "skill" | "event" ;
importanceScore : number ;
createdAt : Date ;
}
export class LongTermMemory {
private pool : Pool ;
private openai : OpenAI ;
constructor () {
this .pool = new Pool ({
host: process.env. POSTGRES_HOST ?? "localhost" ,
port: 5432 ,
user: process.env. POSTGRES_USER ?? "agent_user" ,
password: process.env. POSTGRES_PASSWORD ,
database: process.env. POSTGRES_DB ?? "agent_memory" ,
});
this .openai = new OpenAI ({ apiKey: process.env. OPENAI_API_KEY });
}
/** テキストを埋め込みベクトルに変換 */
private async embed ( text : string ) : Promise < number []> {
const response = await this .openai.embeddings. create ({
model: "text-embedding-3-small" ,
input: text,
});
return response.data[ 0 ].embedding;
}
/** 新しい記憶を保存 */
async save (
userId : string ,
content : string ,
type : Memory [ "memoryType" ],
importanceScore = 0.5
) : Promise < string > {
const embedding = await this . embed (content);
const result = await this .pool. query (
`INSERT INTO long_term_memories
(user_id, content, content_embedding, memory_type, importance_score)
VALUES ($1, $2, $3::vector, $4, $5)
RETURNING id` ,
[userId, content, JSON . stringify (embedding), type, importanceScore]
);
return result.rows[ 0 ].id;
}
/** 類似記憶を検索(コサイン類似度) */
async search (
userId : string ,
query : string ,
limit = 5 ,
threshold = 0.75
) : Promise < Memory []> {
const queryEmbedding = await this . embed (query);
const result = await this .pool. query (
`SELECT id, content, memory_type, importance_score, created_at,
1 - (content_embedding <=> $2::vector) AS similarity
FROM long_term_memories
WHERE user_id = $1
AND (expires_at IS NULL OR expires_at > NOW())
AND 1 - (content_embedding <=> $2::vector) > $3
ORDER BY similarity DESC, importance_score DESC
LIMIT $4` ,
[userId, JSON . stringify (queryEmbedding), threshold, limit]
);
// アクセス頻度の更新(よく参照される記憶は重要度が上がる)
if (result.rows. length > 0 ) {
const ids = result.rows. map (( r ) => r.id);
await this .pool. query (
`UPDATE long_term_memories
SET access_count = access_count + 1, last_accessed_at = NOW()
WHERE id = ANY($1)` ,
[ids]
);
}
return result.rows. map (( row ) => ({
id: row.id,
content: row.content,
memoryType: row.memory_type,
importanceScore: row.importance_score,
createdAt: row.created_at,
}));
}
/** ユーザーの記憶を削除(GDPR対応) */
async deleteAll ( userId : string ) : Promise < void > {
await this .pool. query (
"DELETE FROM long_term_memories WHERE user_id = $1" ,
[userId]
);
await this .pool. query (
"DELETE FROM episode_memories WHERE user_id = $1" ,
[userId]
);
}
}
このクラスのポイントは、検索時にアクセスカウントを更新する 点です。よく参照される記憶は自然と重要度が上がり、長期的にユーザーにとって価値の高い記憶が優先して取り出されるようになります。
Redis 短期記憶の実装:セッションコンテキスト管理
セッション内の直近の会話を管理するために Redis を使います。セッション終了から24時間後に自動削除される設計です。
// src/memory/ShortTermMemory.ts
import { createClient } from "redis" ;
interface Message {
role : "user" | "assistant" ;
content : string ;
timestamp : number ;
}
export class ShortTermMemory {
private client : ReturnType < typeof createClient>;
private ttl : number ; // 秒単位
constructor ( ttlSeconds = 86400 ) {
// デフォルト24時間
this .client = createClient ({
url: process.env. REDIS_URL ?? "redis://localhost:6379" ,
});
this .ttl = ttlSeconds;
this .client. connect ();
}
private key ( userId : string , sessionId : string ) : string {
return `session:${ userId }:${ sessionId }` ;
}
/** メッセージをセッションに追加 */
async push (
userId : string ,
sessionId : string ,
message : Omit < Message , "timestamp" >
) : Promise < void > {
const key = this . key (userId, sessionId);
const entry : Message = { ... message, timestamp: Date. now () };
await this .client. rPush (key, JSON . stringify (entry));
await this .client. expire (key, this .ttl);
// 最大50件を超えたら古いものを削除
const length = await this .client. lLen (key);
if (length > 50 ) {
await this .client. lTrim (key, - 50 , - 1 );
}
}
/** セッションの会話履歴を取得 */
async getHistory (
userId : string ,
sessionId : string ,
limit = 20
) : Promise < Message []> {
const key = this . key (userId, sessionId);
const items = await this .client. lRange (key, - limit, - 1 );
return items. map (( item ) => JSON . parse (item));
}
/** セッションを終了してクリア */
async endSession ( userId : string , sessionId : string ) : Promise < Message []> {
const history = await this . getHistory (userId, sessionId, 100 );
await this .client. del ( this . key (userId, sessionId));
return history;
}
}
短期記憶はリスト構造 で管理します。RPUSH で末尾追加、LRANGE で末尾から取得することで、常に「直近のN件」を高速に取り出せます。Redis のリストは O(1) の追加・先頭/末尾取得をサポートするため、この用途に最適です。
Claude API との統合:ツール呼び出しで記憶を活用する
いよいよ Claude API と記憶システムを統合します。Claude のツール呼び出し機能 を使って、「記憶の検索」と「記憶の保存」をモデルが自律的に行えるようにします。
// src/agent/MemoryAgent.ts
import Anthropic from "@anthropic-ai/sdk" ;
import { LongTermMemory } from "../memory/LongTermMemory" ;
import { ShortTermMemory } from "../memory/ShortTermMemory" ;
const client = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY });
export class MemoryAgent {
private longTerm : LongTermMemory ;
private shortTerm : ShortTermMemory ;
constructor () {
this .longTerm = new LongTermMemory ();
this .shortTerm = new ShortTermMemory ();
}
private tools : Anthropic . Tool [] = [
{
name: "search_memory" ,
description:
"ユーザーの過去の会話や保存済みの知識を検索します。" +
"現在の会話に関連する情報を取得するために使用してください。" ,
input_schema: {
type: "object" as const ,
properties: {
query: {
type: "string" ,
description: "検索クエリ(自然言語で記述)" ,
},
limit: {
type: "number" ,
description: "返す記憶の最大件数(デフォルト: 3)" ,
},
},
required: [ "query" ],
},
},
{
name: "save_memory" ,
description:
"重要な情報を長期記憶に保存します。" +
"ユーザーの好み、重要な事実、スキル、出来事などを保存してください。" ,
input_schema: {
type: "object" as const ,
properties: {
content: {
type: "string" ,
description: "保存する情報の内容" ,
},
memory_type: {
type: "string" ,
enum: [ "fact" , "preference" , "skill" , "event" ],
description: "記憶の種類" ,
},
importance: {
type: "number" ,
description: "重要度スコア(0.0〜1.0)" ,
},
},
required: [ "content" , "memory_type" ],
},
},
];
async chat (
userId : string ,
sessionId : string ,
userMessage : string
) : Promise < string > {
// 短期記憶にユーザーのメッセージを追加
await this .shortTerm. push (userId, sessionId, {
role: "user" ,
content: userMessage,
});
// 短期記憶から会話履歴を取得
const history = await this .shortTerm. getHistory (userId, sessionId, 10 );
const messages : Anthropic . MessageParam [] = history. map (( h ) => ({
role: h.role,
content: h.content,
}));
// ツール呼び出しループ
while ( true ) {
const response = await client.messages. create ({
model: "claude-opus-4-6" ,
max_tokens: 2048 ,
system: `あなたは記憶システムを持つAIアシスタントです。
ユーザーとの会話を始める前に必ず search_memory ツールを使って
関連する過去の情報を確認してください。
重要な情報は積極的に save_memory で保存し、
ユーザーの体験を継続的に向上させてください。` ,
messages,
tools: this .tools,
});
if (response.stop_reason === "end_turn" ) {
const text = response.content
. filter (( b ) => b.type === "text" )
. map (( b ) => (b as Anthropic . TextBlock ).text)
. join ( "" );
// アシスタントの応答を短期記憶に追加
await this .shortTerm. push (userId, sessionId, {
role: "assistant" ,
content: text,
});
return text;
}
if (response.stop_reason === "tool_use" ) {
// ツール呼び出しの処理
const assistantMessage : Anthropic . MessageParam = {
role: "assistant" ,
content: response.content,
};
messages. push (assistantMessage);
const toolResults : Anthropic . ToolResultBlockParam [] = [];
for ( const block of response.content) {
if (block.type !== "tool_use" ) continue ;
let result = "" ;
if (block.name === "search_memory" ) {
const input = block.input as { query : string ; limit ?: number };
const memories = await this .longTerm. search (
userId,
input.query,
input.limit ?? 3
);
result =
memories. length > 0
? memories
. map (
( m ) =>
`[${ m . memoryType }] ${ m . content } (重要度: ${ m . importanceScore })`
)
. join ( " \n " )
: "関連する記憶は見つかりませんでした。" ;
}
if (block.name === "save_memory" ) {
const input = block.input as {
content : string ;
memory_type : "fact" | "preference" | "skill" | "event" ;
importance ?: number ;
};
const id = await this .longTerm. save (
userId,
input.content,
input.memory_type,
input.importance ?? 0.5
);
result = `記憶を保存しました(ID: ${ id })` ;
}
toolResults. push ({
type: "tool_result" ,
tool_use_id: block.id,
content: result,
});
}
messages. push ({ role: "user" , content: toolResults });
}
}
}
}
このエージェントの設計の核心は、Claude 自身が記憶の検索・保存のタイミングを判断する 点です。システムプロンプトでその振る舞いを誘導しつつ、実際の実行はモデルに委ねることで、文脈に応じた柔軟な記憶活用が実現します。
セッション終了時のエピソード記憶生成
ユーザーがセッションを終了する際、会話全体のサマリーをエピソード記憶として保存します。これにより「先月の会議で話したこと」のような粒度の記憶も扱えるようになります。
// セッション終了処理
async endSession (userId: string, sessionId: string): Promise <void> {
const history = await this .shortTerm. endSession (userId, sessionId);
if (history.length < 3) return; // 短すぎるセッションはスキップ
const conversationText = history
.map((h) => `${ h . role }: ${ h . content }` )
.join( " \n " );
// Claude にサマリーを生成させる
const summaryResponse = await client.messages.create({
model: "claude-haiku-4-5-20251001" , // コスト最適化のためHaikuを使用
max_tokens: 512 ,
messages: [
{
role: "user" ,
content: `以下の会話を3文以内で要約してください。
重要なトピックとユーザーが学んだことを中心にまとめてください。
${ conversationText }` ,
},
],
});
const summary =
summaryResponse.content[ 0 ].type === "text"
? summaryResponse.content[ 0 ].text
: "" ;
// エピソード記憶として保存
const pool = ( this .longTerm as any ).pool; // 実際はプロパティを公開する
await pool. query (
`INSERT INTO episode_memories (user_id, session_id, summary, key_topics)
VALUES ($1, $2, $3, $4)` ,
[userId, sessionId, summary, []] // topic抽出は省略
);
}
サマリー生成にはclaude-haiku-4-5-20251001を使うことでコストを最小化しています。セッションごとに高価なモデルでサマリーを生成していると運用コストがかさむため、こうしたモデルルーティング は本番では欠かせない設計判断です。コスト最適化のより詳しい手法はClaude API コスト最適化プロダクションパターン も参照してください。
よくあるエラーと対処法
vector 型のキャストエラー
pgvector にベクトルを挿入する際、JSON.stringify(embedding) した文字列を ::vector でキャストする必要があります。pg ライブラリは数値配列を直接渡せないため、この変換が必要です。
// NG: 数値配列を直接渡す
await pool. query ( "INSERT INTO ... VALUES ($1)" , [embedding]);
// OK: JSON文字列 + ::vector キャスト
await pool. query ( "INSERT INTO ... VALUES ($1::vector)" , [
JSON . stringify (embedding),
]);
Redis 接続の ECONNREFUSED エラー
createClient() の後に await client.connect() を呼ぶのを忘れがちです。非同期の接続確立が完了する前にコマンドを実行するとエラーになります。本番では接続エラー時の再試行ロジックも実装してください。
HNSW インデックスが使われない
WHERE 句でフィルタリングしてからベクトル検索を行うと、場合によってはシーケンシャルスキャンにフォールバックします。SET enable_seqscan = off; でテスト時にインデックス利用を強制し、EXPLAIN ANALYZE でクエリプランを確認してください。
プライバシーとデータ保護の実装
GDPR・個人情報保護法に対応するため、ユーザーの記憶を安全に削除できる仕組み を必ず実装してください。
// GDPR 対応:ユーザーデータの完全削除
async deleteUserData (userId: string): Promise <void> {
await this.longTerm.deleteAll(userId); // long_term_memories + episode_memories を削除
// Redis のセッションデータも削除
const keys = await this .shortTerm.client. keys ( `session:${ userId }:*` );
if (keys.length > 0) {
await this .shortTerm.client. del (keys);
}
}
また、長期記憶に保存する内容については、ユーザーへの明示的な同意取得 と保存内容の透明性確保 が重要です。どのような情報が記憶されているかをユーザーが確認・削除できるUIを設けることをお勧めします。記憶システムに固有の事故(古い事実の残留・ユーザー間のコンテキスト漏れ・ストレージの暴走)についてはClaudeエージェントの記憶設計:本番でハマりやすい落とし穴 で詳しく扱っています。
本番で見えてきた調整ポイント:公式ドキュメントにない運用知見
ここまでの実装で、システムは問題なく動きます。ただ、実際のユーザーを相手にし始めると、ローカル開発では見えてこなかった調整ポイントがいくつも現れます。私自身が個人開発で運営し、App Store と Google Play で配信している壁紙アプリや癒し系アプリに、ユーザーの好みをセッションを跨いで覚える仕組みとして本構成を組み込んだ際の実測値を、いくつか共有します。
ef_search は recall とレイテンシのつまみ
HNSW インデックスの検索精度は、クエリ時の ef_search で大きく変わります。公式の README は既定値の話までしか触れていませんが、本番では明示的に設定したほうが安全です。手元の約 12 万件の記憶テーブル(pgvector pg16・1536 次元)で測ったところ、おおよそ次のような傾向でした。
ef_search recall@5 p95 レイテンシ
40(既定相当) 約 0.91 約 9ms
64 約 0.96 約 14ms
100 約 0.985 約 22ms
SET hnsw.ef_search = 64; あたりが、精度と速度のバランスを取りやすい値でした。チャット応答の途中で記憶検索が複数回走ることを考えると、1 回あたり数 ms の差が積み重なります。recall を欲張りすぎない判断も、実用上は大切だと感じています。
遅延とコストの主因は pgvector ではなく埋め込み生成
見落とされがちですが、レイテンシとコストの大半は pgvector ではなく埋め込み API の呼び出し側に乗ります。1 回あたり 50〜200ms、しかも従量課金です。私の運用では同じ問い合わせ文が繰り返し届く傾向が強かったため、クエリ文字列の MD5 をキーにして、生成済みの埋め込みを Redis へキャッシュしました。TTL は 2 時間ほどです。
// 埋め込みのキャッシュ(Redis・TTL 2時間)
import { createHash } from "crypto" ;
async embedCached (text: string): Promise < number[] > {
const key = `embed:${ createHash ( "md5" ). update ( text ). digest ( "hex" ) }` ;
const cached = await this .redisClient. get (key);
if ( cached ) return JSON. parse ( cached );
const embedding = await this . embed (text);
await this.redisClient.setEx(key, 7200 , JSON.stringify(embedding));
return embedding;
}
この一手だけで、埋め込み API の呼び出しが約 60% 減り、月額の埋め込みコストもほぼ同じ割合で下がりました。記憶機能を載せる前に、まずキャッシュ層を用意しておくことをお勧めします。
importance は「育てる」より「枯らす」設計に
検索のたびに access_count を増やす実装は前述の通りですが、増やすだけでは古い記憶がいつまでも上位に残り続けます。週に一度、最終アクセスから 30 日を超えた記憶の importance_score を 0.9 倍に減衰させるバッチを回したところ、検索結果が「最近の関心」へ自然に寄り、ユーザーの体感がはっきり良くなりました。
記憶の品質を保つ:何を覚え、何を忘れるか
記憶システムで最初に肥大化するのは、ほぼ同じ内容の重複記憶です。「コーヒーが好き」と「コーヒーをよく飲む」が別レコードとして積み上がっていきます。保存の直前に、同じ user_id 内で類似度 0.95 以上の既存記憶を検索し、見つかれば新規挿入ではなく importance_score の引き上げに切り替えると、重複の増殖をかなり抑えられます。手元では、この一手で長期記憶テーブルの増加ペースが体感で 3 割ほど緩やかになりました。
逆に、保存しすぎない判断も品質を左右します。save_memory をモデルに委ねると、その場限りの雑談まで律儀に記憶しようとする場面があります。システムプロンプトで「半年後にも価値が残る情報だけを保存する」と明示し、importance のしきい値(たとえば 0.4 未満は保存しない)をコード側にも設けておくと、記憶の S/N 比が保たれます。
何を覚え、何を静かに忘れるか。この線引きこそが、記憶を持つエージェントの体験品質を決めるのだと考えています。
次の一歩
3 層構成の核心は、速度・精度・コストを別々のストアに分担させ、保存と検索の判断をモデル自身に委ねる点にあります。まずは手元で docker compose up し、search_memory と save_memory の 2 ツールだけで小さく動かしてみてください。記憶が応答に効き始める瞬間が、いちばんの設計の手応えになります。
そこから先は、本記事の ef_search 調整・埋め込みキャッシュ・importance 減衰を一つずつ足していくのが、無理のない育て方だと思います。検索基盤をさらに広げたくなったら企業ドキュメント検索エージェントの実装ガイド も合わせてご覧ください。
お読みいただきありがとうございました。あなたのサービスの記憶機能づくりの一助になれば幸いです。