CodeGym /コース /ChatGPT Apps /エージェントのメモリと状態: session と persistent の比較、checkpoints

エージェントのメモリと状態: session と persistent の比較、checkpoints

ChatGPT Apps
レベル 12 , レッスン 2
使用可能

1. なぜエージェントに独立したメモリが必要なのか

このパートはモジュール 12 のエージェントに関する前回のレッスンに基づいています。そこでは基本アーキテクチャ、run サイクル、ツールについて扱いました。 ここではメモリと状態にフォーカスします。

一般的な Web アプリにたとえると、ここでの LLM は非常に賢い CPU で、複雑な「テキストプログラム」を実行できます。 一方でエージェントの状態は RAM と SSD の組み合わせです。すなわち短命なセッションデータと長期ストレージです。

クラシックな ChatGPT チャット(あなたのコードがない場合)における「メモリ」は、モデルが現在のリクエストで目にする system/user/assistant/tool のメッセージ一覧にすぎません。エージェントにはそれでは足りません。その理由は:

  • 複雑なプロセスの進捗を覚えておく必要がある(workflow のどのステップが済んだか、どのギフト候補を除外済みか、ユーザーが何を承認したか)。
  • ユーザーの長期的な事実(嗜好、配送先住所、過去注文の履歴)を知っている必要がある。
  • 障害に耐える必要がある(サーバがギフト選定の途中で落ちても、ユーザーが全てを入力し直すべきではない)。

これらをすべてプロンプトのコンテキストだけに詰め込もうとすると、すぐにコンテキストウィンドウの制限に当たり、同じ事実に毎回トークンコストを払うことになります。 同時にセキュリティ上のリスクも増します。不要なデータが定期的にモデルへ送信され過ぎるからです。 そのためエージェントシステムでは、常に明示的な状態を持ちます。すなわち、メッセージ履歴の外に生き、あなたが管理するオブジェクト群です。

2. エージェント状態のレイヤ: コンテキスト、session、persistent

レイヤに分解して考えましょう。エージェントには通常、最低でも 3 つの「メモリ」レベルがあります。

  1. メッセージ履歴(dialogue context)。
  2. セッション状態(session state)。
  3. 長期状態(persistent state)。

これらを混同しないことが重要です。

メッセージ履歴: 「汚れたメモリ」

メッセージ履歴とは、各ステップで LLM が目にするものです。 system の指示、ユーザーのリクエスト、エージェントの応答、ツールの結果など。

利点は、これを手動で管理する必要がないことです。Agents SDK やプラットフォーム自体が Session/Conversation というエンティティで面倒を見てくれます。

欠点は「汚れた」メモリだということです。余計な言葉、繰り返し、ユーザーの偶発的なデータが多く含まれます。 これらのデータはトークン的に高価で、構造化もされていません。 例えば、すでに除外済みのギフトリスト 200 件を、毎回プレーンテキストとしてモデルに読み上げたくはないはずです。

Session state: 実務的な短期メモリ

Session の状態は、エージェントの 1 つのセッション/会話の範囲で生きる構造化オブジェクトです。 フロントエンド開発者のアナロジーとしては、タブが開いている間だけ生きる useState や Redux の store に近いでしょう。

そこには次のようなものが入ります:

  • プロセスの現在ステップ(例: "collecting_profile""filtering_candidates")。
  • ツール結果の一時キャッシュ。
  • セッションのパラメータ: ロケール、選択チャネル、「ユーザーが条件に同意した」などのフラグ。

この状態はエージェントの近く(Redis、インメモリの KV ストア、あるいは特定 SDK の組み込み SessionService など)に置けます。重要なのは、これらをすべて system プロンプトに押し込もうとしないことです。

Persistent state: 長期データ

Persistent の状態は長く生きます。セッション、チェックアウト、デバイスをまたぎます。ユーザープロフィール、注文、 ウィッシュリスト、設定などです。

重要な考え方: エージェントが persistent データを「魔法のように覚えている」ことはありません。ツール経由で「読み取る」のです。例えば get_user_profileget_past_orders など。 エージェント内部に隠れたグローバル変数はありません。常に明示的な呼び出しです。

