🔀

Google ADK 2.0 のグラフベース実行を実際に動かしてみた — 経費トリアージで Phase 1 / Phase 2 を並べて比較

に公開

はじめに

Google Cloud Next '26 で発表された ADK 2.0(Beta)を、手元で動かせる範囲だけ実際に触ってみた記録です。Next '26 のセッション内容を再現したり評価したりするものではなく、「ベータが配布されている SDK を、ローカルでどこまで動かせるか」だけを目的にしています。

特に確認したかったのは、ADK 2.0 の目玉である グラフベース実行(Workflow / Nodes & Edges) の手触りです。

"What we added here is essentially a slider to allow you to determine how deterministic versus non-deterministic you want your agent to be."

—— のスライダー比喩が、コードと UI でどう体感できるのかを、自分の目で確かめます。

動作環境

項目
OS macOS(darwin)
Python 3.14
ADK 2.0.0b1adk web の左上に表示されているバージョン)
LLM gemini-flash-latest(Google AI Studio 経由)
ランナー InMemoryRunner および adk web

LLM の経路は最終的に Google AI Studio(環境変数 GOOGLE_API_KEY のみで動く方)に統一しました。途中で Vertex AI 経由を試して 403 を踏んだ経緯は後述します。

題材:経費トリアージのワークフロー

グラフベース実行の「分岐」と「LLM ノードの埋め込み」が同時に試せる題材として、社内経費の自動トリアージを選びました。経費 1 件を JSON で受け取り、金額と単価を見て次のいずれかに振り分ける、という想定です。

START
  └─ parse_expense (JSON → Pydantic)
        └─ amount_router (route: auto_approve / llm_review / manager)
              ├─ auto_approve     ← 少額・低単価は自動承認
              ├─ llm_review       ← 中額帯は LLM が判定(Phase 1 はスタブ、Phase 2 は LlmAgent)
              └─ manager_review   ← 高額はマネージャ承認
END

Phase 1 と Phase 2 で違うのは llm_review ノードの中身だけ です。

Phase 1 Phase 2
parse_expense 同じ 同じ
amount_router 同じ(決定論) 同じ(決定論)
auto_approve / manager_review 同じ 同じ
llm_review ハードコードの if 文(スタブ) LlmAgent(gemini-flash-latest)

これでスライダーが効いていることを観察できます。

  • Phase 1: スライダーを完全に「決定論」側へ倒した状態
  • Phase 2: 中額帯のレビューだけ LLM 側へ寄せた、混在状態

コード(要点だけ)

ADK 2.0 では @node デコレータで Python 関数をノード化し、Workflow(edges=[...]) で DAG を組みます。LlmAgentBaseNode を継承しているので、関数ノードと同じ感覚でエッジに置けます。

from google.adk.agents import LlmAgent
from google.adk.events.event import Event
from google.adk.workflow import Workflow, node
from pydantic import BaseModel


class Expense(BaseModel):
    employee_id: str
    amount_jpy: int
    category: str
    description: str
    units: int = 1


class Decision(BaseModel):
    status: str
    reason: str
    employee_id: str
    amount_jpy: int


@node
def parse_expense(node_input: str) -> Expense:
    return Expense(**json.loads(node_input))


@node
def amount_router(node_input: Expense) -> Event:
    unit_price = node_input.amount_jpy / max(node_input.units, 1)
    if node_input.amount_jpy >= 100_000:
        return Event(output=node_input, route="manager")
    if node_input.amount_jpy <= 5_000 and unit_price <= 2_000:
        return Event(output=node_input, route="auto_approve")
    return Event(output=node_input, route="llm_review")

ポイントは amount_routerEvent(output=..., route="...") を返すところで、後段のエッジ辞書のキーに route 文字列が一致するノードへ遷移します。

edges=[
    ("START", parse_expense),
    (parse_expense, amount_router),
    (
        amount_router,
        {
            "auto_approve": auto_approve,
            "manager": manager_review,
            "llm_review": review_node,   # ここを Phase 1 / Phase 2 で差し替え
        },
    ),
],

Phase 2 の review_nodeLlmAgent です。mode="single_turn" を指定するとワークフローノードとして使えます(既定の chat モードはマルチターン会話用なので、ここでは不要)。

LlmAgent(
    name="llm_review",
    model="gemini-flash-latest",
    mode="single_turn",
    instruction="(略:Decision JSON を返す指示)",
    output_schema=Decision,   # ← LLM の出力を Pydantic で型強制
)

output_schema=Decision を渡すと、Gemini の構造化出力モードで JSON を強制できます。

4 ケースで Phase 1 / Phase 2 を並走

