コンテンツにスキップ

記憶の多層モデル

「LLM が前の会話を覚えていてくれる」と感じるとき、その裏ではぜんぜん違う複数の仕組みが働いている。ここでは代表的な 4 つのレイヤーを整理する。これを知っておくと「さっきの話を覚えていない」のようなトラブルの原因を素早く切り分けられる。

TL;DR

# レイヤー 誰が持っているか 永続性 容量
L1 モデル重み プロバイダ (学習時) 永続 学習データに含まれる事実
L2 プロンプトキャッシュ (TTL) プロバイダ (推論時) 数分〜数時間 コンテキストウィンドウ Anthropic の prompt caching, OpenAI の cached tokens
L3 コンテキスト注入 (毎ターン再送) クライアント側 その呼び出しだけ コンテキストウィンドウ LangChain の messages 配列
L4 外部ストレージ (長期記憶) アプリ / DB 永続 ほぼ無制限 Vector DB, RDB, KV Store

LLM 自体は基本的に記憶を持たない。会話が続いているように見えるのは、ほぼ L3 (クライアントが履歴を再送している) + ときどき L2 (プロバイダがキャッシュを効かせている) の組み合わせ。L4 は「その会話が終わった後にも持ち越したい事実」のためのもの。

L1: モデル重みによる「知識」

LLM は学習時に読んだデータをパラメータの中に圧縮して焼き込んでおり、推論時にはそれを取り出す。これは「記憶」というより「訓練で覚えた事実」で、以下の性質を持つ:

  • 更新できない (ファインチューニング or RAG で補うしかない)
  • 学習カットオフ以降の情報は知らない
  • 入力に依らず常に同じ事実を返す

これを狭義の「記憶」に含めるかは流儀次第だが、他のレイヤーと混同されがちなので最初に切り分けておく。

L2: プロンプトキャッシュ (プロバイダ側 TTL)

OpenAI / Anthropic / Google は、同じプレフィックスを持つプロンプトを短時間内に繰り返し送った場合に、プロバイダ側で中間結果をキャッシュする機能を提供している。

  • Anthropic: prompt_caching で明示的に cache_control をつけた範囲がキャッシュ対象。TTL は数分 (通常 5 分) 〜 1 時間 (extended)
  • OpenAI: prompt_caching は自動で効く。システムプロンプトや長いコンテキストが反復使用されていると課金されたトークン数が減る
  • Google (Gemini): Context Caching API で明示的に cached content を作り、TTL (デフォルト 1 時間) を指定

特徴

  • プロバイダが実装していないと効かない。ローカル推論エンジン (Ollama 等) には無い
  • 通常 TTL は短い (数分〜1 時間)。セッション間をまたぐ永続記憶ではない
  • クライアントには透過的 — リクエストの中身は変えずに、課金・レイテンシが軽くなる効果を得る
  • 内容は保持されているが、クライアントからは読み出せない (読めるのは LLM の生成結果だけ)

代表的な使い所

  • 長い system prompt + few-shot examples を持つエージェントで、各呼び出しの前半プレフィックスをキャッシュ
  • RAG でコンテキスト文書を数ターン連続で使う際の節約
  • コード補助で同じコードベースを繰り返し参照するケース

L3: コンテキスト注入 (毎ターン再送) ← 一番「記憶っぽく見える」もの

LLM が会話を覚えているように見える実体は、ほぼ 100% これ。クライアント側が過去のやり取り (messages 配列) を丸ごと毎ターン再送しているだけで、LLM にとっては毎回が「初めての会話」。

仕組み

agent-demo のチャットモード (examples/agent-demo/src/agent-chat.ts) を見ると分かる:

let messages: any[] = [];

while (true) {
  const userInput = await rl.question("you> ");
  messages = [...messages, { role: "user", content: userInput }];

  const result = await agent.invoke(
    { messages },  // ← 毎ターン全履歴を渡す
    ...
  );
  messages = result.messages;  // ← user + assistant + tool 全部を次ターンへ
}
  • messages 配列に user / assistant / tool の発話が時系列で蓄積される
  • 毎回の agent.invoke 呼び出しでこの配列を丸ごと LLM に送る
  • LLM は受け取った履歴を「文脈」として読んで次の発話を生成する
  • ターンごとに result.messagesmessages に代入し直して履歴を更新する