比較表

レイヤー 保管場所 ライフサイクル データ例
Messages Session / SDK / OpenAI 1 回の run / 対話 system/user/tool メッセージ
Session state KV / SessionService / Redis セッションが生きている間 workflow のステップ、一時キャッシュ
Persistent DB (Postgres/NoSQL/ACP backend) セッションや対話をまたいで プロフィール、注文、保存済みリスト

3. Session state: 何か、どう保存するか

エージェント GiftGenius が多段階のプロセスを進めていると想像してください:

  1. ギフト受取人のプロフィールを収集する。
  2. 候補リストを生成する。
  3. 予算、配送、地域で絞り込む。
  4. 最終的なセレクションを用意する。

この過程で、エージェントはユーザーと常に会話し、ツールを呼び出します。 「この特定のギフト選定セッションの進捗」に関わるものは、session state に保持するのが合理的です。

GiftGenius のセッション状態の構造例

TypeScript でセッション状態の型を定義します:

// 1 回の「ギフト選定」における状態
export type GiftSessionState = {
  step:
    | "collecting_profile"
    | "generating_candidates"
    | "filtering"
    | "finalizing";

  // 受取人プロフィールの下書き
  profileDraft?: {
    recipientType?: string;
    ageRange?: string;
    interests?: string[];
    dislikes?: string[];
  };

  // バックエンドから取得した候補商品の id
  candidateIds?: string[];

  // ユーザーが選んだギフト
  selectedGiftId?: string;

  // 技術的なフラグ
  locale?: string;
};

ここでは意図的に商品オブジェクト全体は入れず、ID のみを置きます。 完全なデータは DB に置き、必要なときにエージェントがツール get_gift_details(gift_id) を呼び出して取得します。

Agents SDK における Session(概念)

多くのエージェント用 SDK にはセッションの抽象があり、メッセージ履歴の管理を担うとともに、構造化状態を追加で保管できます。 擬似コードでは次のように見えます:

import { createRunner, OpenAIConversationsSession } from "@openai/agents";
// 上の例の GiftSessionState 型

const session = new OpenAIConversationsSession<GiftSessionState>({
  sessionId: "chatgpt-thread-id-or-random",
});

const runner = createRunner({ agent });

const result = await runner.run({
  session,
  input: "同僚への50ドルまでのプレゼントが欲しい",
});

SDK が内部で行うこと:

  • このセッションのメッセージ履歴を取得する。
  • 新しいユーザーメッセージを追加する。
  • モデルとツール群へ引き渡す。
  • 更新された状態(session.state を含む)を保存し直す。

あなたは session.state を通常のオブジェクトとして扱います。

ツールからの session state 更新

典型的なパターンとして、何かを計算するツールが同時にセッション状態も更新します。 例えば、ユーザーの回答から受取人プロフィールを集めるツール:

export async function updateProfileDraft(
  session: GiftSessionState,
  answers: { questionId: string; value: string }
): Promise<GiftSessionState> {
  const next: GiftSessionState = { ...session };

  if (!next.profileDraft) {
    next.profileDraft = {};
  }

  if (answers.questionId === "interests") {
    next.profileDraft.interests = answers.value.split(",").map((s) => s.trim());
  }

  // ...その他のフィールド

  next.step = "generating_candidates";
  return next;
}

ここではツールに渡すのは SDK の Session 全体ではなく、その state(型は GiftSessionState)だけです。 実コードでは、この引数名を currentState などにして、Session オブジェクトと混同しないようにするのがよいでしょう。

エージェントはこのツールを呼び出し、新しい状態オブジェクトを受け取り、それを session.state に保存します。

4. Persistent state: エージェントの長期メモリ

GiftGenius は 1 つのチャットだけで動くわけではありません。ユーザーは 1 週間後に別のデバイスから戻ってきて、 「前回と同じ友人向けにギフトを選んで。予算は増えたよ」と言うかもしれません。

