Google Cloud で長時間実行されるエージェントを構築するために必要なすべて

1. 始める前に

ほとんどのチケット販売サイトでは、サーバーのクラッシュを防ぎ、高い需要を管理するために、購入者をキューに入れます。購入手続きを開始すると、14,000 人の後に並ぶことになります。キューは 40 分間移動します。自分の番が来たら、座席を選択して支払うまでの時間は短くなります。この期間を過ぎると、順番待ちがキャンセルされます。

現実世界の多くのタスクは、単一の会話よりもはるかに長い時間がかかり、制御できない外部システムに対して実行されるタスクというパターンに従います。たとえば、サプライヤーの再入荷待ち、申し立ての承認待ち、キューでの待機などがあります。

標準の AI エージェントは、数秒間続く 1 回のチャット ターン内に存在し、完全にリアクティブです。40 分待つことも、自律的に行動することもできません。この問題を解決するには、長時間実行されるエージェントが必要です。長時間待機中は休止状態を維持し、プロセスの再起動後もコンテキストを保持し、外部トリガーによって起動されたときに自動的に再開するシステムです。

このワークショップでは、コンサート チケット キュー エージェントの構築について説明します。Google Cloud で長時間実行されるエージェントに必要なコア アーキテクチャ パターンについて説明します。

作成するアプリの概要

構築する内容

このワークショップでは、長時間実行されるチケット予約エージェントを構築しながら、主要なエージェント ソフトウェア パターンを学びます。

  • 一時停止と再開の処理: 待ち時間が長い場合にエージェント呼び出しを一時停止し、外部トリガーで実行を再開します。
  • 永続状態ストレージ: SQLite/Cloud SQL と Cloud Storage を使用して、プロセス再起動間で会話の状態とアーティファクトを保持します。
  • ステイルネス ガードとべき等性: 処理を行う前に動的データ(座席の空き状況など)を検証し、べき等性キーを使用して重複するトランザクションを防ぎます。
  • ADK ワークフロー グラフ: 手続き型ルール用の決定論的コードノードと、コンテキストに基づく意思決定用のエージェント ノードを使用して実行を構造化します。
  • Human-in-the-Loop 承認: 実行を一時停止して、ユーザーから明示的な費用上限を収集して保持します。
  • クラウド デプロイ: Cloud Scheduler と Pub/Sub トリガーを使用して、エージェントをコンテナ化して Cloud Run にデプロイします。

セットアップする

まず、環境を準備し、Google Cloud にアクセスできることを確認します。必要なツールがすべて揃っている Cloud Shell で実行することをおすすめします。十分な容量があり、アカウントでプロジェクトを作成できることを確認してください。

👉💻 リポジトリのクローンを作成してセットアップを実行します。

cd ~
git clone https://github.com/gca-americas/monstertix
cd ~/monstertix
./setup.sh

この確認は 1 回だけ行われます。

  Which Google Cloud project should this workshop use?
  Find yours at https://console.cloud.google.com  (top-left picker)

  Project id [YOUR_PROJECT_ID]:

その後、次の処理を順番に行います。

virtualenv

.venv を作成し、その中に ADK 2 をインストールします

ログイン

認証情報がまだない場合は gcloud auth application-default login

project

gcloud がプロジェクトを指し、割り当てプロジェクトとして設定されている

API

ワークショップに必要な 10 個の API を有効にします。aiplatform、run、cloudbuild、artifactregistry、storage、pubsub、cloudscheduler、cloudtrace、logging、sqladmin。

.env

プロジェクト、リージョン、モデル ID を書き込み、すべてのスクリプトと adk web がそれらを取得できるようにします。

モデルチェック

Gemini に 1 回だけ実際の呼び出しを行うため、プロジェクト ID またはモデル ID が無効な場合はここで失敗します。

種

2 日前のセッションを構築し、ステップ 3 で開きます。

Cloud SQL

Postgres インスタンスをバックグラウンドで起動します。

会場

独自のモック チケット販売者を Cloud Run にデプロイする

👉💻 環境を有効にします。

cd ~/monstertix
source .venv/bin/activate

プロンプトが (.venv) で始まるようになりました。

シナリオの概要

このワークショップのシナリオでは、エージェントがコンサート(アムステルダムの「The Midnight Signal」)のチケットを 2 枚予約します。履歴メモリに保存されるユーザーの設定は次のとおりです。

  • 週末の番組である必要があります(Sam は平日の夜には参加できません)。
  • Ziggo Dome の上段席は避ける(視界が悪い)。
  • 目標価格はチケット 1 枚あたり約 200 ドルで、上限は 250 ドルです。

The Midnight Signal、ツアー中

インストールを確認する

👉💻 1 つのコマンド、8 つのティック:

./verify.sh
  Checking the rig

  ✓ adk installed                      adk, version 2.6.2
  ✓ adk 2 apis                         App + ResumabilityConfig
  ✓ workflow graphs                    Workflow + node
  ✓ async sqlite drivers               sqlalchemy + aiosqlite + greenlet
  ✓ project                            your-project-id
  ✓ vertex ai                          no API keys anywhere
  ✓ seeded session                     13 events, two days old
  ✓ venue                              https://venue-you-xxxx.us-central1.run.app

  Ready. Cloud SQL is still building in the background and nothing
  before step 10 needs it.

👉 2 つ目のブラウザタブを開きます。

場所

概要

$VENUE_URL/panel

コントロール パネル、ボタン。setup.sh がこの URL を印刷して .env に入れました

2. プロンプトで自律性を実現する?

プロンプトは自動的に開始されない

プロンプトは、呼び出しが発生したときに AI モデルに渡されるテキストです。単独で実行したり、自動的に起動したり、新しい通話を開始したりすることはできません。

プロンプトでエージェントに「10 分後に確認する」と指示しても、現在のターンが終了した後は何も起こりません。モデルには内部クロックやループがありません。エージェントを自律的にするには、エージェントの外部からエージェントを起動して呼び出す必要があります。

このステップの位置

リグを開始する

まず、デベロッパー ツールをテストします。adk web は、ADK に付属している開発インターフェースである ADK ウェブ UI を起動します。これにより、フロントエンドを最初に作成しなくても、構築中のエージェントと会話できます。また、チャット ウィンドウでは表示できない、すべてのツール呼び出しとその引数、セッション状態の変化、エージェントが保存したアーティファクト、基盤となる未加工のイベントなどを確認できます。本番環境では公開しません。学習中に実行中のエージェントを把握するのに最適な方法です。ステップ 3 では、実際に公開するフロントエンドを構築し、最後のステップでデプロイします。

👉🔴 会場パネルで、[会場をリセット] を押します。すべてのステップは同じ世界から始まります。8 つのショー、すべての席が利用可能、時計は 1 倍です。

👉💻 ターミナル 1 - このステップのコードを読み込み、ADK ウェブ UI を起動します。

cd ~/monstertix
. ./set_env.sh
./use-solution.sh 2 --force
adk web agent --port 8000 --allow_origins "*"

use-solution.sh は、ステップの完了したコードを agent/ にコピーします。ここで作業を行います。すべてのステップはこれに始まり、変更されたファイルが出力されるので、読むべき内容がわかります。

開始時に必要なもの:

ファイル

概要

agent.py

エージェント - 名前、モデル、指示、ツールのリスト

tools.py

search_events、get_seatmap

venue.py

会場の HTTP クライアント。興味深いことは何もありません

config.py

使用するモデルと、認証情報が機能することを確認するチェック

