Blog

エージェントのメモリ設計 - 何を覚え、何を捨てるか

2026-08-02 10:30:00

エージェントのメモリ設計 - 何を覚え、何を捨てるか

エージェントのメモリ設計とは、コンテキストウィンドウの外に持つ「メモリ」を設計することです。何を覚え、どう取り出し、いつ捨てるかを決めます。メモリのないエージェントは毎回ゼロから始まり、同じ失敗を繰り返しますが、メモリのあるループは学習します。

この記事では、メモリの種類・設計の軸・落とし穴を、検証実装を動かしながら整理します。対象はエージェントを設計・運用する人です。

メモリ設計とは

メモリ設計は、次の3つを決めることです。

  • 書き込み: 何を保存し、誰のものにするか(scope: user / agent / session / org)。
  • 読み出し: どう引くか(semantic + keyword + entity の融合、そして reranking で順序を直す)。少なく・正確に取り出す。
  • 忘却: いつ捨て、いつ古いと見なすか。

従来のメモリは、会話履歴および圧縮した会話履歴をコンテキストウィンドウに詰めるものでした。現在では、それ自体が設計対象の仕組みとして扱われ、専用のベンチマーク(LoCoMo/LongMemEval/BEAM)と研究の蓄積を持ちます。

この記事では、この整理の参考として mem0 のレポートを使います。mem0 はエージェント用メモリ管理の代表的なプレイヤーで、OSSとマネージドサービスの両方を提供し、この分野の年次レポートも出しています(参考: 「AI Agent Memory 2026: Progress Benchmark Report Evaluations」)。

名前を挙げた3つのベンチマークが何を測るテストかも押さえておきます。いずれも「1回の長文読解」ではなく、セッションをまたぐメモリのために作られています。

  • LoCoMo: ペルソナと出来事グラフから生成した超長期の対話(平均約300ターン・約9Kトークン、最大35セッション)を題材に、単発の事実想起、複数セッションにまたがるマルチホップ推論、時間推論、答えが対話中にない敵対的質問などのQAと要約でメモリを測る。
  • LongMemEval: 長大なチャット履歴に500問を埋め込み、情報抽出・複数セッション推論・時間推論・知識の更新(古い情報を新しい情報で上書きできるか)・棄権(記録にないことに「知らない」と言えるか)の5能力を測る。「知識の更新」と「棄権」は、この記事の後半で扱う陳腐化(staleness)への耐性テストそのものです。
  • BEAM: コンテキストを1Mさらに10Mトークンまで伸ばし、その規模での検索と軽い推論を測る。

メモリの3つの機能: episodic / semantic / procedural

何を覚えるかで、メモリは3つに分けられます。

  • episodic(何が起きたか): 過去の行動とその結果。「この手は前に試して失敗した」を思い出し、繰り返しを避ける。
  • semantic(何を知っているか): ドメイン知識・命名規約・アーキテクチャ上の決定など、変わりにくい事実。
  • procedural(どうやるか): 手順そのもの。PRの作り方、マージ前に走らせるテスト、リリースノートの書き方といった「やり方」の知識。

置き場と引き方: vector / graph / file

どう保存し、どう引くかでも分かれます。vector(埋め込みの類似で引く)、graph(エンティティの関係でたどる)、file(ファイルに状態を置く)。実運用では類似検索だけでなく、キーワード一致やエンティティ一致を融合して精度を上げます。

ちなみに、CLAUDE.md は、人がキュレートする semantic/procedural メモリの好例です。エージェントが間違えるたびに修正を CLAUDE.md に書けば、次のセッションは同じ轍を踏まない。自動生成のメモリより、人が「何を残すか」を決めるため信頼できます。

なぜコンテキストウィンドウに全部入れないのか(トークン効率)

現行モデルのコンテキストウィンドウは広い。なら全部入れればいい、とはなりません。トークン効率が本番可否を分けるからです。コンテキストウィンドウに「入る」と「うまく使える」は別です。

  • context rot: 入力が長くなるほど精度が非一様に落ちる。18のフロンティアモデルで、上限のずっと手前で 30〜50% 落ちることも。1Mコンテキストウィンドウのモデルでも 50K トークンで計測可能な劣化が出ます。
  • lost-in-the-middle: 20文書の中央に正解を置くと精度が 30 ポイント以上落ちる。大きいコンテキストウィンドウは中央を増やしただけ、とも言える。

