blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2026.08.05 技術記事

ADKマルチエージェントでkintone承認を自動化した設計と自動評価(eval)の実践

by Hiroshi Toyama

#adk #ai-agent #multi-agent #ai #llm #gemini #vertex-ai #kintone #evaluation #workflow-automation

はじめに

こんにちは。DeNA でエンジニアをしている外山です。

社内のツール利用申請ワークフロー(kintoneで運用)には、承認前に人間が行っていた3つの確認作業がありました。

  • 予算内かどうか(予算残額シートとの突き合わせ)
  • 申請されたツール名がマスターに存在するか
  • 「法務確認は不要」という選択が本当に妥当か

どれもレコードの内容を読んで、別のスプレッドシートやマスターと照合するだけの定型作業ですが、申請件数が増えると地味に時間を取られます。判断基準がはっきりしていて件数も多いこうした作業は、エージェントに任せて効率化できる余地が大きいと感じていました。

そこでGoogle ADK(Agent Development Kit)でマルチエージェントを組み、Vertex AI Agent Engineにデプロイしました。この技術選定にした理由は2つあります。1つは社内でGoogle Cloud基盤とGeminiを標準的に使っていて、そのまま乗せられること。もう1つはAgent Engineにデプロイすれば推論の実行基盤を自前で運用せずに済み、マネージドで運用負荷が低いことです。

この記事では、Terraformによるインフラ構築の詳細には触れず、その上に載せたマルチエージェントのアプリケーション設計と、adk eval(ADK標準のオフライン評価。あらかじめ用意したテストケースでエージェントの挙動を回帰テストする仕組み)を実運用にどう組み込んだかを中心にご紹介します。

全体構成

kintoneのWebhookをCloud Runで受け、Vertex AI Agent Engine上のroot_agentが3つのサブエージェント(budget_check・tool_name_suggest・legal_check)に振り分け、結果をkintoneにコメント投稿する全体構成図

ステータス変更のWebhookをCloud Runで受け、LLMを使わない軽量なフィルタリングとプロンプト構築だけそこで済ませてから、実際の判定はVertex AI Agent Engine上のADKエージェントに渡す構成にしています。

1つの巨大プロンプトではなくサブエージェントでルーティングする

予算チェック・ツール名照合・法務確認の妥当性検証を1つのエージェントの巨大なinstructionに詰め込むと、プロンプトが肥大化するだけでなく、evalで個別に検証しづらくなります。そこで root_agent はルーターに徹し、3つのサブエージェントがそれぞれ1つの関心事と1つのtoolセットだけを持つ構成にしました。

budget_check_agent = Agent(
    name="budget_check",
    model="gemini-2.5-pro",
    description="kintoneのツール利用申請を審査し、予算内かどうかを判定するエージェント",
    instruction=INSTRUCTION,
    tools=[get_budget_data, get_tool_master, post_kintone_comment],
)

tool_name_suggest_agent = Agent(
    name="tool_name_suggest",
    model="gemini-2.5-pro",
    description="ツール名がマスタに存在するか検証し、存在しない場合は類似ツール名をサジェストする",
    instruction=TOOL_NAME_SUGGEST_INSTRUCTION,
    tools=[search_kintone_tool_exact, get_all_kintone_tools, post_kintone_comment],
)

legal_check_agent = Agent(
    name="legal_check",
    model="gemini-2.5-pro",
    description="法務確認の選択が「不要」の場合に、申請レコードのマスタ情報と申請内容を突合して妥当性を検証する",
    instruction=LEGAL_CHECK_INSTRUCTION,
    tools=[post_kintone_comment],
)

root_agent = Agent(
    name="tool_usage_request",
    model="gemini-2.5-pro",
    description="ツール利用申請に関するリクエストを適切なサブエージェントにルーティングする",
    instruction=(
        "あなたはルーターエージェントです。"
        "ユーザーのリクエスト内容に応じて適切なサブエージェントに処理を委譲してください。\n\n"
        "- 予算チェック・予算確認に関するリクエスト → budget_check\n"
        "- ツール名の検証・サジェストに関するリクエスト → tool_name_suggest\n"
        "- 法務確認の検証に関するリクエスト → legal_check\n"
    ),
    sub_agents=[budget_check_agent, tool_name_suggest_agent, legal_check_agent],
)