特徴

  • 完全にクライアント側の責任。LLM プロバイダに記憶が残っているわけではない
  • クライアントがプロセスを再起動すると消える (プロセス内変数に持っているだけの場合)
  • 履歴がコンテキストウィンドウを超えたら切り捨てるか要約が必要 — ここが長期記憶問題の入り口
  • LLM から見ると「長い prompt を渡された」だけで、前回と今回の区別は無い

コンテキストウィンドウとのせめぎ合い

会話が伸びるほど messages が太っていき、最終的にモデルのコンテキストウィンドウを超える。対処の王道は:

  1. スライディングウィンドウ: 古いメッセージを先頭から捨てる
  2. 要約圧縮: 古い履歴を別 LLM 呼び出しで短く要約して差し替える
  3. 重要度スコアリング: 残すべき発話を選別する
  4. 外部化: 履歴を L4 に退避し、必要なときだけ retrieval で取り戻す

L4: 外部ストレージ (長期記憶)

会話が終わった後も残したい事実は、アプリ側で DB に書き出すしかない。LLM は自発的に外部書き込みできないので、ツールまたはアプリロジックが介在する。

代表的な実装パターン

実装 用途 永続性
Vector DB (Qdrant / Weaviate / pgvector 等) 「過去のやり取りから意味的に似たものを引いてくる」長期記憶 永続
RDB / KV Store (Postgres / Redis 等) ユーザ設定、プロファイル、構造化された事実 永続
Graph DB (Neo4j 等) 人物 / 事物 / 関係性をグラフとして持つ 永続
File-based Memory (Mem0, Letta 等) エージェント用の階層型メモリフレームワーク 永続

取り出し方は 2 種類

  1. 毎ターン読む (context として L3 に注入)
  2. アプリがユーザ ID / セッション ID でメモリを取り出し、system prompt の頭に挿入
  3. 例: "ユーザの好みは和食。最近の話題は量子コンピュータ。"
  4. ツール経由で LLM に検索させる (RAG パターン)
  5. search_memory(query) のようなツールを提供し、LLM が必要に応じて自分で呼ぶ
  6. 情報量が多いときに有利

書き込みも同じく 2 種類

  1. 毎ターン自動保存: アプリが「この会話から重要そうなもの」を抽出して DB に入れる
  2. ツール経由: save_memory(content) のようなツールを提供して LLM に決めさせる

4 層の関係図

[L1: モデル重み]         ── 訓練済み、変更不可
[L2: プロバイダキャッシュ] ── 一時的、クライアントからは不可視
[L3: コンテキスト注入]    ── クライアントが毎ターン全履歴を再送 ← 「会話記憶」の正体
[L4: 外部ストレージ]      ── セッションをまたぐ永続記憶 (Vector DB 等)

上の層ほど「無料 / 自動」だが短命・不可逆で、下の層ほど「手動・コスト」がかかるが永続・制御可能。

実装上の勘違いポイント

ありがちな誤解 実際
「LLM が前回の会話を覚えてくれる」 覚えない。クライアントが履歴を毎回再送している (L3)
「会話が長くなっても全部覚えてくれる」 コンテキストウィンドウを超えたらクライアント側で捨てるか圧縮する必要がある
「プロンプトキャッシュが記憶の代わりになる」 TTL が短い (数分) ので、永続記憶としては使えない
「Vector DB を繋げば勝手に思い出してくれる」 アプリ or ツール経由で明示的に読みに行かないと反映されない
sessionId を渡せば記憶される」 Langfuse 等のトレース上で同じセッションとしてグループ化されるだけ。LLM の入力には影響しない

agent-demo との対応

本リポジトリの examples/agent-demo/ は、L3 のみで構成されている (外部ストレージは無し):

  • agent-chat.tsmessages 配列を保持し、毎ターン agent.invoke({ messages }) で全履歴を渡す
  • プロセスを再起動すると記憶はゼロに戻る
  • L4 を追加するなら search_memory / save_memory ツールを tools.ts に足して、Qdrant (既にローカルで動いている) にベクトル保存するのが自然な拡張

さらに深掘りするなら

  • Anthropic の Prompt Caching ドキュメント (L2 の具体)
  • MemGPT / Letta の論文 — 階層型メモリの実装例
  • Mem0 — 商用オープンソースの memory framework
  • LangGraph の MemorySaver / CheckpointSaver — 会話ステートの永続化