コンテキストウィンドウを大きくしても実効はその6〜7割です。(参考: 「LLM Context Window Limitations」

落とし穴: 肥大・陳腐化・出所不明

覚えること自体にリスクもあります。下記は代表的は3要因です。

  • 肥大: 記憶が増えるほど、コンテキストウィンドウとコストを圧迫する。
  • 陳腐化(staleness): 記憶した時点では正しかった事実が、現実の変化で正しくなくなる。しかも記憶がある分、推測ではなく自信満々に間違える。
  • 出所不明(provenance): マルチエージェントで「ユーザーが言った事実」と「エージェントが推測した事実」が混ざり、誰が言ったかを見失う。

自前実装からプラットフォーム機能へ: 3社の公式メモリ

2025年までメモリは mem0 のような専用フレームワークが牽引する領域でしたが、現在は公式機能が出揃っています。

提供元

機能

要点

Anthropic

memory tool(Messages API)

クライアント側で /memories 配下のファイル操作(view / create / str_replace / insert / delete / rename)を実装して返す。

Anthropic

Memory Stores (Managed Agents)

メモリはファイルとしてサンドボックスにマウントされ、bash・コード実行と同じツールで扱う。複数エージェントでストアを共有でき、スコープ権限(組織全体は読み取り専用・ユーザー別は読み書き等)・監査ログ・ロールバック・APIでのエクスポートに対応。溜まったメモリをまとめて整理する Dreams(リサーチプレビュー)も付随する

OpenAI

なし(アプリ側で実装)

Responses API / Agents SDK はサーバ側に長期メモリを持たない。ユーザーの嗜好やセッション横断のメモリは、アプリ側の外部メモリレイヤー(mem0 等)に委ねる設計

Google

Memory Bank(Gemini Enterprise Agent Platform)

Sessions(短期)と Memory Bank(長期)の2層。会話履歴から非同期に事実を抽出し、既存のメモリと統合して矛盾を解消する

参考リンク

設計比較: mem0・Memory Bank・Memory stores (Managed Agents)・Dreams は何が違うか

4つとも「メモリを綺麗に保ち続ける仕組み」を持ちますが、整理を「いつ・誰が」やるかが違います。

mem0 は会話のやりとりごとに2段のパイプラインを回します(参考: 「Mem0: Building Production-Ready AI Agents with Scalable Long-Term Memory」)。

  1. Extraction(抽出)
    • 直近のやりとりと履歴要約から、覚える価値のある事実(候補)をLLMで抽出する。
  2. Update(更新)
    • 各候補をベクトル検索で既存メモリの上位K件と突き合わせ、LLMが ADD(新規)/UPDATE(補強)/DELETE(矛盾・陳腐化したメモリの削除)/NOOP(何もしない)のどれかを選ぶ。

「書き込み・忘却」の判断を、やりとりのたびにその場でLLMが下す構造です。保存先はベクトルDBで、引くときは類似検索。派生の Mem0g はメモリをエンティティのグラフ(ノード=人・場所・物、エッジ=関係)として持ち、矛盾検出をグラフ上で行います。

Memory Bankも mem0 と同様「抽出→統合」の2段パイプラインです。

  1. Memory extraction(抽出)
    • Sessions に溜めた会話イベントから、意味のある事実だけをLLMで抽出する。何を「意味がある」とみなすかは、トピック指定と few-shot 例でカスタマイズできる。
  2. Memory consolidation(統合)
    • 抽出した事実を既存のメモリと統合し、矛盾を解消しながらメモリを進化させる。mem0 の Update ステップに相当する。

mem0 との違いは実行の形です。生成は非同期で(会話をブロックしない)、イベント数などのバッチ規則で自動起動する。つまり「使いながら、裏で整理し続ける」設計です。加えて TTL、リビジョン履歴、IAMによるスコープ権限、CreateMemory(エージェント直書き)といった機能を持ちます。

Memory stores (Managed Agents) は、抽出・統合のパイプラインを持ちません。実体はワークスペース単位のテキストファイルの集まりで、セッション作成時に添付するとコンテナ内の /mnt/memory/ にマウントされ、エージェントが普段のファイルツールで読み書きします。セッション中に何を書き・何を直すかは、エージェント自身の判断に委ねられます。すべての変更は記録され、誰が・いつ・何を書いたかを追跡・ロールバックでき、機微情報は redact で履歴からも消せます。

Dreams は、この機能と対になる整理役です。セッション中の書き込みはその場では整理せず、後からまとめて、明示的に起動する Dreams のジョブが引き受けます。既存ストアと過去セッションの履歴を入力に、重複の統合・陳腐化したメモリの置き換え・洞察の抽出を行い、整理済みの新しいストアを別に生成します。入力は不変で、出力を採用するか捨てるかは人が決めます。つまり Anthropic のスタックは「日々の書き込みはエージェント主導のMemory stores、定期的な大掃除は Dreams」という分業になっています。(参考: 「Dreams - Claude Platform Docs」)。

観点

mem0

Memory Bank(Google)

Memory stores(Anthropic)

Dreams(Anthropic)

整理の主体

専用パイプラインのLLM

マネージドサービスのLLM

エージェント自身

明示起動するジョブのLLM

整理が走るタイミング

やりとりのたび、その場で(インライン)

非同期。イベント数などのバッチ規則で自動起動

セッションの最中、エージェントが必要と判断したとき

明示的に起動するバッチジョブ(分〜数十分)

整理の中身

ベクトル検索で既存メモリと突き合わせ、ADD/UPDATE/DELETE/NOOP を選択

抽出した事実を既存のメモリと統合し、矛盾を解消

マウントされたファイルを、普段のファイルツールで編集・削除

重複の統合・陳腐化の置き換え・洞察の抽出でストアを再構築

整理対象への影響

既存のメモリを直接更新する

既存のメモリを直接更新する

既存のファイルを直接更新する

入力ストアは不変。整理済みの新ストアを別に生成

人の関与

基本なし(自動)

基本なし(自動。APIで個別修正は可能)

instructions で方針を指示。Console・APIで直接編集・ロールバック・redact

出力をレビューし、採用か破棄かを人が選ぶ

忘却

Update 時の DELETE 判断

統合時の矛盾解消+TTLで自動失効

エージェントの編集・削除の判断+Dreams でまとめて整理

陳腐化したメモリを最新値へ置き換えた新ストアを提示

「整理のタイミング」はそれぞれ異なっており、mem0 は毎回その場、Memory Bank は裏で自動、エージェントメモリはセッション中にエージェントの判断で、Dreams が後でまとめて(入力を壊さず、採否は人が決める)。Anthropic のスタックは「エージェントメモリで書き、Dreams で大掃除」という組み合わせで完成する設計です。

要件として、大量ユーザーの嗜好を自動で覚え続ける個別化なら「その場で自動整理」(mem0・Memory Bank)、少数の長期タスクを人の監督つきで回すなら「自由に書かせて、後でまとめて整理し、採否は人が決める」(Memory stores + Dreams)が合います。

elatt のような「業務フローをend to endで定義し、成果物まで管理してメモリを鍛える」運用は、整理の前後を人が確認できること(読める・直せる・戻せる)が前提になるため、Anthropicとの相性が良いです。

学習/改善し続けるループ

ここまでの書き込み・読み出し・忘却は、人がルール(ヒューリスティクス)で設計する前提でした。研究ではその一歩先、メモリ操作そのものを最終成果から学習させる方向に進んでいます。最終アウトプットの良し悪しを報酬・教師データにして、メモリ管理を鍛えるアプローチです。(参考: 「Memory-R1: Enhancing Large Language Model Agents to Manage and Utilize Memories via Reinforcement Learning」

最近は「数あるメモリ操作のうち、どれが結果に効いたのか」の研究が盛んです。(3つの手法を紹介)

  • Fine-Mem: 最終正解だけを報酬にすると信号が疎すぎて、個々のメモリ操作の良し悪しまで届きません(credit assignment の問題)。そこでチャンク単位の補助QAで工程ごとの即時報酬を作り、さらに最終報酬を「根拠になったメモリ操作」へ配り直して学習します。
  • R²-Mem: 過去のメモリ検索の行動履歴を採点し、良い手・悪い手を抽象化した「経験」として蒸留。以後の検索はその経験を参照して、同じ失敗を繰り返さない。
  • AttriMem: 何を抽出・保存・更新・圧縮・破棄するかというメモリ構築の方策を強化学習で学ぶ際、最終回答へのトークンレベルの寄与を帰属分析し、全体報酬に操作ごとの局所報酬を足す。

elatt では構想時からこの課題を重視し、業務フローの最終的なアウトプット(成果物)まで管理する方針としています。そのため、最終結果に基づいて不要なメモリを削除し、あるべき最新のメモリへ追従させられるように日々改善しています。

人の記憶も睡眠中に重要なものだけが整理されて長期記憶へ定着し(固定化)、使われない記憶は薄れて消え、思い出すたびに現在の文脈で書き換わります(再固定化)。Anthropic がセッションの合間のメモリの整理に Dreams という名前を与えているのは、まさに睡眠中の固定化のアナロジーです。「何を捨てるか・いつ古くなるか」を絶えず処理し続けることこそが記憶の本体である、という点で、エージェントのメモリ設計は人の記憶の仕組みをなぞり始めています。

実検証: お客様ごとにトーンを引き継ぐ回答エージェント

テーマは問い合わせへの回答エージェントです。

過去の会話履歴から言葉遣い・コミュニケーションのトーンを学習してメモリに記録し、新しいセッションでも同じトーンで返信させます。対比が分かりやすいように、性格の違う2人のお客様を用意します。

  1. カジュアルな常連の佐藤さん
  2. とても丁寧なやりとりを好まれる法人の高橋様

この2人に同じ質問を投げたとき、返信のトーンがはっきり分かれることを示します。

メモリストアは1セッションに複数(最大8個)添付できますが、今回は下記の2つに分けて利用します。

  1. 共有ポリシーストア: store-policy(read_only)
    • 営業時間・交換ポリシーなど、全員共通の「事実」。セットアップ時に memories API で /policy.md をシードし、セッションからは書き換えられない。
  2. 顧客別トーンストア: style-<顧客ID>(read_write)
    • そのお客様とのやりとりから抽出したトーンの手引き。顧客ごとに1ストアで、初回接触時に自動作成する。このお客様のセッションにしか添付しないので、他のお客様の情報と混ざらない。

公式ドキュメントでも「エンドユーザー単位・チーム単位でストアを分け、共有の参照資料は read_only の別ストアにする」構成を推奨

流れは顧客ごとに2セッションです。session 1 で過去履歴からトーンを学習して手引きに記録し、session 2(履歴なしの新セッション)では2人に同じ質問「営業時間を教えてください。」を投げます。答えの事実は共有ストアから、話し方は顧客ストアから来るので、同じ質問・同じ事実に対してトーンだけが分かれるはずです。

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;

import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.core.http.StreamResponse;
import com.anthropic.models.beta.agents.AgentCreateParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsAgentToolset20260401Params;
import com.anthropic.models.beta.agents.BetaManagedAgentsModel;
import com.anthropic.models.beta.agents.BetaManagedAgentsModelConfigParams;
import com.anthropic.models.beta.environments.EnvironmentCreateParams;
import com.anthropic.models.beta.memorystores.MemoryStoreCreateParams;
import com.anthropic.models.beta.memorystores.MemoryStoreListParams;
import com.anthropic.models.beta.memorystores.memories.MemoryCreateParams;
import com.anthropic.models.beta.memorystores.memories.MemoryRetrieveParams;
import com.anthropic.models.beta.sessions.BetaManagedAgentsMemoryStoreResourceParam;
import com.anthropic.models.beta.sessions.SessionCreateParams;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsStreamSessionEvents;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsTextBlock;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsUserMessageEventParams;
import com.anthropic.models.beta.sessions.events.EventSendParams;
import com.google.common.base.Ascii;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Iterator;
import java.util.List;
import tools.jackson.core.JsonGenerator;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.core.ObjectReadContext;
import tools.jackson.core.ObjectWriteContext;
import tools.jackson.core.json.JsonFactory;

/**
 * Managed Agents の Memory を使い、共通ポリシーと顧客別トーンを分離して保持するデモ。
 *
 * <p>初回セットアップ: mvn -q compile exec:java -Dexec.args="setup" 通常実行: mvn -q compile exec:java
 */
public final class CustomerToneReplyDemo {

  private static final Path CONFIG_FILE = Path.of(".customer-tone-demo.json");
  private static final JsonFactory JSON = JsonFactory.builder().build();

  private static final String POLICY_STORE_NAME = "store-policy";
  private static final String STYLE_GUIDE_FILE = "style-guide.md";
  private static final String COMMON_INQUIRY = "営業時間を教えてください。";

  private static final ImmutableList<Customer> CUSTOMERS =
      ImmutableList.of(
          new Customer(
              "sato",
              "佐藤さん(カジュアル)",
              """
              [問い合わせ]
              こんにちは〜!この前のホーローケトル、母がすごく喜んでました。
              同じシリーズのポットってあります?(佐藤)

              [店主の返信]
              佐藤さん、こんにちは!わあ、それは嬉しいです。
              お母さまにもよろしくお伝えください!
              同じシリーズのポット、ありますよ〜。
              来週入荷する分、1つ取り置きしておきますね!

              [問い合わせ]
              ありがとう!じゃあお願いします〜(佐藤)

              [店主の返信]
              りょうかいです!入荷したらすぐ連絡しますね。
              いつもありがとうございます!
              """,
              "ポットって食洗機いけます?(佐藤)"),
          new Customer(
              "takahashi",
              "高橋様(とても丁寧)",
              """
              [問い合わせ]
              いつも大変お世話になっております。
              株式会社ヤマガタ総務部の高橋でございます。
              このたび、創立記念品といたしましてマグカップ50個のお見積を
              頂戴できますと幸いに存じます。
              ご多忙のところ恐れ入りますが、何卒よろしくお願い申し上げます。(高橋)

              [店主の返信]
              株式会社ヤマガタ 総務部 高橋様
              いつも格別のお引き立てを賜り、誠にありがとうございます。
              雑貨店ノルドでございます。
              お見積のご依頼を頂戴し、ありがとうございます。
              数量50個にて、本日中にお見積書をお送り申し上げます。
              納期や名入れのご希望がございましたら、
              お手数ですがあわせてお知らせくださいませ。
              今後とも変わらぬご愛顧のほど、よろしくお願い申し上げます。
              """,
              "恐れ入りますが、お見積書の宛名は社名のみとしていただくことは可能でしょうか。(高橋)"));

  private final AnthropicClient client;

  private CustomerToneReplyDemo(AnthropicClient client) {
    this.client = client;
  }

  public static void main(String[] args) throws IOException {
    String apiKey = System.getenv().getOrDefault("ANTHROPIC_API_KEY", "");
    if (apiKey.isBlank()) {
      System.out.println("ANTHROPIC_API_KEY を設定してください。");
      return;
    }

    var app = new CustomerToneReplyDemo(AnthropicOkHttpClient.fromEnv());
    boolean setup = isSetupCommand(args);

    System.out.println(setup ? "# Customer Tone Reply Demo - setup" : "# Customer Tone Reply Demo");
    if (setup) {
      app.setup();
    } else {
      app.runDemo();
    }
  }

  private void setup() {
    var environment =
        client
            .beta()
            .environments()
            .create(EnvironmentCreateParams.builder().name("customer-tone-environment").build());

    var policyStore =
        client
            .beta()
            .memoryStores()
            .create(
                MemoryStoreCreateParams.builder()
                    .name(POLICY_STORE_NAME)
                    .description("店の共通ポリシーと基本情報。全顧客の返信で必ず従う。")
                    .build());

    createPolicyMemory(policyStore.id());

    var agent =
        client
            .beta()
            .agents()
            .create(
                AgentCreateParams.builder()
                    .name("customer-tone-reply-agent")
                    .model(
                        BetaManagedAgentsModelConfigParams.builder()
                            .id(BetaManagedAgentsModel.CLAUDE_SONNET_5)
                            .build())
                    .system(agentInstructions())
                    .addTool(fileToolset())
                    .build());

    ResourceConfig config = new ResourceConfig(environment.id(), agent.id(), policyStore.id());
    saveConfig(config);

    System.out.println("セットアップが完了しました。");
    System.out.println("リソースIDを " + CONFIG_FILE + " に保存しました。");
  }

  private void runDemo() {
    ResourceConfig config = loadConfig();

    for (Customer customer : CUSTOMERS) {
      runForCustomer(config, customer);
    }
  }

  private void runForCustomer(ResourceConfig config, Customer customer) {
    String customerStoreId = findOrCreateCustomerStore(customer);

    System.out.println(customer.displayName() + " / session 1: 履歴からトーンを学習");
    String firstAnswer = runSession(config, customerStoreId, buildLearningTask(customer));
    System.out.println("A. " + firstAnswer);

    System.out.println(customer.displayName() + " / session 2: 保存済みトーンで回答");
    String secondAnswer =
        runSession(config, customerStoreId, "次の問い合わせに返信してください。\n\n[問い合わせ]\n" + COMMON_INQUIRY);
    System.out.println("A. " + secondAnswer);

    System.out.println(customer.displayName() + " / 顧客トーンストアの内容");
    printAsJson(readMemories(customerStoreId));
  }

  private String runSession(ResourceConfig config, String customerStoreId, String task) {

    var session =
        client
            .beta()
            .sessions()
            .create(
                SessionCreateParams.builder()
                    .environmentId(config.environmentId())
                    .agent(config.agentId())
                    .addResource(policyResource(config.policyStoreId()))
                    .addResource(customerResource(customerStoreId))
                    .build());

    sendUserMessage(session.id(), task);
    return collectAnswer(session.id());
  }

  private void sendUserMessage(String sessionId, String task) {
    var textBlock =
        BetaManagedAgentsTextBlock.builder()
            .type(BetaManagedAgentsTextBlock.Type.TEXT)
            .text(task)
            .build();

    var content = BetaManagedAgentsUserMessageEventParams.Content.ofText(textBlock);

    client
        .beta()
        .sessions()
        .events()
        .send(
            EventSendParams.builder()
                .sessionId(sessionId)
                .addUserMessageEvent(ImmutableList.of(content))
                .build());
  }

  private String collectAnswer(String sessionId) {
    StringBuilder answer = new StringBuilder();

    try (StreamResponse<BetaManagedAgentsStreamSessionEvents> response =
        client.beta().sessions().events().streamStreaming(sessionId)) {

      Iterator<BetaManagedAgentsStreamSessionEvents> events = response.stream().iterator();

      while (events.hasNext()) {
        BetaManagedAgentsStreamSessionEvents event = events.next();

        if (event.isAgentMessage()) {
          event.asAgentMessage().content().forEach(block -> answer.append(block.text()));
        }

        if (event.isSessionStatusIdle() && event.asSessionStatusIdle().stopReason().isEndTurn()) {
          break;
        }
      }
    }

    return answer.toString().strip();
  }

  private void createPolicyMemory(String policyStoreId) {
    client
        .beta()
        .memoryStores()
        .memories()
        .create(
            MemoryCreateParams.builder()
                .memoryStoreId(policyStoreId)
                .path("/policy.md")
                .content(
                    """
                    # 店の共通ポリシー(雑貨店ノルド)
                    - 営業時間: 11:00〜19:00(木曜定休)
                    - 不具合の連絡には、まず謝り、交換品を即送する。返送は求めない
                    - 絵文字は使わない
                    - 履歴・ポリシーにない事実(在庫・価格など)は書かない
                    """)
                .build());
  }

  private String findOrCreateCustomerStore(Customer customer) {
    String storeName = customer.memoryStoreName();

    return client
        .beta()
        .memoryStores()
        .list(MemoryStoreListParams.builder().limit(100).build())
        .data()
        .stream()
        .filter(store -> storeName.equals(store.name()))
        .findFirst()
        .map(store -> store.id())
        .orElseGet(() -> createCustomerStore(customer));
  }

  private String createCustomerStore(Customer customer) {
    return client
        .beta()
        .memoryStores()
        .create(
            MemoryStoreCreateParams.builder()
                .name(customer.memoryStoreName())
                .description("このお客様(" + customer.id() + ")専用の返信トーン手引き。")
                .build())
        .id();
  }

  private ImmutableList<MemoryView> readMemories(String storeId) {
    var result = ImmutableList.<MemoryView>builder();

    client.beta().memoryStores().memories().list(storeId).data().stream()
        .filter(item -> item.isMemory())
        .map(item -> item.asMemory())
        .forEach(
            memory -> {
              var full =
                  client
                      .beta()
                      .memoryStores()
                      .memories()
                      .retrieve(
                          memory.id(),
                          MemoryRetrieveParams.builder().memoryStoreId(storeId).build());

              result.add(new MemoryView(full.path(), full.content().orElse("")));
            });

    return result.build();
  }

  private static BetaManagedAgentsMemoryStoreResourceParam policyResource(String storeId) {
    return BetaManagedAgentsMemoryStoreResourceParam.builder()
        .type(BetaManagedAgentsMemoryStoreResourceParam.Type.MEMORY_STORE)
        .memoryStoreId(storeId)
        .access(BetaManagedAgentsMemoryStoreResourceParam.Access.READ_ONLY)
        .instructions("店の共通ポリシー。返信前に必ず読み、内容に従う。")
        .build();
  }

  private static BetaManagedAgentsMemoryStoreResourceParam customerResource(String storeId) {
    return BetaManagedAgentsMemoryStoreResourceParam.builder()
        .type(BetaManagedAgentsMemoryStoreResourceParam.Type.MEMORY_STORE)
        .memoryStoreId(storeId)
        .access(BetaManagedAgentsMemoryStoreResourceParam.Access.READ_WRITE)
        .instructions(
            "このお客様専用のトーン手引き。返信前に必ず読み、" + "履歴が与えられた場合は、このストア内の " + STYLE_GUIDE_FILE + " を更新する。")
        .build();
  }

  private static String buildLearningTask(Customer customer) {
    return """
        以下は、このお客様との過去のメール対応履歴です。
        言葉遣い・トーン・定型の特徴を、読み書き可能な顧客メモリストア内の
        %s に記録または更新してください。
        そのうえで、最後の新しい問い合わせに同じトーンで返信してください。

        [過去の履歴]
        %s

        [新しい問い合わせ]
        %s
        """
        .formatted(STYLE_GUIDE_FILE, customer.history(), customer.firstInquiry());
  }

  private static String agentInstructions() {
    return """
        あなたは小売店のメール返信エージェントです。

        返信を書く前に、セッションに添付されたメモリストアを必ず確認してください。
        - 読み取り専用ストア: 店の共通ポリシー
        - 読み書き可能ストア: 顧客ごとのトーン手引き

        共通ポリシーには厳密に従ってください。
        読み書き可能ストア内に style-guide.md がある場合は、
        その言葉遣い・トーン・定型に合わせてください。
        過去の会話履歴が与えられた場合は、特徴を style-guide.md に記録・更新してください。
        """;
  }

  private static BetaManagedAgentsAgentToolset20260401Params fileToolset() {
    return BetaManagedAgentsAgentToolset20260401Params.builder()
        .type(BetaManagedAgentsAgentToolset20260401Params.Type.AGENT_TOOLSET_20260401)
        .build();
  }

  private static void saveConfig(ResourceConfig config) {
    try (var output = new BufferedOutputStream(Files.newOutputStream(CONFIG_FILE));
        JsonGenerator generator = JSON.createGenerator(ObjectWriteContext.empty(), output)) {
      generator.writeStartObject();
      generator.writeStringProperty("environmentId", config.environmentId());
      generator.writeStringProperty("agentId", config.agentId());
      generator.writeStringProperty("policyStoreId", config.policyStoreId());
      generator.writeEndObject();
    } catch (IOException e) {
      throw new IllegalStateException(CONFIG_FILE + " に保存できません", e);
    }
  }

  private static ResourceConfig loadConfig() {
    ResourceConfig fromEnvironment = configFromEnvironment();
    if (fromEnvironment != null) {
      return fromEnvironment;
    }

    checkState(
        Files.exists(CONFIG_FILE),
        "%s がありません。先に `mvn -q compile exec:java -Dexec.args=setup` を実行してください。",
        CONFIG_FILE);

    try (var input = new BufferedInputStream(Files.newInputStream(CONFIG_FILE));
        JsonParser parser = JSON.createParser(ObjectReadContext.empty(), input)) {
      checkState(parser.nextToken() == JsonToken.START_OBJECT, "%s の形式が不正です", CONFIG_FILE);

      String environmentId = null;
      String agentId = null;
      String policyStoreId = null;
      while (parser.nextToken() != JsonToken.END_OBJECT) {
        String fieldName = parser.currentName();
        parser.nextToken();
        switch (fieldName) {
          case "environmentId" -> environmentId = parser.getValueAsString();
          case "agentId" -> agentId = parser.getValueAsString();
          case "policyStoreId" -> policyStoreId = parser.getValueAsString();
          default -> parser.skipChildren();
        }
      }
      return new ResourceConfig(environmentId, agentId, policyStoreId);
    } catch (IOException e) {
      throw new IllegalStateException(CONFIG_FILE + " を読み込めません", e);
    }
  }

  private static ResourceConfig configFromEnvironment() {
    String environmentId = System.getenv("ANTHROPIC_ENVIRONMENT_ID");
    String agentId = System.getenv("ANTHROPIC_AGENT_ID");
    String policyStoreId = System.getenv("ANTHROPIC_POLICY_STORE_ID");

    boolean noneSet =
        Strings.isNullOrEmpty(environmentId)
            && Strings.isNullOrEmpty(agentId)
            && Strings.isNullOrEmpty(policyStoreId);
    if (noneSet) {
      return null;
    }

    checkState(
        !Strings.isNullOrEmpty(environmentId)
            && !Strings.isNullOrEmpty(agentId)
            && !Strings.isNullOrEmpty(policyStoreId),
        "IDを環境変数で指定する場合は ANTHROPIC_ENVIRONMENT_ID、ANTHROPIC_AGENT_ID、"
            + "ANTHROPIC_POLICY_STORE_ID の3つをすべて設定してください");

    return new ResourceConfig(environmentId, agentId, policyStoreId);
  }

  private static void printAsJson(List<MemoryView> memories) {
    try (var json = new StringWriter();
        JsonGenerator generator = JSON.createGenerator(ObjectWriteContext.empty(), json)) {
      generator.writeStartArray();
      for (MemoryView memory : memories) {
        generator.writeStartObject();
        generator.writeStringProperty("path", memory.path());
        generator.writeStringProperty("content", memory.content());
        generator.writeEndObject();
      }
      generator.writeEndArray();
      generator.close();
      System.out.println(json.toString());
    } catch (IOException e) {
      throw new IllegalStateException("Memoryの表示用JSONを生成できません", e);
    }
  }

  private static boolean isSetupCommand(String[] args) {
    return args.length > 0 && Ascii.equalsIgnoreCase(args[0], "setup");
  }

  public record ResourceConfig(String environmentId, String agentId, String policyStoreId) {

    public ResourceConfig {
      checkState(!Strings.isNullOrEmpty(environmentId), "environmentId must not be empty");
      checkState(!Strings.isNullOrEmpty(agentId), "agentId must not be empty");
      checkState(!Strings.isNullOrEmpty(policyStoreId), "policyStoreId must not be empty");
    }
  }

  private record Customer(String id, String displayName, String history, String firstInquiry) {

    private Customer {
      checkArgument(!Strings.isNullOrEmpty(id), "customer id must not be empty");
      checkArgument(!Strings.isNullOrEmpty(displayName), "displayName must not be empty");
      checkArgument(!Strings.isNullOrEmpty(history), "history must not be empty");
      checkArgument(!Strings.isNullOrEmpty(firstInquiry), "firstInquiry must not be empty");
    }

    private String memoryStoreName() {
      return "style-" + id;
    }
  }

  public record MemoryView(String path, String content) {}
}

実行すると、こうなります(出力例。session 1 の学習時の返信は省略し、比較の要である session 2 と、ストアの中身だけを載せます。文面と手引きの書きぶりは実行ごとに変わります)。

=== 佐藤さん(カジュアル) / session 2: 履歴なしの新セッションで同じ質問 ===
返信:
佐藤さん、こんにちは!

お問い合わせありがとうございます!

営業時間は 11:00〜19:00 です〜 木曜日は定休日となっておりますので、ご来店の際はご注意くださいね!

またご不明な点があれば、お気軽にご連絡ください〜 いつもありがとうございます!


=== 高橋様(とても丁寧) / session 2: 履歴なしの新セッションで同じ質問 ===
返信:
株式会社ヤマガタ 総務部 高橋様

いつも格別のお引き立てを賜り、誠にありがとうございます。
雑貨店ノルドでございます。

お問い合わせいただきました営業時間につきまして、下記のとおりご案内申し上げます。

営業時間:11:00~19:00
定休日:木曜日

ご来店またはお問い合わせをご検討の際は、上記時間内にご連絡いただけますと幸いです。
その他ご不明な点がございましたら、お気軽にお知らせくださいませ。

今後とも変わらぬご愛顧のほど、よろしくお願い申し上げます。

同じ「営業時間を教えてください。」という質問に答えているのに、回答のトーンがきれいに分かれています。

注意点:

  • エンドユーザー単位のメモリストアには個人情報が入ります。保持期間と削除・履歴からの除去(redact)を最初から設計に入れてください。退会したお客様のストアを消す手順まで含めてメモリ設計です。
  • Memory Stores は1セッション最大8個、1メモリ100kB(約2.5万トークン)の上限があります。

Memory Storesの中身は下記のようになっています。

まとめ

エージェントのメモリ設計とは、何を(episodic/semantic/procedural)、どのように保存し、どのように捨てるかを決めることです。

この設計が長期的なプロダクト品質とコストを分けます。また、現状の主要プレイヤーのうち、OpenAI以外は公式ツールを提供しています。

elatt では、この「自律的に改善し続けるメモリ構築」を実運用で日々やっています。設計や事業の相談などお気軽にお問い合わせからご連絡ください。

エージェントのメモリ設計 - 何を覚え、何を捨てるか - 合同会社ふんどし