agent_engine = AdkApp(agent=root_agent)

サブエージェントごとにtoolを絞っている点がポイントです。legal_check_agent は予算シートに触れませんし、tool_name_suggest_agent は予算判定を勝手に投稿できません。この絞り込みは後述のevalでも効いてきて、「間違ったtool呼び出し」は自由文の判定より遥かに検出しやすいシグナルになります。

budget_checkのinstructionは緩い自然文ではなく、番号付きの厳密な手順として書いています。抜粋すると次のような形です。

### ステップ3: 購入金額の確認
- purchasePrice を購入金額(円)として使用する
- 未入力・不正な値の場合 → 「購入金額が未入力」として終了
- 購入金額が 0 の場合 → 予算内として終了(金額0円は常に予算内)。
  この場合は get_tool_master / get_budget_data を呼ばない。

### ステップ5: ツールマスタでマッピングを取得(必須)
- get_tool_master を呼び出す
- toolName に一致する行を探し、予算タイトルを特定する
- 一致する行が見つからない、または予算タイトルが「(予算外)」の場合 → 「予算管理対象外」として終了

Geminiは曖昧な推論もこなせますが、出力が実際の承認判断に直結するシステムでは、分岐をモデルの都度の解釈に委ねるのではなく、すべてテスト可能な明示的ステップとして書き下すようにしています。

ローカル/リモートを環境変数1個で切り替える

Agent Engineにデプロイしてからでないと動作確認できないと開発が遅くなります。そこでエージェントを呼び出す側のコード(本番のWebhookハンドラーやローカルの実行・評価スクリプト)は、環境変数1つを見てローカルのADK Runner とリモートのAgent Engineセッションを同じインターフェイスで切り替えられるようにしています。

AGENT_ENGINE_RESOURCE_NAME = os.getenv("AGENT_ENGINE_RESOURCE_NAME", "")

def _use_local() -> bool:
    return not AGENT_ENGINE_RESOURCE_NAME

async def query_agent_engine(message: str, user_id: str = "webhook-user") -> str:
    if _use_local():
        return await _query_local_async(message=message, user_id=user_id)
    else:
        return await asyncio.get_event_loop().run_in_executor(
            None, _query_remote, message, user_id
        )

ローカルモードはデプロイ済みアーカイブと同じ root_agent オブジェクトから直接 Runner を組み立てます。

def _get_local_runner() -> tuple[Runner, InMemorySessionService]:
    global _runner, _session_service
    if _runner is None:
        from kintone_approve_agent.agents.tool_usage_request.agent import root_agent
        _session_service = InMemorySessionService()
        _runner = Runner(
            app_name=root_agent.name,
            agent=root_agent,
            session_service=_session_service,
        )
    return _runner, _session_service

リモートモードはリソース名からデプロイ済みのreasoning engineを取得して stream_query で呼び出します。呼び出し側(FastAPIハンドラー)はどちらのモードかを意識する必要がありません。ローカルでは AGENT_ENGINE_RESOURCE_NAME を単に未設定にし、本番ではTerraformの deployment_spec で注入します。両パスともまったく同じ Agent 定義を実行するため、挙動のズレが起きない状態でローカルの高速イテレーションを確保できました。

モデルを呼ぶ前に軽いチェックで弾く

Webhookで飛んでくるイベントをすべてGeminiに投げる必要はありません。ハンドラーは、LLMを使わない軽い判定だけで済む3段階のフィルターを、プロンプト構築の前にかけています。

def should_process_webhook(
    event_type: str, current_status: str, record: dict[str, Any]
) -> tuple[bool, str]:
    if event_type != KintoneConstants.EVENT_UPDATE_STATUS:
        return False, f"イベントタイプ {event_type} は処理対象外です"

    if current_status != TARGET_STATUS:
        return False, f"ステータス「{current_status}」は処理対象外です"

    budget_confirmation = record.get(KintoneConstants.FIELD_BUDGET_CONFIRMATION, {}).get("value", "")
    if budget_confirmation != KintoneConstants.BUDGET_CONFIRMATION_REQUIRED:
        return False, f"予算確認対象外: {budget_confirmation}"

    return True, ""