InMemoryRunner で同じ4ケースを両ワークフローに流して、ログを並べました。

ケース 入力概要 経路 Phase 1 の reason Phase 2 の reason
E001 文房具 ¥800 / 2本 auto_approve(LLM呼ばず) "Below auto-approve threshold..." 同一(Phase 2 でも LLM は呼ばれない)
E002 懇親会 ¥30,000 / 5名 llm_review "Reviewed; no policy violation detected."(テンプレ) "The unit price of JPY 6,000 per person for a team dinner is reasonable and clearly for business purposes."
E003 水 15本で ¥30,000 llm_review "Unit price JPY 2,000 too high for water." "The unit price of 2,000 JPY per bottle of water exceeds the policy limit of 1,500 JPY."
E004 研修 ¥250,000 manager_review(LLM呼ばず) "Amount JPY 250,000 >= 100,000 requires manager approval." 同一

ここで観察できたこと:

  1. E001 / E004 は Phase 2 でも LLM を呼ばない。決定論側のルールで早期に確定するため、llm_review ノードまで到達しない。これがスライダー比喩の実体。
  2. E002 / E003 では Phase 1 のテンプレ文に対し、Phase 2 は文脈を読んだ自然な reason が返る。"per person for a team dinner" / "per bottle of water" のような単位の言い換えは、ハードコードでは書きにくい。
  3. どちらの Phase も同じ Decision 型で揃うoutput_schema=Decision のおかげで、LLM が何を返してきても status / reason / employee_id / amount_jpy の形に強制される。Phase 1 のスタブ実装と完全に互換のまま、reason 文だけ自然になる。

ハマったところ(記事の本題はここかも)

1. Vertex AI を最初に試して 403

最初は scaffold で生成された雛形(gemini-flash-latest を Vertex AI 経由で呼ぶ設定)に揃えようとしたのですが、gcloud config の既定プロジェクトが古いまま残っていて、認証は通っているのにそのプロジェクトでは Vertex AI が叩けないという状態でした。

google.genai.errors.ClientError: 403 PERMISSION_DENIED.
service: aiplatform.googleapis.com
reason: CONSUMER_INVALID

CONSUMER_INVALID は概ね「API 未有効」「Billing 未紐付」「プロジェクト不存在」のいずれか、というエラーです。今回はそもそも当該プロジェクトへのアクセスが無くなっていました。

検証目的では Vertex でなく Google AI Studio の API キー で十分なので、そちらに切り替えました。

# Vertex AI を使わず、AI Studio 経由にする
os.environ.setdefault("GOOGLE_GENAI_USE_VERTEXAI", "False")
# GOOGLE_API_KEY は .env 経由で読み込む

LlmAgent(model="gemini-flash-latest", ...) のコード自体は一切変えず、環境変数の経路を切り替えるだけで動くのは ADK の良いところです。

2. event.output が外部消費者には None で返る

ここが個人的に一番ハマったところで、ADK のソースを読まないと気づけませんでした。

LlmAgentoutput_schema=Decision 付きで使うと、ワークフロー内部では Decision に validate された辞書がちゃんと作られるのですが、runner.run_async() の購読側で event.output を見ると None になっている、という挙動です。

ADK 内部でこういう処理が入っています(google/adk/runners.py の該当行)。

if not event.partial:
    if event.node_info.message_as_output and event.content is not None:
        event = event.model_copy()
        event.output = None

message_as_output フラグは LLM ノードが立てるフラグで、これが立っているイベントは「LLM の発話そのものが出力」という扱いになり、外部の購読側へ渡るときには event.output がわざと None に書き換えられます。

回避策:購読側で「output が None で message_as_output が立っている」場合だけ、event.content の text を JSON として読み直します。

out = event.output
if (
    out is None
    and event.content is not None
    and event.node_info
    and event.node_info.message_as_output
):
    text = "".join(
        p.text for p in (event.content.parts or []) if getattr(p, "text", None)
    )
    if text.strip():
        try:
            out = json.loads(text)
        except json.JSONDecodeError:
            out = text

この処理を入れたら Phase 2 の Decision がきれいに取れるようになりました。

なお、後続ノードへ繋いだ場合は ctx.output 経由で validate 済みの辞書が渡るので、ノード間連結時は問題になりません。今回のように ワークフロー外部からイベントストリームを購読するケース固有の挙動 です。

3. amount_router[NO DEFAULT] 警告が出る

adk webamount_router ノードを見ると、

⚠ [NO DEFAULT]

