AIエージェントの記憶設計 — 5つの保持先と1つの検索面

生成AIは、こちらが何も渡さなければ前回の会話を覚えていない。だから長期で使うエージェントには、記憶を外側に作る必要がある。私の環境では記憶を5つの保持先に分け、そのうち3つを1つのベクトル検索面に統合した。合計21,883件、うち17,264件(78.9%)がベクトル化済みである。

本記事では、その実装を実測値とともに開示し、保持と取り出しをどう分けているかを書く。

本記事の数値はすべて 2026-10-25 時点の実測値である。記憶基盤は稼働中であり、会話や閲覧のたびに件数は増える。したがって数値は「その瞬間のスナップショット」として読んでほしい。実際、本記事を書いている間にも会話履歴は1件増え、作業用ファイルは1件増えた。

目次

結論 — 記憶は「保持」と「取り出し」を分けて設計する

先に結論を述べる。記憶の設計で重要なのは、何を保存するかではなく、どう取り出すかである。

保存だけを考えると、ファイルでもDBでも置けばよい。しかし実際にエージェントを動かすと問題になるのは「過去のどの断片を、今のこの判断に間に合わせるか」である。私の環境では、この問いに対して次のように答えた。

  1. 保持先を5系統に分ける(会話履歴/.md/ライフログ/独自DB/タスク管理)
  2. 意味の近さで引くもの(会話・閲覧・記事)だけをベクトル化し、1つの検索面に統合する
  3. 正確さが要るもの(会計・株・作業構造)はベクトル化しない。SQLで厳密に引く
  4. ルールと手順(.md)は毎回読み直す。ベクトル化せず、プロンプトに直接注入する
  5. 未完了の記憶は時間で起動させる。タスク管理の期日が来たら、過去の自分が書いた指示を能動的に呼び戻す

この5点が、後述する他社ツールとの違いの実体である。

なぜAIエージェントに記憶の設計が必要なのか

理由は3つある。

第一に、LLMはステートレスである。API を1回呼ぶたびに、モデルは前の会話を忘れる。会話が続いて見えるのは、毎回こちらが履歴を送り直しているからにすぎない。

第二に、コンテキストウィンドウは有限である。何でも詰め込めばよいわけではない。私のループエンジンはメッセージ数が120を超えると、古いツール実行結果を「トークン制限保護のため圧縮済」というプレースホルダーに置換する。つまり古い記憶は意識的に捨てている。捨てた記憶を必要なときに甦らせる仕組みが、別に要る。

第三に、記憶の性質が違う。会計の金額を「意味が近い」で引いたら事故になる。逆に「先月見たあの記事」は、タイトルを正確に覚えていなくても意味で引ける必要がある。同じ「記憶」という言葉で括っているが、求められる検索方式は正反対である。

AIエージェントの記憶設計の全体像。意味で引きたい3系統(会話履歴・LifeLog・記事本文)がベクトル化されて1つの検索面に統合され、正確さが要る記憶と毎回読む記憶は検索面に載せないことを示した図。

図1: 記憶の全体像 — 5つの保持先と1つの検索面(実測 2026-10-25 / 総件数 21,883件・うちベクトル化 17,264件=78.9%)

記憶の全体像 — 5系統を一枚の表で俯瞰する

まず全体を表で示す。これが本記事の骨格である。

# 記憶の種類 保持先 実測件数 取り出し方 寿命
1 会話履歴 MongoDB(表示用)+ PostgreSQL chat_turns 7,864件(7,715件ベクトル化) 直近はプロンプト注入/過去はベクトル検索 永続
2 ルール・手順・成果物 .md ファイル(git 管理) 1,208件 / 22,396,267 bytes 毎ループ全読み(JIT) 永続
3 Web閲覧履歴(LifeLog) PostgreSQL input_logs 6,827件(2,208件ベクトル化) ベクトル検索+キーワード一致 永続
4 会計・株・利用ログ PostgreSQL 22テーブル + SQLite 22テーブル SQL(厳密一致・集計) 永続
5 タスク管理 OpenProject(API v3) 695件(新規220/進行中30/保留21/完了424) ステータス・期日で検索/期日到来で自動復帰 永続(状態を持つ)