この情報は session state ではなく、persistent ストレージ(データベース、commerce/ACP バックエンド(commerce 層。別モジュールで扱います)など)に置くべきです。

persistent モデルの例

受取人プロフィールの DB モデルを(簡略化して TypeScript の型として)記述します:

// DB に保存されるもの
export type RecipientProfile = {
  id: string;
  userId: string;
  label: string; // "マーケティングの同僚"
  recipientType: string;
  ageRange?: string;
  interests: string[];
  dislikes: string[];
  lastUsedAt: string; // ISO 日付
};

そしてリポジトリ(ここでは簡単のため Map。実際には ORM/SQL 層を作るでしょう):

const profiles = new Map<string, RecipientProfile>();

export const RecipientRepo = {
  async findByUser(userId: string): Promise<RecipientProfile[]> {
    return [...profiles.values()].filter((p) => p.userId === userId);
  },

  async save(profile: RecipientProfile): Promise<void> {
    profiles.set(profile.id, profile);
  },
};

エージェントはツール経由で persistent にアクセスする

エージェントが DB に直接触らず、ツールを介して動くことが重要です。 そうすれば「クリーン」なエンティティのままでいられます。ある場所には LLM と計画ロジック、別の場所には統合実装がある、という分離です。

例えば、ツール get_recipient_profiles:

export async function getRecipientProfilesTool(input: {
  userId: string;
}): Promise<{ profiles: RecipientProfile[] }> {
  const profiles = await RecipientRepo.findByUser(input.userId);

  return {
    profiles,
  };
}

エージェントはツール記述を読み、「現在のユーザーの受取人プロフィールを取得するにはこの tool を使え」と理解します。 呼ぶタイミングはエージェント自身が判断します。

まとめると、session state は特定の会話の進捗や、なくなっても問題ない一時キャッシュのためのもの。 persistent データはセッションやデバイスをまたいで残すべきもの(プロフィール、注文、ウィッシュリスト)。 エージェントはそれらを「魔法の記憶」でなく、常にツール経由で読みます。

5. run サイクルで session と persistent がどう連携するか

ここまでを全体スキームにまとめます。エージェントの各 run サイクルでは短い手順があります:

  1. sessionId で session state を取得する。
  2. 必要に応じて、ツールで関連する persistent データを DB から読み込む。
  3. モデル向けのコンテキスト(messages + 構造化状態)を形成する。
  4. モデルが判断する: テキストで答えるか、ツールを呼ぶか。
  5. ツールは session state または persistent データ(DB 経由)を更新する。
  6. 新しい session 状態を保存し、必要なら checkpoint を作成する(後述)。
  7. ユーザーへ応答を返す。

mermaid の図:

flowchart TD
    A[ユーザー入力を受け取る] --> B["Session(state + messages)を読み込む"]
    B --> C{persistent データは必要か?}
    C -- はい --> D[ツールを呼び出す: get_user_profile, get_recipient_profiles]
    C -- いいえ --> E[LLM のためのコンテキストを構築]
    D --> E
    E --> F["モデル(LLM)を呼び出す"]
    F --> G{モデルは tool を呼び出したいか?}
    G -- はい --> H[tool を実行し、session/persistent を更新]
    G -- いいえ --> I[最終回答を準備]
    H --> J[checkpoint を作成し、Session を保存]
    I --> J
    J --> K[ユーザーへ応答]

このサイクルにより、エージェントの振る舞いは再現可能になります。各ステップで、モデル呼び出し前にどんな状態で、呼び出し後に何が変わったかを明示的に把握できるからです。

6. Checkpoints: エージェント状態のスナップショット

Checkpoints は、プロセスの重要なステップにおけるエージェントの「状態スナップショット」を保存したものです。 単なる「現在の session state」ではなく、外部ストレージに記録された事実、すなわち ステップ N でどんな state だったか、ツール結果は何だったか、ユーザー入力は何だったか、です。