イベント種別 → ステータス値 → 業務フラグ、の順に処理の軽いチェックから並べています。ほとんどのWebhook配信(他フィールドの編集や別ステータスへの遷移)は、レコード本文を見るより前に弾かれます。地味ですが、Gemini呼び出しに課金が発生するシステムでは、モデルより手前でフィルターするのがもっとも効くコスト最適化だと考えています。

レコードを絞ってYAML化してからプロンプトに渡す

元のkintoneレコードは、作成者・更新日時など判定に不要なフィールドを何十個も持っています。プロンプトに渡す前に、除外フィールドリストで削ぎ落としてからYAML化しています。フィールド名は社内固有のため、以下では一般化した名前に置き換えています。

_AGENT_EXCLUDE_FIELDS = frozenset([
    # 判定に使わない管理者・担当者系フィールド
    "assignee", "manager_a", "manager_b",
    # 判定に使わない付帯情報フィールド
    "org_field_a", "org_field_b", "display_name",
    # kintone の内部フィールド(作成者・更新者・タイムスタンプ・リビジョン等)
    "$id", "$revision", "Created_by", "Updated_by",
    "Created_datetime", "Updated_datetime",
])

def _build_agent_message(app_id: str, record_id: str, full_record: dict[str, Any]) -> str:
    extracted: dict[str, Any] = {}
    for field_key, field in full_record.items():
        if field_key in _AGENT_EXCLUDE_FIELDS:
            continue
        value = field.get("value") if field is not None else None
        if value is None or value == "" or value == []:
            continue
        extracted[field_key] = value

    record_yaml = yaml.dump(
        extracted, allow_unicode=True, default_flow_style=False, sort_keys=False,
    ).strip()

    return (
        f"以下のkintoneツール利用申請レコードを確認して、予算内かどうかを判定してください。\n"
        f"app_id: {app_id}\nrecord_id: {record_id}\n\n"
        f"```yaml\n{record_yaml}\n```"
    )

生のJSONペイロードをそのまま渡すより、2つの点で優れています。判定に無関係なフィールドが減る分、モデルが余計な情報に引っ張られる余地が減ること、そしてYAMLのフラットな key: value 表現は {"value": ...} のネストしたJSONよりトークン効率が良く、ログでも読みやすいことです。

なお、ここで示した予算チェック側は「不要なフィールドを除外リストで引く」方式ですが、法務確認チェック側は逆に「必要なフィールドだけを許可リストで残す」方式にしています。法務確認では判定に使うフィールドが十数個に限られるため、たくさんのフィールドから少しずつ引くより、必要な分だけを挙げる方がシンプルに書けるからです。

adk evalでハマった話:final_response_match_v2は信用しない方がよい

結論から言うと、ADK標準メトリクスの1つ final_response_match_v2 に振り回されました。

27ケース中7件がFAILした

ADKの adk eval にはevalsetといくつかの組み込みメトリクスが付いてきます。今回使ったのは次の5つです。

  • tool_trajectory_avg_score : ツール呼び出しの並びが期待どおりか
  • rubric_based_tool_use_quality_v1 : ツールの使い方をルーブリックで採点
  • rubric_based_final_response_quality_v1 : 最終応答の品質をルーブリックで採点
  • hallucinations_v1 : 根拠のない出力をしていないか
  • final_response_match_v2 : 最終応答が参照回答と一致するか(今回問題になったもの)

3エージェント用のevalsetは、get_budget_data/get_tool_master/search_kintone_tool_exact/post_kintone_comment をすべて固定データを返すフェイクに差し替えてあります。そのため実際に通信するのはGemini(エージェント自身の推論とjudgeモデルの採点)だけです。

3エージェント合計27ケースをフル実行したところ、final_response_match_v2 だけが原因のFAILが7件出ました。ただし実害のあるバグではなく、原因は完全にメトリクス側の設計にありました。

原因はメトリクスが見ている場所だった

final_response_match_v2 は、エージェントが最後に返す自然文(final response)を、あらかじめ用意した参照回答とjudgeモデルで比較して一致度を採点するメトリクスです。見ているのは「最後のテキストターンだけ」です。