という黄色の警告が出ます。これは「ルータの条件にマッチしなかった入力が来たらどこへも行けない」という ADK 側のリント警告です。今回は3条件で全カバーしているので無害ですが、本番ではフォールバックルートを足すべし、ということだと理解しました。

adk web で UI から確認する

ADK 2.0 には adk web という開発用 UI が同梱されていて、ローカルの 127.0.0.1 で立ち上がります。サブディレクトリごとにエージェントを認識するので、Phase 1 / Phase 2 を別ディレクトリで並べておくと UI のドロップダウンで切り替えられます。

agents/
├── _shared.py                   # Expense, Decision, build_workflow() を共有
├── expense_phase1/
│   ├── __init__.py
│   └── agent.py                 # use_llm=False
└── expense_phase2/
    ├── __init__.py
    └── agent.py                 # use_llm=True
adk web ./agents --port 8000

ブラウザで http://127.0.0.1:8000 を開くと、左ペインに「Graph」「Trace」が、右ペインに「Events」「Traces」が出ます。

UI 観察ポイント:

  • 緑のノードが今回の入力で実走したノード、グレーは通らなかったノード
  • ノード名のプレフィックス:f = @node で作った関数ノード、 = LlmAgent(LLM ノード)
  • イベント一覧に route: auto_approve / route: llm_review / route: manager がラベル表示され、amount_router の判定結果が一目で分かる

(ここに adk web のスクショを4枚貼る予定。Phase 1 / E001、Phase 2 / E002、Phase 2 / E003、Phase 2 / E004)

特に印象的だったのは 「Phase 2 + E001(少額・自動承認)」のスクショで、llm_review ノード(✨アイコン)がグレーのまま、auto_approve だけが緑に光ります。同じワークフロー定義でも、入力次第で LLM を呼ばない経路が成立する——スライダー比喩の最も分かりやすい絵だと思います。

触ってみての所感

良かったところ:

  • @node + Workflow(edges=[...]) の宣言が素直route 文字列で分岐先を選ぶ書き方は、馴染みのあるルーティングの書き味に近く、最初の壁が低い。
  • LlmAgent を関数ノードと同列に扱える。型 (Pydantic) を output_schema に渡しておけば JSON 強制まで一気通貫。
  • adk web のグラフ可視化は記事映えする。読者に「同じグラフでも経路が違う」を見せやすい。

引っかかりやすそうなところ:

  • 外部購読時の event.output = None 仕様は、ドキュメントを軽く読んだだけでは気づきにくい。message_as_output の存在を知っていれば対処できる。
  • ルータの [NO DEFAULT] は本番で必ず潰したい。リント警告は親切ですが、テストで全分岐をカバーしないと見逃しがち。

今回試していないこと(次回以降)

冒頭でも書いた通り、本記事のスコープ外として残しているもの:

  • Ambient Agents(Pub/Sub・Eventarc・BigQuery トリガでの起動)
  • Skills + Environments(ファイル操作・シェル実行)
  • Agents CLI によるデプロイ(Agent Runtime)、Eval、Gemini Enterprise への Publish
  • Collaboration Modes の他モード(Chatbot 型以外)
  • Python 以外の言語サポート(Java / Go / TypeScript)

特に Ambient は Pub/Sub エミュレータのセットアップから入る必要があるため、別記事で扱う方が読みやすいと判断しました。

まとめ

  • ADK 2.0 のグラフベース実行は、@nodeWorkflow(edges=[...]) だけでミニマルに動かせる
  • LlmAgent をエッジに置くことで「決定論ノードと LLM ノードを 1 グラフに混ぜる」ことが普通にできる
  • 同じグラフ定義でも、入力次第で LLM を呼ばない経路が成立する=スライダー比喩は本物
  • ハマりどころとしては、Vertex AI 経路の認証外部購読時の event.output = None 仕様 の 2 つ
  • adk web の可視化が想像以上に良くできていて、記事や勉強会用素材としても優秀

「LLM のふるまいに業務クリティカルな判断を任せる勇気はないが、人間の想定を超えた説明文や曖昧な分類は LLM に頼みたい」——という現実的な落とし所を、グラフでそのまま設計に落とせるのが ADK 2.0 の体験的な価値だ、と感じました。


付録:再現用コマンド

# 仮想環境
uv venv && source .venv/bin/activate

# ADK インストール
uv pip install google-adk

# .env に AI Studio の API キーを置く
echo 'GOOGLE_API_KEY=<your-api-key>' > .env
chmod 600 .env

# CLI で 4 ケースを Phase 1 / Phase 2 並走
python expense_triage.py

# UI で確認
adk web ./agents --port 8000
# → http://127.0.0.1:8000 をブラウザで開く

Discussion