必要な理由:

  • エラーやクラッシュからの復元。
  • ユーザーの「あとで続ける」を可能にする。
  • デバッグ: 問題の run の再現性。
  • 監査: 例えば注文を作成する前にエージェントが何をしたかの記録。

通常 checkpoint に含めるもの

典型的な checkpoint は次を含みます:

  • 識別子: runIduserIdworkflowIdstepId
  • その時点のセッション状態。
  • persistent エンティティの主要な ID(例: 注文ドラフトの id)。
  • メタデータ: 作成時刻、エージェントのバージョン。

会話テキスト全体をそこへ引きずり込まないことが重要です。後述のメモリ衛生の章で、何を保存し何を保存しないかに再度触れます。

Session への参照や、ステップの簡潔な要約を持つ方がよいでしょう。

7. GiftGenius のためのチェックポイント設計

ギフト選定のプロセスを取り上げ、どこにチェックポイントを置きたいかを決めます。例えば:

  • 受取人プロフィールを集め終えたあと。
  • 候補を生成し一次フィルタをかけたあと。
  • ユーザーに最終選択を提示する直前。

checkpoint と workflow 状態の型

workflow の状態を記述します(GiftSessionState に非常に似ていますが、こちらはチェックポイント用の「スナップ」):

export type GiftWorkflowStep =
  | "profile_collected"
  | "candidates_generated"
  | "filtered"
  | "final_choice_made";

export type GiftCheckpoint = {
  id: string;
  runId: string;
  userId: string;

  step: GiftWorkflowStep;

  // 復元に必要な
  // session 状態の一部
  sessionState: GiftSessionState;

  // 生成された候補の id
  candidateIds: string[];

  createdAt: string; // ISO
  agentVersion: string;
};

チェックポイントのストレージ(簡略版)

前と同様に、ここでも本物の DB の代わりに単純な Map を使います:

const checkpoints = new Map<string, GiftCheckpoint>();

export const GiftCheckpointRepo = {
  async save(cp: GiftCheckpoint) {
    checkpoints.set(cp.id, cp);
  },

  async findByRun(runId: string): Promise<GiftCheckpoint[]> {
    return [...checkpoints.values()].filter((c) => c.runId === runId);
  },

  async findLastByUser(userId: string): Promise<GiftCheckpoint | undefined> {
    return [...checkpoints.values()]
      .filter((c) => c.userId === userId)
      .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
  },
};

エージェントコードからチェックポイントを作成

重要なステップの後に呼び出すヘルパーを用意します:

import { randomUUID } from "crypto";

export async function createCheckpoint(params: {
  runId: string;
  userId: string;
  step: GiftWorkflowStep;
  sessionState: GiftSessionState;
  candidateIds: string[];
}) {
  const checkpoint: GiftCheckpoint = {
    id: randomUUID(),
    runId: params.runId,
    userId: params.userId,
    step: params.step,
    sessionState: params.sessionState,
    candidateIds: params.candidateIds,
    createdAt: new Date().toISOString(),
    agentVersion: "v1.3.0",
  };

  await GiftCheckpointRepo.save(checkpoint);
}

エージェントは必要なタイミングで次のように呼び出せます:

await createCheckpoint({
  runId,
  userId,
  step: "filtered",
  sessionState,
  candidateIds,
});

復元時には次を行います:

  1. runId または userId で最後のチェックポイントを見つける。
  2. checkpoint.sessionState から session.state を復元する。
  3. 必要なら candidateIds を用いて DB から最新データを取り直す。

8. session・persistent・checkpoints を技術的にどこへ保存するか

インフラレベルでは、通常 3 つのクラスのストレージを使います:

  • In‑memory — 開発/デモ用。高速だが一時的。
  • Redis(または他の KV ストア)— session 状態用。
  • リレーショナル/NoSQL DB — persistent データとチェックポイント用。

ローカル開発向けの In‑memory ストア

ローカル開発モードでは、簡易なインメモリストアで十分です。例えば、セッション用の TTL 付きミニストレージ:

type StoredSession<T> = {
  state: T;
  expiresAt: number;
};

const sessions = new Map<string, StoredSession<GiftSessionState>>();