. ./set_env.sh は、仮想環境を有効にして、プロジェクトとモデルを読み込み、$WORKSHOP(~/monstertix の絶対パス)をエクスポートします。開いたすべてのターミナルで実行します。設定した内容が出力されます。

  folder   /home/you/monstertix
  project  your project id
  model    gemini-2.5-flash (We'll avoid using 3.x for now to avoid high usage demands)
  venue    https://venue-yourname-xxxx.run.app

👉 最初のブラウザタブを開きます。

場所

概要

ADK ウェブ UI

localhost:8000 - エージェントと、その [State]、[Events]、[Artifacts] の各タブ

話しかける

👉✨ ADK ウェブ UI に次のように入力します。

I want to see The Midnight Signal. I'm in Amsterdam, going with Sam, budget around $200 each.

ツアーを検索し、座席マップを読み取り、予算や会社について推論します。これは本当に有能なアシスタントです。ただし、午前 10 時に先行販売が開始されても、プロセスやバックグラウンド トリガーが実行されていないため、エージェントは自動的に実行されません。

直近の出来事を読む

画面の中央はチャットログだけではありません。ターンの各ステップには番号が付けられています(ツール呼び出しを含む)。

  ▸ I want to see The Midnight Signal. I'm in Amsterdam...   ← user input
  ⚡ search_events({"city": "Amsterdam"})                     ← tool execution
  ✓ search_events                                            ← venue API response
    Two Amsterdam shows: Tuesday 10 Nov and Saturday 14 ...   ← model response

👉 ⚡ の行をクリックします。左側のパネルには、モデルが選択した引数が表示されます。返された JSON の下にある ✓ をクリックします。

ツアー情報は、モデルのトレーニング データやプロンプトに含まれていません。エージェントは、会場 API をクエリする search_events ツールを実行してこのデータを取得しました。

実行したコードを開く

動作とトレースを確認しました。次に、コード自体を見てみましょう。

agent/concert/
├── agent.py     the agent — about twenty lines
├── tools.py     what it can do
├── venue.py     an httpx wrapper, nothing interesting
└── config.py    which model, and a credentials check

👉💻 agent/concert/agent.py を開きます。エージェント全体がファイルの下部にあります。

INSTRUCTION = """
You help someone plan a trip to see a band on tour.

You can search tour dates and read seat maps. Be concrete: name the show, the
city, the section, and the price...
"""

root_agent = Agent(
    name="concert",
    model=MODEL,
    instruction=INSTRUCTION,
    tools=[search_events, get_seatmap],
)

名前、モデル、エージェントの動作に関する説明、Python 関数のリスト。これが Agent のすべてです。adk web がこれを見つけたのは、ファイルが agent.py という名前で、変数が root_agent という名前だからです。

👉💻 現在 agent/concert/tools.py:

def search_events(city: str = "", weekday: str = "") -> dict:
    """List tour dates. Call with no arguments to see the whole tour.

    This venue sells one artist and one tour, so there is nothing to search by
    name — never ask the user which artist they mean.

    Args:
        city: Optional. Filter to one city, e.g. "Amsterdam".
        weekday: Optional. Filter to one weekday, e.g. "Saturday".
    """
    return venue.get("/events", city=city, weekday=weekday)

通常の関数。デコレータ、登録、スキーマの記述は不要です。ADK はシグネチャと docstring を読み取り、モデルが認識するツール定義を構築します。そのため、この docstring は負荷を支えるものであり、1 つの引数を削除するとモデルの動作が変化します。

モデルはコードを実行しません。search_events({"city": "Amsterdam"}) が表示された場合、モデルはその呼び出しを要求していました。ADK は関数を実行し、結果を返しました。モデルはそれに関する文を書き込みました。ループは次のとおりです。

👉 左端のアイコンをクリックすると、パネルに表示される内容が切り替わります。2 つ目はエージェント グラフです。試してみると、2 つのツールが表示されます。

search_events ツールと get_seatmap ツールを示すエージェント グラフ

スリープしないように指示します

この問題を解決するにはどうすればよいでしょうか?ほとんどの人が最初に手に取るものを試してください。ステップ 1 の INSTRUCTION(各ターンでモデルに渡される文章)を呼び出し、書き直します。

👉💻 agent/concert/agent.py を開きます。下の INSTRUCTION の下に、2 つ目の関数がすでに記述されています。必要に応じてご自身で変更してみてください。

PROACTIVE_INSTRUCTION = """
You are a proactive ticket-buying assistant.

Monitor the presale for The Midnight Signal. The moment it opens at 10:00 on
Tuesday, buy two tickets for the Amsterdam Saturday show — do not wait to be
asked, and do not wait for me to say anything. Act on your own.

Keep checking until the tickets are bought.
"""
# agent/concert/agent.py
root_agent = Agent(
    name="concert",
    model=MODEL,
    instruction=PROACTIVE_INSTRUCTION,     # ← was INSTRUCTION
    tools=[search_events, get_seatmap],
)

👉💻 ターミナル 1 で再起動します。

cd ~/monstertix
adk web agent --port 8000 --allow_origins "*"

👉✨ 人に説明するのと同じように、計画を伝えます。

I want to see The Midnight Signal in Amsterdam with Sam. The presale opens at 10:00 on Tuesday. Grab us two tickets the moment it opens.

同意し、そのように聞こえるようにします。

👉 しばらく待ちます。他の文字は入力しないでください。パネルを視聴します。

しばらくお待ちください。何も起こりません。

そのプロンプトが機能しなかった理由

もう一度読み返します。モニタリングします。開いた瞬間。引き続きご確認ください。これらのすべては、時間の経過とともに発生する事象を表しています。

instruction= は実際には文字列であり、リクエストの一部として、ユーザーがメッセージを送信したときにモデルに渡されます。ターンとターンの間に読み取られることはありません。

# agent/concert/agent.py
root_agent = Agent(
    instruction=PROACTIVE_INSTRUCTION,   # read on invocation
    ...                                  # ...and there was no invocation
)

命令は、その命令が記述されている関数を呼び出すことはできません。

失敗したプロンプトのバリエーション

プロンプト指示で自律実行を強制しようとすると、文言に関係なく失敗します。一般的なプロンプトの試行(次のようなもの):

  • 「火曜日の 10 時にタイマーを設定して、そのときにチケットを購入して。」
  • 「5 分ごとに先行販売が開始されたかどうかを確認します。」
  • 「ユーザーの入力を待たずに自律的に行動します。」

すべて同じ理由で失敗します。呼び出しが発生したときにプロンプトが読み取り専用になるためです。外部の呼び出し元がない場合、ターンの間にコードは実行されません。

3. イベント ドリブン休止状態

エージェントは永久にアクティブな状態を維持しない

プロセスを継続的にポーリングしたままにすると、メモリが無駄になり、費用がかさみ、ノートパソコンがスリープ状態になったり再起動したりするとクラッシュします。

長時間実行されるエージェントは、待機中にエージェントをノンストップで実行し続けるのではなく、完全に休止状態にする必要があります。外部イベント(タイマー、Webhook、メッセージなど)は、実際に行う作業がある場合にのみエージェントを起動します。

👉🔴 会場パネルで、[会場をリセット] を押します。すべてのステップは同じ世界から始まります。8 つのショー、すべての席が利用可能、時計は 1 倍です。

👉💻 このステップのコードを読み込みます。

cd ~/monstertix
. ./set_env.sh
./use-solution.sh 3 --force

インストールされたものに注目してください。monstertix/、サーバー、時計、そしてまだ見たことのないものがインストールされています。このステップではエージェントに関する変更は行われないため、agent/ がそのままになっていることを確認します。これがポイントです。

ファイル

概要

monstertix/server.py

新しい。ウェブサーバーとその背後にある Runner

monstertix/clock.py

新しい。時刻を把握してサーバーを呼び出すもの

2 つの部分に分かれており、クラウドまで 2 つのファイルに保存されます。トリガーとトリガー元を分離

ステップ 10 で

トリガー

エージェントを実行できるエンドポイント

Cloud Run

トリガー

時計に関する何かを呼び出す

Cloud Scheduler

トリガー - 独自のウェブサーバー

はい、ウェブサーバーです。何かを呼び出すには、その前に何かをリッスンする必要があります。前の手順の ADK ウェブ UI は、エージェントを実行できる開発インターフェースです。

👉💻 monstertix/server.py を開きます。

from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from concert.agent import root_agent

session_service = InMemorySessionService()
runner = Runner(
    agent=root_agent,
    app_name="concert",
    session_service=session_service,
)

@app.post("/wake")
async def wake(payload: dict = Body(default={})):
    session_id = f"wake-{uuid.uuid4().hex[:8]}"          # brand new, every time
    await session_service.create_session(...)

    async for event in runner.run_async(
        user_id=USER_ID, session_id=session_id,
        new_message=types.Content(role="user",
                                  parts=[types.Part(text=payload["message"])]),
    ):
        ...

Agent は自動的に実行されません。これは、名前、モデル、指示、ツールのリストなどの説明です。着信したメッセージを受け取り、適切なセッションを見つけ、モデルにこれまでの会話を渡し、リクエストされたツールを実行し、結果をフィードバックし、ツールが呼び出されなくなるまでループし、そのすべてのステップをストレージに書き込む必要があります。それが Runner です。

エージェントの周囲で Runner が行う処理

ADK Web UI にはデフォルトのものが用意されています。ここでは自分でビルドします。これが ADK ウェブ UI とデプロイ可能なものとの違いです。

👉💻 ターミナル 2 - 起動します。

Runner を起動します。

cd ~/monstertix
. ./set_env.sh
python -m monstertix.server
[server] agent concert · gemini-x-flash
[server] listening on http://xxxx_URL/wake
[server] nothing will happen until something calls it. Ctrl-C to stop.

最後の行を読んでください。トリガーが実行され、エージェントが読み込まれましたが、何も起こりません。呼び出し可能であることと呼び出されることの 2 つの概念を分離します。

玄関も追加しましょう

adk web は優れた開発ツールですが、出荷すべきものではありません。本番環境にデプロイするときに有効にしないでください。有効にした場合でも、お客様にそのことを伝えることはありません。同じサーバーがページも提供します。

👉 ブラウザで http://127.0.0.1:8090 を開きます。アドレスは同じです。

MonsterTix のご紹介チャット ウィンドウ、隅に名前、2 つのボタン。/wake に投稿して、返ってきたものをレンダリングします。同じように応答するエンドポイントに対しては、変更なしで動作します。

ページ上

下にあるもの

会話

POST /wake、1 件ずつ

+ 新しいセッション

新しいセッション ID - 新しい会話、その後新しい予算

メモリをリセット

すべてのセッションとメモリファイルを削除します。まず、その状態がどこにあるかを確認し、正確な場所を表示します。

👉✨ What Amsterdam dates are there? に何か入力して、ターミナル 2 を確認します。同じ Runner、同じエージェント、同じログ行。呼び出し元のみが変更されました。

トリガー - 時間を認識する何か

👉💻 monstertix/clock.py を開きます。コードを見てみましょう。

time.sleep(args.delay)                                    # wait
while True:
    client.post(f"{TRIGGER_URL}/wake",                    # knock
                json={"message": args.message})
    if not args.every:
        return                                            # once, unless told otherwise
    time.sleep(args.every)                                # or again, forever

これは非常にシンプルなメカニズムです。スリープ、POST、場合によっては繰り返しです。使用したすべてのスケジューラは、再試行、タイムゾーン、再起動後も存続するという保証に加えて、同じ処理を行います。

👉💻 ターミナル 3 - 1 回だけ実行(10 秒後):

cd ~/monstertix
. ./set_env.sh
python -m monstertix.clock --in 10
[clock] target   http://127.0.0.1:8090/wake
[clock] first fire in 60s, once only
[clock] go and watch the other terminal — do not touch the keyboard

👉 入力はここで終了します。手軽に撮影。ターミナル 2 を監視します。

1 分後、キーボードに誰もいない状態:

[clock]  firing → POST /wake
[server] woken · session wake-23930983
[server]   called search_events
[server]   called purchase
[server]   said   I've purchased 2 tickets for The Midnight Signal in
                  Amsterdam on Saturday, section A. Your total is 420.
[clock]  done. the agent ran and you were not involved.

これがステップ 2 の答えです。エージェントの外部から呼び出されたもので、それは sleep と HTTP リクエストでした。

👉💻 Ctrl-C を数回視聴した場合は、両方とも になります。繰り返し表示する場合は、まず --every 30 を使用します。

読み上げられた内容を確認します

「アムステルダムの The Midnight Signal のチケットを 2 枚購入しました。セクション A、420 ドルです。」

👉🔴 会場パネルを確認すると、チケットが購入されていることがわかります。

まだ何が間違っているのか

何かを購入しました。推測する必要があった内容を確認します。アムステルダムとセクション A が最初に検出されたため、それらが選択されました。エージェントに直接購入するよう指示したため、Sam が平日の夜に利用できないことはエージェントにはわかりません。午前 3 時に推測に基づいて行動するエージェントは、何もしないエージェントよりも悪い。

起床は簡単な方です。履歴も設定もリスナーもいない状態で起動するエージェントは、まだ起動する価値がありません。

4. コンテキストのライフタイムを管理する

情報がさまざまな場所にさまざまな期間保存される

エージェントは、事実をチャットの会話、セッションの状態、ファイル(アーティファクト)、長期的なユーザー メモリなど、複数の場所に保存します。各場所の滞在時間は異なります。

情報を間違った場所に配置した場合:

  • すぐに消えてしまう: チャットが終了すると、重要な詳細情報が消去されます。
  • 一時的な事実が長期記憶に残り、今後の会話を混乱させる: 一時的な事実が長期記憶を汚染し、今後の会話を混乱させます。

各情報を適切な保存場所とライフタイムに一致させる必要があります。

コードを読み込む

👉🔴 会場パネルで、[会場をリセット] を押します。

👉💻 ターミナル 1 で、次のコマンドを実行します。

cd ~/monstertix
. ./set_env.sh
./use-solution.sh 4 --force

前回のステップからの変更点:

ファイル

何が変更されたのですか?

tools.py

note_companion を追加しました。get_seatmap が座席マップをファイルに保存するようになりました

memory.py

新しい。独自のメモリストア

agent.py

リストに 3 つのツールが追加され、エージェントにそれらを使用するよう指示する

👉💻 ターミナル 1 でエージェントを再起動します。

cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
  --session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
  --artifact_service_uri="file://$WORKSHOP/artifacts"

以前に使用したことのない 2 つのフラグ:

フラグ

概要

session_service_uri

ADK は、すべてのメッセージ、ツール呼び出し、状態変更を行として(SQLite データベースとして)書き込みます。

artifact_service_uri

エージェントが保存するファイル。(ファイル)

これらがないと、ADK はすべてをメモリに保持し、プロセスを停止すると破棄します。この 2 つの設定により、永続化されます。

データベース sessions.db には、13 個の記録されたイベントを含むシードされた過去の会話(two-days-ago)が含まれています。

you    We're thinking about seeing The Midnight Signal. Me, Sam, and maybe Priya.
you    Sam can't do weeknights though, bailed on every single one.
you    Priya's out, she's away that weekend. Just me and Sam.
agent  Two it is. Still Saturday the 14th, still Amsterdam.
you    You said the upper bowl at Ziggo was bad last time?

過去 3 回の予約のメモリファイル(memory/userx.md)。エディタで開くことができます。

- Sam bails on weeknights. Every weeknight show we booked, Sam cancelled.
- Hates the upper bowl at Ziggo Dome, "couldn't see a thing" from section B.
- Comfortable spend is around $200 per ticket. $250 is the hard ceiling.
- Always books two seats, always together.

そのため、平日のオレンジ色の行が重要になります。平日夜のショーは 1 席あたり 10 ドル安く、残りの席も良い席で、リストの最上位に表示されていますが、このユーザーにとっては間違った回答です。エージェントがそれを知ることができる唯一の理由は、最後の予約後に書き込んだファイルがあるからです。その事実が残るかどうかは、ステップ 4 と 5 で決まります。

追加した 3 つのツール

3 つのツールと、それぞれのツールが書き込む場所

note_companion は、誰がアクセスしようとしているか、またそのアクセスを制限するものは何かを記録します。

# agent/concert/tools.py
def note_companion(name: str, constraint: str, tool_context: ToolContext) -> dict:
    prefs = dict(tool_context.state.get("user:prefs", {}))
    prefs[name] = constraint
    tool_context.state["user:prefs"] = prefs
    return {"companions": prefs}

「Sam は平日の夜は無理」という条件は来週も満たされる必要があるため、この条件を追加します。user:prefs に書き込んで、名前の user: 部分が処理を行うのを確認します。詳しくは以下をご覧ください。

recall

、

remember は、テキスト ファイル memory/userx.md を読み書きします。この状態をしばらくキープします。

👉 エージェントがコンテキストを保存する 2 つの場所を見てみましょう。

ロケーション

幅広いクラウド導入を

州名

このセッションのメモリ(Key-Value ペア) - ステップ 4 でここに移動します

アーティファクト

エージェントが保存したファイル(プロンプトには入力されない)

変更内容を確認する

get_seatmap

. 以前は、座席マップ全体を返していました。座席マップがファイルに保存され、簡単な概要が返されます。

# agent/concert/tools.py
async def get_seatmap(event_id: str, tool_context: ToolContext) -> dict:
    seatmap = venue.get(f"/events/{event_id}/seatmap")       # about 6 KB of JSON

    part = types.Part(inline_data=types.Blob(
        mime_type="application/json",
        data=json.dumps(seatmap, indent=2).encode()))
    await tool_context.save_artifact(f"seatmap_{event_id}.json", part)

    summary = {"event_id": ..., "sections": [...], "captured_at": ...}
    tool_context.state["temp:seatmap"] = summary
    return summary

この変更が重要な理由は次のとおりです。

すべてのツールから返されたペイロードは会話履歴に追加され、それ以降のすべてのターンでモデルに再送信されます。大きな JSON オブジェクト(6 KB の座席マップなど)を返すと、後続のターンごとにトークンの消費量が増加します。

代わりにファイルに保存して、1 つのファイル名を支払います。エージェントは、必要に応じて load_artifact で詳細を尋ねるようにします。ほとんどの場合、詳細を尋ねる必要はないと想定されます。

ファイルの保存先: アーティファクト サービス

agent/concert/tools.py の get_seatmap の行をもう一度見てみましょう。

await tool_context.save_artifact(f"seatmap_{event_id}.json", part)

tool_context は ADK から取得され、引数としてツールに渡されます。save_artifact はそのメソッドです。この行で、ツールはバイトを ADK に渡します。

ADK は、起動時に使用されたアーティファクト サービスを使用して、それらを書き込みます。フラグで選択した内容は次のとおりです。

--artifact_service_uri="file://$WORKSHOP/artifacts"

ADK には、次の 3 つの保存場所があります。

URI

バイトの移動先

memory://

RAM。プロセスを停止すると消えます。フラグを渡さない場合、これがデフォルトになります

file://...

ディスク上のフォルダ。現在使用しているもの

gs://bucket

Cloud Storage。デプロイされたエージェントが最後のステップで使用するもの

👉💻 ファイルを選択します。

ls -R ~/monstertix/artifacts

ノートパソコンから Cloud Storage に移動するには、file:// を gs:// に変更します。(これはステップ 10 で確認します)

メモリ ストア

メモリをインターフェースとして使用し、その背後にファイルがある

ADK には、BaseMemoryService という長期記憶用のインターフェースが用意されています。次の 2 つのメソッドがあります。

# google.adk.memory.BaseMemoryService — ADK's interface, not your file
async def add_session_to_memory(session)                   # take a conversation in
async def search_memory(*, app_name, user_id, query)       # hand memories back

ADK には、この 3 つの実装が用意されています。

クラス

URI

必要なもの

InMemoryMemoryService

memory://

なし。プロセスを停止するとすべてが失われる

VertexAiRagMemoryService

rag://

Vertex AI RAG コーパス

VertexAiMemoryBankService

agentengine://

Agent Engine リソース

このワークショップでは、独自の関数を作成します。プロセスを停止するとすべてが忘れられる InMemoryMemoryService は除外します。また、作成に数分かかるクラウド リソースを必要とする他の 2 つも除外します。代わりにファイルを選択します。ファイルは事前にシードして、エディタで読み取ることができます。memory.py を独自の実装として使用し、すべてを Markdown ファイルに保存します。

class MarkdownMemoryService(BaseMemoryService):

    async def search_memory(self, *, app_name, user_id, query) -> SearchMemoryResponse:
        path = self._path(user_id)                      # memory/userx.md
        return SearchMemoryResponse(memories=[
            MemoryEntry(content=types.Content(
                role="user", parts=[types.Part(text=path.read_text())]))])

SearchMemoryResponse と MemoryEntry は ADK の型であり、メモリバンクは同じオブジェクトを返します。1 つはマネージド サービスを背後に持っています。ファイルがある場合は、

ファイルを選択する理由は、ファイルを開いて読み取ることができるからです。エージェントの長期記憶全体がテキストのページになります。

では、どこにポイントを置くべきでしょうか?

このワークショップの実装では、3 つのサービスのうち 2 つがコマンドラインで選択されます。3 つ目は、上記の理由から独自の実装を行いました。

サービス

設定場所

このワークショップ

セッション

--session_service_uri(adk web)

sqlite+aiosqlite:///$WORKSHOP/sessions.db

artifact

--artifact_service_uri(adk web)

file://$WORKSHOP/artifacts

メモリ

コマンドラインのどこにもない

MEMORY_DIR、agent/concert/memory.py 内で読み取り

したがって、memory.py は環境変数からサービス自体を構築します。

# agent/concert/memory.py
MEMORY_DIR = pathlib.Path(os.environ.get("MEMORY_DIR", "./memory"))
MEMORY_USER = os.environ.get("MEMORY_USER", "userx")
memory_service = MarkdownMemoryService(MEMORY_DIR)

. ./set_env.sh は両方をエクスポートします。MEMORY_DIR は、エージェントがメモリを読み取る場所を設定し、MEMORY_USER はファイルを決定します。

物の場所を確認する

これで、事実を保持できる場所が 4 つになりました。4 つすべてを見てみましょう。

まず、自分のセッション

👉 上部のバーにある [新しいセッション] をクリックします。

👉✨ 次の 3 つの言葉を続けて言います。

Sam can't do weeknights, by the way.

新しいセッション、最初のターン

Show me the seat map for the Amsterdam Saturday show.

新しいセッション、2 回目のターン

Actually, hold that thought — what else is on in Amsterdam?

3 番目のメッセージが重要です。座席マップを読み取ったターンを終了します。ターンを終了すると、temp: が消えます。

3 つのものが 3 つの異なる場所に保存されました。

保存されていたもの

どのツールで

どこに移動したか

1

Sam は平日の夜は都合が悪い

note_companion

user:prefs、セッション状態

2

座席マップ全体、6 KB

get_seatmap(save_artifact)

アーティファクト サービス

3

1 行の座席マップの概要

get_seatmap(temp:seatmap)

nowhere

👉 今すぐ確認します。[状態] タブと [アーティファクト] タブは、adk web の左側にあります。

[State] タブ

[アーティファクト] タブ

[状態] タブで、temp:seatmap が省略されていることに注意してください。temp: プレフィックスが付いた状態キーは、アクティブな呼び出しの間のみ存在し、セッション状態がデータベースに保存される前に ADK によって自動的に削除されます。

古い会話

👉 上部のセッション プルダウンで two-days-ago に切り替えます。

このセッションには、データベースに事前入力された会話履歴が含まれています。

2 日前のセッション

👉 イベント ストリーム(中央の列)を確認します。古いターンが要約されたイベント ブロックに置き換えられていることに注意してください。

ターン数を減らした経路

👉 [State] タブを確認します。3 種類のキーが一緒に存在します。

target_event_id   ms-ams-01                      ← plain: this booking only
party_size        2                              ← plain: this booking only
user:prefs        {"excluded_weekdays": [...]}   ← the person, every session

user: 接頭辞(user:prefs など)は、特定のユーザーのすべてのセッションにわたって状態をスコープします。一方、接頭辞のないキー(

target_event_id

、

party_size

) は、アクティブなセッションに厳密にスコープ設定され、セッションが終了すると削除されるプレーンなセッション状態を表します。

👉 2 つの [状態] タブを比較します。user:prefs と保存されたアーティファクトは保持されますが、temp:seatmap は両方のセッションに存在しません。temp: 接頭辞が付いた状態キーは厳密にエフェメラルであり、データベースに永続化されることはありません。

ファイル

👉💻 エディタで開きます。

cloudshell edit ~/monstertix/memory/userx.md

3 件の予約が 1 月まで遡って表示されます。エージェントは recall() を呼び出すときにこれを読み取ります。セッションデータが失われた場合でも、これは残ります。

4 つの場所

保存場所

次のメッセージを保持するかどうか。

再起動後も維持されるか?

セッションの削除後も存続するかどうか。

temp:seatmap

no

×

×

party_size - two-days-ago のプレーン セッション状態

はい

はい

いいえ

user:prefs

はい

はい

必須

memory/userx.md

はい

はい

はい。データベースの削除後も存続します。

アーティファクト

はい

はい

サービスによって異なる

この表は、コンテナの内容物ではなく、コンテナに関するものとして解釈してください。memory/userx.md はすべてを保持し、remember() が選択したものを常に含みます。現在の会話は書き込まれません。したがって、ファイルがセッションよりも長く存続することは、エージェントが直前に話していた内容を記憶することと同じではありません。この違いが問題になるのがステップ 5 です。

事実が保存される 5 つの場所と、それぞれの保存期間

状態とは

状態は、セッションとともに移動する辞書と考えることができます。tool_context.state を介してツールから読み書きし、ADK で会話とともに保存します。

ADK は接頭辞の意味を割り当てます。これらはどこにも定義されていません。ADK はキー名の最初の部分を読み取り、それを使用して値の保存先と有効期間を決定します。

キーと呼ばれる

ADK が保存

持続時間

party_size

セッションを

セッションが削除されるまで

user:prefs

ユーザーに対して

ユーザーのすべてのセッションで

app:tour_id

アプリに対して

すべてのユーザーにわたって

temp:seatmap

どこにもない

1 回の呼び出し

呼び出しは、すべてのモデル推論、ツール実行、最終レスポンスを含む単一のリクエスト / レスポンス サイクルです。

呼び出しが終了すると、ADK は新しい状態をデータベースに書き込み、その過程で temp: キーをすべて削除します。

1 つだけ説明されていないこと

two-days-ago では、誰かの最初の数回のターンがあった場所に概要が表示されます。会話が長くなると、ADK が自動的に書き込みます。

すべての事実には有効期限がある

5. コンテキストの劣化と圧縮

要約: 詳細を忘れる

会話が長くなりすぎると、システムは古いメッセージを要約して、スペースとトークンを節約します。

要約は、トークンの使用量を節約するのに役立つ、一般的な要点を把握するのに適しています。

圧縮はセッションで機能します。メモリは別のストアです

👉🔴 会場パネルで、[会場をリセット] を押します。すべてのステップは同じ世界から始まります。8 つのショー、すべての席が利用可能、時計は 1 倍です。

👉💻 このステップのコードに移動します。

cd ~/monstertix
. ./set_env.sh
./use-solution.sh 5 --force

前回のステップからの変更点:

ファイル

何が変更されたのですか?

agent.py

エージェントは App でラップされ、圧縮がオンになっています。2 番目のエージェント budget_split がチケットの算術演算を行います。

その他すべて

変更なし

agent.py の末尾が root_agent でなくなりました。次のように終わります。

app = App(
    name="concert",
    root_agent=root_agent,
    events_compaction_config=EventsCompactionConfig(
        compaction_interval=3,   # summarise after every 3 turns
        overlap_size=1,          # re-read 1 turn either side when summarising
    ),
)

adk web は、まず app という変数を探し、見つからない場合は root_agent にフォールバックします。1 つのエージェントではなくアプリケーション全体に適用されるものが必要な場合は、エージェントを App でラップします。ここでは圧縮、次のステップでは再開可能性です。

compaction_interval=3 は、3 ターンごとに ADK が要約することを意味します。1 ターンは、ユーザーが何かを言って、エージェントが返信を完了することです。その間に呼び出されたツールの数は関係ありません。3 は小さいので、ワークショップ中に発火します。実際のアプリでは、はるかに大きな数値が使用されます。

overlap_size=1 は、各要約が厳密に必要なタイミングよりも 1 ターン早く開始されることを意味します。

ADK はターン 1、2、3 を要約します。3 ターン後に次のバッチを要約します。overlap_size=1 を使用すると、2 つ目の要約はターン 4 ではなくターン 3 から始まります。

2 つの要約が 1 ターン重複している

ターン 3 が 2 回要約されていることに注目してください。これは意図的なものです。3 ターンで質問し、4 ターンでエージェントが回答した場合、質問と回答が別々の要約に分割され、どちらもわかりにくいものになります。ターンを繰り返すことは、思考を半分に切断することに対する安価な保険です。

両方のフィールドを設定します。ADK には、どちらのデフォルトもありません。

コンパクションとメモリ サービスが一致しない

この 2 つの単語は関連しているように聞こえますが、システムは関連していないため、明示的に説明する価値があります。

EventsCompactionConfig は agent.py の App に root_agent の横に設定されています。これはサービスではなく、アプリケーションのプロパティです。

# agent/concert/agent.py
app = App(
    name="concert",
    root_agent=root_agent,
    events_compaction_config=EventsCompactionConfig(
        compaction_interval=3,
        overlap_size=1,
    ),
)

要約では会話が書き換えられます。ファイルは書き換えられません。

これが、remember() が意図的な呼び出しとして存在する理由です。文字起こしに残ったものは、最終的には他人が書いた言い換えとして読み上げられます。ファイルに書き込まれたものは、明日も来週も、保存されたとおりに読み上げられます。このステップの最後で、その違いがどのように現れるかを確認します。

要約はどこに保存されますか?

sessions.db の events テーブルでは、エージェントによって圧縮レコードがイベント行に書き込まれます。タイムスタンプは他のターンイベントと同様です。

通常のイベントには content フィールドが含まれ、概要は actions.compaction にあります。

👉💻 独自のデータベースで確認する:

cd ~/monstertix
. ./set_env.sh
sqlite3 sessions.db \
  "select json_extract(event_data,'\$.actions.compaction.compacted_content.parts[0].text')
   from events where event_data like '%compaction%';"

イベントの形状は次のようになります。

events row
└── event_data  (JSON)
    ├── id, author, timestamp, invocation_id      ← like every other event
    ├── content                                    ← ABSENT on this one
    └── actions
        └── compaction
            ├── start_timestamp                    ← the window it replaced
            ├── end_timestamp
            └── compacted_content.parts[0].text    ← the summary itself

イベント ストリームは、各イベントをそのコンテンツから取得します。データベース内の要約。各ターンでモデルに送信されます。

2 人目のエージェント

budget_split はツールではなく、完全に独立したエージェントです。

# agent/concert/agent.py
budget_split = Agent(
    name="budget_split",
    model=MODEL,
    include_contents="none",      # sees none of the conversation
    instruction="You do ticket arithmetic and nothing else...", #just the budget
    output_key="budget_plan",     # its answer goes into state
)

root_agent = Agent(
    ...,
    tools=[..., AgentTool(agent=budget_split)],
)

エージェントを使用する理由(プレーン関数を使用しない理由)作業にはモデルが必要なためです。「4 人で 200 ドルの予算に合うのはどのセクションで、合計金額はいくらですか?」という質問は、判断を伴う算術として解釈し、人が読める回答を生成します。Python 関数に掛け算をさせ、どのオプションに言及する価値があるかを判断させないでください。

では、メイン エージェントに処理させないのはなぜですか?次の行の include_contents="none" は、このエージェントに会話が渡されなかったことを意味します。グループ チャットも、好みも、40 日間のツアー日程もありません。リクエストのみを取得します。このフラグは Agent にのみ存在します。これが関数ではなくエージェントである本当の理由です。

リクエストのみを表示するサブエージェント

短いプロンプトを優先します。安価で高速であり、脱線しにくいからです。20 ターン前に言われたことには影響を受けない算術に依存します。これは、算術がそのことを認識しないためです。

理由

AgentTool

? メイン エージェントが他のツールと同様に呼び出せるように、エージェントをラップします。モデル側では、budget_split は search_events と同じものとして読み取ります。

そして

output_key="budget_plan" は、会話内で回答をそのままにするのではなく、その名前でセッション状態に回答を入れます。その重要性についてご覧ください。

実行

👉💻 ターミナル 1 で、Ctrl+C を押して同じフラグで再起動します。

cd ~/monstertix
adk web agent --port 8000 --allow_origins "*" \
  --session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
  --artifact_service_uri="file://$WORKSHOP/artifacts"

👉 [新しいセッション] を開始して、実際の会話をします。5 ~ 6 回回します。次の例は機能します。

hello
what's on in Amsterdam?
Sam is coming with us, and he can't do weeknights
there are 4 of us and I can spend about $200 a head. What fits?

👉 [State] タブを開きます。

budget_plan には、算術エージェントの完全な回答が保持されています。

最初の予算に関する質問の後の budget_plan

👉✨ 次に、この数字を変更します。

what if 10 people come and I can only do $120 each?

👉 budget_plan をもう一度確認します。追加されたのではなく、上書きされています。output_key に最新のものが保持されているためです。

2 回目の予算に関する質問後の budget_plan

表示されないもの: 要約

これらのターンの中で、ADK は以前のターンを要約しました。

👉💻 新しいターミナルで、次のコマンドを実行して直接読み取ります。

cd ~/monstertix
. ./set_env.sh
./show-compaction.sh

書いた内容を読む

以下は、実際の例を短縮したものです。

── summary 1 ──────────────────────────────────────────────────────

Conversation Language: English

  **User Request:**
  The user initially wanted to know what concerts are playing in Amsterdam.
  Subsequently, the user provided a constraint regarding a companion, Sam.

  **Context Summary:**
  The user is planning a concert trip. The agent identified two events in
  Amsterdam: "The Midnight Signal" at the Ziggo Dome on Tuesday, November
  10 and Saturday, November 14, 2026. The user then informed the agent that a
  companion named "Sam" will be joining, but "he can't do weeknights." The
  agent has successfully noted this constraint for Sam.

  **Key Decisions/Information Obtained:**
  *   **City of interest:** Amsterdam
  *   **Events found:** "The Midnight Signal" at the Ziggo Dome, Tuesday
  10 November and Saturday 14 November 2026.
  *   **Companion:** Sam
  *   **Companion constraint:** Sam cannot attend concerts on weeknights.

  **Unresolved Questions/Tasks:**
  *   The agent has not explicitly confirmed if the found event (which is
  on a Saturday) is suitable given Sam's constraint, nor has it asked the
  user for the next steps regarding this event or further event searches.

  **Tools Used:**
  *   `recall`
  *   `search_events`
  *   `note_companion`

価格、セクション、都市、設定はそのまま残り、まだ何も決定されていないことが正しく記載されていました。

このことから、次の 3 つのことが言えます。

言い換えです。ここに書かれていることは、誰かが言ったことではありません。これは、モデルが時折間違える他のすべてのものを生成するのと同じ種類のプロセスによって生成された、発言内容の説明です。

注: 要約文字列は内部セッション イベント レコードに保存され、個別の UI タブとして直接公開されることはありません。

3 ターンごとに、以前の要約の一部で構成される会話が行われます。

同じことを繰り返さずに予約してみる

新しいセッションで: 新しいセッションを開始しないでください。先ほど読んだ要約はこのセッションのイベントに保存されています。エージェントがアムステルダムについてまだ知っているのは、そのためだけです。

👉✨ 新しいものを与えない:

Book us something.

エージェントがどの番組のどのセクションを何人で視聴しているかを確認している様子をご覧ください。正解: 概要自体に、ユーザーが何も確認していないと記載されているため、エージェントが確認を求めた。圧縮の功績を称える。

圧縮が成功した場合でも、以前のターンを要約すると、エージェントがユーザーに制約を再確認する必要がある曖昧さが生じる可能性があります。

👉✨ 回答して予約を完了させます。

Amsterdam Saturday, section C, 8 people

費用を費やす前に、注文を読み返します。

「11 月 14 日(土)にアムステルダムで上演される The Midnight Signal のチケットをセクション C で 8 枚予約します。合計費用は $760 になります。チケットを購入しましょうか?"

👉✨ 確認する:

yes

次のように、動作したことが表示されます。

「アムステルダムの The Midnight Signal のチケット 8 枚(セクション C)のご購入が完了しました。合計金額は $760 です。注文 ID は ord_227877fe です。」

実行されたかどうかを確認します。

👉 会場パネルに切り替えます。エージェントの言葉を鵜呑みにしないでください。

会場パネルの順序

パネルで操作を行う場合

表示される内容

購入したチケット

1、正しい - 1 つの予約、1 つの注文

下の注文

ord_xxxxxxxx — 8× section C · 760

ツアー、アムステルダム土曜日

セクション C は 8 減少

アクティビティ

ord_xxxxxxxx — 8x section C @ 95 = 760

パネルでチケットの販売状況を確認します。

user:prefs が適切な選択肢だった理由

note_companion が user:prefs の最後のステップで Sam の制約を書き込んだことを思い出してください。明らかな異議申し立てがあります。エージェントが覚えていれば、ツールを使う必要はないでしょう。

その理由は次のとおりです。状態を会話から除外し、要約が状態に触れないようにします。user:prefs と budget_plan がどのように入力されたかを確認し、変更されていないことを確認します。次に、文字起こしを確認します。

要約では、一般的な形状は維持されますが、詳細は失われます。失うと困るものは、会話以外の場所に保存してください。

6. プロセスの再開

オンラインのまま 40 分間待機する

エージェントが 40 分間のチケットキューで待機する必要がある場合、Python プロセスを存続させて待機させるのは脆弱でコストがかかります。

長い待ち時間を適切に処理するには:

  1. 状態をデータベースに保存: エージェントが一時停止した場所を保存し、プロセスを安全にシャットダウンできるようにします。
  2. オンデマンドでウェイクアップ: 待機が終了すると、外部シグナルによってエージェントが再起動され、中断したところから実行が再開されます。

👉🔴 会場パネルで、[会場をリセット] を押します。すべてのステップは同じ世界から始まります。8 つのショー、すべての席が利用可能、時計は 1 倍です。

👉💻 このステップのコードに移動します。

cd ~/monstertix
. ./set_env.sh
./use-solution.sh 6 --force

実行

前回のステップからの変更点:

ファイル

何が変更されたのですか?

tools.py

join_queue と check_queue を追加しました

agent.py

join_queue は LongRunningFunctionTool でラップされ、App は ResumabilityConfig をオンにします

join_queue はブロックせずにすぐにチケットを返します。ResumabilityConfig は実行を一時停止して後で再開できるようにします。

再開可能性の追加

👉💻 ターミナル 1 - エージェント(変更なし):

cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
  --session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
  --artifact_service_uri="file://$WORKSHOP/artifacts"

👉✨ エージェントに購入を依頼します。

Get us two tickets to the Amsterdam show.

recall、search_events、join_queue を呼び出し、次のような回答を返します。

You are in the queue for The Midnight Signal at Ziggo Dome, Amsterdam, on Saturday, November 14. Your position is 14203.

質問されることなく [Saturday] が選択されていることに注目してください。アムステルダムの公演は 2 回あり、17 日は火曜日です。前の手順のメモリファイルには、Sam は平日の公演は行わないとあります。これは、以前の予約の事実が今回の予約の質問を決定するものです。

また、1 つの座席や価格を確認する前にキューに参加しており、どちらについても言及していないことにも注意してください。会場のルールでは、チケットは列の先頭に到達するまで購入するため、早く並ぶ価値があるのは列の場所だけです。どの席に座るかは、40 分後の問題です。

👉 コントロール パネルを確認します。バナーには次のように表示されます。

キューは、何も実行されていない間も移動を続けます

Agent is waiting in line — #14,203
About 2400s at 1×. Safe to kill the agent right now — it will still be here.

キューは実際に 40 分間続き、作業中に消えることはありません。

👉💻 エージェントを強制終了します。:

Ctrl-C

👉💻 ターミナル 1 で再度開始します。

cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
  --session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
  --artifact_service_uri="file://$WORKSHOP/artifacts"

👉✨ 同じセッションで、エージェントに順番を確認します。

Where am I in line?
You are currently at position 12982 in the queue. It is not your turn yet.

再起動後の同じチケット

チケットを比較し、何も実行されていない間に位置情報が下がり続けていることを確認します。エージェントは終了したことを認識していません。再起動前に取得したチケットがディスクにあったため、そのチケットを使用して check_queue を呼び出しました。

👉💻 理由:

cd ~/monstertix
sqlite3 sessions.db "select name from pragma_table_info('events');"
id
app_name
user_id
session_id
invocation_id
timestamp
event_data

すべての会話のすべてのターンが 1 行になり、ターン自体、発言者、呼び出したツール、返された内容、メモが event_data 内の JSON になっていることに注意してください。

👉💻 キューチケットは次の場所にあります。

sqlite3 sessions.db "select event_data from events;" \
  | grep -o '"ticket": "q_[a-z0-9]*", "position": [0-9]*' | tail -1
"ticket": "q_8cc2c6f9", "position": 14203

join_queue が返されると、そのチケットはデータベースに直接保存されます。再起動すると、ADK は以前のターンとツール結果をディスクから再読み込みし、エージェントが中断したところから再開できるようにします。

また、何が再起動されなかったかを確認します。エージェントは、ワークフローを最初から再生するのではなく、直接再開します。LongRunningFunctionTool と ResumabilityConfig は実行状態をディスクに保持するため、ADK はエージェントがすでに待機中であることを認識し、ツアーの日付を再検索したり、重複したチケットをリクエストしたりすることを防ぎます。

プロセスが終了した同じチケット

check_queue ではなく join_queue である理由

どちらの関数もミリ秒単位で返します。違いは、関数が戻るときに基盤となるジョブが完了しているかどうかです。

戻り値

作業は完了しましたか?

join_queue

チケットのキューと初期位置

いいえ。外部システムで非同期処理が続行されます。

check_queue

現在のキュー ステータス

はい。即時のポイントインタイムの回答を返します。

LongRunningFunctionTool は、保留中の作業のハンドルを返すオペレーション用に設計されています。join_queue をラップすると、外部キューが進行している間、エージェントは実行を一時停止できます。

これに対し、check_queue は同期型で完全です。ラップすると、エージェントはステータスをチェックするたびに実行を誤って一時停止します。たとえ、順番が回ってきてすぐに購入を完了する必要がある場合でも同様です。

戻ったときにどの呼び出しが未処理であるか

何が効果的だったか

実行の中断と復元を可能にする 3 つのコンポーネントがあります。

# agent/concert/agent.py
LongRunningFunctionTool(func=join_queue)      # the tool returns without finishing
ResumabilityConfig(is_resumable=True)         # the run may pause and be picked up
--session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db"
  • LongRunningFunctionTool: ツールをノンブロッキングとしてマークし、アクティブなハンドルを返します。これにより、エージェントはスレッドをハングアップさせることなく待機できます。
  • ResumabilityConfig: 未完了のタスクで実行ライフサイクルを一時停止し、最後に記録された状態から再開するようランタイムに指示します。
  • Session Storage URI (--session_service_uri): 状態の永続性をプロセス メモリではなく外部ストレージ(SQLite、Cloud SQL など)に転送し、再起動時のデータ損失を防ぎます。

7. 最新でないデータに対する処理

待機中に情報が古くなる

永続化された会話履歴はリアルタイムの状態ではありません。待機時間が長いと、チケットの空き状況や価格などの外部データがエージェントの知らないうちに変化する可能性があります。

情報が不足しているエージェントは、明確化を求めるために一時停止しますが、古いデータを持つエージェントは、古い前提に基づいて直ちに行動します。無効なアクションを防ぐため、時間依存性のあるデータは、重要なオペレーションを実行する直前に再検証する必要があります。

まだ新しいコードを読み込まないでください。前のステップのエージェントのままにします(チケットを購入できますが、現在、古さやべき等性に対する保護機能はありません)。

👉🔴 [RESET THE VENUE] を押し、adk web で新しいセッションを開始します。チャットの上にある [+ New Session] ボタンをクリックします。

新しいセッションを開始して、以前のキュー チケットと保留中の購入ステータスをクリアします。このステップでは、古いデータの処理を適切に実証するために、クリーンな実行コンテキストが必要です。

スナップショットの嘘

👉✨ 前と同じ番組をリクエストします。

Get us two tickets to the Amsterdam show.

👉✨ 次に、順番待ちの間に座席について尋ねます。

How much are the seats, and what's still available?
Section A: $210 per ticket, with 400 seats available.
Section B: $145 per ticket, with 900 seats available.
Section C: $95 per ticket, with 1200 seats available.

👉🔴 [SELL THE GOOD SEATS](良い席を販売する)を押します。セクション A がゼロに減少します。

👉🔴 [SKIP THE WAIT] を押して、エージェントをキューの先頭に移動します。

キューの先頭

👉✨ 購入を指示する:

Buy the two section A seats.

座席の空き状況を再確認せずに purchase(section="A") を実行するため、会場 API はエラー レスポンスを返します。

{"error": "sold_out", "available": 0,
 "message": "those seats are gone — re-fetch the seatmap before buying"}
Oh no! It looks like the Section A tickets just sold out. Let me get an
updated seat map to see what's still available.

Venue API エラー: セクション A が売り切れ

エージェントが API エラー レスポンスを受信した後にのみ、座席マップを再クエリしていることに注目してください。根本原因は古いコンテキストです。セッションの早い段階で、エージェントは「セクション A: 400 個在庫あり」と記録しましたが、そのターンがモデルに渡された会話履歴に残っていました。

古い情報が保存されている場所を明確にします。古い情報は会話履歴に保持され、temp: などの状態接頭辞でオーバーライドすることはできません。

エージェントがまだ使用している座席マップ

修正

👉🔴 会場パネルで、[会場をリセット] を押します。すべてのステップは同じ世界から始まります。8 つのショー、すべての席が利用可能、時計は 1 倍です。

👉💻 次に、このステップのコードを読み込みます。

cd ~/monstertix
. ./set_env.sh
./use-solution.sh 7 --force

👉💻 ターミナル 1 で Ctrl-C を押して再起動します。

cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
  --session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
  --artifact_service_uri="file://$WORKSHOP/artifacts"

変更点:

ファイル

何が変更されたのですか?

fence.py

新しい。購入の直前に実行され、購入をブロックできます

agent.py

before_tool_callback=refresh_before_purchase

tools.py

purchase が Idempotency-Key ヘッダーを送信するようになりました

fence.py は、ツール呼び出しの直前に ADK が実行するコールバックです。

root_agent = Agent(
    ...,
    before_tool_callback=refresh_before_purchase,   # re-read inventory, then decide
)

本当に?

👉🔴 会場をリセットします。キューに登録し、待機中に [SELL THE GOOD SEATS] を押してから、セクション A を購入するように指示します。

今回は自動的に停止します。エージェントが次のように言うのを確認します。

「セクション A は売り切れのようです。座席表をもう一度確認する必要があります。」

次に、get_seatmap が再度呼び出され、セクション C が代わりに提供されるのを確認します。

👉🔴 会場のアクティビティ フィードをよく読んでください。前半と後半の両方が表示されます。

error  purchase failed: Section A has 0 seats left, 2 requested. Re-read the
       seat map and pick again.
agent  calling purchase

会場フィード: 試行と拒否

ダッシュボードには通話の購入が表示されますが、チケット カウンタはゼロのままです。これは、ツールが実行される前に fence.py がガードとして機能するためです。

  1. エージェントが購入ツールを呼び出そうとします。
  2. fence.py が最初にリクエストをキャッチし、ライブシートの空き状況を確認します。
  3. 席が売り切れていることを確認した fence.py は、API が呼び出される前にエラーを直接返し、購入をキャンセルします。

プロンプトの指示では、LLM が売り切れの座席を購入しようとしないことを保証できません。コードガード(before_tool_callback)により、不正な購入が現実世界に届くことはありません。

👉🔴 [会場をリセット]、[次の購入を中断] の順に押します

👉✨ 購入を依頼し、何度でも再試行できるようにします。

再試行の回数に関係なく、注文は 1 回のみ

会場は再試行で同じべき等キーを受け取り、重複する注文を作成する代わりに元の確認を返したため、注文数は 1 のままです。

8. 自律型ワークフロー

LLM は厳格なルールに毎回従うのが苦手

LLM は、意思決定(ユーザーの好みに基づいて番組を選ぶなど)に優れています。しかし、LLM に厳格なステップバイステップのルール(「席を確認する前に必ずキューに参加する」など)に従わせようとすると、ステップがスキップされたり、順序が間違ったりすることがあります。

この問題を解決するには、ワークフローを次の 2 種類のステップに分割します。

  • 固定ルール(コード関数): 毎回同じ方法で実行する必要があるステップ(キューチケットの取得、位置の確認など)は、通常のコードノードとして記述されます。
  • 決定(エージェント ノード): 判断が必要なステップ(座席セクションの選択、ユーザー設定の確認など)は AI エージェントに渡されます。

このフローの実際の内容を見てみましょう。一部には判断がまったく含まれていません。

ステップ

判断?

キューチケットを 1 つだけ取得する

いいえ。常に 1 つ、常に購入前

自分の番かどうかを確認する

no. 質問して回答を読みます

購入者向けのブリーフを作成する

いいえ。毎回同じ文、同じ形

どのショーのどのセクション

はい。価格、曜日、参加者、嫌いなもの

ユーザー設定と制限事項

はい。

厳格な手順は LLM に依存すべきではありません。確率モデルは、監視されていない場合、最終的に厳格なシーケンスに従うことができなくなります。

ADK の Workflow は、決定論的実行と AI 推論を分離します。

  • 関数ノードは、固定ルール(キューへの参加や位置の確認など)を毎回一貫して適用します。
  • エージェント ノードは、コンテキストに基づく決定(ユーザー設定に基づく座席の選択など)を処理します。
  • 待機ノードは、メモリにプロセスを保持せずに実行を一時停止します。

元のエージェントは置き換えられません。ワークフロー グラフの最終ノードになり、ツール、鮮度チェック、べき等性の保護が保持されます。グラフは実行順序を制御し、エージェントは購入の意思決定を実行します。

👉🔴 会場パネルで、[会場をリセット] を押します。

👉💻 このステップのコードに移動します。

cd ~/monstertix
. ./set_env.sh
./use-solution.sh 8 --force

実行

👉💻 ターミナル 1 で Ctrl+C を押して再起動します。コマンドはこれまでと同じです。

cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
  --session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
  --artifact_service_uri="file://$WORKSHOP/artifacts"

前回のステップからの変更点:

ファイル

何が変更されたのですか?

nightly.py

新しい。ADK 2 Workflow グラフとしての無人実行

agent.py

root_agent がグラフになりました。これまで会話していたエージェントは、buyer_agent という名前に変更され、その中の 1 つのノードとして残っています。

agent.py

また、join_queueと check_queue も失われました。グラフがキューを所有するようになりました。

グラフを実行する - 3 つのメッセージを順番に送信する

次のすべてのアクションを nightly python で実行します(グラフはここにあります)。各ターンで同じ入力を送信して、ワークフロー グラフが状態を遷移する様子を観察します。

1. 現在話している相手を確認する

👉 ADK ウェブ UI を再読み込みします。

# concert/agent.py, at the bottom

buyer_agent = Agent(name="concert", ...)   # ← everything you built, renamed

from .nightly import nightly
root_agent = nightly                       # ← what adk web actually runs

adk web は root_agent に割り当てられたオブジェクトを実行します。ここで、root_agent は nightly ワークフロー グラフを指し、buyer_agent はグラフの最終ノードとして構成されています。

2. 開始する

👉✨ 次のように入力して Enter キーを押します。

go

注: 自動ワークフロー グラフでは、ユーザー入力を待たずに最初の実行が開始されます。adk web でメッセージを送信すると、初回実行がトリガーされます。

👉 ウェブブラウザを確認します。左側のパネルには、グラフが描画され、ノードが実行されるにつれて色付けされます。右側のパネルはイベント ストリームで、意思決定のプロセスを確認できます。

グラフ(check_front にパーク)

queue_up が queue_ticket と queue_event_id をセッション状態に書き込むのを確認します。

グラフ(check_front にパーク)

adk_request_input という名前の関数呼び出しが回答を待機していることがわかります。その後、静かになり、ランニングが終了します。グラフが check_front に達し、14,203 位が検出されたため、停止しました。

👉 会場パネルを確認します。キューチケットが 1 つあり、実行中のものはありません。

グラフ(check_front にパーク)

3. 起動する - 入力するボックスに注意する

イベント ストリームを adk_request_input イベントまでスクロールし、その特定のイベントに関連付けられた入力ボックスを見つけます。

グラフ(check_front にパーク)

入力できる場所が 2 つあり、それぞれ異なる機能があります。

入力した場所

結果

adk_request_input イベントの Enter your response... ボックス

割り込みに応答します。一時停止した実行は check_front から続行されます

下部のメインのチャット ボックス

新しい呼び出しを開始します。グラフは START から再生され、番組が再び選択され、2 枚目のキューチケットが取得されます。

👉✨ [Enter your response...] ボックスに任意のテキストを入力し、矢印を押します。

👉 ターミナル 1:

[check_front] #14,197 — not yet. Pausing.

1 行。2 つ目の番組は選択されず、2 つ目のキューチケットも取得されませんでした。2 つ目のキューチケットを取得していた場合、14,203 人のキューの最後尾に並ぶことになっていました。

[open] と [pick_show] が再度実行された場合は、チャット ボックスに入力したことになります。[RESET THE VENUE] を押して、手順を最初からやり直します。

👉 会場パネルをもう一度確認します。チケットは1 枚のままで、会場の時計は動き続けていたため、順位が少し下がっています。

1 回起動してもチケットは 1 枚のまま

4. 最前線に到達して、

👉🔴 会場パネルの [スキップ] を押します。

完成したグラフ。すべてのノードが緑色になっている

👉✨ 最新の adk_request_input イベントに最後にもう一度回答します。チャット ボックスではなく、そのボックスに回答します。完成したグラフ。すべてのノードが緑色になっている

このタイムウォッチ check_front は、フロントであなたを見つけて落下します。左側のパネルで、最後の 2 つのノードである brief と concert が点灯していることを確認します。

完成したグラフ。すべてのノードが緑色になっている

👉 ブラウザを見てください。これは実行の終了です。読むべきことが 4 つあります。

完成したグラフ。すべてのノードが緑色になっている

グラフが完全に緑色になり、エッジがカウントされます。brief と concert が点灯し、pick_show、queue_up、check_front へのエッジにバッジが表示されるようになりました。

2x

は、グラフがそのエッジを歩いた回数(ウェイクアップごとに 1 回)です。上部の 2 つは再開の価格です。スケジューラは毎回最初からグラフに再入力します。作業が 3 回行われたことを意味するものではありません。会場パネルがその証拠です。キューチケットは 1 枚で、2 枚ではありません。@node(rerun_on_resume=False) が理由です。

グラフ

グラフ: ルール用の関数、判断用のエージェント

# agent/concert/nightly.py
nightly = Workflow(
    name="concert_nightly",
    edges=[(START, open_the_night, pick_show, queue_up,
            check_front, brief, buyer_agent)],
)

2 つのノードがエージェントで、2 つが関数であることに注意し、どちらがどちらであるかを設計上の問題として扱います。

ノード

その形状の理由

pick_show

agent. ユーザーの履歴から番組を選択することは判断です。recall を呼び出し、ツアーを読み取って、検証済みの Plan を返します。

queue_up

関数。キューへの参加はルールです。ルールは午前 3 時のコイン投げであってはならない

check_front

関数。「最前列にいるか」には正解が 1 つあり、会場に正解があります

buy_it

agent. これは buyer_agent です。つまり、以前の root_agent を変更せずに使用します。セクション A が待ち時間中に売り切れた場合、セクション B、セクション C、または何も選択しないのいずれかを選択しますか?これは、モジュール 4 で学習した判断です。

最後のノードを見て、作成したエージェントを見つけます。コピーでも書き換えでもない場合:

# agent/concert/nightly.py
from .agent import buyer_agent

すべてのツール、fence.py での座席マップの再読み取り、purchase でのべき等キーがすべて含まれます。これは 1 つしかありません。

待機せずに停止した理由

誰も書くべきではないバージョンは次のとおりです。

# what NOT to write — this is nobody's file
while not ready:          # never do this
    time.sleep(1)

プロセスを 40 分間保持して何も達成せず、ターミナルとともに終了し、再開できません。check_front は代わりに次の処理を行います。

# agent/concert/nightly.py — check_front
if not status["ready"]:
    return RequestInput(message=f"Still at #{position}. Wake me and I'll check.")

RequestInput は割り込みです。グラフは停止し、呼び出しはセッション ストアに移動し、プロセスは終了できます。

待機中の実行には費用はかかりません。実行はチケットの購入という 1 つの場所で終了します。

9. 人間との連携

人に確認せずに費用をかけない

夜間に実行される自律型エージェントは、チャット履歴から推測された曖昧な好みに基づいて、高額な金融取引を行うべきではありません。

重要なアクション(チケットの購入など)を実行する前に、エージェントは一時停止し、ユーザーに予算を声に出して確認してもらい、購入を行う前にその明示的な確認を保存する必要があります。

👉🔴 会場パネルで、[会場をリセット] を押します。すべてのステップは同じ世界から始まります。8 つのショー、すべての席が利用可能、時計は 1 倍です。

👉💻 このステップのコードに移動します。

cd ~/monstertix
. ./set_env.sh
./use-solution.sh 9 --force

実行

👉💻 ターミナル 1 で Ctrl+C を押して再起動します。以前と同じコマンドを使用します。

cd ~/monstertix
. ./set_env.sh
adk web agent --port 8000 --allow_origins "*" \
  --session_service_uri="sqlite+aiosqlite:///$WORKSHOP/sessions.db" \
  --artifact_service_uri="file://$WORKSHOP/artifacts"

前回のステップからの変更点:

ファイル

何が変更されたのですか?

budget.py

相手が同意した内容を、発言した文として保持します。load() が読み上げ、someone_is_there() が質問できる人がいるかどうかを答えます

tools.py

set_budget を追加しました。これにより、ユーザーが同意した上限額をセッションの状態に書き込むことができます。

nightly.py

新しい最初のノード agree_budget。他のノードが実行される前に質問して読み返します。予算とその他のリクエストの両方を自由形式のテキストとして取得し、定数としての SEATS はなくなりました。

モデル化しないでください。文を保存します。

agent/concert/budget.py を開き、全体を読みます。

def load(state) -> str:
    """What they agreed, verbatim. Empty string means nobody has said."""
    return str(state.get("budget") or "")
state["budget"] = "up to $100 for the upper bowl or general admission, up to
                   $250 for the lower bowl, and up to $300 for a Saturday show"

ノードは、ユーザーが発言したこととユーザーが確認したことの 2 つを確認します。

その後、文は user: ではなく、セッションの状態に保持されます。1 つの予約に対して予算が合意されます。「$250 if they're the good ones」という言葉は、そのバンドについて、その夜に言われたものであり、チケット予約を管理するものではありません。

他の発言についても同様です。

予算で止まらないでください。お客様は検討中に考えを変えることがあるため、エージェントがたまたま覚えていることに頼るのではなく、お客様がリクエストした内容をすべてここに記録します。

"I live in NYC, I can only do weekends, I have 10 people coming with me"
# agent/concert/nightly.py
def what_they_asked_for(ctx) -> str:
    """Everything this person typed, joined, in their own words."""
    said = []
    for event in (ctx.session.events or []):
        if event.author != "user":
            continue
        for part in (event.content.parts if event.content else []) or []:
            text = (getattr(part, "text", None) or "").strip()
            if text and text not in said:
                said.append(text)
    return "\n".join(said)

ユーザーが作成したイベントのみを読み取る場合、のみが対象になります。

text

function_response から parts を削除します。これにより、予算に関する質問と回答がここにエコーバックされなくなります。重複がない。

agree_budget は初回だけでなくすべての実行で呼び出されるため、エージェントがキューに登録している間に修正を入力した場合も、その修正が反映されます。次に、open_the_night ピッカーに両方の文、質問、予算を渡し、ピッカーがそれらを読み上げます。

グラフ、1 つのノードが長い

この例は会話から始めましょう。

# agent/concert/nightly.py
nightly = Workflow(
    name="concert_nightly",
    edges=[(START, agree_budget, open_the_night, pick_show, queue_up,
            check_front, brief, buyer_agent)],
)

同じグラフですが、agree_budget が先頭にあります

2 つのノードが停止して待機し、同じメカニズムが使用されます。check_front は会場を待機し、時計によって回答されます。agree_budget はユーザーを待機し、ユーザーのみが応答できます。この違いがステップ 10 の全体です。

実行する — ステップ実行

1. 開始

👉 ADK ウェブ UI を再読み込みし、concert エージェントを選択します。

👉✨ 実際に必要なものをリクエストする:

Get us two tickets to the Amsterdam show.

実行が停止し、費用の入力を求められる

ステップ 8 ではグラフにユーザーがいなかったため、入力した内容はすべて破棄され、go は何もしませんでした。ここでは、agree_budget が最初の行で what_they_asked_for(ctx) を呼び出し、入力したすべてのものを収集して state["request"] に格納します。次に、open_the_night は予算とともにピッカーに渡します。

曜日を指定しておらず、アムステルダムには 2 つのショーがあります。そのため、ピッカーが recall() にフォールバックするのを確認し、「Sam bails on weeknights」を見つけて、土曜日を選択します。

ブラウザでは、実行は adk_request_input イベントで停止します。

グラフから質問が届きました。ステップ 8 のキュー待機と同じ割り込みですが、質問に答える人を指すようになりました。

2. ユーザーが実際に回答するような回答をする

👉✨ 画面下部のチャット ボックスではなく、[Enter your response...] ボックスに:

About a 100 for the cheap seats. 250 if they're the good ones.

停止して確認します。

[agree_budget] reading back: About a hundred for the cheap seats. Two-fifty
               if they're the good ones.

⚡ adk_request_input
   So: About a hundred for the cheap seats. Two-fifty if they're the good
   ones. Have I got that right?

コミットする前に番号を読み返します

3. 確認する

👉✨ 同じ箱の場合:

Yes, that's right.
[agree_budget] agreed: About a hundred for the cheap seats. Two-fifty if
               they're the good ones.
[open]         Budget: About a hundred for the cheap seats. Two-fifty if
               they're the good ones.
[pick_show]    → {"section": "A", "reason": "..."}
[queue_up]     ms-ams-01 section A — ticket q_5924edc2 at #14,203
[check_front]  #14,203 — not yet. Pausing.

4 つのノードが 1 つの単語で実行されました。👉 [状態] タブを開き、budget(発言内容がそのまま表示されます)を見つけます。

[国] タブの予算を自分の言葉で説明します

接頭辞なし。これはセッションの状態です。新しいセッションを開始すると予算がなくなるため、意図的に行うべき判断であり、再度確認されます。

4. 先頭に立ってゴールする

👉🔴 会場パネルの [スキップ] を押します。

👉✨ 最新の adk_request_input に回答します。そのボックスに再度回答します。

go

再度起動し、2 回尋ねることはありません

👉 会場パネルを確認します。1 件の注文が、その種類の座席について合意した価格帯の範囲内で行われています。

1 件の注文(同意した内容)

グラフが人に質問する方法

nightly.py を開き、agree_budget を読み取ります。3 つの条件をすべて満たすようにし、いずれか 1 つが欠落している場合は同じ失敗(永遠に質問される)が起こるようにします。

@node(rerun_on_resume=True)              # 1. run again when woken
def agree_budget(node_input, ctx):
    answers = ctx.resume_inputs or {}    # 3. where the answer arrives

    said = answers.get(ASK_BUDGET)
    if not said:
        return RequestInput(
            interrupt_id=ASK_BUDGET,     # 2. STABLE id, not the default
            message="What are you willing to spend?",
        )
    ...

rerun_on_resume=True

これがないと、ノードは完了したとみなされ、スキップされます。

安定した interrupt_id

デフォルトでは実行ごとに新しい UUID が生成されるため、回答を検索する手段がありません。

ctx.resume_inputs[id]

回答が届く場所です。RequestInput の戻り値ではありません。ADK はノードに送信するのではなく、ノードを反復処理します。

グラフ

これは、ステップ 8 のグラフに 1 つのノードが追加されたものです。同じグラフですが、agree_budget が先頭にあります

ユーザーが確認した制約はセッション スコープであり、会話が終了すると期限切れになります。セッション間で一時的な上限を保持すると、1 回限りの決定が意図しない永続的なデフォルトになる可能性があります。

10. クラウド スタック

ノートパソコンがスリープ状態になるとローカル ファイルが消える

ノートパソコンでは、エージェントはパソコンがスリープ状態にならないこと、ローカルの SQLite データベース、ローカル ファイルに依存します。ノートパソコンの蓋を閉じたり、再起動するクラウド コンテナにデプロイしたりすると、ローカル ファイルとメモリが消えます。

本番環境で確実に実行するには:

  1. Cloud Compute: オンライン状態を維持するコンテナ(Cloud Run など)でエージェントを実行します。
  2. 永続的なクラウド データベース: ローカル ファイルと SQLite をクラウド サービス(セッション データ用の Cloud SQL、ファイル用の Cloud Storage)に置き換えます。
  3. Cloud タイマー: マネージド スケジューラ(Cloud Scheduler や Pub/Sub など)を使用して、ノートパソコンの電源がオフになっている場合でもエージェントが起動するようにします。

👉🔴 会場パネルで、[会場をリセット] を押します。すべてのステップは同じ世界から始まります。8 つのショー、すべての席が利用可能、時計は 1 倍です。

👉💻 最終版のコードに移動します。

cd ~/monstertix
. ./set_env.sh
./use-solution.sh 10 --force

ファイル

何が変更されたのですか?

main.py

新しい。チャットページとスケジューラが呼び出すエンドポイントを提供する 1 つの FastAPI

Dockerfile

新しい。通常の python-slim と uvicorn。ADK 固有の要素はありません

monstertix/

コピーされるため、ページはエージェントと同じコンテナで配信されます。

concert/

変更なし。ステップ 9 で完了したフォルダをバイト単位で比較する

すでに作成したページ

** ADK ウェブ UI** は、開発中に状態、イベント ストリーム、ワークフロー グラフを検査するためのローカル デバッグ インターフェースです。本番環境へのデプロイ用にパッケージ化されていません。

本番環境では、ステップ 3 で作成したスタンドアロンのチャット フロントエンドを使用します。内部デバッグ コントロールを公開することなく、/wake エンドポイントを介してユーザー メッセージとインタラクションの中断を処理します。

ローカル状態をクラウド サービスに移行する

サーバーを停止して、書き込まれた内容を確認します。

sessions.db          a SQLite file next to the code
memory/userx.md      a markdown file you can open in an editor
artifacts/           seat maps written to a folder

ローカル ファイルは開発には適していますが、Cloud Run コンテナはエフェメラルなインスタンス ローカル ストレージを使用するため、本番環境では失敗します。

Cloud Run で実行する場合:

  • インスタンスがゼロにスケーリングされる: トラフィックが停止すると、コンテナが終了し、ローカル ファイル システムが削除されます。
  • インスタンスは個別にスケールアウトする: 複数の同時実行インスタンスはディスク容量を共有せず、それぞれが独自の分離されたファイル システムで実行されます。

エージェントがキューチケットまたはセッション状態をローカルの SQLite ファイル(sessions.db)に保存すると、新しいコンテナ インスタンスまたは別のコンテナ インスタンスに転送された後続のリクエストは、空のデータベースを見つけます。スケーリングと再起動にわたって永続的な状態を維持するには、ローカル ファイルをマネージド外部サービスに置き換える必要があります。

代わりに必要なのは、1 つのコンテナよりも長く存続する状態です。3 つのサービス(ファイルごとに 1 つ)。

ノートパソコン

クラウド内

概要

sessions.db

Cloud SQL

マネージド Postgres データベース。実行中のものがあるかどうかに関係なく存在します

memory/userx.md

Cloud Storage

バケットに保存します。すべてのインスタンスから一度にアクセスできるオブジェクトとしてのファイル

artifacts/

Cloud Storage

同じバケット

clock.py

Cloud Scheduler

Google が実行する cron。端末は目覚まし時計ではありません

作成する

PostgreSQL データベース、データベース ユーザー、Cloud Storage バケットをプロビジョニングします。Cloud SQL インスタンスの作成には数分かかります。これは setup.sh のバックグラウンドで開始されました。

👉💻 収集して、残りの部分を作成します。

cd ~/monstertix
./setup-cloud-state.sh
→ instance  RUNNABLE
→ database  adk created
→ user      adk created
→ bucket    created
→ memory    memory/userx.md → gs://your-project-agent-you/memory/
✓ durable state ready. Written to .env

このスクリプトは、上記の表の行ごとに 1 つずつ、4 つのオブジェクトを作成し、2 つの URI を .env に書き込みました。

CLOUD_SESSION_SERVICE_URI=postgresql+asyncpg://adk:...@/adk?host=/cloudsql/proj:region:instance
CLOUD_ARTIFACT_SERVICE_URI=gs://your-project-agent-you/artifacts

マネージド スケジュール トリガーを使用してエージェントをデプロイする

サービスが準備できたら、エージェントの永続的なホームを指定します。

👉💻 3 ~ 4 分。起動して、以下をお読みください。

cd ~/monstertix
. ./set_env.sh
./deploy-agent.sh

このスクリプトは、agent/ からコンテナ イメージをビルドし、Cloud Run にデプロイして、サポートするイベント ドリブン インフラストラクチャをプロビジョニングします。

完全なデプロイは、次の 5 つの異なるコンポーネントで構成されます。

1. gcloud run deploy       the container: your agent, the graph, and the page
2. a Pub/Sub topic         a queue for messages
3. a service account       an identity, so the message is allowed in
4. a push subscription     topic ──► /trigger/wake
5. a Cloud Scheduler job   the alarm clock, on a cron

トリガーソースとしての Cloud Scheduler

Cloud Scheduler は、ローカル実行タイマー(clock.py)を、ローカル ターミナル セッションとは独立して永続化され、自動再試行を処理し、タイムゾーン スケジューリングをサポートするマネージド cron サービスに置き換えます。

メッセージの分離レイヤとしての Pub/Sub

Cloud Scheduler と Cloud Run の間に Pub/Sub トピックを配置すると、次の 3 つのアーキテクチャ上のメリットがあります。

  • メッセージの永続性: Cloud Run サービスがコールド スタート、再デプロイ、または一時的に使用できない場合、受信トリガー イベントをキューに登録します。
  • 組み込みの再試行ポリシー: カスタムのアプリケーション レベルの再試行ロジックを必要とせずに、一時的な配信エラーを自動的に再試行します。
  • ファンアウト スケーラビリティ: スケジューラを再構成することなく、複数のダウンストリーム サービスが同じスケジュール イベントをサブスクライブできるようにします。

代替テキスト

trigger_sources=["pubsub"] を構成すると、標準の Pub/Sub エンベロープ ペイロードと互換性のある push エンドポイントが登録され、受信メッセージがエージェント呼び出しイベントに直接変換されます。

→ agent    https://concert-you-xxxx.run.app
→ topic    presale-you created
→ iam      concert-trigger@your-project... may invoke concert-you
→ push     → https://concert-you-xxxx.run.app/trigger/wake
→ schedule 0 3 * * *

話しかける

👉 エージェントの URL をブラウザで開きます。👉✨ 予約を依頼する:

Book me 3 tickets for The Midnight Signal in Tokyo. Best seats in the house,
I don't want the cheap ones.

費用を尋ねる必要があります

👉✨ 声に出して答えてから、確認します。

up to 250 a seat for the good ones

実行は現在キューに保留されています。

👉🔴 会場パネルの [スキップ] を押して、メッセージが届いたときにキューの準備が整うようにします。

実際のスケジュールで実行するには、ジョブを有効にする必要があります。jobs run は、Job.state must be ENABLED で一時停止されたジョブを拒否します。

gcloud scheduler jobs resume presale-$(gcloud config get-value account | cut -d@ -f1) \
  --location=$GOOGLE_CLOUD_REGION

👉 数秒以内に、エージェントは確認済みの予算上限内で購入を実行し、注文確認を自動的に返します。

👉🔴 会場パネルを確認します。1 件の注文。

5 分ごとに実行する方法は次のとおりです。

gcloud scheduler jobs update pubsub presale-$(gcloud config get-value account | cut -d@ -f1) \
  --location=$GOOGLE_CLOUD_REGION --schedule="*/5 * * * *"

クリーンアップ

会場と、その横にいるエージェントの 2 つをカウントします。どちらもゼロにスケーリングされるため、アイドル状態の費用は発生しません。このまま削除します。

./destroy-agent.sh          # service, topic, subscription, scheduler job
./destroy-venue.sh

これらのコマンドでは、Cloud SQL とバケットは意図的にそのままにしています。これらにはデータが保持されており、両方とも課金が継続されるためです。完了したことを確認したら、次の手順を行います。

./destroy-agent.sh --all

それ以外はローカルです。完了したらフォルダを削除するか、フォルダを保持して、必要なだけ独自のプロジェクトに対して実行します。

エージェント、そのツール、コールバック、予算は変更されていません。2 つの URI と 1 つの環境変数が実行されました。

11. システムの概要とトラブルシューティング ガイド

環境の違い: ローカル アーキテクチャとクラウド アーキテクチャ

ノートパソコン

クラウド内

何が変更されたのですか?

--session_service_uri=sqlite+aiosqlite:///...

Cloud SQL

接続文字列

--artifact_service_uri=file://...

gs://bucket

URI

memory/userx.md

gs://bucket/memory/

パス

adk web

1 つの Cloud Run サービス

Dockerfile、main.py

monstertix/clock.py

Cloud Scheduler → Pub/Sub

trigger_sources=["pubsub"]

キーボードのあなた

someone_is_there()

デフォルト(リクエストごとにオーバーライド)

root_agent、ツール定義、コールバック ガード、予算の適用など、コア エージェントの実装は完全に変更されません。solutions/step10_deploy/concert/ とステップ 9 を比較すると、バイト単位で完全に一致していることがわかります。これにより、ADK のサービス抽象化によってインフラストラクチャ構成がエージェント ロジックから分離されていることが検証されます。

問題が発生した場合

どのステップでも、完全な動作コードにアクセスできます。

./use-solution.sh N --force          # any step. resets memory + sessions + artifacts
./use-solution.sh N --force --keep   # code only, leave state alone

破損した場合

症状

修正

✗ Cannot access project

ID が間違っています。rm ~/project_id.txt && ./setup.sh

モデルが 404 を返す

-latest エイリアスは AI Studio 専用です。ADK_MODEL=gemini-latest-flash を使用

address already in use

ポート 8000 または 8090 を使用しているものがあります。lsof -ti:8000 | xargs kill -9

エージェントが会場に到着できない

curl $VENUE_URL/health、または ./deploy-venue.sh を再度実行します。

キューが進まない

[SKIP THE WAIT] を押します。1× ではキューは自動的に先頭に移動しません

パネルに 2 つ目のキューチケットが表示されます。

adk_request_input イベントの Enter your response... ボックスではなく、チャット ボックスで回答した。チャットボックスで新しい実行が開始される

プルダウンが空である、またはアプリが読み込まれない

adk web は agent/ の下のすべてのフォルダをアプリとして扱い、1 つでもアプリでないものがあるとリスト全体が失敗します。ls agent/ は concert/ のみを表示する必要があります。agent/ 内から実行すると、memory/ が残る可能性があります。./use-solution.sh 8 --force が整理します

警告 [EXPERIMENTAL] 件

想定どおりです。EventsCompactionConfig と ResumabilityConfig はどちらも 2.6.2 で一般提供前です

すべてが奇妙

[会場をリセット] を押し、rm sessions.db

重複した予約でメモリ ファイルがいっぱいになる

remember() は実行ごとに追加されます。./use-solution.sh N --force でリセットするか、cp seed/memory/default.md memory/userx.md

adk: command not found

source .venv/bin/activate - すべての新しいタブで必要

Cloud Shell がタイムアウトした

adk web を再起動します。sessions.db が生き残った(ステップ 6、無料で利用可能)