ところが3エージェントとも、実際の判定内容は post_kintone_comment ツール呼び出しの text 引数に入ります。最後の自然文応答は、そのコメント文をそのまま繰り返す場合もあれば、「〜についてコメントを投稿しました」という短い完了報告だけで終わる場合もあり、どちらになるかはLLMのサンプリングでブレます。後者のパターンに当たるたびに、参照回答とは似ていないと判定されて0点になってしまいます。エージェント自体は正しく動作しているのに、実行するたびにFAIL/PASSが入れ替わるノイズ源になっていました。

他の4メトリクスは同じ実行でも安定してPASSしていました。tool_trajectory_avg_score はツール呼び出しの並びを、rubric_based_* の2つはルーブリックに沿った内容を、hallucinations_v1 は出力の根拠を見ており、いずれも判定の本体が乗っている「ツール呼び出し」の側を評価しているためです。締めの文がどちらのパターンでも結果は変わりません。コードを一切変えずに再実行しても、締めの文の選ばれ方だけでPASS/FAILがひっくり返るのは final_response_match_v2 だけで、ここから原因を1つに切り分けられました。

メトリクスを外し、ルーブリックで判定内容を評価する

3つの test_config.json すべてから final_response_match_v2 を除外しました。判定内容の正しさ(ツール呼び出しの中身を含む)は rubric_based_final_response_quality_v1 でカバーしています。名前は「最終応答の品質を評価する」ですが、ルーブリックの書き方次第で評価対象を最終応答のテキストに限定せず、ツール呼び出しの内容まで見させられます。たとえば「post_kintone_comment に渡したコメント文が、予算判定の結論として正しいか」を採点基準に書けば、締めの文がコメントの繰り返しでも完了報告でも、判定の中身そのものを評価できます。

uv run adk eval evals/tool_usage_request/budget_check \
  evals/tool_usage_request/budget_check/budget_check.evalset.json \
  --config_file_path evals/tool_usage_request/budget_check/test_config.json \
  --print_detailed_results

副作用で動くエージェントのeval設計

今回の教訓は、エージェントの本当の出力が何かを見極めてからメトリクスを選ぶ、ということです。判定結果がチャット応答ではなく副作用(ツール呼び出し)に乗るタイプのエージェントでは、final_response_match_v2 のように最終応答テキストだけを見るメトリクスは、そもそも見るべきでない場所を見ています。

避けるべきは「期待する最終応答テキストを固定し、それとの一致で採点するeval」でした。作るべきだったのは「ツール呼び出しの引数や順序を検証するeval」です。呼び出しの並びが決まっているなら tool_trajectory_avg_score で完全一致を見れば十分ですし、コメント文のように自由度が高い出力は、ルーブリックベースのjudgeで内容の妥当性を採点するのが向いています。

もう1点、rubric_based_final_response_quality_v1hallucinations_v1 は1ケースあたりjudgeモデルを5回サンプリングするため、フルセット実行には数分かかります。instructionを変更しながらイテレーションする際は、evalset.json:case_id で1ケースだけ実行した方が圧倒的に速く済みます。

デプロイ:2つの成果物を1本のパイプラインで

このシステムはCloud Run(Webhookハンドラー)とAgent Engine(推論本体)という2つのデプロイ対象を持ちますが、両方を同じソースツリーから同じCIジョブでビルド・デプロイしています。mainブランチへのpushはdev環境、バージョンタグのpushはprod環境に反映されます。

1本のパイプラインにまとめているのは、Webhookハンドラーとそれが呼ぶAgent Engineのロジックが、常に同じコミットからデプロイされる状態を保つためです。Cloud Run側だけが新しくてAgent Engine側が古い、といったバージョンのズレが起きる余地をなくしています。

まとめ

  • ルーター+toolを絞ったサブエージェントに分けると、巨大な1エージェントより設計もevalもシンプルになります
  • AGENT_ENGINE_RESOURCE_NAME 1つでローカルとリモートを切り替えれば、同じ Agent 定義のまま高速に試せます
  • Geminiを呼ぶ前に軽いチェックで弾き、レコードは必要な分だけYAML化して渡すのが効きました
  • 出力がツール呼び出しに乗るエージェントに final_response_match_v2 は不向きで、判定内容はルーブリックで評価しました

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。