Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Vertex AI Agent Engine で学ぶ「記憶」の設計

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for t-kikuchi t-kikuchi
February 23, 2026

Vertex AI Agent Engine で学ぶ「記憶」の設計

Vertex AI Agent Engine で学ぶ「記憶」の設計

Avatar for t-kikuchi

t-kikuchi

February 23, 2026
Tweet

More Decks by t-kikuchi

Other Decks in Technology

Transcript

  1. # セクション 時間 備考 0 導入(なぜ作ったか) 1分 登壇テーマを決めた経緯とアプリ概要 1 Agent

    Engine 全体感 1分 概念・アーキテクチャの全体像を一気に流す 2 Runtime / Sessions / Memory Bank の詳細 - 資料は用意するが時間がないため割愛 3 詰まった点 3選 6分 実際に開発して引っかかったポイント 4 設計 Tips 6分 Agent Engine を使う上での設計判断 アジェンダ 4
  2. Agent Engine をテーマに登壇したいと思った。 Agent Engine はただのデプロイ基盤ではなく、Sessions(短期記憶) や Memory Bank(長 期記憶)

    といった独自の機能を持っている。せっかくなのでこれらを実際に動かしてみたかった。 これらを活かせるテーマとして思いついたのが、恋愛シミュレーションだった。 Agent Engine をテーマに登壇したかったから 6
  3. Vertex AI Agent Engine + Gemini で動く恋愛シミュレーション型チャットアプリ キャラクターとの会話が続くほど 親密度 が上がる

    重要な出来事は Memory Bank(長期記憶) に残り、次回以降の会話に反映される 感情やシーンが変わると 画像が自動生成 される → これを作る過程でハマったポイントと、そこから得た設計知見を共有する。 作ったもの 7
  4. ローカルで書いたエージェントコードをGCP上で動かす実行基盤 ADK / LangChain / LangGraph / LlamaIndex 等をサポート スケーリング・セキュリティ(Secret

    Manager / VPC-SC / CMEK)をすべて肩代わり デプロイ方法 2種類: 方法 向いている場面 エージェントオブジェクトから直接デプ ロイ Colab等のインタラクティブ開発 ソースファイルからデプロイ CI/CDパイプライン向け(Terraform はインフラ設定のみ、コードデプロイは Python SDK 必須) Runtime 12
  5. 出典: https://docs.cloud.google.com/agent-builder/agent-engine/deploy ① app(RAM上のオブジェクト)を cloudpickle で .pkl ファイルに書き出す ② requirements.txt

    + dependencies.tar.gz とバンドル化 ③ Cloud Storage にアップロード(staging用) ④ Agent Engine がコンテナをビルドしてHTTPサーバーを起動 Runtime デプロイ パターン①: エージェントオブジェクトから直接デプ イ 13
  6. 出典: https://docs.cloud.google.com/agent-builder/agent-engine/deploy ① source_packages の .py ファイルを tar.gz に固めて Vertex

    AI API に送付 ② GCS への中間アップロードなしでコンテナをビルド ③ 起動時に entrypoint_module.entrypoint_object を import して使う Runtime デプロイ パターン②: ソースファイルからデプロイ 14
  7. 1セッション = 1つの会話スレッド Event: 会話の記録(追記のみ・不変)→「何が起きたか」 State: セッションに紐づくキーバリューストア(事前設定・動的更新どちらも可)→「今どうい う状態か」 State のキープレフィックスによるスコープ制御:

    プレフィックス 有効範囲 例 (なし) 現在のセッション内のみ booking_step: "payment" user: そのユーザーの全セッションをまたいで保持 user:language: "ja" app: アプリ全体で共有 app:maintenance_mode: false temp: 現在の1ターン(1 invocation)のみ temp:validation_result: true user: / app: の永続化は Database または VertexAI SessionService 使用時のみ有 効。 user: プレフィックスを使うと、Memory Bank を使わずに軽量なユーザー設定を保持する こともできる。 Sessions(短期記憶) 15
  8. user_id: "taro" ├── session_id: "abc-001" (1日目の会話スレッド) ├── session_id: "abc-002" (2日目の会話スレッド)

    └── session_id: "abc-003" (3日目の会話スレッド) user_id : 128文字以内。ユーザーを識別するスコープ session_id : セッション作成時に自動生成。同一ユーザーの複数セッションを識別 list_sessions(app_name, user_id) で特定ユーザーの全セッションを取得できる Sessions - user_id と session_id の関係 16
  9. 渡すのはユーザーとエージェントの**生の会話ログ(Event)**で、実際に記憶として保存するかは Memory Bank 側の LLM が判断する。 # ターン終了コールバックで最新1ターン分(user + model

    ペア)を渡す await callback_context.add_events_to_memory( events=session.events[-2:] ) # events[-2:] の中身イメージ # Event(author="user", content="猫を2匹飼ってるんです。ミケとタマっていう名前で") # Event(author="agent", content="ミケちゃんとタマちゃん、かわいい名前ですね!") → Memory Bank の LLM が「ユーザーは猫を2匹(ミケ・タマ)飼っている」という事実を抽出 して保存する。 Memory Bank - 内部動作(3段階) 19
  10. add_events_to_memory(events) を呼んだとき。いつ呼ぶかはアプリ側で制御する。 今回のアプリでの設計: save_to_memory ツール → LLM が「重要なイベント」と判断したターンのみ呼ぶ ツールを呼ばなければ記憶されない(毎ターン自動保存ではない) 呼び出されるタイミング:

    方法 タイミング 特徴 PreloadMemoryTool 毎ターン自動 システムプロンプトに注入。受動的 LoadMemoryTool LLM が必要と判断したとき LLM が能動的にセマンティック検索 from google.adk.tools.preload_memory_tool import PreloadMemoryTool from google.adk.tools.load_memory_tool import LoadMemoryTool agent = Agent( tools=[PreloadMemoryTool(), LoadMemoryTool()] # これだけ ) Memory Bank - 記憶されるタイミング / 呼び出されるタイミング 21
  11. 勘違い(問題): 最初はよくわからないまま Runner クラスを使ってエージェントを動かしていた。 Agent Engine は使えていると思っていたが、実は Runner はローカル実行専用で、Agent Engine

    のランタイムには一切リクエストが飛んでいなかった。 # Runner を使ったローカル実行(やっていたこと) runner = adk.Runner( agent=agent, app_name=APP_NAME, session_service=VertexAiSessionService(...), # GCP にセッションは保存される memory_service=VertexAiMemoryBankService(...), # GCP にメモリは保存される ) await runner.run_async(user_id=..., session_id=..., new_message=...) # → ローカルで LLM を直接呼ぶ。Agent Engine のランタイムは使っていない # Agent Engine のランタイムを使う正しい方法 adk_app = vertexai.agent_engines.get(AGENT_ENGINE_ID) # デプロイ済みリソースを取得 await adk_app.async_stream_query(user_id=..., message=...) # → Agent Engine ランタイム(GCP)経由で LLM を呼ぶ ① Runner では Agent Engine のランタイムは使えない 23
  12. Sessions / Memory Bank は「エージェントコードのデプロイ」とは独立している。 空のインスタンスを作るだけでストレージとして使える。 # コードをデプロイしない空インスタンスを作成するだけで # Sessions

    / Memory Bank のストレージが利用可能 agent_engine = client.agent_engines.create() # 引数なし 公式ドキュメント: "You don't need to deploy any code to start using Sessions" つまり Agent Engine は ランタイム(コード実行) と ストレージ(Sessions / Memory Bank) が独立しており、ストレージだけ使うことも、両方使うこともできる。 ① その過程で気づいた発見 24
  13. エラー: Publisher Model .../locations/us-central1/.../gemini-3.1-pro-preview was not found 原因: 前提: エージェントのコードは

    Agent Engine の中で動き、そこから Gemini を呼ぶ Agent Engine は global へのデプロイ不可 → AdkApp を動かすには GOOGLE_CLOUD_LOCATION=us-central1(デプロイ先リージョン)の設定が必要 ↓ AdkApp 内のコードが Gemini を呼ぶとき、同じ GOOGLE_CLOUD_LOCATION=us-central1 を読む ↓ Gemini 3 は us-central1 に存在しない(global エンドポイントのみ)→ 404 NOT_FOUND かといって GOOGLE_CLOUD_LOCATION=global にすると Agent Engine 自体の呼び出しが global を向いて Agent Engine 側が動かなくなる ② Gemini 3系が Agent Engine で使えない 25
  14. 解決: Gemini サブクラスで api_client をオーバーライドして location='global' を強制 class _Gemini3Global(Gemini): @property

    # @cached_property はロック競合が起きることがある → @property を使う def api_client(self) -> Client: return Client( project=os.getenv("GOOGLE_CLOUD_PROJECT", ""), location="global", http_options=HttpOptions( headers=self._tracking_headers(), # ← () を忘れると dict 型エラー retry_options=self.retry_options, ), ) ADK issue #3628 として報告済みの既知バグ。 出典: https://github.com/google/adk-python/issues/3628 ② 解決策:Gemini サブクラスでapi_client をオーバーライド 26
  15. 背景: なぜ標準パターンを使わなかったか ADK の一般的な Memory Bank の使い方は、 after_agent_callback で毎ターン終了後にセッシ ョン全体を

    Memory Bank に送る方法 # ADK の想定パターン: 毎ターン終了後に会話を記憶として保存 async def after_agent(callback_context: CallbackContext): await callback_context.add_session_to_memory() ただし毎ターン Memory Bank に送ると、 GenerateMemories API が呼ばれるたびに Extraction LLM(Gemini 2.5 Flash Thinking On)が動き、トークン課金が発生する(GCP の課金 SKU で実際に確認済み) SKU 課金単位 memory bank Gemini 2.5 Flash GA Text Output (Thinking On) トークン数 memory bank memories stored in global メモリ数 × 月 memory bank memories retrieved in global 取得回数 Thinking On モデルは通常の Gemini より割高なため、毎ターン呼ぶと積み上がる。 ③ Memory Bank に保存できているのに読み込めなかった 27
  16. そこで「エージェント自身が重要と判断したときだけ保存する」ツールベースの設計を採用した: # 今回の設計: LLM が重要イベント時にのみ tool を呼んで保存 save_to_memory(content="ユーザーはプログラミングが好き") この標準パターンから外れた実装をしたことで、自前で VertexAiMemoryBankService

    をインスタ ンス化する必要が生じ、次の罠にはまった。 症状: save_to_memory は saved: True を返すのに、次セッションで記憶が空 根本原因: Agent Engine ランタイム内なのに MemoryBankService を自前でインスタンス化 していた 実行環境 MemoryBankService のインスタンス化 操作方法 Runner(ローカル実行) 必要 memory_service.add_events_to_memory(app_name=..., ...) Agent Engine ランタイム 不要 tool_context.add_events_to_memory(events=[...]) Agent Engine ランタイムで動いている場合、Memory Bank の管理は Agent Engine が担う。 tool_context を通じて操作すれば、 app_name / user_id / session_id はすべて自動で正しく 解決される。 ③ ツールベースの設計を採用した 28
  17. 自前で VertexAiMemoryBankService をインスタンス化して app_name をハードコードすると、 Agent Engine が内部で使っている app_name (=

    Agent Engine ID)と不一致になり、書く場 所と読む場所がズレる。 # 問題のコード(Before) # 自前で MemoryService を作って app_name をハードコード memory_service = VertexAiMemoryBankService( project=project, location=location, agent_engine_id=agent_engine_id, ) await memory_service.add_events_to_memory( app_name="character_agent", # ← この値が読み込み側と一致しない user_id=user_id, events=[event], ) # 修正後(After) # tool_context に任せる → app_name が自動で正しくなる await tool_context.add_events_to_memory(events=[event]) ③ Before / After コード比較 29
  18. # 新: output_schema に Pydantic モデルクラスを直接渡す class StructuredResponse(BaseModel): dialogue: str

    # キャラクターのセリフ narration: str # 情景描写 emotion: Emotion # 感情(happy / sad / neutral ...) scene: Scene # シーン(cafe / park / indoor ...) affinity_level: int # 現在の親密度(0-100) agent = Agent( output_schema=StructuredResponse, # ← これだけ ... ) ① エージェントの応答を構造化された形で指定できる 32
  19. 実際のレスポンス例: { "dialogue": "「やあ」、ですね。以前、挨拶に迷うからついそう言ってしまうって話してくれたのを覚えていますよ。相変わらず思考のショートカットを選択しているみたいですけど、それはそれであなたらしい『仕様』なのかもしれませんね。", "narration": "Hanaは手元のカップを少し傾け、静かにこちらを見つめる。", "emotion": "excited", "scene": "cafe",

    "affinity_level": 69 } 1回のLLM呼び出しで「セリフ・感情・シーン・親密度」を一気に取得できる。 何が嬉しいか: emotion / scene の変化を FastAPI 側で検知 → 画像生成のトリガーに使える(ツール不要) affinity_level を毎ターン LLM が直接返す → ツールを呼ばずに現在の親密度を取得できる ポイント: システムプロンプトに JSON フォーマットの説明をあえて書かない。 output_schema が API 側で構造を強制するため、プロンプトに重複して書くと逆に出力品質が下がる(公式ドキュメ ントの推奨) 。 ① 実際のレスポンス例と何が嬉しいか 33
  20. update_affinity をツールから外した理由: 最初は親密度の更新(Firestore への書き込み)を update_affinity ツールとして持たせてい た。 しかしツール呼び出しが挟まると1ターンの応答時間が伸びる。 構造化レスポンスに affinity_level

    が含まれているので、LLM が判断した値を FastAPI 側で受 け取り、前回との差分をプログラム的に計算して書き込めばよいと気づきツールから外した。 画像生成をツールにしなかった理由: 「画像を生成するか」の判断に会話の文脈は不要(emotion / scene の変化という機械的な条件 で判断できる) 生成処理 5〜15秒 を Agent Engine のターン内に含めず後処理として実行できるため、会話応 答を待たせずに済む ② update_affinity・画像生成をツールから外した理由 36
  21. 背景: Firestore に意図しないユーザーのデータが書き込まれる事象が発生 ツールの引数として user_id を LLM に渡す設計にしていたところ、Firestore の別ユーザーのド キュメントに書き込まれる事象が起きた。

    LLM が user_id を引数として渡す場合、その値が正しいかどうかは確率的であり、本質的に保証 できない。 一般化: 厳密に正しい値が必要なパラメータは LLM の推測に任せない user_id のような「間違えると別ユーザーのデータを破壊する」パラメータを、LLM の判断に委 ねるのはリスクが高い。 LLM の引数として渡す Sessions の State から取得する 正確性 確率的(LLM が推測) 確定的(セッション作成時に埋め込んだ値) リスク 誤った user_id で別ユーザーのデータを上書き ゼロ ③ LLM に推測させてはいけない情報は Sessions の State から取得す る 37
  22. 解決策: セッション作成時に State に埋め込み、ツール内で参照する ToolContext.state は Sessions の State そのもの。セッション作成時に書き込んでおけば、ツ

    ール内から確定的に読み出せる。 # セッション作成時に state(= Sessions の State)に user_id を埋め込む session = await adk_app.async_create_session( user_id=user_id, state={"user_id": user_id} # ← Sessions の State に書き込む ) # ツール側は LLM の引数ではなく state から取得(LLM は関与しない) def initialize_session(tool_context: ToolContext) -> dict: user_id = tool_context.state["user_id"] # 完全に確定的 ... この設計なら LLM は user_id を引数として渡す必要がなく、誤動作のリスクがゼロになる。 デモでの判断: 変更範囲は限定的(3ツール関数の引数変更 + state 埋め込み + 再デプロイ)だ が、デモ用途では「LLM がほぼ従う」現行実装で許容範囲として据え置き。 ③ 解決策: セッション作成時に State に埋め込む 38