第8章 エージェントメモリ(Agent Memory)
8.1 概要
Section titled “8.1 概要”第2章2.1節で述べた3つの性質のうち、最初のものを思い出してほしい。LLMはステートレスである。 モデルは前回の会話を覚えていない。会話が続いているように見えるのは、毎回すべての履歴を送り直しているからである。
この事実から、エージェントの「記憶」はすべて開発者が実装するものだという帰結が導かれる。モデルに記憶機能はない。あるのは「入力に含まれているものだけを見る」という性質だけである。
したがってエージェントメモリの設計とは、突き詰めれば次の1つの問いに答えることである。
いま、この瞬間の推論に、何をコンテキストへ入れるべきか。
情報を入れれば判断材料は増えるが、コスト・レイテンシ・注意の散逸という代償を払う。入れなければ安価で速いが、必要な情報を欠いた判断になる。メモリシステムとは、この取捨選択を自動化する仕組みにほかならない。
8.2 エージェントメモリとは何か
Section titled “8.2 エージェントメモリとは何か”8.2.1 3つの層
Section titled “8.2.1 3つの層”エージェントの記憶は、保持期間と場所によって3層に整理できる。
| 層 | 場所 | 保持期間 | 内容 |
|---|---|---|---|
| 作業記憶(Working) | コンテキスト内 | 1ステップ | 現在のツール結果、直前の推論 |
| 短期記憶(Short Term) | コンテキスト内 | 1セッション | 会話履歴、これまでのツール結果 |
| 長期記憶(Long Term) | 外部ストレージ | 永続 | ユーザーの嗜好、過去の事例、獲得した知識 |
人間の記憶モデルからの類推だが、実装上はこの区別が有用である。作業記憶と短期記憶はコンテキスト管理の問題であり、長期記憶はストレージと検索の問題である。解くべき技術課題がまったく違う。
8.2.2 メモリが必要になる場面
Section titled “8.2.2 メモリが必要になる場面”すべてのエージェントに長期記憶が必要なわけではない。まず本当に必要かを判断することから始める。
| 状況 | 長期記憶 | 理由 |
|---|---|---|
| 1回で完結する単発タスク | 不要 | セッション内で完結する |
| 数ステップのループ | 不要 | コンテキストに収まる |
| コンテキストを超える長いタスク | 必要(短期の圧縮) | 履歴が入りきらない |
| セッションをまたぐ継続作業 | 必要 | 前回の状態を復元する必要がある |
| ユーザーごとの嗜好を覚える | 必要 | 個別化が価値の中心 |
| 過去の事例から学ぶ | 必要 | 経験の蓄積が価値 |
第4章4.3.3節の「最も単純な構成から始める」という原則は、ここにも適用される。メモリシステムは複雑さとバグの温床であり、必要になってから作るべきである。
8.3 短期記憶 — コンテキスト内の管理
Section titled “8.3 短期記憶 — コンテキスト内の管理”短期記憶とは、要するにメッセージ履歴の管理である。第5章5.4.3節で「コンテキスト溢れ」を失敗モードとして挙げ、対策を3層で示した。ここではその実装に踏み込む。
8.3.1 何が膨張するのか
Section titled “8.3.1 何が膨張するのか”エージェントのコンテキストで支配的なのは、圧倒的にツール実行結果である。
ステップ 1: システムプロンプト + ツール定義 = 6,000 トークンステップ 3: + ファイル読み取り結果 ×2 = 18,000ステップ 7: + 検索結果 + ログ抜粋 = 42,000ステップ 12: + テスト実行の出力 = 71,000ステップ 20: + さらなるファイル読み取り = 130,000重要な観察は、古いツール結果の多くはもう不要であるという点だ。ステップ3で読んだファイルの内容は、その情報をもとに判断を下した後は、たいてい参照されない。にもかかわらず、それは最後まで毎ターン再送され、課金され続ける。
短期記憶の管理とは、この不要になったものを落とす作業である。
8.3.2 手法1: ツール結果の切り詰め(入口での制御)
Section titled “8.3.2 手法1: ツール結果の切り詰め(入口での制御)”最も効果的かつ単純な対策は、そもそも大きなものを入れないことである。第7章7.4.2節で扱ったとおり、ツール側で上限を設ける。
これは「入口での制御」であり、後段のあらゆる手法より優先される。1万行のクエリ結果を入れてから圧縮するより、最初から100行に絞るほうが安く確実である。
8.3.3 手法2: context editing(サーバー側での自動削除)
Section titled “8.3.3 手法2: context editing(サーバー側での自動削除)”Anthropic APIには、古いツール結果を自動的に削除するサーバー側の機能がある(context editing)。指定したトークン数を超えたら、古い順にツール結果を消していく。
response = client.beta.messages.create( model="claude-opus-5", max_tokens=4096, messages=messages, tools=tools, betas=["context-management-2025-06-27"], context_management={ "edits": [ { "type": "clear_tool_uses_20250919", "trigger": {"type": "input_tokens", "value": 50000}, # 発動する閾値 "keep": {"type": "tool_uses", "value": 5}, # 直近5件は残す "clear_at_least": {"type": "input_tokens", "value": 5000}, "exclude_tools": ["web_search"], # 消さないツール "clear_tool_inputs": False, # 引数は残す } ] },)
# 何が消されたかを確認できるif response.context_management and response.context_management.applied_edits: for edit in response.context_management.applied_edits: print(f"削除: ツール結果 {edit.cleared_tool_uses} 件 / " f"{edit.cleared_input_tokens:,} トークン")const response = await client.beta.messages.create({ model: 'claude-opus-5', max_tokens: 4096, messages, tools, betas: ['context-management-2025-06-27'], context_management: { edits: [ { type: 'clear_tool_uses_20250919', trigger: { type: 'input_tokens', value: 50000 }, // 発動する閾値 keep: { type: 'tool_uses', value: 5 }, // 直近5件は残す clear_at_least: { type: 'input_tokens', value: 5000 }, exclude_tools: ['web_search'], // 消さないツール clear_tool_inputs: false, // 引数は残す }, ], },})
// 何が消されたかを確認できるif (response.context_management && response.context_management.applied_edits) { for (const edit of response.context_management.applied_edits) { // applied_edits は clear_thinking の結果との合併型。Python は属性を動的に // 引けるが、TypeScript では type で絞り込まないと件数を読めない if (edit.type !== 'clear_tool_uses_20250919') continue console.log( `削除: ツール結果 ${edit.cleared_tool_uses} 件 / ` + `${edit.cleared_input_tokens.toLocaleString('en-US')} トークン` ) }}思考ブロックを対象とする方式もある。
context_management={ "edits": [ # 複数指定する場合、clear_thinking を先に置く必要がある {"type": "clear_thinking_20251015", "keep": {"type": "thinking_turns", "value": 2}}, {"type": "clear_tool_uses_20250919", "trigger": {"type": "input_tokens", "value": 50000}, "keep": {"type": "tool_uses", "value": 5}}, ]}// Python 版は create() に渡すキーワード引数の断片。TypeScript では型注釈を// 付けた定数にして、create() には context_management: contextManagement と渡すconst contextManagement: Anthropic.Beta.BetaContextManagementConfig = { edits: [ // 複数指定する場合、clear_thinking を先に置く必要がある { type: 'clear_thinking_20251015', keep: { type: 'thinking_turns', value: 2 } }, { type: 'clear_tool_uses_20250919', trigger: { type: 'input_tokens', value: 50000 }, keep: { type: 'tool_uses', value: 5 }, }, ],}プロンプトキャッシュとの相互作用に注意が必要である。 ツール結果の削除はプレフィックスを変えるため、キャッシュを無効化する(第2章2.2.2節)。削除が発動した回はキャッシュ書き込みのコストが発生し、その後のリクエストで新しいキャッシュが再利用される。閾値を低くしすぎて頻繁に削除が起きると、かえって高くつく。
一方、思考ブロックの削除は、保持される場合はキャッシュを維持する。
8.3.4 手法3: compaction(履歴の要約による置換)
Section titled “8.3.4 手法3: compaction(履歴の要約による置換)”削除ではなく要約して置き換える方式である。閾値を超えたら、それまでの会話全体を構造化された要約に圧縮し、履歴をその要約で置き換える。
context editing と同じ context_management の枠組みで、サーバー側の機能として指定する。
response = client.beta.messages.create( betas=["compact-2026-01-12"], # compaction 専用のベータヘッダ model="claude-opus-5", max_tokens=4096, messages=messages, tools=tools, context_management={ "edits": [ { "type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 100_000}, # "pause_after_compaction": False, # 要約後に一時停止するか(既定 False) # "instructions": None, # 要約指示の差し替え(既定の指示を完全に置換) } ] },)
messages.append({"role": "assistant", "content": response.content})const response = await client.beta.messages.create({ betas: ['compact-2026-01-12'], // compaction 専用のベータヘッダ model: 'claude-opus-5', max_tokens: 4096, messages, tools, context_management: { edits: [ { type: 'compact_20260112', trigger: { type: 'input_tokens', value: 100_000 }, // pause_after_compaction: false, // 要約後に一時停止するか(既定 false) // instructions: null, // 要約指示の差し替え(既定の指示を完全に置換) }, ], },})
messages.push({ role: 'assistant', content: response.content })設定項目は次のとおりである。
| キー | 既定値 | 内容 |
|---|---|---|
trigger |
{"type": "input_tokens", "value": 150000} |
発動閾値。input_tokens のみ指定可。最小値は 50,000 |
pause_after_compaction |
false |
要約を生成した時点で応答を止めるか |
instructions |
null |
要約の指示。指定すると既定の指示を完全に置き換える |
要約は「タスク概要 / 現在の状態 / 重要な発見 / 次のステップ / 保持すべき文脈」といった構造で生成される。
注意: SDK側の
compaction_controlパラメータ(クライアント側で要約を行う旧方式)は非推奨であり、将来のバージョンで削除される。Anthropicはサーバー側の compaction を推奨している。古い記事やサンプルコードでcompaction_controlを見かけたら、上記の形に読み替えること。
8.3.5 削除と要約の使い分け
Section titled “8.3.5 削除と要約の使い分け”| context editing(削除) | compaction(要約) | |
|---|---|---|
| 指定方法 | context_management.edits に clear_tool_uses_20250919 |
context_management.edits に compact_20260112 |
| ベータヘッダ | context-management-2025-06-27 |
compact-2026-01-12 |
| 方式 | 古いものを消す | 全体を要約で置換 |
| 追加コスト | なし(キャッシュ無効化はある) | 要約生成のためのトークン |
| 情報の保存 | 消えたものは戻らない | 要点は残るが詳細は失われる |
| 発動閾値 | 任意 | 最小 50,000 トークン(既定 150,000) |
| 適する場面 | 読み終えたツール結果の破棄 | 判断の経緯を残したい長時間タスク |
いずれもサーバー側で動作するため、アプリケーション側で履歴を組み替える必要はない。
実務的な指針は、次の順序である。まず入口での切り詰め(手法1)を徹底し、次に context editing を入れ、それでも足りなければ compaction を検討する。
8.3.6 手法4: 外部への退避(メモリツール)
Section titled “8.3.6 手法4: 外部への退避(メモリツール)”削除も要約も、情報の損失を伴う。失いたくない情報がある場合は、消す前に外部へ書き出す。
これが次節の長期記憶につながる。実際、context editing / compaction とメモリツールは組み合わせて使うことが推奨されている。要約で失われては困る構造化された情報(進捗ログ、確認済み事項のチェックリスト)はファイルに書き出し、流動的な会話部分は自動圧縮に任せる、という分担である。
8.4 長期記憶 — 外部ストレージ
Section titled “8.4 長期記憶 — 外部ストレージ”8.4.1 3つの実装方式
Section titled “8.4.1 3つの実装方式”長期記憶の実装には大きく3つの方式がある。どれか1つを選ぶのではなく、記憶の性質に応じて使い分けるのが実務である。
| 方式 | 得意なこと | 苦手なこと | 典型的な用途 |
|---|---|---|---|
| ベクトルDB | 意味的な類似検索 | 厳密一致、範囲指定、集計 | 過去の事例、文書、会話の想起 |
| リレーショナルDB | 構造化データ、厳密な検索、集計 | 曖昧な意味検索 | ユーザープロファイル、設定、履歴の記録 |
| ファイル(メモリツール) | 自由な構造、人間可読、モデル自身が管理 | 検索性(全文検索は別途必要) | 進捗ログ、作業メモ、チェックリスト |
第3章3.5.4節で見たとおり、ベクトル検索は固有名詞の厳密一致や範囲指定が苦手である。「田中さんの設定」を探すのにベクトル検索を使うのは筋が悪い。SQLの WHERE user_id = ? で引くべきである。
8.4.2 ベクトルDBによる想起
Section titled “8.4.2 ベクトルDBによる想起”第3章3.6節で扱ったRAGの仕組みを、そのまま記憶の想起に使える。違いは、インデックスの対象が文書ではなく過去のやりとりや獲得した知識である点だけである。
from dataclasses import dataclassfrom datetime import datetime
@dataclassclass MemoryEntry: id: str user_id: str content: str # 記憶の内容(自然文) kind: str # "preference" | "fact" | "episode" created_at: datetime last_accessed_at: datetime access_count: int source: str # どの会話・どのステップで得られたか
def recall(user_id: str, query: str, top_k: int = 5) -> list[MemoryEntry]: """クエリに意味的に近い記憶を取り出す。""" vector = embed(query) return vector_db.search( vector=vector, filter={"user_id": user_id}, # ユーザーで必ず絞る(重要) top_k=top_k, )
def mark_used(entry_ids: list[str]) -> None: """実際にプロンプトへ注入した記憶だけ、使用実績を更新する。
recall した全件を更新してはならない。理由は 8.7.2 節を参照。 """ for eid in entry_ids: touch(eid) # last_accessed_at と access_count を更新interface MemoryEntry { id: string userId: string content: string // 記憶の内容(自然文) // Python 版は kind: str としてコメントで候補を示していたが、TypeScript では // 合併型で書けるため型で表す kind: 'preference' | 'fact' | 'episode' createdAt: Date lastAccessedAt: Date accessCount: number source: string // どの会話・どのステップで得られたか}
/** クエリに意味的に近い記憶を取り出す。 */function recall(userId: string, query: string, topK: number = 5): MemoryEntry[] { const vector = embed(query) return vectorDb.search({ vector, filter: { user_id: userId }, // ユーザーで必ず絞る(重要) top_k: topK, })}
/** * 実際にプロンプトへ注入した記憶だけ、使用実績を更新する。 * * recall した全件を更新してはならない。理由は 8.7.2 節を参照。 */function markUsed(entryIds: string[]): void { for (const eid of entryIds) { touch(eid) // lastAccessedAt と accessCount を更新 }}filter={"user_id": user_id} を必ず入れる点が重要である。メタデータによる絞り込みを怠ると、他人の記憶が混入する。これは単なるバグではなく、情報漏洩事故である。
使用実績の更新を recall から切り離している点にも意味がある。検索でヒットしただけの記憶まで「使われた」と記録すると、8.7.2節で見る想起スコアが自己強化ループを起こす。実際にコンテキストへ入れたものだけを更新する。
8.4.3 リレーショナルDBによる構造化記憶
Section titled “8.4.3 リレーショナルDBによる構造化記憶”「ユーザーの好み」のようにキーが定まっているものは、SQLで管理するほうが正確で扱いやすい。
CREATE TABLE user_preferences ( user_id TEXT NOT NULL, key TEXT NOT NULL, -- 'language' | 'report_format' | ... value TEXT NOT NULL, confidence REAL NOT NULL, -- どれだけ確かか(推測か明言か) source TEXT NOT NULL, -- 'explicit' | 'inferred' updated_at TIMESTAMPTZ NOT NULL, PRIMARY KEY (user_id, key));confidence と source を持たせている点に注目してほしい。ユーザーが「日本語で返して」と明示した場合(explicit)と、日本語で話しかけられたから推測した場合(inferred)では、記憶の確からしさが違う。推測した記憶を事実として扱うと、誤りが固定化する。
8.4.4 ファイルによる記憶(メモリツール)
Section titled “8.4.4 ファイルによる記憶(メモリツール)”モデル自身にファイルを読み書きさせる方式である。Anthropic APIはこれをメモリツールとして提供している。
モデルはタスク開始時に自動的にメモリディレクトリを確認し、必要に応じて読み書きする。
| コマンド | 用途 |
|---|---|
view |
ディレクトリ・ファイルの内容を見る(行範囲の指定可) |
create |
ファイルを作成する |
str_replace |
ファイル内の文字列を置換する |
insert |
指定行に挿入する |
delete |
ファイル・ディレクトリを削除する |
rename |
名前変更・移動 |
import anthropicfrom anthropic.tools import BetaLocalFilesystemMemoryTool
client = anthropic.Anthropic()memory = BetaLocalFilesystemMemoryTool(base_path="./memory")
runner = client.beta.messages.tool_runner( model="claude-opus-5", max_tokens=1024, messages=[{"role": "user", "content": "Acme社は電話よりメールでのフォローアップを好む、と覚えておいて"}], tools=[memory],)print(runner.until_done().content)import Anthropic from '@anthropic-ai/sdk'import { betaMemoryTool, BetaLocalFilesystemMemoryTool } from '@anthropic-ai/sdk/tools/memory/node'
const client = new Anthropic()const memory = new BetaLocalFilesystemMemoryTool('./memory')
const runner = client.beta.messages.toolRunner({ model: 'claude-opus-5', max_tokens: 1024, messages: [ { role: 'user', content: 'Acme社は電話よりメールでのフォローアップを好む、と覚えておいて', }, ], // Python 版はツールのインスタンスをそのまま渡すが、TypeScript の SDK は // ハンドラを betaMemoryTool() でツール定義に包んでから渡す tools: [betaMemoryTool(memory)],})console.log((await runner.runUntilDone()).content)モデルから見えるパスと、実際の保存先は別である。 モデルは常に /memories/... という仮想パスで操作し、実装側がそれを base_path(上の例では ./memory)配下へ写像する。この対応があるため、下の検証関数は /memories を基準に書かれている。
独自のストレージ(DB、クラウドストレージ、ユーザーごとのディレクトリ)を使う場合は、抽象基底クラスを継承して実装する。ユーザーごとにディレクトリを分ける実装にすれば、/memories という同じ仮想パスのまま、テナントを分離できる(8.4.2節)。
セキュリティ上の必須要件: メモリ操作のパスは /memories で始まることを検証し、それ以外を拒否する。第7章7.6.6節で扱ったパストラバーサルと同じ問題であり、対策も同じである。
from pathlib import Pathfrom urllib.parse import unquote
MEMORY_ROOT = Path("/memories").resolve()
def validate_memory_path(requested: str) -> Path: # ① URLエンコードされた迂回を先に潰す(%2e%2e%2f = ../) decoded = unquote(requested) if decoded != requested: raise PermissionError("パスにURLエンコードは使用できません")
# ② 前置検査。単なる startswith では不十分: # "/memories_backup/secrets" や "/memoriesX" も通ってしまう if not (decoded == "/memories" or decoded.startswith("/memories/")): raise PermissionError("メモリ操作は /memories 配下に限定されています")
# ③ シンボリックリンクを含めて正規化してから範囲を検査する(第7章7.6.6節) resolved = Path(decoded).resolve() if not resolved.is_relative_to(MEMORY_ROOT): raise PermissionError("パスが /memories の外を指しています") return resolvedimport { basename, dirname, join, resolve, sep } from 'node:path'import { realpathSync } from 'node:fs'
const MEMORY_ROOT = resolve('/memories')
function validateMemoryPath(requested: string): string { // ① URLエンコードされた迂回を先に潰す(%2e%2e%2f = ../) // Python の unquote は不正なパーセント記号(例: "100%")をそのまま返すが、 // decodeURIComponent は URIError を投げる。どちらも拒否したい入力なので // 例外は同じ「URLエンコードは使用できません」に寄せる。 // なお decodeURI では %2F が復号されず迂回を見逃すため、必ず // decodeURIComponent を使うこと let decoded: string try { decoded = decodeURIComponent(requested) } catch { throw new Error('パスにURLエンコードは使用できません') } if (decoded !== requested) { throw new Error('パスにURLエンコードは使用できません') }
// ② 前置検査。単なる startsWith では不十分: // "/memories_backup/secrets" や "/memoriesX" も通ってしまう if (!(decoded === '/memories' || decoded.startsWith('/memories/'))) { throw new Error('メモリ操作は /memories 配下に限定されています') }
// ③ シンボリックリンクを含めて正規化してから範囲を検査する(第7章7.6.6節) // Python の Path.resolve() はシンボリックリンクも辿るが、node:path の // resolve() は文字列を正規化するだけ。実体を得るには fs の realpath が要る const resolved = realpathAllowingMissing(resolve(decoded)) // Path.is_relative_to に相当する。ここでも startsWith だけでは // "/memoriesX" を通してしまうため、区切り文字まで含めて比較する if (resolved !== MEMORY_ROOT && !resolved.startsWith(MEMORY_ROOT + sep)) { throw new Error('パスが /memories の外を指しています') } return resolved}
/** * realpath は存在しないパスに ENOENT を返す。Python の Path.resolve() は * 未作成のパスでも解決できるため、存在する最上位の祖先まで遡って解決し、 * 残りを繋ぎ直すことで同じ挙動にする。 */function realpathAllowingMissing(target: string): string { let current = target const tail: string[] = [] for (;;) { try { return join(realpathSync(current), ...[...tail].reverse()) } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e const parent = dirname(current) if (parent === current) return target // ルートまで遡っても存在しなかった tail.push(basename(current)) current = parent } }}3段階すべてが必要である。②だけでは /memories/../etc/passwd を通し、③だけでは(実害は防げるものの)/memories_backup のような紛らわしい入力を早期に弾けない。そして①がないと、%2e%2e%2f によって②と③の両方を迂回されうる。
8.4.5 セッションをまたぐ作業パターン
Section titled “8.4.5 セッションをまたぐ作業パターン”長時間タスクをセッションをまたいで継続する場合、次のパターンが有効である。
第6章6.5.5節で扱った「進捗の外部化」の指示は、まさにこのパターンを実現するためのものである。各セッションの終了時に進捗ログを更新し、次のセッションはそれを読んで再開する。メモリが復旧機構として機能する。
8.5 エピソード記憶と意味記憶
Section titled “8.5 エピソード記憶と意味記憶”8.5.1 区別
Section titled “8.5.1 区別”認知科学からの分類だが、エージェントの設計でも実用的である。
| エピソード記憶(Episodic) | 意味記憶(Semantic) | |
|---|---|---|
| 内容 | 出来事の記録 | 知識の抽象 |
| 例 | 「2026年6月14日、田中さんが決済APIの障害について質問した。ログのタイムアウト設定が原因と判明した」 | 「決済APIのタイムアウトは30秒に設定されている」 |
| 時制 | 特定の時点に紐づく | 時点に依存しない |
| 検索の仕方 | 「似た状況」で引く | 「事実」として引く |
| 蓄積の仕方 | 起きたことをそのまま記録 | 複数のエピソードから抽出・統合 |
エピソード記憶は「経験」であり、意味記憶は「学習」である。
8.5.2 実装上の意味
Section titled “8.5.2 実装上の意味”この区別が実装に与える示唆は明快である。
エピソード記憶はそのまま蓄積し、ベクトル検索で引く。 「以前これに似た問題があったか」を探すのに適している。
意味記憶は抽出・統合が必要で、構造化して保持する。 同じ事実が複数のエピソードに現れたら、1つの知識にまとめる。矛盾する情報が来たら、新しいほうで更新する。
def consolidate(user_id: str) -> None: """エピソード記憶から意味記憶を抽出する(定期実行)。
人間の睡眠中の記憶固定化に相当する処理。 エピソードを溜めっぱなしにすると検索精度が落ちるため、 繰り返し現れるパターンを抽象化して意味記憶に昇格させる。 """ recent = fetch_episodes(user_id, since=days_ago(7)) if len(recent) < 5: return
extracted = llm_extract_facts(recent) # LLMに抽象化させる for fact in extracted: existing = find_semantic(user_id, fact.key) if existing and existing.value != fact.value: # 矛盾: 新しい情報で更新し、旧値は履歴として残す supersede(existing, fact) else: upsert_semantic(user_id, fact)// Python 版は fact.key / fact.value をそのまま参照している。// TypeScript では形を明示する必要があるので、抽出結果の型を先に定めるinterface SemanticFact { key: string value: string}
/** * エピソード記憶から意味記憶を抽出する(定期実行)。 * * 人間の睡眠中の記憶固定化に相当する処理。 * エピソードを溜めっぱなしにすると検索精度が落ちるため、 * 繰り返し現れるパターンを抽象化して意味記憶に昇格させる。 */function consolidate(userId: string): void { const recent = fetchEpisodes(userId, daysAgo(7)) if (recent.length < 5) { return }
const extracted = llmExtractFacts(recent) // LLMに抽象化させる for (const fact of extracted) { const existing = findSemantic(userId, fact.key) if (existing && existing.value !== fact.value) { // 矛盾: 新しい情報で更新し、旧値は履歴として残す supersede(existing, fact) } else { upsertSemantic(userId, fact) } }}8.5.3 何を記憶するかの判断
Section titled “8.5.3 何を記憶するかの判断”すべてを記憶してはならない。 記憶が増えるほど検索精度は落ち、無関係な記憶が想起される確率が上がる。
記憶すべきものの基準を挙げる。
| 記憶する | 記憶しない |
|---|---|
| ユーザーが明示的に述べた嗜好・制約 | 一度きりの雑談 |
| 繰り返し現れる事実 | 一時的な状態(「いま出先です」) |
| 失敗とその原因 | 推論の途中経過 |
| 訂正された内容(「それは違う、正しくは〜」) | すぐに検証できる公開情報 |
| 明示的に「覚えておいて」と言われたこと | 機微な個人情報(法的・倫理的判断が必要) |
最後の項目は特に重要である。記憶すべきでない情報を記憶しない設計は、第13章のプライバシー保護と直結する。健康状態、政治信条、財務情報などは、業務上の必要が明確でない限り記憶すべきではない。
8.6 ユーザープロファイルの保存
Section titled “8.6 ユーザープロファイルの保存”個別化を価値とするエージェントでは、ユーザープロファイルが記憶の中心になる。
8.6.1 構造
Section titled “8.6.1 構造”@dataclassclass UserProfile: user_id: str # 明示的な設定(ユーザーが直接指定したもの) explicit: dict[str, str] # 推測した嗜好(観察から導いたもの。確信度つき) inferred: dict[str, tuple[str, float]] # 過去の重要な出来事への参照 episode_refs: list[str] updated_at: datetimeinterface UserProfile { userId: string // 明示的な設定(ユーザーが直接指定したもの) explicit: Record<string, string> // 推測した嗜好(観察から導いたもの。確信度つき) inferred: Record<string, [string, number]> // 過去の重要な出来事への参照 episodeRefs: string[] updatedAt: Date}明示と推測を分けて保持するのが要点である。この区別があると、次のような扱い分けができる。
- プロンプトへの入れ方を変える(「〜と設定されています」vs「〜を好む傾向があるようです」)
- 矛盾したときに明示を優先する
- ユーザーに「私についてどう記憶しているか」を提示するとき、推測部分を訂正可能にする
8.6.2 プロンプトへの注入
Section titled “8.6.2 プロンプトへの注入”プロファイルは、システムプロンプトの後段に注入する。
def build_system_prompt(base: str, profile: UserProfile) -> list[dict]: """キャッシュを効かせるため、固定部分と可変部分を分ける(第2章2.2.2節)。""" blocks = [ { "type": "text", "text": base, # 全ユーザー共通・不変 "cache_control": {"type": "ephemeral"}, # ← ここまでをキャッシュ } ] if lines := render_profile(profile): blocks.append({"type": "text", "text": lines}) # ユーザーごとに変わる return blocks
def render_profile(profile: UserProfile) -> str: parts = [] if profile.explicit: parts.append("<user_settings>\n" + "\n".join( f"- {k}: {v}" for k, v in profile.explicit.items()) + "\n</user_settings>") if profile.inferred: parts.append("<observed_preferences>\n" + "\n".join( f"- {k}: {v}(確信度 {c:.0%})" for k, (v, c) in profile.inferred.items() ) + "\n以上は観察から推測したものです。ユーザーの明示的な指示と矛盾する場合は、" "指示を優先してください。\n</observed_preferences>") return "\n\n".join(parts)/** キャッシュを効かせるため、固定部分と可変部分を分ける(第2章2.2.2節)。 */function buildSystemPrompt( base: string, profile: UserProfile): Anthropic.TextBlockParam[] { const blocks: Anthropic.TextBlockParam[] = [ { type: 'text', text: base, // 全ユーザー共通・不変 cache_control: { type: 'ephemeral' }, // ← ここまでをキャッシュ }, ] // JavaScript の if は代入式を宣言できないため、Python の := は変数に分ける const lines = renderProfile(profile) if (lines) { blocks.push({ type: 'text', text: lines }) // ユーザーごとに変わる } return blocks}
function renderProfile(profile: UserProfile): string { const parts: string[] = [] if (Object.keys(profile.explicit).length > 0) { parts.push( '<user_settings>\n' + Object.entries(profile.explicit) .map(([k, v]) => `- ${k}: ${v}`) .join('\n') + '\n</user_settings>' ) } if (Object.keys(profile.inferred).length > 0) { parts.push( '<observed_preferences>\n' + Object.entries(profile.inferred) // Python の書式指定 {c:.0%} に相当するものはないので百分率にして丸める。 // ちょうど 0.5 のときだけ結果が違う(Python は偶数丸め、Math.round は切り上げ) .map(([k, [v, c]]) => `- ${k}: ${v}(確信度 ${Math.round(c * 100)}%)`) .join('\n') + '\n以上は観察から推測したものです。ユーザーの明示的な指示と矛盾する場合は、' + '指示を優先してください。\n</observed_preferences>' ) } return parts.join('\n\n')}共通部分を前に、ユーザー固有部分を後ろに置くことで、プロンプトキャッシュが全ユーザーで共有される。逆順にすると、ユーザーごとにキャッシュが分断されて効果が激減する。
8.6.3 プロファイルの更新
Section titled “8.6.3 プロファイルの更新”更新のタイミングには2つの方式がある。
方式A: 明示的な記憶ツールを与える
{ "name": "remember", "description": ( "ユーザーについて長期的に覚えておくべき情報を記録する。" "ユーザーが明示的に嗜好や制約を述べたとき、" "または訂正を受けたときに使うこと。" "一時的な状態や、その場限りの情報は記録しないこと。" ), "input_schema": { "type": "object", "properties": { "key": {"type": "string", "description": "記憶のキー。例: preferred_language"}, "value": {"type": "string", "description": "記憶する内容"}, "source": { "type": "string", "enum": ["explicit", "inferred"], "description": "ユーザーが直接述べたなら explicit、観察からの推測なら inferred", }, }, "required": ["key", "value", "source"], },}const rememberTool: Anthropic.Tool = { name: 'remember', description: 'ユーザーについて長期的に覚えておくべき情報を記録する。' + 'ユーザーが明示的に嗜好や制約を述べたとき、' + 'または訂正を受けたときに使うこと。' + '一時的な状態や、その場限りの情報は記録しないこと。', input_schema: { type: 'object', properties: { key: { type: 'string', description: '記憶のキー。例: preferred_language' }, value: { type: 'string', description: '記憶する内容' }, source: { type: 'string', enum: ['explicit', 'inferred'], description: 'ユーザーが直接述べたなら explicit、観察からの推測なら inferred', }, }, required: ['key', 'value', 'source'], },}利点は、モデルが「これは覚える価値がある」と判断したものだけが記録されることである。欠点は、モデルが記録し忘れる可能性があること。
方式B: セッション終了後に一括抽出する
会話全体を後処理で分析し、記憶すべき情報を抽出する。利点は取りこぼしが少ないこと。欠点は追加のLLM呼び出しコストと、抽出の精度である。
実務では両方を併用し、方式Aを主とし方式Bで補完する構成が扱いやすい。
8.7 忘却と経年劣化
Section titled “8.7 忘却と経年劣化”8.7.1 なぜ忘れる必要があるのか
Section titled “8.7.1 なぜ忘れる必要があるのか”記憶システムで最も見落とされるのが忘却である。しかしこれは装飾的な機能ではなく、必須の機構である。理由は3つある。
① 記憶は腐る。 「田中さんは東京オフィス勤務」という記憶は、異動すれば誤りになる。古い記憶を保持し続けると、エージェントは自信を持って誤った情報を使う。
② 検索精度が落ちる。 記憶が10万件あるなかから上位5件を選ぶのと、1,000件から選ぶのでは、後者のほうが的確である。無関係な古い記憶が上位に来る「ノイズ」の問題は、記憶数に比例して悪化する。
③ プライバシーとコンプライアンス。 個人データの保持期間には法的な制約がありうる。削除の仕組みがないシステムは、削除要求に応えられない。
8.7.2 忘却の戦略
Section titled “8.7.2 忘却の戦略”具体的な手法を挙げる。
① 時間減衰(Time Decay): 検索スコアに時間の要素を掛ける。新しい記憶ほど想起されやすくする。
import mathfrom datetime import datetime, timezone
HALF_LIFE_DAYS = 90
def decayed_score(similarity: float, entry: MemoryEntry) -> float: """類似度に時間減衰とアクセス頻度を加味した想起スコア。""" age_days = (datetime.now(timezone.utc) - entry.last_accessed_at).days recency = 0.5 ** (age_days / HALF_LIFE_DAYS) # 90日で半減 frequency = math.log1p(entry.access_count) / 5 # よく使う記憶は残りやすい stability = 1.0 if entry.kind == "preference" else 0.7 # 設定は減衰しにくい return similarity * (0.6 + 0.4 * recency) * stability + 0.1 * min(frequency, 1.0)// Python 版の import math / datetime に相当するものは組み込みのため不要
const HALF_LIFE_DAYS = 90const MS_PER_DAY = 24 * 60 * 60 * 1000
/** 類似度に時間減衰とアクセス頻度を加味した想起スコア。 */function decayedScore(similarity: number, entry: MemoryEntry): number { // Python の timedelta.days と同じく、日数は切り捨てる const ageDays = Math.floor((Date.now() - entry.lastAccessedAt.getTime()) / MS_PER_DAY) const recency = 0.5 ** (ageDays / HALF_LIFE_DAYS) // 90日で半減 const frequency = Math.log1p(entry.accessCount) / 5 // よく使う記憶は残りやすい const stability = entry.kind === 'preference' ? 1.0 : 0.7 // 設定は減衰しにくい return similarity * (0.6 + 0.4 * recency) * stability + 0.1 * Math.min(frequency, 1.0)}人間の記憶研究に着想を得た形だが、重要なのは「よく使われる記憶ほど残る」という性質である。
ただしこの性質が正しく働くかどうかは、何をもって「使われた」とみなすかにかかっている。検索でヒットした記憶をすべて touch してしまうと、次のような自己強化ループが起きる。
一度スコアが高くなる → 検索で上位に来る → touch されて さらにスコアが上がる→ 実際には役に立っていなくても、永久に上位に居座るこれを避けるため、8.4.2節では recall(検索)と mark_used(使用実績の更新)を分けた。実際にプロンプトへ注入した記憶だけを更新することで、「役立っている記憶が生き残る」という意図が実際に成立する。さらに厳密にやるなら、最終的な回答に寄与した記憶だけを更新する、という設計も考えられる。
② 確信度の減衰: 推測した嗜好は、確認されないまま時間が経つと確信度を下げる。閾値を下回ったら削除するか、プロンプトに含めなくする。
③ 統合による圧縮: 8.5.2節の consolidate は、忘却の一形態でもある。100件のエピソードを5件の意味記憶に統合すれば、95件は削除できる。
④ 明示的な期限: 記憶に有効期限を持たせる。「来月の出張の準備中」という記憶は、来月を過ぎたら不要である。
⑤ 矛盾による無効化: 新しい情報が古い記憶と矛盾したら、古いほうを無効化する。ただし削除せず「置き換えられた」印をつけて残すと、後から「いつ変わったか」を追える。
8.7.3 ユーザーによる制御
Section titled “8.7.3 ユーザーによる制御”記憶システムには、ユーザーが自分の記憶を確認・修正・削除できる手段を用意すべきである。
- 何を記憶しているかを一覧できる
- 個別の記憶を訂正・削除できる
- すべての記憶を消去できる
- 記憶機能自体を無効化できる
これは倫理的な要請であると同時に、実用的な必要でもある。エージェントが誤った記憶に基づいて振る舞うとき、ユーザーが直接それを直せなければ、問題は永続する。
8.8 メモリシステムの全体設計
Section titled “8.8 メモリシステムの全体設計”これまでの要素を統合すると、次のような構成になる。
設計の順序としては、次のように段階的に導入するのが現実的である。
- まず何もしない。コンテキストに収まるなら記憶システムは不要
- 入口での切り詰め(第7章)を徹底する
- context editing を入れる
- セッションをまたぐ必要が出たら、ファイルベースの記憶(メモリツール)を導入する
- ユーザー個別化が必要なら、構造化プロファイル(SQL)を追加する
- 過去事例からの想起が必要なら、ベクトル記憶を追加する
- 記憶が育ってきたら、忘却と統合の定期処理を入れる
いきなり7を作らないこと。多くのエージェントは3か4で十分である。
8.9 まとめ
Section titled “8.9 まとめ”- LLMはステートレスであり、エージェントの記憶はすべて開発者が実装するものである
- メモリ設計の本質は「いま、この瞬間の推論に何をコンテキストへ入れるか」という取捨選択である
- 記憶は3層(作業記憶・短期記憶・長期記憶)。前2つはコンテキスト管理、最後はストレージと検索の問題であり、解くべき課題が違う
- コンテキストで支配的に膨張するのはツール実行結果であり、その多くは判断後に不要になる
- 短期記憶の管理は4手法: 入口での切り詰め → context editing(削除) → compaction(要約) → 外部への退避。この順で検討する
- context editing も compaction も、いまはどちらもサーバー側の
context_managementで指定する。SDKのcompaction_controlは非推奨 - context editing はプロンプトキャッシュを無効化する。閾値を低くしすぎると逆効果になりうる
- 長期記憶の実装は3方式。ベクトルDBは意味的想起、SQLは構造化データ、ファイルは自由な作業メモに向く。使い分ける
- ベクトル検索では
user_idによるフィルタを必ず入れる。怠ると他人の記憶が混入する情報漏洩になる - エピソード記憶は経験、意味記憶は学習。エピソードは蓄積して類似検索、意味記憶は抽出・統合して構造化する
- 記憶は明示(explicit)と推測(inferred)を分けて保持する。推測を事実として扱うと誤りが固定化する
- プロファイルは共通部分の後ろに置く。前に置くとプロンプトキャッシュがユーザーごとに分断される
- 忘却は必須の機構である。 記憶は腐り、検索精度を落とし、コンプライアンス上の負債になる
- 時間減衰・確信度減衰・統合・期限・矛盾による無効化を組み合わせる。よく使われる記憶が生き残る設計にする
- ユーザーが自分の記憶を確認・訂正・削除できる手段を用意する
- メモリシステムは段階的に導入する。多くのエージェントは「切り詰め + context editing」で十分である
問1 次の各情報について、(a) 記憶すべきか、(b) 記憶するならエピソード記憶と意味記憶のどちらか、(c) 保存先(ベクトルDB / SQL / ファイル)はどれか、を判断し理由を述べよ。
- 「レポートは常にPDFで出力して」というユーザーの明示的な指示
- 2026年6月14日に発生した決済APIの障害と、その原因調査の経緯
- 「今週は出張中なので返信が遅れます」というユーザーの発言
- 進行中のリファクタリング作業で、どのファイルまで完了したかの記録
- ユーザーが過去5回とも午前中に問い合わせている、という観察
問2
あるエージェントが、context editing を trigger: {"type": "input_tokens", "value": 20000}、keep: {"type": "tool_uses", "value": 2} で設定したところ、コストがかえって増加した。原因として考えられることを第2章の内容を踏まえて説明し、設定の改善案を示せ。
問3
8.7.2節の decayed_score について答えよ。
(a) HALF_LIFE_DAYS = 90 を 7 に変更すると、エージェントの振る舞いはどう変わるか。利点と欠点を述べよ
(b) stability が preference で 1.0、それ以外で 0.7 になっている。この設計の意図を説明せよ
(c) この関数には、新しく作られたばかりで一度も使われていない記憶が不利になる問題がある。どう改善できるか
問4 カスタマーサポート用のエージェントを設計する。1人の担当者が1日に50件の問い合わせを処理し、エージェントは過去の対応事例を参照して回答案を作る。
(a) 8.8節の7段階のうち、どこまで実装すべきか。理由とともに述べよ (b) 顧客の氏名・連絡先・問い合わせ内容を記憶する際、8.5.3節と第13章の観点から、どのような配慮が必要か (c) 「3か月前に同じ顧客から似た問い合わせがあった」を検出するには、どの方式が適するか
問5 あるエージェントで、ユーザーAへの応答にユーザーBの情報が混入する事故が発生した。8.4.2節を参考に、原因として考えられる実装上の欠陥を2つ挙げ、それぞれの対策を述べよ。また、この種の事故を事前に検出するテストをどう設計するか。
参考文献・出典
Section titled “参考文献・出典”| 出典 | 内容 | 参照日 |
|---|---|---|
| Anthropic — Memory Tool | メモリツールのコマンド体系、/memories の規約、パストラバーサル対策、複数セッションのパターン |
2026-07-29 |
| Anthropic — Context Editing | context editing と compaction の設定パラメータ、発動条件、プロンプトキャッシュとの相互作用 | 2026-07-29 |
| Anthropic — Pricing | キャッシュ無効化のコスト影響の算定根拠 | 2026-07-29 |
| roadmap.sh — AI Agents Roadmap | 章構成の基準、短期/長期・エピソード/意味記憶の分類 | 2026-07-29 |
次章予告: 第9章ではエージェントアーキテクチャを扱う。ReAct、Planner-Executor、DAGといった代表的な構成パターンと、Chain-of-Thought / Tree-of-Thought による推論の構造化を取り上げる。さらに、ツール連携の標準規格である MCP(Model Context Protocol) も扱う。本書で最も長い章のひとつになる。
Built with Astro ・ Deployed on Cloudflare Pages
© 2026 watakumi — made with 💜 & ☕ ・watakumi.page