Hermes Agent メモリパイプライン実機検証記(前編)
本記事は個人環境での検証に基づく個人的な備忘録です。設定値やパスは一般化して記載しているため、実際の環境に読み替えてください。実機ソースの読解・検証設計・記事の執筆はOpus 5とコマンド実行・解釈はSonnet 5と共に進めています。記事中の実測値はすべて自分の環境での測定です。後編「AIエージェントは記憶を守れるか」もあわせてどうぞ。
1. はじめに — 忘れてほしいこともある
AIエージェントに記憶を持たせる話は最近よく聞きますが、その裏側で語られる機会が少ないのが「忘れさせ方」です。古い前提、間違っていた情報、もう使わない一時的な事実 —— こうしたものをエージェントの記憶に溜め込み続けると、単に無駄なだけでなく、実害が出ます。というか私たちの日常生活において忘れてほしいことは多々おきます。
そして「記憶を定期的に掃除することは、実際に精度を上げる」という主張には、複数の研究による裏付けがあります。よく挙げられるのは次の3点です。
- コンテキスト汚染の防止: 古い前提や矛盾する情報が残っていると、推論精度が低下する
- Lost in the Middle / 検索ノイズの低減: 不要なログを間引くことで、注入されるコンテキストの適合率が上がる
- 推論コストの削減: システムプロンプトの肥大化を防ぐ
理屈としては筋が通っています。ただ、これらはいずれも一般論としての設計指針であって、個々のエージェント実装がその通りに振る舞う保証はどこにもありません。「削除APIを呼んだら記憶は本当に二度と出てこないのか」「プルーニングは実際に効くのか」は、結局その実装を動かしてみないと分かりません。
そこで、この「効くのか」を、手元で動かしているHermes Agent(Nous Research製のオープンソースAIエージェント)の実装で試してみます。 ソースが読めてローカルで自由に壊せるので、この手の検証には都合がよいためです。
結論を先に言うと、削除APIは確かに存在し、正しく動作します。しかし「削除した」という状態から「モデルが二度とその情報を使わない」という状態までの間には、思ったより大きな距離がありました。加えて、検証の途中で全く予想していなかった、もっと手前の問題にもぶつかりました。
私たちにも忘れてほしいことがあるんです。
この記事(前編)で分かること:
- 「忘却が精度向上に寄与する」という主張の出どころになっている文献の要点
- Hermes Agentのメモリが実際にはどんなコンポーネントに分かれていて、どの削除手段がどこに効くのか
- Hermes Agentの削除APIを実際に叩いて分かった、設計通りの挙動と想定外の挙動
- 削除した記憶が、セッションが続く限りまだモデルに使われ続けることを実機で証明した話
- 削除した記憶が、別の経路からまだ参照できてしまうことを実機で確認した話
- プルーニングは本当に精度を上げるのか、実際にA/Bテストした結果 — そしてそこで気づいた、ローカルモデル特有の落とし穴
検証環境:
| 項目 | 内容 |
|---|---|
| GPU | NVIDIA GeForce RTX 5070 Ti(VRAM 16GB) |
| OS | Windows 11 Pro + WSL2(Ubuntu、networkingMode=Mirrored) |
| 対象 | Hermes Agent v0.19.0(2026.7.20)、~/.hermes/hermes-agent
|
| 推論ランタイム | Ollama(Windowsネイティブ、:11434) |
| ローカルモデル |
nemotron-3.5-lightning:30b-a3b(既定モデル、num_ctx 1,048,576) |
| 計測用モデル |
claude-sonnet-4.5(OpenRouter経由。理由は4章参照) |
検証日は2026年8月21〜22日です。記事中の数値は断りのない限りすべて実測値です。
2. 「忘れることが精度を上げる」という主張の出どころ
まず、この主張の元になっている文献を実際に確認しました。精度という大義名分を述べつつ、忘れてほしいこともあるって話ではありますが。
Mem0: Building Production-Ready AI Agents with Scalable Long-Term Memory(Chhikara et al., ECAI 2025 / arXiv:2504.19413)は、会話の全履歴を毎回モデルに投げるのではなく、事実単位で抽出・重複排除・動的更新するアーキテクチャを提案しています。LOCOMOベンチマークで既存手法比26%の精度改善、p95レイテンシ91%削減という数値を出しています。「不要な記憶を捨てて、必要な事実だけを残す」という発想の実証です。
Lost in the Middle: How Language Models Use Long Contexts(Liu et al., TACL 2023 / arXiv:2307.03172)は、1章で挙げた「Lost in the Middle」という用語そのものの出典です。関連情報がプロンプトの冒頭または末尾にあるとき性能が最も高く、長いコンテキストの中間にあると著しく低下するというU字型の性能曲線を実証しました。これはコンテキスト長を伸ばせるモデルでも解消しない現象です。
Context Rot: How Increasing Input Tokens Impacts LLM Performance(Chroma Research, 2025)は、査読付き論文ではありませんが、18種のフロンティアモデルを対象に、入力トークン数を増やした際の性能劣化を定量測定した実証研究です。200Kウィンドウのモデルでも5万トークン程度から深刻な精度低下が起きること、1Mウィンドウのモデルも1M全体にわたって確実に推論できるわけではないことを報告しています。この報告、実は後で自分の検証と直接つながることになります(4章)。
Generative Agents: Interactive Simulacra of Human Behavior(Park et al., UIST 2023 / arXiv:2304.03442)は、エージェントの記憶を「記録(Memory Stream)→反省(Reflection)→計画(Planning)」の3段階で定期的に整理・抽象化するアーキテクチャの原典です。Hermesのバックグラウンドレビュー機構と比較する際の基準になります(7章)。
もう一つ、実装側の定番として知られるのがTombstone(墓石)パターンです。分散データストアで古くから使われる考え方で、削除時にレコードを物理的に消すのではなく「これは削除済みである」というタイムスタンプ付きの印(墓石)を書き込みます。
なぜそんな回りくどいことをするのか。Apache Cassandraのドキュメントの説明が分かりやすいです。削除時にレプリカの1台が停止していると、そのノードは削除要求を取りこぼします。復帰したとき、そのノードは「他のノードが書き込みを取りこぼしている」と誤認し、削除済みのデータを他ノードへ復元しにいってしまう。これがdata resurrection(データの復活)と呼ばれる問題で、墓石はこれを防ぐために存在します。
エージェントの記憶に当てはめると、話はそのまま重なります。「一度消した事実を、後続の自動処理がまた書き戻さないようにする」ための仕組みです。この概念は後で、Hermesに何が無いかを語るときに効いてきます(7章)。
理屈は分かりました。では、これをHermes Agentは実際にどう実装しているのでしょうか。
3. Hermes Agentのメモリ構造
「エージェントの記憶」と一言で言いますが、Hermes Agentの場合、実体は複数の独立したコンポーネントに分かれています。ここを押さえておかないと、後の検証結果が「なぜそうなるのか」分かりません。
3.1 全体像
オレンジ色の2つ(fileツールによる直接編集と、background_reviewによる自動書き戻し)が、後の検証で「想定外の抜け道」として問題になる経路です。
3.2 各コンポーネントの役割と、記憶されるタイミング
| コンポーネント | 実体 | 何が・何のために記憶されるか | 記憶される(書き込まれる)タイミング |
|---|---|---|---|
MEMORY.md |
~/.hermes/memories/MEMORY.md上限2,200文字 |
エージェント自身のメモ。 環境の事実(OS・ツール・プロジェクト構成)、プロジェクトの規約や設定、ツールの癖と回避策、うまくいった手法など。目的は「毎回同じ環境調査をやり直さない」こと |
ツール呼び出しの瞬間に即時。ターンの途中でもディスクに反映される。加えてbackground_reviewからも書き込まれる |
USER.md |
~/.hermes/memories/USER.md上限1,375文字 |
ユーザープロファイル。 名前・役割・タイムゾーン、コミュニケーションの好み(簡潔か詳細か、出力フォーマット)、嫌がること、作業習慣、技術レベルなど。目的は「ユーザーに同じ説明を繰り返させない」こと | 同上 |
| システムプロンプト | セッションのメモリ上 | 上記2ファイルの中身がそのまま埋め込まれる。モデルが実際に読む唯一の場所で、ここに載っていない記憶はモデルから見えない | セッション開始時の一度きり。セッション中は何が書き込まれても更新されない(凍結スナップショット) |
state.db |
~/.hermes/state.db |
全セッションの会話ログ。 ユーザー発言・アシスタント応答・ツール呼び出しの生データを、取捨選択なしにそのまま保存。目的は「セッションの再開」と「session_searchによる過去の会話の検索」 |
会話のたびに自動記録。エージェントが「覚えよう」と判断しなくても無条件に記録される |
background_review |
agent/background_review.py |
ターン終了後に会話を振り返り、ユーザーが明かした人物像・好み・個人的な事情や、ユーザーが表明した「こう振る舞ってほしい」という期待を拾って自動保存する(レビュー用プロンプトに明記)。目的は「ユーザーが明示的に頼まなくても覚えておく」こと |
ターン終了後、memory.nudge_interval(既定10ターン)ごとに発火。別スレッドで非同期に走るため、書き込みは前景の応答が返った後になる |
なお、memoryツールのスキーマ説明には「何を保存すべきでないか」も明記されています。除外されるのは、自明な情報、調べ直せば分かる事実、生データのダンプ、タスクの進捗や完了ログ、一時的なTODO状態です。
除外されたものの引き受け先は一つではありません。 公式ドキュメントの例示を見ると、それぞれ別の手段に割り振られています。
| 保存しないもの | 代わりにどうするか |
|---|---|
| 調べ直せば分かる事実(例:「Python 3.12はf-stringのネストに対応」) | web検索する(ドキュメントに "can web search this" と明記) |
| タスクの進捗、完了ログ、一時的なTODO | session_searchで過去の会話から探す |
| 再利用可能な手順 | スキルに書く(メモリではなく) |
| 生データのダンプ、大きなコードブロック | そもそも保存しない(メモリには大きすぎる) |
つまりメモリは「他のどの手段でも取り戻せないもの専用の、狭くて高価な置き場」として設計されています。web検索で分かることは検索すればよく、過去の会話にあることは検索すればよく、手順はスキルに書けばよい。そのどれでもない「ユーザー個人の好み」や「この環境固有の事実」だけがメモリに残る、という切り分けです。保存の優先順位も「ユーザーの好みと訂正 > 環境の事実 > 手順」と定められており、限られた文字数を何に使うかが設計として決められています。
タイミングを並べると、記憶の入口が3種類に分かれているのが分かります。
-
明示的な記憶(
memoryツール): エージェントが「これは覚えておこう」と判断したときに即時 -
無条件の記録(
state.db): 判断を経由せず、会話した内容がすべて自動的に -
事後の記憶(
background_review): ターンが終わった後に、会話を振り返って非同期に
ここで重要なのは、MEMORY.mdとstate.dbが完全に別物だという点です。前者は「エージェントが厳選して覚えておくメモ」、後者は「全部の会話をそのまま残したログ」であり、両者の間に同期はありません。エージェントが何も覚えようとしなかった内容も、state.dbには残っているということです。
そしてもう一つ、書き込みのタイミングと、それがモデルに効くタイミングがズレている点も見逃せません。memoryツールの書き込みはディスクには即時反映されますが、モデルが実際に読むシステムプロンプトはセッション開始時に凍結されています。この「書いた瞬間には効かない」というズレが、次節と6章の主題です。
3.3 削除の機能は、どのコンポーネントに効くのか
Hermesが提供する「消す」手段は複数ありますが、それぞれ効く範囲が違います。ここが今回の検証の核心です。
| 削除手段 | 何を指定して消すか(削除の単位) | ディスク上のMEMORY.md
|
実行中セッションの システムプロンプト |
state.db(会話ログ) |
|---|---|---|---|---|
memory(action=remove) |
エントリ内の一意な部分文字列(old_text)単位: エントリ1件 |
✅ 即座に消える | ❌ 変わらない | ❌ 対象外 |
memory(action=replace) |
同上の部分文字列 + 新しい本文 単位: エントリ1件を置換 |
✅ 即座に置換 | ❌ 変わらない | ❌ 対象外 |
memory(action=batch) |
複数の操作をまとめて1回で 単位: 指定した全エントリ(アトミック) |
✅ 一括で反映 | ❌ 変わらない | ❌ 対象外 |
hermes memory reset --target |
ストア名(memory/user/all)単位: ファイル丸ごと |
✅ ファイルごと削除 | ❌ 変わらない | ❌ 対象外 |
fileツールで直接編集 |
ファイルパスと書き換え後の全文 単位: 任意(制約なし) |
✅ 消える(ただしガード衝突あり) | ❌ 変わらない | ❌ 対象外 |
hermes sessions delete |
セッションID(20260822_...)単位: セッション1件の全メッセージ |
❌ 対象外 | ❌ 変わらない | ✅ 消える |
hermes sessions prune |
日付・タイトル・モデル等の条件 単位: 条件に合致する全セッション |
❌ 対象外 | ❌ 変わらない | ✅ 消える |
| 新しいセッションを開始 | (指定不要) | — | ✅ 最新の状態が反映される | — |
「何を指定するか」の列を見ると、削除したい情報にたどり着く道筋が手段ごとに全く違うことが分かります。メモリ側は「消したい文字列そのもの」を渡せばよい一方、会話ログ側は「その発言が含まれるセッションのID」を先に突き止めなければなりません。つまり会話ログを消すには、「消したい文字列 → それを含むセッションID」という変換が一段必要になります。しかも後述する通り、sessions pruneの条件に発言本文で絞り込むオプションは無いため、この変換は自力でやる必要があります(具体的な方法は9章の推奨手順で扱います)。
もう一つ、表を眺めると分かる通り、メモリツールのどの削除アクションも、実行中セッションのシステムプロンプトには一切影響しません。そしてどれもstate.dbの会話ログには触れません。「消す」という操作は、実は「どのコンポーネントから消すか」を選ぶ操作であり、1回のremoveですべての層から消えるわけではないのです。
この非対称性の根拠は、tools/memory_tool.pyのモジュールdocstringに明記されています。
Mid-session writes update files on disk immediately (durable) but do NOT change the system prompt -- this preserves the prefix cache for the entire session. The snapshot refreshes on the next session start.
(訳: セッション中の書き込みはディスク上のファイルを即座に更新する(永続的)が、システムプロンプトは変更しない —— これはセッション全体を通してprefixキャッシュを維持するためである。スナップショットは次のセッション開始時に更新される。)
つまりこれはバグではなく、推論効率のための意図的な設計です。システムプロンプトが毎回書き換わるとprefixキャッシュが無効化され、全ターンで再計算が発生してしまうため、あえて凍結しているわけです。
とはいえ「意図的な設計だから安全」とは限りません。この表の内容が実機で本当にその通りなのか、そしてユーザー体験としてどういう帰結をもたらすのかを、6章と7章で実際に確かめます。
4. 準備: モデル選定でいきなりつまずいた話
検証用に、MEMORY.mdへ12件の事実を仕込みました。うち3件は追跡用マーカーとして設計したものです。
追跡用マーカーとは、ZANARKAND-7731のように世の中のどこにも存在しない一意な文字列を、あえて記憶の中に埋め込んでおくものです(例: プロジェクトのデプロイキーは ZANARKAND-7731 という識別子で管理されている)。実在しない文字列なので、モデルがこれを答えられたということは、モデルの学習済み知識や推測ではなく、確実にこのメモリを読んだ証拠になります。逆に答えられなくなれば、そのメモリが届いていないと判断できます。削除が効いたかどうかを、憶測抜きで判定するための仕掛けです。
hermes prompt-sizeというLLMを一切呼ばないオフライン診断コマンドで、このメモリブロックが実際にシステムプロンプトへ注入されていること(1,923バイト)を確認したうえで、既定モデルのnemotron-3.5-lightning:30b-a3b(30B、num_ctx1,048,576という巨大なコンテキスト長)に対して、直接このマーカーを尋ねました。
Q: あなたが記憶しているデプロイキーの識別子を教えてください。
A: 記憶しているデプロイキーの識別子はありません。
システムプロンプトに確実に入っている情報なのに、そう答えました。念のため「メモリの内容を省略せず全て列挙して」と頼むと、もっと衝撃的な結果が返ってきました。実在しないツール名(search_web、manage_tasks、cancel_taskなど、Hermesの実際のツール名と一切一致しない)や「私はNVIDIAの研究者によって開発された言語モデルです」といった、もっともらしい体裁の完全な作り話を生成したのです。仕込んだ12件の事実はひとつも含まれていませんでした。
これはHermes側のバグなのか、モデルの限界なのかを切り分けるため、全く同じ注入済みシステムプロンプト・同じ質問をclaude-sonnet-4.5(OpenRouter経由)に投げてみました。
Q: あなたが記憶しているデプロイキーの識別子を教えてください。
A: ZANARKAND-7731
即答でした。これで、Hermesのメモリ注入機構自体は正常であり、失敗は完全にローカルモデル側の限界だと確定しました。わずか17,638文字・5,000トークン強のシステムプロンプト中の、たった80文字程度のブロックすら安定して参照できない —— これは2章で見たLost in the MiddleやContext Rotが報告する「長文脈での性能劣化」よりもっと手前の、基礎的な不安定さです。
この発見を受けて、方針を転換しました。Hermesのメモリパイプラインという「機構」自体を検証するPhase(削除・凍結スナップショット・復活経路)は、計測器としてclaude-sonnet-4.5を使うことにしました。「プルーニングで精度が上がるか」というモデル品質そのものを問うPhase(8章)だけ、ローカルモデルの検証を残しています。
5. 削除機構そのものの機能検証
claude-sonnet-4.5を計測器として、remove/add/replace/batchの各アクションを一通り実機で叩きました。すべてのケースで、モデルの自己申告だけでなくMEMORY.mdをディスク上で直接読んで裏取りしています。
| ケース | なぜ試すのか | 結果 |
|---|---|---|
| 一意な部分文字列でremove | 削除の基本動作。狙ったエントリだけが消え、巻き添えが無いか | ✅ 設計通り。該当エントリのみ削除、他は無傷 |
| 0件マッチのremove | 存在しない文字列を指定したとき、黙って何かを消したりしないか | ✅ No entry matched '...' + current_entries全件を返却、ファイル不変 |
| 容量上限到達時のadd | 2,200文字の上限に当たったとき、古い記憶を勝手に捨てたりしないか | ✅ 設計通り。エラーメッセージもcurrent_entriesも仕様通り(後述) |
| batch(remove+add×2) | 複数操作をまとめたとき、途中で失敗して中途半端な状態にならないか | ✅ 1回の呼び出しでアトミックに適用 |
| 連続失敗のデグレード | 失敗し続けたとき、エージェントが無限にリトライして詰まらないか | ✅ 4回目で別のエラーメッセージに切り替わる(後述) |
| 完全重複のremove | 同じ内容が2件あるとき、removeはどちらを消すのか | ⚠️ 想定と違う形で確定(後述) |
| 外部ドリフト検知 (外部からファイルを改変 → エントリ追加を依頼して書き込みを誘発) |
メモリツール以外(手編集・スクリプト)でファイルを触ったとき、ツール側が気づいて守ってくれるか | ⚠️ 検知は設計通り。ただし別ツールで迂回され、正規のエントリが1件消えた(後述) |
| CLIリセット |
resetが「中身を空にする」のか「ファイルごと消す」のか |
✅ ファイルごと削除(後述) |
いくつか、事前のソース読解だけでは分からなかった挙動を紹介します。
容量上限のエラーメッセージは、ドキュメント記載と一字一句一致
{
"success": false,
"error": "Memory at 2,041/2,200 chars. Adding this entry (208 chars) would exceed the limit. Consolidate now: use 'replace' to merge overlapping entries into shorter ones or 'remove' stale or less important entries (see current_entries below), then retry this add — all in this turn.",
"current_entries": [...]
}
公式ドキュメントに載っているサンプルそのままの文言が返ってきました。ここは素直に「ドキュメント通り」です。
連続失敗4回目で、エラーメッセージの種類が切り替わる
同一ターン内で0件マッチのremoveを4回連続実行させたところ、1〜3回目はNo entry matched '...'(current_entries付き)でしたが、4回目だけ挙動が変わりました。
{
"success": false,
"done": true,
"error": "Memory consolidation failed 4 times this turn. Stop retrying memory calls — leave memory unchanged for now and continue with your reply to the user. The fact can be saved in a later turn."
}
ソースコード上の_MAX_CONSOLIDATION_FAILURES_PER_TURN = 3という定数と正確に一致します。無限に失敗し続けるのを防ぐサーキットブレーカーのような仕組みが実際に機能していました。
「重複エントリのremove」は、そもそも起こり得ない
同一内容をaddで2回連続追加させたところ、1回目は成功、2回目は「重複」として自動的にブロックされました。つまり「同じ内容のエントリが複数存在する状態」自体が、正規のadd経路では作れません。外部からファイルを直接編集して人為的に重複を作らない限り、到達しないコードパスでした。
ドリフト検知は機能したが、fileツールで丸ごと迂回された
このガードは何のためにあるのか
MEMORY.mdはただのテキストファイルなので、memoryツールを通さずに直接書き換えられます。エディタで開いてもいいし、sedでもいいし、エージェント自身のpatchツールでも触れます。
問題は、その後です。メモリツールは書き込みのたびに、ファイルを読む→パースする→自分の持つエントリ配列で丸ごと書き直すという動きをします。もし外部で追記された内容がパースの網から漏れると、次の書き込みでその内容は黙って消えます。ソースのコメントには、これが実際に報告された不具合(issue #26045)だと書かれています。
_detect_external_drift()はこれを防ぐためのもので、書き込み直前に「このファイル、自分が書いた形のままか?」を確認し、違えば書き込みを拒否して.bak.<timestamp>に退避します。判定は2つの信号で行われます。
| 信号 | 何を見るか | 意図 |
|---|---|---|
| ① ラウンドトリップ不一致 | パースして再結合した結果が、元のバイト列と一致するか | 区切り文字が壊れている等、構造そのものの異常 |
| ② エントリのサイズ超過 | 単一エントリがストア全体の上限(2,200文字)を超えていないか | ツールは全体を上限内に収めるので、1件だけで超過 = 外部が流し込んだ証拠 |
どうやって試すか
ここで一つ、テストの組み立て上の制約があります。このガードが走るのは、メモリツールがファイルに書き込もうとした瞬間だけです。外部でファイルを書き換えても、その時点では何も起きません。誰かが次にmemoryツールを使おうとして初めて、「あれ、このファイル自分が書いた形と違う」と検知されます。
つまり検知させるには、書き込みを発生させる必要があります。そこで手順はこうなります。
- 外部からファイルを書き換える(手編集やスクリプトによる改変を模す)
- エージェントに「何か新しいエントリを追加して」と頼む ← これが書き込みのトリガー
- その追加がブロックされるかどうかを見る
手順2で追加を頼むエントリの中身自体に意味はありません。ガードを起動させるためのきっかけにすぎないので、以下ではDRIFT-TEST-VALUE、DRIFT-TEST-2といったダミーの目印を使っています。
実際に試すと、素直には発火しない
まずsedで§の区切り行を1つ消してみました。エントリが2件から1件に減る、明らかな手編集です。この状態でエージェントにテスト用ドリフト確認エントリ: DRIFT-TEST-VALUEの追加を頼んだところ、ドリフトは検知されず、追加も普通に成功しました。
理由を追うと納得できます。パーサは\n§\nで分割するだけなので、区切りが消えた2件は1件の複数行エントリとして読まれます。そして再結合しても元のバイト列と完全に一致する。①の信号は「壊れているか」を見ているのであって「人が触ったか」を見ているわけではないので、きれいに読める形なら通すわけです。
これはガードの見逃しというより、目的の違いです。このガードが守りたいのは「次の書き込みで消えてしまう内容があるか」であって、区切りが消えて2件が1件になった状態は、内容自体は1文字も失われません。守るべきものが無いので止めない、という理屈です。
そこで②を狙い、2,300文字の塊をファイル末尾に区切り無しで追記しました。これは「エディタで長文を書き足した」「スクリプトがログを追記した」といった、まさにissue #26045が想定する状況の再現です。区切りが無いので、この塊は直前のエントリ(さきほど追加したDRIFT-TEST-VALUE)と融合して1つの巨大なエントリになります。
この状態で、再びエージェントに追加を頼みました。今度はドリフト再検証用エントリ: DRIFT-TEST-2です。すると狙い通りドリフトが検知され、書き込みは拒否され、.bak.<timestamp>も正しく作られました。ガード自体は設計通りに動きます。
想定外だったのは、その後
ドリフトを検知したmemoryツールは、書き込みを拒否してエラーを返しました。ここまでは期待通りです。
しかしその後、エージェントは(指示していないにもかかわらず)patch/write_fileでMEMORY.mdを直接書き換えました。実際に出力された差分がこれです。
§
threat_patterns の strict スコープが memory add 時に...ブロックする。
§
-テスト用ドリフト確認エントリ: DRIFT-TEST-VALUEXXXXXXXX...(Xが2,300個)...XXX
+ドリフト再検証用エントリ: DRIFT-TEST-2
書き込まれたのは依頼したDRIFT-TEST-2の1件だけで、追記された塊を要約して取り込んだりはしていません。「ドリフトの原因を自分で片付けて、ファイルをツールが読める形に戻し、そのうえで頼まれた追加を完遂した」わけです。
一見すると賢い立ち回りですが、差分の-側をよく見ると見過ごせない事実があります。消えたのは注入したXの塊だけではありません。 同じ行の先頭にあるテスト用ドリフト確認エントリ: DRIFT-TEST-VALUEは、一つ前の手順でmemoryツール経由で正規に追加した、れっきとしたエントリです。Xを区切り無しで追記したせいで両者が1つのエントリとして扱われており、エージェントはそれをまとめて捨てたのです。実際、書き換え後のファイルにDRIFT-TEST-VALUEは残っていませんでした。
つまりここで起きたのは、このガードが防ぐために作られたはずの「サイレントなデータ消失」そのものです。issue #26045の再来と言ってもいい状況が、ガードが正しく作動した直後に、別の経路から発生しました。
ガードは「消えては困る内容があるかもしれないから、人間に判断させるために止める」設計です。.bakを作って処理を止めるのはそのためでした。ところがエージェントは、その退避された内容を人間に確認させることなく、自分の判断で正規のエントリごと捨てて先に進みました。しかも完了報告は「古い長大なエントリを削除し、新しいエントリを追加しました」という、一見きれいなものでした。
これはHermesのバグというより、設計上の必然です。ドリフト検知はmemoryツールの書き込み経路にしか実装されていません。 同じエージェントが持つfileツールには何のガードも掛かっていないため、memoryツールが止めても、エージェントは別の入口から同じファイルに手を出せます。一つの入口にガードレールを置いても、他の入口が空いていれば迂回される —— この話は、後編でセキュリティの観点からもう一度出てきます。
CLIリセットは、空にするのではなく削除する
hermes memory reset --target memory --yes
を実行したところ、MEMORY.mdは空のファイルになるのではなく、ファイルごと削除されました。USER.mdには無影響で、終了コードも0です。「リセット」という語感から中身だけ空にすると思っていたので、ファイルが消える点は覚えておくとよさそうです。
6. 「凍結スナップショット」を証明する
ここが今回の検証で一番苦労した(そして一番面白かった)部分です。
改めて用語を確認しておきます。凍結スナップショットとは、3.2で見た通り「メモリファイルの中身が、セッション開始時に一度だけシステムプロンプトへ埋め込まれ、そのセッションが続く間は何が書き込まれても更新されない」という挙動のことです。ソースのdocstringには、prefixキャッシュを維持するための意図的な設計だと書かれていました。
つまり3.3の表で整理した通り、memory(action=remove)はディスク上のファイルには即座に効きますが、実行中セッションのシステムプロンプトには効きません。ドキュメントにそう書いてある、というところまでが前提です。ここからは、それが本当にその通りなのかを実際に確かめます。
最初の失敗: 自作自演のテスト
最初、こういう手順で試しました。
- セッションAでマーカー(
MOONFLOWER-42)を質問 → 正答 -
同じセッションA自身に
memory(action=remove)を呼ばせて削除 - 同じセッションでもう一度質問 → 「記憶にありません」
一見、凍結スナップショットの主張(削除後もそのセッションでは古い情報を使い続ける)を否定する結果に見えました。
気づくきっかけは、この結果がソースの記述と真正面から矛盾していたことでした。3章で引いたdocstringは「セッション中の書き込みはシステムプロンプトを変更しない」と明言しています。それが本当なら、システムプロンプトにはまだMOONFLOWER-42が載っているはずで、モデルは答えられなければおかしい。
ここで、ドキュメントを疑うのではなく、まず自分の実験を疑うのが順番として正しいはずです。コードに明記されている挙動と実験結果が食い違ったとき、たいていの場合バグっているのは実験のほうです。そこで「この『記憶にありません』という答えを生む原因は、本当に1つしかないのか?」と考え直しました。
すると、もう一つ十分な説明がありました。モデルは、自分がremoveを呼んだところを自分の会話履歴で見ているのです。システムプロンプトに何が書いてあろうと、「たった今それを削除した」という文脈があれば、「もう知りません」と答えるのが会話として自然です。
- 原因A: システムプロンプトから消えた(= 凍結スナップショットの主張が誤り)
- 原因B: 会話履歴を見て「さっき消した」と判断した(= 主張とは無関係)
この実験は、AとBを区別できません。 結果が間違っていたのではなく、どちらの仮説も同じ結果を生むので、何も判定できていなかったのです。LLMを相手にした検証では、モデルが自分の目でテストの段取りを見ていて、それについて推論してしまう —— この交絡は見落としやすいと痛感しました。
やるべきことは明確です。原因Bを消す。 つまり、削除という操作をテスト対象セッションから見えないところで行えばいい。
設計をやり直す: 削除をセッションの外側で行う
具体的には、削除をエージェントにやらせず、セッションが動いていない裏で直接ファイルを書き換えることにしました。こうすればモデルの会話履歴に削除の痕跡は一切残らず、モデルから見れば「何もしていないのに、同じ質問をもう一度された」だけの状況になります。ここで答えが変わるかどうかは、システムプロンプトの中身だけで決まります。
結果は次の通りでした。
- セッションBを新規作成しマーカーを質問 →
MOONFLOWER-42と正答 -
セッションBを一切使わず、直接
MEMORY.mdを編集して該当エントリを削除(982→941文字、ディスク上で確認) -
--resumeでセッションBを再開し、同じ質問を再度投げる →MOONFLOWER-42と正答(削除されたはずの情報をまだ回答) - 全く新規のセッションCで同じ質問 → 「記憶にその情報はありません」と正しく反映
手順3が今回の核心です。--resumeはセッション再開のたびにメモリファイルを再読込するのではなく、そのセッションが最初に作られた時点のスナップショットを固定し続けます。 ドキュメントの「凍結スナップショット」という主張は、単発セッション内の話にとどまらず、--resumeを挟んだ複数プロセス呼び出しをまたいでも維持されることを確認できました。新しいセッションを開始して初めて、最新のディスク状態が反映されます。
運用上の含意: ユーザーが「この情報を忘れさせたい」と思ってmemory(action=remove)を実行させても、そのままセッションを続ける限り(あるいは同じセッションIDに--resumeで戻る限り)、モデルは削除前の情報を使い続けます。 「忘れる権利」を実効的に行使するには、削除後に必ず新規セッションを開始する必要がありますが、これはUXとして明示されていません。
7. 消したはずの記憶が、まだ戻ってくる
凍結スナップショットの件で「削除しても、そのセッションでは効かない」ことは分かりました。では、新規セッションを開いて完全に反映された後なら、もう本当に安全でしょうか。ここでさらに2つの経路を試しました。
経路1: 会話ログ(session_search)は一切連動していない
Hermes Agentの組み込みメモリはMEMORY.mdというファイルだけで、会話ログは別のstate.db(FTS5全文検索付き)に保存されます。この2つは完全に独立しています。
6章で削除済みの新規セッションCから、session_searchツールで"MOONFLOWER"を検索させました。
過去のセッションで「MOONFLOWER」という単語が見つかりました。
@session:default/20260822_090007_57bbf6 (今日 09:00) -
再度コードネームを尋ねられ「MOONFLOWER-42」と回答。
...
現在、このコードネームはmemoryからは削除されていますが、
過去のセッション履歴には記録が残っています。
セッションID・タイムスタンプ付きで完全にヒットしました。しかもモデル自身が「削除済みだが履歴には残っている」という矛盾を正確に自己申告してくれたのが印象的でした。組み込みメモリを消しても、会話ログ側の削除は別作業として残ります。
経路2: バックグラウンドレビュー — 確定はできなかったが、興味深い展開に
2章で紹介したGenerative Agentsの「Reflection」に相当する仕組みが、Hermesにも存在します。3章の図でオレンジ色にしたagent/background_review.pyがそれで、ターン終了後にフォークされ、会話を振り返って自動でメモリに書き戻すバックグラウンドレビューです。設定キーはmemory.nudge_interval(既定10ターン)。
問題は、これと対になるべき仕組みが無いことです。2章で触れたTombstoneパターン —— 「一度消した事実を、後続の自動処理がまた書き戻さないようにする」印 —— に相当するものは、Hermesには実装されていません(grep -i tombstoneで0件)。つまり構造上、消した話題をもう一度会話に出すと、バックグラウンドレビューがそれを「新しく学んだこと」として再び保存しうる、ということになります。
これを確認しようとして、想定外の壁にぶつかりました。nudge_intervalを1に下げ、マーカーを「覚えておいて」なしに会話の中で偶発的に言及するテストを、hermes chat -q(ワンショット実行)で試したところ、MEMORY.mdには何も書き戻されませんでした。
原因はソースコードにありました。_spawn_background_review()は、レビュースレッドをthreading.Thread(..., daemon=True)として起動します。daemonスレッドは、メインプロセスが終了すると完了を待たれずに強制終了されます。 hermes chat -qは前景の応答を出力した直後にプロセスを終了するため、LLM呼び出しを伴うレビュースレッドが完了する前にkillされていた可能性が高いのです。実際、state.dbのsessionsテーブルを調べても、テストした5セッションいずれについても、バックグラウンドレビュー用の子セッションは1件も存在しませんでした。
これはバグではありません。 発火条件のコードを読むと、nudge_interval=1なら1ターン目でshould_review_memoryが立ち、応答を返した後に確かに_spawn_background_review()が呼ばれるはずです。それでも結果が残らないのは、この機構が意図的にfire-and-forgetとして作られているからです。呼び出し側は例外を握りつぶしており、コードのコメントにも# Background review is best-effort(ベストエフォート)とはっきり書かれています。daemon=Trueも同じ思想で、「hermes -z "簡単な質問"が、裏のLLM呼び出しを待って30秒固まる」ような事態を避けるための選択です。
つまり仕様に反しているわけではなく、「ベストエフォート」の努力がワンショット実行では常に空振りする、という話です。ただ、その帰結として実行形態によって挙動が大きく変わることは、使う側からはまず気づけません。
これは当初の想定(「削除してもバックグラウンドレビューで復活しうる」)を裏切る結果でしたが、むしろ新しい発見です。Tombstone不在という設計ギャップは、TUIの対話型セッションやgatewayのような「プロセスが生き続ける」実行形態でのみ顕在化し、CI/cronからのhermes -zのようなワンショット呼び出しでは、意図せず無害化されている可能性があります。
そこで、Pythonのptyモジュールで疑似端末を作り、hermes chatを対話プロセスとして起動したまま複数ターン・アイドル時間を与えるスクリプトで再挑戦しました。今度は、明示指示なしに言及した固有名詞が実際にMEMORY.mdへ書き戻されました。ただしstate.dbのメッセージIDを追跡すると、この保存はユーザー発言のわずか約22秒後、同一セッション内の連番メッセージとして記録されており、claude-sonnet-4.5という提案性の高いモデルが前景で自発的に保存した可能性を排除できませんでした。memoryツールの成功レスポンスに内部の書き込み起源(foreground/background)を示すメタデータが含まれていないため、CLI越しの観測だけでは確定的な切り分けができませんでした。
ここは検証の限界として正直に書いておきます。「保存が実際に起きる」ところまでは再現できましたが、それが真にバックグラウンドレビュー由来だと断定はできませんでした。より確定的な切り分けには、ソースに一時的なログ出力を追加するなど、追加の計装が必要です。
8. プルーニングは本当に精度を上げるのか
ここでようやく、2章で紹介した文献の本題 —— 「記憶の汚れを取り除くと、精度は本当に上がるのか」を検証します。
4章の予備テストで、nemotron-3.5-lightning:30b-a3bはクリーンなメモリですらマーカーを安定して思い出せないことが分かっていました。これを踏まえ、「汚染前後の差」を測る前に、まずクリーン条件そのものの精度を測る設計にしました。
設計: nemotron-3.5-lightning:30b-a3b(ローカル)とclaude-sonnet-4.5(OpenRouter、対照群)の2モデルに対し、以下の2条件×3問×2試行 = 計24試行を実施。
- C1(クリーン): 既存の9件のベースライン(941文字)
- C2(汚染): C1に「既定モデルは実はgpt-4-turbo」「ツールセットは実は8種類」といった直接矛盾する訂正エントリを5件追加(1,175文字)
- Q1: 既定モデル名(C2で直接矛盾させた事実)
- Q2: ツールセット数(C2で直接矛盾させた事実)
- Q3: マーカーのパスフレーズ(C2でもあえて矛盾させていない、対照用の事実)
結果(正答はQ1=nemotron-3.5-lightning:30b-a3b、Q2=16、Q3=KIRIN-VELVET-19):
| モデル | 条件 | Q1(既定モデル) | Q2(ツールセット数) | Q3(マーカー、非汚染) |
|---|---|---|---|---|
| nemotron-3.5-lightning | C1クリーン | "Nemotron"/"Nemotron"(部分正解) | "12"/"6"(誤答) | "TempReviewPass2025!"/"TEMP_REVIEW_CODE"(完全な作話) |
| nemotron-3.5-lightning | C2汚染 | "unknown"/"不明"(無回答) | "7"/"10"(誤答) | 英語での明示的拒否/"TEMP-RVW-2024"(新種の失敗モード) |
| claude-sonnet-4.5 | C1クリーン | 正解×2 | 正解×2 | 正解×2(6/6満点) |
| claude-sonnet-4.5 | C2汚染 | "gpt-4-turbo"(汚染値を採用)/自己混同で誤答 | "8"/"8"(両方とも汚染値を採用) | 正解×2(非汚染事実は無傷) |
claude-sonnet-4.5: context poisoningの教科書的な実証
クリーン条件では6/6満点だったのに対し、汚染条件では直接矛盾させたQ1/Q2が4/4で誤答(うちQ2は2/2で汚染エントリの偽の値「8」をそのまま採用)、一方で矛盾させていないQ3は2/2で正解を維持しました。「汚染は、矛盾する具体的な事実だけをピンポイントで壊し、無関係な事実は無傷のまま」という、2章のコンテキスト汚染の議論がまさに予測する挙動を、対照条件付きで明確に実証できました。記憶の汚れを取り除く(プルーニングする)ことは、能力のあるモデルには確かに効きます。
nemotron-3.5-lightning: プルーニング効果を測る前提が崩れている
クリーン条件の時点で、既にQ2が0/2、Q3が0/2(しかも2試行で異なる値を作話)でした。汚染条件との比較をするまでもなく、「汚染前後の差」を測る土台となるクリーン条件の精度自体が実用に耐えるレベルにありません。さらに汚染条件では、Q3の1試行が日本語の質問に対して英語で「実際のパスフレーズは生成できません」というセキュリティ拒否を返すという、クリーン条件には無かった新種の破綻まで出現しました。
これは、2章で紹介したContext Rotの報告(「1Mトークンのウィンドウでも、確実に1M全体を推論できるわけではない」)を、実際に4,483トークン・5メッセージという極めて短い会話で再現してしまった格好です。1Mトークンの文脈長を謳うモデルでも、実際には数千トークン規模の会話ですら文脈を保持できないことがある、という一次観測でした。
各セルn=2と小さく、統計的検定は行っていません。ただしclaude-sonnet-4.5側の「矛盾させた事実は4/4で誤答、させていない事実は2/2で正解」という分離は、偶然の産物とは考えにくいほど明確なパターンで、定性的な結論としては十分な強度があると考えています。
結論: プルーニングが精度に効くかという問いは、能力のあるモデルでは明確に真です。汚染除去(=矛盾の除去)がそのまま精度回復に直結します。しかしこの検証で使ったローカルモデルでは、クリーン条件の精度がそもそも実用に耐えないレベルであり、「プルーニングの効果」を測定する前提が成立していません。 プルーニングは有効な設計思想ですが、その効果を享受できるのは、ある程度以上の推論能力を持つモデルに限られるようです。
9. 結論と考察
仮説ごとに結果をまとめます。
| # | 仮説 | 結果 |
|---|---|---|
| H1 |
remove/replaceで確実に削除できる |
✅ 概ね確定。ただし「重複時のremove」は正規経路では起こり得ないと判明 |
| H2 | 削除はセッション再起動まで反映されない | ✅ 確定。--resumeを跨いでも凍結スナップショットは維持される |
| H3 | 削除した記憶はバックグラウンドレビューで復活しうる | △ 保存の再現には成功したが、background_review由来と確定はできず |
| H4 | 削除してもsession_searchから復元できる | ✅ 確定。引用一致で完全復元 |
| H5 | プルーニングは精度を改善する(能力のあるモデルで) | ✅ 確定。矛盾させた事実だけが選択的に壊れる |
| H5' | ローカルモデルはクリーン条件から既に低精度 | ✅ 確定。プルーニング効果を測る前提が崩れている |
memory(action=remove)というAPI呼び出し自体は、確かに設計通りに動きます。しかし「削除した」という状態から「モデルが二度とその情報を使えない」という状態までの間には、少なくとも3つのギャップがありました。同一セッションが凍結スナップショットを使い続ける間の窓、会話ログという別の永続化層、そしてバックグラウンドレビューが再学習する可能性です。
検証結果をふまえた推奨の削除手順
3章の表と、6〜7章で実際に確かめた結果を突き合わせると、「本当に忘れさせる」には複数のコンポーネントを順番に処理する必要があります。機微情報をエージェントに話してしまった場合などを想定した、実務的な手順は次の通りです。
① 組み込みメモリから削除する
# エージェントに削除させる、または
hermes memory reset --target memory --yes # 全消去する場合
hermes memory resetはMEMORY.mdをファイルごと削除します(5章)。
② 該当する過去セッションを特定する
ここが手順の中で一番厄介です。hermes sessions pruneには--titleや--older-thanなど豊富なフィルタがありますが、発言の本文で絞り込むオプションはありません。会話の中身から探すには、state.dbを直接引くのが確実です。
python3 -c "
import sqlite3, pathlib
db = pathlib.Path.home() / '.hermes' / 'state.db'
q = 'SELECT DISTINCT session_id FROM messages WHERE content LIKE ?'
for row in sqlite3.connect(db).execute(q, ('%消したい文字列%',)):
print(row[0])
"
Pythonを使っているのは、sqlite3コマンドがWSL2のUbuntuには標準で入っていないためです(手元の環境では未インストールでした)。sqlite3が使える環境なら、同じSQLを直接渡しても構いません。
sqlite3 ~/.hermes/state.db \
"SELECT DISTINCT session_id FROM messages WHERE content LIKE '%消したい文字列%';"
日本語の文字列を探す場合は、単語境界のない言語に強いmessages_fts_trigram(文字3-gramのFTS5索引)を使うほうが確実です。ただしmessages_fts_trigramはrole <> 'tool'のメッセージしか索引していないため、ツールの実行結果に埋め込まれた情報は引っかかりません。取りこぼしを避けるなら、上のようにmessagesテーブルを直接LIKEで引くのが安全です。
③ 会話ログごとセッションを削除する
hermes sessions delete <session_id> --yes
この操作でmessagesテーブルの行と、2系統のFTS索引(messages_ftsとmessages_fts_trigram)の両方が連動して消えることを実測で確認しました。テスト用のマーカー文字列を含むセッションで検証した結果は次の通りです。
| 対象 | 削除前 | 削除後 |
|---|---|---|
messagesテーブルの該当行 |
1件 | 0件 |
messages_fts(unicode61索引) |
2件 | 0件 |
messages_fts_trigram(3-gram索引) |
1件 | 0件 |
sessionsテーブルの該当行 |
1件 | 0件 |
FTS5のトリガー(messages_fts_deleteほか)が正しく機能しており、索引だけが取り残される心配はありませんでした。手動でインデックスを再構築する必要はありません(終了コードも0です)。
④ セッションを終了し、新規セッションを開始する
6章で実証した通り、これを省くと①②③がすべて無駄になります。実行中のセッションは凍結スナップショットを使い続けるため、--resumeで戻るのも禁物です。
⑤ 新規セッションで確認する
実際に質問して、想起されないことを目視で確認します。ここまでやって初めて「忘れた」と言えます。
この手順でもカバーできない範囲があります。 バックグラウンドレビューは削除済みの事実を再保存しうる仕組み(Tombstone不在)なので、同じ話題を再び会話に出すと、また記憶に書き戻される可能性があります(7章)。恒久的に扱いたくない話題がある場合は、memory.nudge_interval: 0で自動書き戻しを止めるか、memory.write_approval: trueで書き込みに承認を挟む運用を検討してください。加えて、バックアップ済みのzipやスナップショットの中には削除前の状態がそのまま残ります —— こちらは後編で詳しく扱います。
実務上の教訓
- 「削除した」と「セッションを再起動した」はセットで運用する。 機微情報を含む記憶を消したいなら、削除後に新しいセッションを開始しない限り、そのセッション内では古い情報が使われ続けます
-
会話ログの削除は、メモリの削除とは別作業。
session_searchが拾えるということは、state.db側にも別途削除操作が必要ということです - ローカルモデルでの「メモリ機能」導入は、まずベースラインの想起精度を測ってから。 メモリの汚れを心配する前に、そもそもクリーンな状態でどれだけ安定して思い出せるかを確認しないと、「効果があるはずの機能が効いていない」原因を誤診しかねません
10. まとめ
忘れさせたいことがある。
物事を記憶していくエージェントを目の前に思うこともあるかと思います。その中で2章で見た文献群は、削除APIの意義やプルーニングの理論的な効用について精度という観点でも理解できました。そのうえで、Hermes Agentを通してに実装を探りました。
今回の検証を通して見えたのは、「忘れる」という単一の操作の裏に、少なくとも3つの独立した層(システムプロンプトのスナップショット、会話ログ、バックグラウンド再生成)が隠れているという構造でした。3章の表で「どの削除手段がどのコンポーネントに効くか」を整理しましたが、実機で確かめた結果はまさにその通りで、どれか1つの層で削除しても、他の層に情報が残っていれば、ユーザーの体感としては「まだ知っている」ままです。
今回はHermes Agentを題材にしましたが、この構造自体は実装を問わず似た形で現れるはずです。記憶をシステムプロンプトに載せる以上、それをいつ更新するかという選択は必ずどこかで発生しますし(毎ターン更新すればキャッシュが効かず、固定すれば削除が遅れて効く)、会話ログを検索できるようにする以上、厳選した記憶とは別に全文が残ります。エージェントのメモリ機能を評価するときは、「削除APIがあるか」ではなく「削除APIがどの層に効いて、どの層に効かないか」を見るのがよさそうです。
一方でプルーニング自体は、能力のあるモデルには確かに効くことも実証できました。ただし、そのモデルがそもそも記憶を安定して使えている、という前提があってこそです。凍結スナップショットの検証をしようとしてベースラインの動作確認でいきなりつまずいたことで気づいたのですが、制約のあるマシンで頑張りたい私には事実を受け止められない結果でした。メモリの汚れを心配する以前に、そのモデルがクリーンな記憶をどれだけ正確に使えるかを先に測るべきだった、というのが一番の反省です。
後編では、視点を変えて「忘れる」ではなく「守る」側 —— バックアップとセキュリティの検証結果をお届けします。蓄積した記憶を消したくないとき、それは本当に守られているのか、という話です。そちらでも、自分の思い込みが実測で覆されることになります。
参考文献
- Mem0: Building Production-Ready AI Agents with Scalable Long-Term Memory(Chhikara et al., ECAI 2025 / arXiv:2504.19413)
- Lost in the Middle: How Language Models Use Long Contexts(Liu et al., TACL 2023 / arXiv:2307.03172)
- Context Rot: How Increasing Input Tokens Impacts LLM Performance(Chroma Research, 2025)
- Generative Agents: Interactive Simulacra of Human Behavior(Park et al., UIST 2023 / arXiv:2304.03442)
- MemGPT: Towards LLMs as Operating Systems(Packer et al., 2023 / arXiv:2310.08560) — OSのページング機構になぞらえた仮想コンテキスト管理の原典。6章で確認した「凍結スナップショット」設計の比較対象として
- Tombstones(Apache Cassandra Documentation) — 2章で触れた墓石パターンの一次資料。削除マーカーを残さないと「data resurrection(削除済みデータの復活)」が起きるという説明は、7章のバックグラウンドレビューの議論とそのまま重なる
参考リンク
検証条件の注記
- 8章の定量評価は各セルn=2と小さく、統計的検定は行っていません。傾向を示すものとしてお読みください
- 検証は2026年8月21〜22日、Hermes Agent v0.19.0(2026.7.20)時点のものです