export function saveSession(sessionId: string, state: GiftSessionState) {
  sessions.set(sessionId, {
    state,
    expiresAt: Date.now() + 30 * 60 * 1000, // 30 分
  });
}

export function loadSession(sessionId: string): GiftSessionState | undefined {
  const stored = sessions.get(sessionId);
  if (!stored) return undefined;
  if (stored.expiresAt < Date.now()) {
    sessions.delete(sessionId);
    return undefined;
  }
  return stored.state;
}

この方式はローカル開発には最適ですが、本番での水平スケーリング(複数インスタンス)では動作しません。

session state 用の Redis

本番では、session 状態を Redis に保存すると便利です:

  • 読み書きが高速。
  • TTL が「箱から出してすぐ」使える。
  • サービスの全インスタンスからアクセス可能。

疑似例(簡略):

// Redis クライアントのラッパー
export async function saveSessionToRedis(
  sessionId: string,
  state: GiftSessionState
) {
  const json = JSON.stringify(state);
  await redis.set(`session:${sessionId}`, json, "EX", 60 * 30); // 30 分
}

export async function loadSessionFromRedis(
  sessionId: string
): Promise<GiftSessionState | undefined> {
  const json = await redis.get(`session:${sessionId}`);
  return json ? (JSON.parse(json) as GiftSessionState) : undefined;
}

Persistent とチェックポイント用の Postgres/他の DB

Persistent 状態とチェックポイントは「本格的」なエンティティです。トランザクション、マイグレーション、インデックスなどが重要になります。 これらは Postgres、MySQL、Firestore などに配置します。

ここでのアーキテクチャパターンはシンプルです:

  • session は TTL 付きで Redis。
  • persistent と checkpoints は TTL なし(またはビジネスに応じた保持ポリシー付き)で DB。

9. メモリ衛生: サイズ、プライバシー、責務分離

エージェントのメモリは「どこかにオブジェクトを置けば OK」ではありません。お金を節約し、安心して眠るための重要なルールがいくつかあります。

すべてを messages に詰め込まない

メッセージ履歴は高価なリソースです:

  • その長さはモデルへのリクエストコストに大きく影響する。
  • たいてい「ノイズ」が多い。

したがって:

  • できるだけ早く、履歴から事実を取り出して構造化状態へ移す。
  • 古い部分の履歴には要約(summarization)を使う。
  • checkpoint にテキスト履歴を保存する場合、それをモデルに送るものとは分離する。

プライバシーと PII

特に commerce シナリオでは、機微なデータを不適切な場所に保管しないことが重要です。 メモリアーキテクチャのドキュメントも、PII を messages やチェックポイントに無加工で置くべきではないと強調しています。

実践的なルール:

  • エージェントの動作に不要であれば、email/電話/住所を session state に直接入れない。
  • ログやチェックポイントには、生の文字列ではなく識別子(userIdrecipientProfileId など)を書く。
  • PII を複数ステップで引き回す必要があるなら、persistent ストアの別の保護フィールドに置き、state にはキーだけを渡す。

ビジネスデータとチャットログの分離

よいパターンは、state を「クリーン」メモリ、messages を「汚れた」メモリとみなすことです。

つまり:

  • ビジネスエンティティ(プロフィール、注文、カート)は常に DB に生かしておく。
  • state/チェックポイントには、プロセス復元に必要な最小限だけを入れる。
  • ログ/チャット履歴は別に保存(例: ベクトルストア)し、分析に使うが、毎回のモデル呼び出しに混ぜない。

10. ミニ演習: 何を保存するか?

メモリレイヤの違いを定着させるため、具体的なケースで少し考えてみましょう。 コードを書く必要はなく、紙や頭の中で構造を検討するだけでも十分です。

あなたのエージェント GiftGenius がユーザーと次のように会話したと想像してください:

  • ユーザー: 「同僚の開発者向けのギフトが必要です。予算は 50 ドルまで、ボードゲームとカフェインが好きです」。
  • エージェント: いくつか確認質問をする。
  • ユーザー: 「彼はマグカップが嫌いで、ノートはもう山ほどあります」。
  • エージェント: アイデアを 10 件生成。ユーザーは 1 つ選ぶが、「後で戻って手続きを完了する」と言う。