5つの保持先を『取り出し方』で4つに分ける図。意味で引く(ベクトル検索)、厳密に引く(SQL)、毎回読む(ルールと手順のJIT注入)、向こうから来る(期日到来で自動復帰)の4列に整理し、保存形式ではなく取り出し方で置き場所が決まることを示している。

図2: 5つの保持先を「取り出し方」で4つに分ける(実測 2026-10-25)

この表から読み取れることが4つある。

  • ベクトル化しているのは5系統のうち3系統だけである。全部をベクトル化してはいない。
  • ベクトル化率が系統ごとに大きく違う。chat_turns は98.1%だが、input_logs は32.3%に留まる。この差は後述する弱点である。
  • .md は検索していない。毎回全部読んでいる。ファイルサイズが22.4MBあるので、全部読むのは無理に見えるが、実際に読むのは prompts/ 5件と agents/*/skill.md 15件、knowledge/*.md 2件という小さな核だけである。
  • 5番目だけは「引かない」。期日が来れば、こちらが検索しなくても向こうから出てくる。これは他の4系統に無い性質である。

パターン1: 会話履歴 — 「捨てる前提」で保持し、ベクトルで甦らせる

会話履歴は二重に持っている。

会話履歴の二重化を示した図。左に LibreChat(MongoDB)=会話の表示用の「正」(スレッド構造・UI表示・人間が読み返す場所)、右に broker(PostgreSQL)=機械が引く「正」(chat_turns テーブル・user_content/model_content・embedding 3072次元・request_id 等)を置き、両者を save-turn の非同期POST(応答を返したあとに保存=保存待ちで応答を遅らせない)で結んでいる。

図3: 会話履歴の二重化 — 表示の「正」(LibreChat / MongoDB)と検索の「正」(broker / PostgreSQL)を、save-turn の非同期POSTで結ぶ(実測 2026-10-25 / chat_turns 7,864件・うちベクトル化 7,715件=98.1%)

LibreChat 側の MongoDB が「人間が読み返す正」であり、broker 側の chat_turns が「機械が引く正」である。broker は応答を返したあと、/save-turn へ非同期でPOSTし、その中で Embedding を付与する。非同期にしているのは、記憶の保存待ちで応答が遅くなるのを避けるためである。

現在の実測値は次のとおり。

  • chat_turns … 7,864件(うち embedding が入っているもの 7,715件 = 98.1%)
  • conversation_summaries … 1,104件(会話単位の要約)

直近はプロンプト、過去はベクトル

コンテキストには直近の履歴だけを入れる。120メッセージを超えたら古いツール結果から圧縮する。つまり直近の文脈は「送る」、遠い過去は「引く」という分担である。

これは人間の会話に近い。昨日の会話は覚えているが、3か月前の会話は話題に出たときに思い出す。全部を常に意識しているわけではない。

パターン2: .md ファイル — ルールと手順を毎ループ読み直す記憶

.md は「記憶」というよりOSのカーネルパラメータに近い。エージェントの振る舞いを定義するファイル群であり、書き換えると次のループから挙動が変わる。

プロンプト合成のしくみを示した図。base_system_prompt.md(全ドメイン共通の絶対ルール)、openproject_workflow.md(起票・検収・クローズの手順)、preference_ledger.md(選好台帳)、agents/domain/skill.md(ドメイン固有の手順書)を、毎リクエストごとにディスクから読み直してシステムプロンプトへ合成し、キャッシュしないことを示している。

図4: プロンプト合成のしくみ — 4層の .md を毎リクエスト読み直して合成する(キャッシュなし)

実測した .md の内訳は次のとおり。

階層 役割 実測件数
prompts/ 全ドメイン共通ルール(絶対ルール・起票手順・選好台帳) 5件
agents/*/skill.md ドメイン固有の手順書(インフラ・会計・記事化など) 15件
agents/*/knowledge/*.md ドメイン固有の知識 2件
workspace/ 配下 全体 レポート・原稿・手順書・調査結果 1,208件 / 22,396,267 bytes

なぜベクトル化しないのか

ルールと手順は毎回全文が必要である。「起票前に確認する」というルールを、意味の近さで引いて一部だけ適用したら危険である。だから検索対象にせず、無条件にプロンプトへ注入する。

代わりに、サイズを小さく保つことで成立させている。22.4MBのうち、毎回読むのは数十KBの核だけである。残りは必要なときに read_file で読む。

この設計の効き目

ファイルを書き換えると、プロセスを再起動しなくても次のループから新しいルールで動く。実際、記事化作業の途中で「下書きはアーティファクトで出力する」というルールを追記すると、その回のバッチから出力されるようになった。記憶を書き換えることが、そのまま行動の修正になる。

パターン3: LifeLog(Web閲覧履歴)— 見たものを全部ベクトル化する

私のスマートフォンとPCの閲覧履歴は、自動でサーバへ送られ、本文抽出とベクトル化を経て蓄積される。これを LifeLog と呼んでいる。

LifeLog の取り込みパイプラインを示した図。スマホやPCから URL とタイトルを送信し、lifelog_api が URL解決・ページネーション検出・スクレイピング・価値判定・input_logs への保存・Gemini Embedding によるベクトル化を行い、PostgreSQL vectordb へ格納する流れ。

図5: LifeLog の取り込みパイプライン — 送信からベクトル化までの6段(実測 2026-10-25 / 6,827件・source_type 41種類)

実測の内訳は 6,827件、source_type は41種類に及ぶ。

source_type 件数 性格
Chrome 2,372 PC・スマホのブラウザ閲覧
Google検索(AI回答) 1,937 検索クエリに対するAI生成回答を保存したもの
Recorder 973 録音・記録系アプリ経由
その他 684 —
YouTube 265 動画視聴
Google ニュース 156 RSS経由
SmartNews 44 ニュースアプリ

ここで注目したいのは Google検索(AI回答) の1,937件である。これは「検索して読んだページ」ではなく、検索クエリそのものに対する回答を保存している。つまり閲覧履歴だけでなく、そのとき何を知りたかったかという問いの記録も溜まっている。

弱点はベクトル化率

input_logs は6,827件あるが、embedding が入っているのは 2,208件(32.3%) に留まる。残り4,619件はベクトル化されていない。

これは意図した設計ではなく、未処理のバックログである。ただし救済措置はある。後述する複合スコアは、ベクトルが無い行をキーワード一致だけで拾えるようにしてある(類似度を0.0にフォールバックさせる)。だから「見たことはあるがベクトル化されていない記事」も、語が一致すれば検索に出る。

パターン4: 独自DB — 会計・株・利用ログという構造化記憶

ベクトル化しない記憶の代表が、PostgreSQL の22テーブルである。

分類 テーブル例 なぜベクトル化しないか
会計・認証 freee_tokens, freee_rate_limits トークンやレート制限は1文字の誤りも許されない
投資 stock_portfolio, trade_history, stock_price_log, portfolio_balance_log 金額・株数の集計は厳密一致とSUMが要る
資産管理 asset_allocation_targets, investment_deposits 目標比率との差分計算が主目的
利用ログ llm_usage_logs コスト集計は正確な合計値が要る
記憶基盤 input_logs, chat_turns, memory_chunks, conversation_summaries ここだけがベクトル対象
タスク tasks 状態遷移は厳密一致

さらに SQLite がある。task_registry.db(dev 側 2,813,952 bytes)は再帰的なJSON WBS(作業分解構造)を永続化するためのもので、タスクの親子関係と状態遷移を保持する。

2種類のDBを使い分ける理由

PostgreSQL は「共有される記憶」、SQLite は「単一プロセスの作業記憶」である。会計も株もブラウザ履歴も、複数のプロセスから同時に触る。一方 WBS は、エージェントの1プロセスが再帰探索するための構造なので、ファイル1個のSQLiteで十分であり、そのほうが高速で壊れにくい。

記憶は「全部同じDBに入れる」必要はない。 アクセス形態が違えば、置き場所も変える。

パターン5: OpenProject — タスク管理を「外部記憶」として使う

5つ目は、タスク管理ツールをそのまま記憶として使うパターンである。OpenProject の API v3 に対して、ワークパッケージ(チケット)の作成・検索・更新・コメント追加を行う。

実測した現在のワークパッケージ数は次のとおり。

ステータス 件数
新規 220件
進行中 30件
保留 21件
完了 424件
合計 695件

ベクトルDBや .md と決定的に違うのは、状態を持てることである。

  • 「未完了」という状態を保持できる
  • 期日を持てる
  • 誰が何のために起票したかの履歴が残る
  • コメントで時系列の追記ができる(上書きではなく追記)

つまり 「まだ終わっていないこと」を記憶できる。ベクトル検索面も .md も、原理的に「終わったかどうか」を持たない。終わっていない作業の集合は、別の場所に置く必要がある。

期日が来たら自動で復帰する — 時間トリガーの記憶

ここが本記事で一番書きたかった部分である。タスク管理を記憶として使うと、「引く記憶」ではなく「向こうから出てくる記憶」が作れる。

私は、保留にしたタスクを期日が来たら自動で復帰させる仕組みを動かしている。これは単なるリマインダーではない。復帰したタスクは、過去の自分が書いた指示書を読んで、新しい会話として立ち上がる。

期日が来たら過去の自分が書いた指示で会話が立ち上がる自動復帰の流れ。systemd timer が毎日09:10に起動し、保留チケットを全件取得して期日到来分を抽出し、本文の『復帰時の指示』を読んで会話を新規起動し、状態を戻して人へ1通だけ通知する7段のフロー。

図6: 時間で起動する記憶 — 期日が来たら、過去の自分が書いた指示で会話が立ち上がる(2026-10-23 09:10:58 の実績で確認)

実際の稼働実績は次のとおりである。

  • wp-resume.timer … OnCalendar=*-*-* 09:10:00 Asia/Tokyo / Persistent=true / RandomizedDelaySec=120
  • 直近の復帰 … 2026-10-23 09:10:58 に WP#919 を 保留 → 進行中、期日を 2026-11-01(スプリント末) へ更新し、そのタスク専用の会話を自動起動
  • 二重起動の防止 … 処理済みガードの累計 30件(WP<番号>:<日付> のキーで記録)

なぜこれが「記憶」なのか

検索は、こちらが引くまで何も出てこない。記憶が受け身である。だから「忘れていること」には気づけない。3か月前に自分が「これは11月にやる」と決めたことを、11月の自分が思い出せる保証はどこにもない。

自動復帰は、この非対称を埋める。期日という時間のインデックスで、過去の自分が書いた指示が向こうから差し出される。私はこれを「手紙を書いておく記憶」だと考えている。未来の自分宛てに指示を書いておけば、その日が来たときに、その指示を持った会話が始まる。

そして、この仕組みが成立するには条件がある。WP の本文に ## 復帰時の指示 という節を書いておくことである。ここに「復帰したらまず何を読むか」「何を決めるか」「どこまで進めるか」を書く。ジョブはそれを抽出してプロンプトに埋め込み、会話を起動する。書いていなければ「記載がありません」という一文が入るだけで、ただの空の会話になる。

3つの記憶の役割分担を示した2軸の図。縦軸は正確さが要る方向で、独自DB(金額・集計・厳密一致)とOpenProject(状態・期日・未完了の集合、期日到来で自動復帰)、.md(ルール・手順・成果物、毎回全文)を置き、横軸は曖昧さを許容する方向でベクトル検索面(会話・閲覧・記事)を置いている。

図7: 3つの記憶の役割分担 — 正確さが要る記憶と、曖昧さを許容する検索面

検索面が「意味の近さ」で横に引くのに対し、自動復帰は「時間」で縦に落ちてくる。軸が違うから、両方要る。

ベクトル化の実装 — Gemini Embedding 3072次元 + pgvector

ここからは検索面の中身である。設計したスペックは次のとおり。

項目 設計値
埋め込みモデル gemini-embedding-2-preview
次元数 3,072
列型 halfvec(3072)(memory_chunks / chat_turns)、vector(3072)(input_logs)
インデックス HNSW(halfvec_cosine_ops)
DB PostgreSQL 15 + pgvector(pgvector/pgvector:pg15)
統合ビュー searchables(3テーブルを UNION ALL)

halfvec を使っている点は実務的に効く。3,072次元を単精度(float32)で持つと1件あたり約12KB、半精度(float16)なら約6KBになる。2万件規模でも、インデックス込みで扱いやすいサイズに収まる。

searchables ビュー — 3系統を1つの検索面にする

検索対象はテーブルではなくビューである。定義は次の構造になっている。

searchables =
    input_logs      (Web閲覧=LifeLog)      → source_table='input_logs'
  UNION ALL
    memory_chunks   (記事本文などのチャンク)  → source_table='memory_chunks'
  UNION ALL
    chat_turns      (会話履歴)              → source_table='chat_turns'

列は id / title / content / source_type / embedding / search_date / source_table の7つに揃えてある。出所が違っても同じ形で引けるようにするのが、このビューの役割である。

chat_turns の content は、user_content と model_content を「【私】…【AI応答】…」の形で連結して作る。さらに conversation_summaries を左結合して、会話の話題タイトルを title に入れている。会話の1ターンが、検索可能な1つの文書になるという設計である。

取り出しの設計 — 類似度・日付減衰・キーワード一致の複合スコア

検索は単純なコサイン類似度ではない。3つの要素を合成する。

複合スコアの合成式を示した図。final_score = cosine_similarity × date_weight + keyword_match で、date_weight = (1 - decay) ^ 経過日数(decay 既定値 0.01)。30日後 0.740、180日後 0.164、365日後 0.026 という減衰の実例を示している。

図8: 複合スコアの合成 — コサイン類似度・日付減衰・キーワード一致の3要素

3つの意図はこうである。

  1. コサイン類似度 … 意味の近さ。これが主軸。
  2. 日付減衰 … 同じ意味なら新しいものを優先する。decay_factor は引数で指定でき、既定は0.01(=1日あたり1%減衰)。0にすれば減衰なし、1にすれば即時に無効化される。
  3. キーワード一致 … ベクトルが無い行、または固有名詞のように意味埋め込みでは拾いにくい語を救う。

日本語クエリの扱い

日本語の自然文をそのままLIKEで引いても当たらない。そこでクエリを日本語トークンへ分解する。ストップワード(「の」「こと」等)を除き、CJK文字は2-gram(2文字ずつの断片)に切る。これを text[] としてSQLへ渡し、title + content に対するトークン一致率を計算する。

さらに「最後に」「いつ」「直近」といった日付意図を検出すると、スコア順ではなく新しい順で返すSQLへ切り替える。

つまり検索は1本のSQLではなく、状況に応じて3〜4本のSQLを使い分ける構造になっている。意味で引くのか、語で引くのか、時系列で引くのかを、クエリから判定している。

Claude Code や ChatGPT との違いはどこにあるのか

ここが本記事の主題の一つである。一般に使われているツールと、何が違うのか。

ChatGPT・Claude Code・自前環境の記憶設計の比較表。記憶の単位・所在・横断検索・スコア設計・ルールの反映・未完了の管理・データ量の7観点で並べ、自前環境は全部見えて全部自分で決める方向に振ってあることを示している。

図9: ChatGPT / Claude Code / 自前環境 — 記憶設計のどこが違うか(数値は 2026-10-25 時点の実測値)

観点 ChatGPT の記憶 Claude Code の記憶 私の環境
記憶の単位 ユーザー単位のフラットな事実 プロジェクト内のファイル(CLAUDE.md 等) 5系統(会話/.md/LifeLog/DB/チケット)
記憶の所在 サービス側 手元のリポジトリ すべて手元のサーバ・自分のDB
横断検索 提供機能に依存 主にファイル読み込み 3系統を1つのビューで UNION ALL
スコア設計 非公開 非公開(そもそも検索しない) 自分で定義(類似度×減衰+語一致)
ルールの反映 プロンプト次第 ファイルを読ませる 毎ループJIT読み直し=書き換え即反映
未完了の管理 なし なし OpenProject + 期日到来で自動復帰
データ量 非公開 リポジトリ依存 21,883件 / 17,264件ベクトル化

優劣の話ではなく、設計思想の違いである。ChatGPT の記憶は「サービスが賢く配慮する」方向に最適化されており、利用者は中身を見られない。私の環境は「全部見えて、全部自分で決める」方向に振ってある。

その帰結として、自分でできることが4つ増える。

  1. 記憶の混入を検査できる。 誤った記憶が入っていないかをSQLで数えられる。
  2. 検索の重みを変えられる。 「最近の情報を優先したい」なら decay_factor を上げる。
  3. 記憶を書くことが行動修正になる。 .md を1行直せば、次のループから振る舞いが変わる。
  4. 時間で記憶を起動できる。 期日を入れておけば、その日にタスクが自分から戻ってくる。

特に3点目と4点目は、他ツールでは代替しにくい。プロンプトを書き換えるのではなく、記憶そのものを編集することで挙動を変える設計である。

この設計の弱点と、まだ解決していないこと

正直に書く。この構成には未解決の問題がある。

第一に、input_logs のベクトル化率が32.3%しかない。 6,827件中4,619件が未処理である。閲覧した記事の3分の2は、意味では引けない。キーワード一致で救ってはいるが、表現が違うと同じ内容に到達できない。

第二に、ビュー定義に不整合がある。 searchables の定義を読むと、memory_chunks の行に 'input_logs' という source_type を与えている。そのため source_type で絞り込むと、チャンク由来の行が LifeLog 扱いになる。列の設計は揃っているのに、値の割り当てが揃っていない。これは修正すべき不具合として記録した。

第三に、日付減衰が強い。 既定の decay_factor=0.01 では、1年で重みが0.026倍になる。長期の知識を引くには弱すぎる。だから私は「古い知識は .md に移す」運用で補っている。時間で劣化する記憶(ベクトル)と、劣化しない記憶(ファイル)を分けて持つというのが、現時点の答えである。

第四に、記憶の重複を排除していない。 同じ記事を2回読めば2件入る。URL重複は1時間の時効付きで検査しているが、文章としては別物なので、ベクトル検索では似た行が並ぶ。これは実害が小さいので後回しにしている。

第五に、自動復帰は期日が入っていないと動かない。 実測では保留21件のうち8件が期日未設定であり、これらは原理的に復帰しない。期日を入れるのは人間の運用であり、機械では補えない。自動化できるのは「期日が来たものを起動する」ところまでで、「期日を決める」ところは自動化できていない。ここは現在の設計の穴である。

まとめ — 記憶は「保存」ではなく「取り出しやすさ」で設計する

本記事の内容を整理する。

  • 記憶は5つの保持先に分けた。会話履歴、.md、ライフログ、独自DB、タスク管理である。
  • そのうち3つ(会話・閲覧・記事)だけをベクトル化し、searchables ビューで1つの検索面に統合した。合計21,883件、うち17,264件(78.9%)がベクトル化済みである。
  • 正確さが要る記憶はベクトル化しない。 会計・株・利用ログはSQLで厳密に引き、チケットは状態と期日を持つ外部記憶として使う。
  • ルールと手順は毎ループ読み直す。 キャッシュしないことで、ファイルの書き換えが即座に行動の修正になる。
  • 取り出しは類似度・日付減衰・キーワード一致の複合スコアで行う。重みは引数で変えられる。
  • 未完了の記憶は時間で起動する。 保留タスクの期日が来たら、毎日09:10のジョブが本文の『復帰時の指示』を読んで会話を立ち上げる。これは「引く記憶」ではなく「向こうから出てくる記憶」である。
  • 弱点は、閲覧履歴のベクトル化率(32.3%)、ビューの source_type 不整合、日付減衰の強さ、そして期日未設定のタスクが復帰しないことである。

最後に一点。記憶の設計で最初に決めるべきは、保存形式ではない。「この記憶は、意味で引きたいのか、正確に引きたいのか、未完了として持ち続けたいのか、それとも時間が来たら起こしてほしいのか」である。この問いに答えると、置き場所も検索方式も自動的に決まる。逆に、保存形式から考え始めると、後から検索で困る。

私はこの順序で設計して、ようやく「過去の自分が何を考えていたか」を機械に引かせられるようになった。同時に、未来の自分に宛てて書いておけば、その日が来たときに機械が起こしてくれるようにもなった。記憶は貯めるものではなく、引けるように置き、必要なときに起きるように仕込むものだと考えている。

参考にした一次情報

関連記事

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

金重総合研究所の主席研究員。
子供の頃から研究者を目指し、ライフワークとして日々様々な研究をしています。
経営・マネジメント・金融・DXあたりが本職です。
私を採用したい人、私と一緒に働きたい人、一緒に知識を肥やしていきたい人はぜひお声がけ下さい。

目次