第8章 エージェントメモリ — 解答例
本文: 第8章 エージェントメモリ
→ 参照: 8.4.1節(3方式)/ 8.5.1〜8.5.3節(エピソード/意味記憶、何を記憶するか)/ 8.7.2節
| # | 記憶するか | 種別 | 保存先 |
|---|---|---|---|
| 1 | する | 意味記憶 | SQL |
| 2 | する | エピソード記憶(+ 意味記憶へ抽出) | ベクトルDB(+ SQL/ファイル) |
| 3 | しない | — | — |
| 4 | する | (作業状態) | ファイル |
| 5 | する(ただし inferred) |
意味記憶 | SQL |
1.「レポートは常にPDFで出力して」
記憶する。8.5.3節の表で「ユーザーが明示的に述べた嗜好・制約」に該当する。時点に依存しない意味記憶である。キーが report_format と定まっているので、8.4.3節の user_preferences に source='explicit'、confidence 高で入れる。ベクトル検索は不要 — 「レポート形式の設定」を意味的類似で探すのは筋が悪く、WHERE user_id = ? AND key = 'report_format' で確実に引くべきである(3.5.4節)。explicit なので8.7.2節の減衰対象からも外す(stability = 1.0)。
2. 決済APIの障害と調査経緯(2026-06-14)
記憶する。特定の時点に紐づく出来事なので典型的なエピソード記憶。「以前これに似た障害があったか」という引き方をするため、ベクトルDBに入れて類似検索する(8.5.2節)。加えて、調査で確定した事実(「決済APIのタイムアウトは30秒」)は時点に依存しない知識なので、8.5.2節の consolidate で意味記憶として抽出し、SQLまたはファイルに構造化して保持する。1件の情報から2種類の記憶が生まれる例である。
3.「今週は出張中なので返信が遅れます」 記憶しない。 8.5.3節の表が「一時的な状態(『いま出先です』)」を明示的に除外している。長期記憶に入れると、来月には確実に誤りになり、エージェントが自信を持って古い情報を使う(8.7.1節①)。会話中の文脈としてコンテキストに保持すれば足りる。どうしても跨いで使う必要があるなら、8.7.2節④の明示的な期限を付けて短期のスロットに置き、期限を過ぎたら自動削除する。
4. リファクタリングの進捗
記憶する。ただしエピソード/意味のどちらでもなく、現在の作業状態である。保存先はファイル(メモリツール)。第6章6.5.5節の「進捗の外部化」と8.4.5節の「セッションをまたぐ作業パターン」がまさにこれを想定している。人間可読で、モデル自身が str_replace で更新でき、次セッションの起点になる。作業完了時に削除する(期限つき記憶)。ベクトルDBは不適 — 「どこまで完了したか」を意味的類似で探す必要はなく、必ず全体を読むからである。
5.「過去5回とも午前中に問い合わせている」
記憶する。複数のエピソードから抽出された抽象なので意味記憶である。ただし重要なのは、これがユーザーの明言ではなく観察からの推測である点だ。8.4.3節に従い、SQL に source='inferred'、confidence 付きで保存する。推測を事実として扱うと誤りが固定化する(8.4.3節)。プロンプトへの注入も <observed_preferences> 側に置き、「観察からの推測であり、明示的な指示と矛盾する場合は指示を優先せよ」と添える(8.6.2節)。確認されないまま時間が経てば確信度を下げる(8.7.2節②)。
別解として妥当: 5について「そもそも記憶しない」も正解になりうる。8.5.3節は「すべてを記憶してはならない」と述べており、この観察を使って何をするのかが不明であれば、検索精度を落とすだけの記憶になる。用途(午前中に定型レポートを先回りで用意する等)が明確な場合にのみ記憶する、という判断は本文の趣旨に沿う。
→ 参照: 8.3.3節 / 2.2.2節(プロンプトキャッシュ)/ 8.3.2節 / 8.3.5節
原因
① 削除がプロンプトキャッシュを毎回破壊している(主因)。 context editing はメッセージ列のプレフィックスを書き換えるため、キャッシュを無効化する(8.3.3節)。第2章2.2.2節の倍率で言えば、キャッシュが効いていれば入力は 0.1x で済むが、無効化されると 1.25x(書き込み)を払い直すことになる。つまり単価が実質12.5倍になる。
trigger を 20,000トークンに設定したのが致命的である。エージェントはシステムプロンプトとツール定義だけで6,000〜8,000トークンあり(8.3.1節)、数ステップで20,000を超える。以後はほぼ毎ステップ削除が発動し、毎ステップ全プレフィックスを書き直す。削除で節約できるトークン量より、キャッシュ喪失による単価上昇のほうが大きくなる。
② keep: 2 が再取得を誘発している。
直近2件のツール結果しか残らないため、モデルは3ステップ前に読んだファイルの内容を失う。判断に必要ならもう一度読みに行く。呼び出し回数とステップ数が増え、消したはずのトークンが再び入ってくる。削除は無料に見えて、再取得というコストを後払いさせる。
③ 削除量が小刻みである。
clear_at_least の指定がなければ、閾値を少し超えるたびに少しずつ消す動きになりうる。「めったに、まとめて消す」ほうがキャッシュ破壊の回数が減って有利である。
改善案
- まず入口で絞る(8.3.2節)。 7.4.2節に従い、ツール側の返り値に上限を設ける。1万行を入れてから消すより、最初から100行にするほうが安く確実である。後段のあらゆる手法より優先される。
triggerを大幅に引き上げる。 コンテキスト上限に近い水準(例: 100,000〜150,000トークン)にし、削除が発動する回数そのものを減らす。「困ったときだけ効く安全弁」として扱う。clear_at_leastを大きく取る。 例えば 30,000トークン。1回の発動でまとめて消し、次の発動までの間隔を稼ぐ。keepを増やす。 5件程度にして、直近の作業文脈を保つ。再取得の往復を防ぐ。exclude_toolsで再取得コストの高いツールを保護する。 課金APIの結果や、生成に時間のかかる集計結果は消さない。- 効果を測る。
response.context_management.applied_editsをログし、削除の発動頻度と削除トークン数、あわせてusageのキャッシュヒット率を記録する。「入れたら安くなるはず」ではなく、実測で判断する。 - それでも足りなければ compaction か外部への退避(8.3.6節)へ進む。消えると困る情報があるのに削除で対処していること自体が、設計の選択を誤っている兆候でもある(8.3.5節)。
(a) HALF_LIFE_DAYS を 90 → 7 にした場合
recency = 0.5 ** (age_days / HALF_LIFE_DAYS) が急峻になる。実際の値を見ると性質がはっきりする(スコアへの寄与は 0.6 + 0.4 * recency なので、係数は 0.6〜1.0 の範囲を動く)。
| 経過日数 | half-life 90 の係数 | half-life 7 の係数 |
|---|---|---|
| 7日 | 0.98 | 0.80 |
| 30日 | 0.92 | 0.62 |
| 90日 | 0.80 | 0.60 |
| 365日 | 0.62 | 0.60 |
利点: 直近の記憶が強く優先される。状況変化への追随が速く、古くなって腐った記憶(8.7.1節①)が上位に来にくい。異動や仕様変更が頻繁な領域では望ましい。
欠点: 2つある。第一に、長期的に有効な記憶が沈む。半年前に一度設定した嗜好や、年1回しか使わないが重要な事例が想起されなくなる。第二に、これが本質的だが、1か月を超えるとほぼすべての記憶が係数0.6に張り付き、新旧の区別がつかなくなる。90日なら1年かけて 0.98 → 0.62 と滑らかに順位付けできるが、7日では30日以降が実質フラットになり、時間減衰という機構そのものが機能しなくなる。「速く忘れる」つもりが「古いものを区別できない」に化けるという、直感に反する挙動である。
(b) stability の設計意図
明示的な設定(preference)は時間で腐りにくく、エピソードや推測は腐りやすいという差を反映している。8.7.2節の図で「明示的な設定 → 保持(ユーザーが決めたもの)」「推測した嗜好 → 確信度を下げる」「エピソード → 統合してから削除」と分岐しているのと同じ思想である。
「レポートはPDFで」という指示は、ユーザーが「やっぱりExcelで」と言い直すまで有効であり、時間が経ったから無効になるものではない。これを他の記憶と同じ速さで減衰させると、半年後には想起されなくなり、ユーザーが毎回同じことを言い直す羽目になる — 記憶システムを入れた意味が失われる。逆にエピソードは、状況が変わればそのまま適用できない可能性が高いので、割り引いてよい。
設計への批評(採点で加点する観点): ただし現実装の
stabilityは乗算定数であり、スコア全体を 0.7 倍しているだけで、減衰の速さ自体は変えていない。「preference は減衰しにくい」という意図を厳密に実装するなら、stabilityではなくkindごとにHALF_LIFE_DAYSを変える(preference は 365日、episode は 30日など)ほうが正しい。現状は「preference を常に優先する」という別の効果になっている。
(c) 新規記憶が不利になる問題
原因は frequency = math.log1p(access_count) / 5 の項である。作成直後の記憶は access_count = 0 なので log1p(0) = 0 となり、この項の寄与がゼロになる。一方、よく使われてきた既存の記憶は最大 0.1 の加点を得ている。同じ類似度なら、新規記憶は常に0.1点の差で負ける。
さらに悪いことに、これは自己強化の裏返しである。上位k件に入れなければ mark_used されず、access_count は0のまま増えない。一度も選ばれないから選ばれないという状態に固定される(8.7.2節が警告した自己強化ループの、負の側)。
改善案(いずれか、または組み合わせ):
- 新規記憶への猶予期間を設ける。 作成後14日間は
frequency項を満点(0.1)として扱う。「まだ使われる機会がなかった」ことと「使われなかった」ことを区別する。 - 探索枠を確保する。
top_kのうち1〜2枠を、access_count = 0の記憶から類似度順に割り当てる。ε-greedy 的な発想で、評価の機会そのものを与える。 access_countの絶対値ではなく利用率を使う。access_count / max(recall_count, 1)(想起された回数のうち実際に使われた割合)に変えれば、1回想起されて1回使われた新規記憶は最高値になる。実績の少なさが不利にならない。frequencyの重みを下げる。 0.1 → 0.03 程度にし、類似度と新しさで順位が決まるようにする。最も単純で、多くの場合これで足りる。created_atに基づく新規性ボーナスを加える。 ただしrecencyと二重計上にならないよう注意する。
いずれの案も、入れたら評価セットで想起の質を測ること(第11章)。スコア式は直感で書くと簡単に壊れる。
→ 参照: 8.8節(段階的導入)/ 8.5.3節 / 8.4.1〜8.4.2節 / 13.4.2節
(a) どこまで実装すべきか
1〜3 と 6 を実装し、7 を計画に入れる。4 と 5 は当面不要。
- 1(何もしない)は不可。「過去の対応事例を参照して回答案を作る」ことが要件に明記されており、8.2.2節の表で「過去の事例から学ぶ → 必要」に該当する。長期記憶は必須である。
- 2(入口での切り詰め)・3(context editing)は必須。1件の問い合わせ処理でチケット本文・添付・過去事例が積み上がる。8.8節の順序どおり、まずここを固める。
- 4(ファイルベースの記憶)は不要。1件の問い合わせは1セッションで完結し、セッションをまたいで作業を継続する構図ではない。8.4.5節のパターンが要らない。
- 5(構造化プロファイル)は原則不要。顧客の契約内容や連絡設定は既存のCRMに正規の情報があるはずで、エージェント側に別の真実の源を作るべきではない。読み取り専用ツールでCRMを引く(第7章)。新規に作るのは二重管理を招く。
- 6(ベクトル記憶)が本体。「過去の対応事例を意味的に想起する」ことが価値の中心であり、8.4.2節の
recallがそのまま当てはまる。 - 7(忘却と統合)は初期リリースでは不要だが、計画には入れる。担当者1人が1日50件、担当者が20人いれば年間25万件溜まる。8.7.1節②のとおり検索精度は記憶数に比例して悪化するので、半年〜1年以内に
consolidate(頻出パターンをFAQ化して意味記憶へ昇格)と保持期間による削除を導入する前提で設計しておく。
(b) 顧客情報を記憶する際の配慮
- 記憶するのは「事例の型と解決策」であって「顧客の個人情報」ではない。 8.5.3節の「機微な個人情報は記憶しない」に従い、インデックス対象のテキストから氏名・電話番号・メールアドレス・カード番号を除去または仮名化してから埋め込む(13.4.2節②、種別を保った仮名にする)。「同一顧客か」の判定は仮名ではなく、メタデータの内部
customer_idで行う。 - ベクトルDBのフィルタを必ず効かせる。 8.4.2節の
filter={"user_id": user_id}に相当するものとして、テナント(自社サービスが複数企業向けなら顧客企業)を必ず絞る。怠れば他社の対応事例が混入する情報漏洩になる。 - 保持期間を決める(13.4.2節④・8.7.1節③)。 個人データの保持には法的制約がありうる。顧客単位で一括削除できる設計にしておく — 削除の仕組みがないシステムは削除要求に応えられない。
- 記憶以外の流出経路も塞ぐ。 13.4.1節のデータフロー図のとおり、記憶に入れた情報はトレースとログにも残る(第12章)。記憶だけマスキングしてトレースが素通しでは意味がない。
- 最小限のデータしか渡さない(13.4.2節①)。 回答案の作成に顧客の氏名は通常不要である。「30代・法人契約・プランB」といった、解決に必要な属性だけを残す。
(c) 「3か月前に同じ顧客から似た問い合わせがあった」の検出
2つの条件が性質の異なる検索を要求していることに気づくのが要点である。
- 「同じ顧客から」は
customer_idの厳密一致、「3か月前」は日付の範囲指定である。第3章3.5.4節・8.4.1節が述べるとおり、これらはベクトル検索が最も苦手とする操作である。 - **「似た問い合わせ」**は意味的類似であり、ベクトル検索の独壇場である。表記が違っても(「ログインできない」/「認証エラーが出る」)拾える。
したがってメタデータフィルタつきのベクトル検索が適する。8.4.2節の recall と同じ構造である。
def recall_similar_cases(customer_id: str, query: str, top_k: int = 3) -> list[MemoryEntry]: return vector_db.search( vector=embed(query), filter={"customer_id": customer_id}, # 厳密一致はメタデータで top_k=top_k, )function recallSimilarCases( customerId: string, query: string, topK: number = 3): MemoryEntry[] { return vectorDb.search({ vector: embed(query), filter: { customer_id: customerId }, // 厳密一致はメタデータで top_k: topK, })}なお、この用途では 8.7.2節の時間減衰を適用してはならない。「3か月前の事例を見つける」ことが目的なのに、古い記憶のスコアを下げては本末転倒である。想起スコアの設計は用途ごとに変える必要がある — 「ユーザーの現在の嗜好を思い出す」用途と「過去の類似事例を探す」用途では、時間の意味が正反対である。
→ 参照: 8.4.2節 / 8.4.4節 / 13.4.2節 / 11.5節・11.6.1節
欠陥① — recall のフィルタが機能していない
8.4.2節が「filter={"user_id": user_id} を必ず入れる」「怠ると他人の記憶が混入する。これは単なるバグではなく情報漏洩事故である」と警告した、まさにその失敗である。具体的な形は複数ある。
- 呼び出し側でフィルタを渡し忘れている(引数が任意になっている)
- ベクトルDBがそのフィルタ構文をサポートしておらず、エラーも出さずに無視している
- フィルタは効いているが、
user_idの解決元が間違っている(セッションではなくリクエストボディの値を信用している、など)
対策:
- 省略できない関数シグネチャにする(ポカヨケ、4.4.5節)。
user_idを必須の位置引数にし、Noneや空文字を早期に例外にする。 - 物理分離を優先する。 テナント/ユーザーごとにコレクションや名前空間を分ければ、フィルタの有無に関わらず他人のデータに到達できない。権限や構造で解けるものを、条件式に頼らない。
- 多層防御として出口でも検査する。 検索結果をプロンプトに入れる直前に、全エントリの
entry.user_id == user_idを再検証し、違反があれば例外を送出してアラートを上げる。「静かに間違う」を「うるさく落ちる」に変える。 - フィルタが実際に効いているかを起動時に検証する(ヘルスチェック)。DB側の仕様変更で黙って無視されるようになる事故を検知できる。
欠陥② — ユーザーをまたいで状態を使い回している
リクエスト単位であるべきオブジェクトが、プロセス全体で共有されている。13.4.2節の PIIVault に「1リクエストにつき1つ生成すること。使い回すと、あるユーザーの unmask で別ユーザーの実値が復元されうる」という注意書きがあるのと同型の事故である。典型例:
- モジュールレベルのキャッシュや、
user_idをキーに含めていないメモ化 UserProfileをグローバル変数やシングルトンで保持している- メモリツールの
base_pathが全ユーザー共通になっている(8.4.4節は「ユーザーごとにディレクトリを分ける実装にすれば、/memoriesという同じ仮想パスのままテナントを分離できる」と述べている) - 非同期処理で、並行するリクエスト間でコンテキスト変数が混線している
対策:
- ユーザースコープを持つオブジェクトはリクエストごとに生成し、使い回さない
- あらゆるキャッシュキーに
user_idを含める - メモリツールの
base_pathをユーザー単位に切る - 「ユーザー固有の状態を持つグローバル変数を作らない」をレビュー観点に加える
この種の事故を事前に検出するテスト設計
11.5節の「セキュリティ境界のテストは特に重要」、11.6.1節⑥の「やってはならないことをしなかったか」という観点に対応する。
# ① 単体: 最も意地悪なケース — A と B に「まったく同じ文言」の記憶を入れる# 類似度だけならBが最上位になりうる状況を作り、フィルタが唯一の防壁になるようにするdef test_recall_は他ユーザーの記憶を返さない(): store_memory("user_A", "決済APIのタイムアウトは30秒") store_memory("user_B", "決済APIのタイムアウトは30秒") hits = recall("user_A", "決済APIのタイムアウト", top_k=10) assert hits, "自分の記憶は取得できること" assert all(h.user_id == "user_A" for h in hits) # 全件を検査する
# ② 単体: フィルタの渡し忘れが「静かに通る」ことがないかdef test_user_idなしの呼び出しは例外になる(): with pytest.raises((TypeError, ValueError)): recall(None, "決済APIのタイムアウト") with pytest.raises((TypeError, ValueError)): recall("", "決済APIのタイムアウト")
# ③ 統合: カナリア方式。B にしか存在しない一意な文字列を仕込むasync def test_カナリアが他ユーザーの応答経路に現れない(): canary = "CANARY-B-8f14e45f" store_memory("user_B", f"社内の内線番号は {canary} です")
result = await run_agent("内線番号を教えて", user_id="user_A")
# 最終出力だけでなく、LLM へ送った全メッセージとトレースも検査する assert canary not in result.final_text assert canary not in serialize(result.messages_sent) assert canary not in serialize(result.trace)// ① 単体: 最も意地悪なケース — A と B に「まったく同じ文言」の記憶を入れる// 類似度だけならBが最上位になりうる状況を作り、フィルタが唯一の防壁になるようにするtest('recall は他ユーザーの記憶を返さない', () => { storeMemory('user_A', '決済APIのタイムアウトは30秒') storeMemory('user_B', '決済APIのタイムアウトは30秒') const hits = recall('user_A', '決済APIのタイムアウト', 10) assert.ok(hits.length > 0, '自分の記憶は取得できること') assert.ok(hits.every((h) => h.userId === 'user_A')) // 全件を検査する})
// ② 単体: フィルタの渡し忘れが「静かに通る」ことがないかtest('user_id なしの呼び出しは例外になる', () => { // Python は None を渡せるが、TypeScript では型が拒む。 // 型の外から来る値を模すため unknown 経由で渡す assert.throws( () => recall(null as unknown as string, '決済APIのタイムアウト'), TypeError ) assert.throws(() => recall('', '決済APIのタイムアウト'), TypeError)})
// ③ 統合: カナリア方式。B にしか存在しない一意な文字列を仕込むtest('カナリアが他ユーザーの応答経路に現れない', async () => { const canary = 'CANARY-B-8f14e45f' storeMemory('user_B', `社内の内線番号は ${canary} です`)
const result = await runAgent('内線番号を教えて', 'user_A')
// 最終出力だけでなく、LLM へ送った全メッセージとトレースも検査する assert.ok(!result.finalText.includes(canary)) assert.ok(!serialize(result.messagesSent).includes(canary)) assert.ok(!serialize(result.trace).includes(canary))})設計上の要点を3つ。
- カナリアを使う。 LLMの出力は確率的で「意味が同じか」の判定は不安定だが、
CANARY-B-8f14e45fという一意な文字列が含まれるかは決定論的に検査できる(11.6.2節)。混入検知はカナリアと相性が極めてよい。 - 最終出力だけを見ない。 モデルが言及しなかっただけで、プロンプトには入っていたなら、それは既に漏洩である。LLMに送ったメッセージ列とトレース(13.4.1節でも流出経路として挙がっている)まで検査対象にする。
- 並行実行のテストを別に用意する。 欠陥②のような状態使い回しのバグは、逐次実行のテストでは再現しない。A と B のリクエストを
asyncio.gatherで同時に走らせ、双方の応答にカナリアが現れないことを確認する。
最後に、発生した事故そのものを回帰テストとして固定し、CIに組み込む(11.10.1節)。この種の欠陥は一度直しても、次のリファクタリングで容易に戻る。
Built with Astro ・ Deployed on Cloudflare Pages
© 2026 watakumi — made with 💜 & ☕ ・watakumi.page