考えてみましょう:

  1. 30 分で消えるかもしれない session state に何を入れますか?
  2. ユーザーが 1 週間後に戻れるように、persistent ストアへ何を入れますか?
  3. アイデアを選んだ後、注文確定の前に作る checkpoint はどのような姿になりますか?

この講義の例にならい、対応する TypeScript の型と関数 saveSessionStatesavePersistentStatecreateGiftIdeaCheckpoint をざっと設計してみてください。 やる気があれば、上の例にならってエディタで型と関数を書いてみてもよいでしょう。次の講義へ進む前のよいミニチェックポイントになります。

11. エージェントのメモリに関するよくある誤り

誤り No.1: すべてをメッセージ履歴だけに保存しようとする。
開発者はこう喜びます。「モデルはどうせ全対話を見られるのだから、わざわざ state なんて要らないのでは?」。 結果として、数十メッセージを超えるとコンテキストウィンドウはゴミで埋まり、トークン代は新しい MacBook 並み、そしてエージェントの挙動は不安定になります。重要な古い事実が見えなくなるからです。 この問題は、session state と persistent ストアを明示的に分けることで解決すべきで、単に上限を増やす話ではありません。

誤り No.2: session と persistent を 1 つのオブジェクトに混ぜる。
大きな AgentState を作って何でもかんでも入れ、「そのまま」DB に保存したくなることがあります。 そうすると、特定会話の一時データとユーザーの長期データの境界が曖昧になります。 「デプロイ後にすべてのセッションが昨年のデータから謎に復元された」「あるユーザーのセッションが別人の persistent プロフィールを拾った」などの事故が起きがちです。 レイヤは意識的に分けましょう。

誤り No.3: チェックポイントに詰め込み過ぎる。
よくあるのは、ツール応答の JSON 全体、対話履歴の全量、統合の生データ等を checkpoint に書いてしまうことです。 数週間でチェックポイント用 DB が肥大化し、バックアップに何時間もかかり、DB クエリが遅くなります。 チェックポイントには、プロセスを継続するのに本当に必要な事実と最小限のメタデータだけを置きましょう。

誤り No.4: session state の TTL とクリーンアップを忘れる。
session 状態に寿命がないと、ユーザーのちょっとした Dev モードの実験が Redis に永遠に残ります。 数カ月後、監視を見ると「忘れられた」セッションが山となり、メモリを食い尽くします。 session レイヤは明示的な TTL で設計し、persistent レイヤはよく考えられた保持ポリシーで運用しましょう。

誤り No.5: 必要もないのに state とチェックポイントに PII を保存する。
特に危険なのは、session state に無思慮に email・住所・カード番号を入れ、そのオブジェクトがログへシリアライズされ、分析や checkpoint にも流れていくケースです。 これは規制やセキュリティの観点で重大なリスクになります。 安全な識別子を保存し、必要に応じて保護された別ツールで実データへ解決するのが良い方法です。

誤り No.6: チェックポイントからの復元戦略がない。
真面目に checkpoint を記録していても、そこからエージェントをどう復元するかを検討していないチームがあります。 その結果、「何かがうまくいかなかった」時に、開発者は美しい JSON の表を眺めるだけで、run を再構築するコードを持ちません。 復元シナリオのないチェックポイントは高価なログであって、信頼性の手段ではありません。

誤り No.7: エージェントを特定のストレージ実装に強く結び付ける。
エージェントのコードが Redis/Postgres に直でアクセスすると、移植性・テスト性・拡張性が下がります。 アーキテクチャ変更(例えば MCP リソースや専用 state サービスの登場)の度に、エージェントロジックを大改修する羽目になります。 Session とツールの抽象だけをエージェントから見せ、どこにデータがあるかはツール側が知る方が、はるかによい設計です。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION