🔬

社内データ基盤 × Agent Engine × ADK × Next.jsで、分析エージェントを作っている話(技術編)

に公開

私たちREADYFORの社内には、dbtとBigQueryにより整備されたデータ基盤があり、日々多くの情報が蓄積されています。

しかし、社内の誰もが自分の手で「今知りたいこと」をデータから引き出すのは、いまだに簡単ではありません。

SQLを書く、BIツールのダッシュボードを更新する——こうした手間が意思決定のスピードを鈍らせていました。

この課題を解決するために、READYFORでは社内データ基盤とLLMを組み合わせた“社内データ分析エージェント”の開発に取り組んでいます。

この記事では、どんなエージェントをどんなアーキテクチャで構築したか・どんな工夫や苦労があったかをかいつまんで紹介します。
「自社でエージェントを作ってみよう!」という際の参考になれば嬉しいです。

どんなエージェントか?

端的に言えば、「人間の代わりにSQLを書いて実行したり全文検索ツールを駆使したりしてデータを集めて結果を教えてくれるエージェント」です。


マスコットキャラクターもいます。めぐりちゃんと言います

UIは至ってシンプル。チャットの入力欄からエージェントに対して指示や依頼を送信できます。

ユーザーの求めに応じてツールを呼び出し、必要な情報を集めた上で回答を生成してくれます。

READYFORはクラウドファンディングと寄付のプラットフォームを運営しているので、

  • 「こういう条件に当てはまるプロジェクトの事例を探して」
  • 「これまでで最も支援金額の多かったリターン上位10件は?」

といった問いに対して、社内データをもとに回答を得られます。

利用状況

回答の安定性・精度を改善しながら、段階的に社内でのアナウンスを進めています。

現在、週あたり10〜20人のアクティブユーザーがいて、AIに対する呼び出し回数は累計800件を超えたところです。

職種は営業・マーケティング・カスタマーサポート・カスタマーサクセスと幅広く、会話履歴の分析からは職種ごと職位ごとに様々な観点からデータと向き合っている様子が見られます。

特に、営業における事例探しには明確な実用性が見えていて、以下のような嬉しい声をもらいました。

事例AI、本当に重宝しております!!特に、地域と最終金額がすぐ出るのがありがたく・・
また、「〇〇に紹介するとしたら、どんな事例が良い?」みたいな質問にも答えてくれるので、めっちゃ勉強になる・・

アウトバウンドだとお電話して初めて情報を得ることができる中でご紹介する事例がある一定決まってしまっているという課題がありました。
お電話しながら、情報をいれてその場で事例をご紹介できるのはめちゃくちゃありがたかったです!

事例の有無であれば、ユーザー自身でハルシネーションかどうかの検証もしやすいので、AI活用の入口としてはうってつけかなと思います。

(PoCのプロジェクトとしてどのように立ち上げ・社内展開・評価と改善を進めてきたかについては、READYFOR Advent Calendar 2025 の11日目の記事として紹介する予定です)

現在の開発体制

1名(筆者。今のところは社内の個人開発プロジェクトです)

実用性があるかどうか分からない実験的な取り組みだったため、機動力重視の1名だけでプロトタイプ開発を行い、ビジネスメンバーとの自由研究会(週1)で意見・フィードバックをもらいながら機能追加・改善を進めています。

筆者はもともとプロダクトマネージャー出身ですが、個人事業主としてコードを書いてきた経験はそれなりにあり、コーディングエージェントも積極活用しながらPython・TypeScript・Terraformを行ったり来たりしながら開発しています。

アーキテクチャ概要

ここからは技術的な詳細について紹介していきます。

インフラは全てGoogle Cloudで、以下のような技術スタックで構築しています。

  • 認証:Identity-Aware Proxy
  • Webアプリケーション:Cloud Run
    • Next.js
    • AI SDK UI
    • AI Elements
  • エージェントバックエンド:Vertex AI Agent Engine
    • Agent Development Kit (Python)
  • ツール呼び出しによる連携先
    • プロジェクト検索ツール → Vertex AI Search
    • データモデル検索ツール → dbtのデータカタログ(インメモリのcatalog.json)
    • SQL実行ツール → BigQuery

設計のポイント

1. Agent Engine + ADKでブーストをかける

まずは、サービスの要であり、技術的な困難も多いだろう「AIエージェント」を作るための技術選定です。

Googleが公開しているAIエージェントフレームワークであるAgent Development Kit (ADK) を、同じくGoogleが提供するAIエージェント用のフルマネージドサービス Agent Engine にデプロイする形にしました。

Agent Starter PackというProduction-readyなテンプレートが公開されており、その中で採用されていたのがこの構成だった、というのも大きかったです。

https://github.com/GoogleCloudPlatform/agent-starter-pack

この組み合わせのおかげで、会話履歴を保持するAIエージェントを素早く構築できることができました。


ADKの公式ドキュメントより。Agent Engine + ADKだけでこの図にあるやり取りを実現できます

また、Observability(可観測性)やEvaluation(評価)の仕組みも組み込まれており、AIエージェント運用のベストプラクティスにいち早く近づけることも大きな利点でした。

先行事例としてメルカリのSocratesがあったのも心強かったです。

https://speakerdeck.com/na0/merukariniokerudetaanariteikusu-ai-eziento-socrates-to-adk-huo-yong-shi-li

2. Next.js + Cloud Runでアプリケーションの土台を固める

Agent Engineを採用するにあたっては、Pythonの技術スタック(Streamlit, Fast APIなど)でアプリケーション全体を作っていくことが第一の選択肢になると思います。

先行事例のSocratesも、StreamlitでUIを構築しているように見えます。

Googleの公式のドキュメントを見ても、Agent EngineにデプロイしたエージェントをVertex AI SDK for Pythonで呼び出すやり方が充実していて、Python以外から呼び出す方法については情報が少ない印象でした。

一応RESTのエンドポイントは用意されていますが、ドキュメント不足感はあり、実際に呼び出しながら試行錯誤する必要がありました。

一方で、弊社で採用されている技術スタックを考えると、PythonよりもTypeScript(React, Next.js)のほうが断然相性がよいのが実情です。

いまは個人開発でも、ゆくゆくは皆のものにしなければなりません。

結果として、多少遠回りになってもエージェント以外の機能については使い慣れた技術スタックで構築できることを優先しました。

また、AI SDKをはじめとしてVercelが提供するライブラリが豊富で、AIサービスでよくあるUIを素早く構築することができることも利点でした。

https://ai-sdk.dev/

インタラクティブなデータの可視化も、Rechartsなどのライブラリが充実しているので、今後の発展のことも考えて、Next.js / Reactの技術スタックを選びました。

(ADK × Next.jsの苦労や工夫については、個人的にADK Advent Calendar 2025の9日目に寄稿する予定です)

3. 既存のデータ基盤を活かす

READYFORでは元々、dbt (data build tool)を用いたデータパイプラインがあり、SQLで記述されたモデルをもとにBigQueryに分析用のテーブル・ビューが作られる仕組みになっています。

dbtにはデータカタログの機能もあり、メタデータ(各テーブルやカラムの説明)をYAMLファイルにまとめておくと、BigQueryのスキーマ情報(データ型など)と一緒にまとめて一つのJSONファイル(catalog.json)を吐き出してくれます。

これを利用して、データ基盤との連携のためにエージェントには以下のツールを持たせました。

  • データモデル検索ツール:catalog.jsonをもとに、分析に必要なモデルを検索できる
  • クエリ実行ツール:BigQuery上のデータマートにクエリを実行できる

この2つを組み合わせることで、エージェントはユーザーの要求に応えるために必要なデータがどのテーブルやビューに保存されているのかを自ら探し、スキーマ情報やカラム定義に基づいてSQLを書いて実行できるようになります。

dbtに入門したい方はこちらの本が参考になると思います。

https://zenn.dev/foursue/books/31456a86de5bb4

実装の工夫・ハマりどころ・得られた知見

ここからは、実装しながら工夫した点・ハマった点、開発を通して得られた知見を一気に紹介します。

1. コンテキストウィンドウ計算

エージェントを作る上で最も重要だと思っているのはコンテキストの適切な管理です。

Vertex AIでLLMを呼び出すと、以下のようなデータ構造でトークン消費状況を把握することができます。

{
  usage_metadata: {
    "traffic_type": "ON_DEMAND",
    
    // 入力(プロンプト)としてモデルに渡されたトークン数
    "prompt_token_count": 15180,
    // prompt_token_count をモダリティ別に分解した配列
    "prompt_tokens_details":[{ "modality": "TEXT", "token_count": 15634 }],

    // モデルが生成した「候補(=出力)」のトークン数
    "candidates_token_count": 412,
    "candidates_tokens_details": [{ "modality": "TEXT", "token_count": 412 }],

    // モデルが思考(thinking)を生成するために使ったトークン量
    "thoughts_token_count": 281,

    // キャッシュから利用されたトークンの合計数
    "cached_content_token_count": 7033,
    "cache_tokens_details": [{ "modality": "TEXT", "token_count": 7033 }],

    // 全トークン数。prompt、candidates、thinking の合計
    "total_token_count": 15873
  }
}

ただし、この中には最大トークン(コンテキストウィンドウ)については含まれていません。

Gemini 2.5 Flashの場合、公式のドキュメントを見ると

最大入力トークン: 1,048,576
最大出力トークン: 65,535(デフォルト)

と記載されています。

なので、「コンテキストウィンドウに占めるトークン消費量」を計算するなら、

total_token_count / 1_048_576

のように求めることができます。

ちなみに、asia-northeast1(東京)を含む一部リージョンでは、「128K コンテキスト ウィンドウでのみ使用可能」と記載されていて、

具体的には1,024 × 128 = 131,072が最大入力トークンです (なんと8分の1)。

Geminiならではの巨大なコンテキストウィンドウの恩恵を受けるには、ap-northeast1ではなくus-central1やグローバルリージョンを使うようにしましょう。

2. Geminiが空文字しか返してこない

再現条件は不明で、どなたか知っていたら教えてほしいのですが、Geminiが空文字しか返してこないことがあります。

{"content":{"parts":[{"text":""}],"role":"model"}}

いまだに根本解決はしていませんが、以下の対策を取ることで、問題になりづらくなりました。

  • 思考予算を明示的に指定する(思考にトークンを食われすぎている感触があったため)
  • モデル実行後に毎回呼び出される after_model_callback のなかで、空文字を検出したらエラーメッセージに差し替える
コード例
def after_model_callback(callback_context: CallbackContext, llm_response: LlmResponse):
    empty_response_detected = check_empty_response(llm_response)

    if empty_response_detected:
        # フォールバックメッセージを含む新しいレスポンスを作成
        fallback_content = genai_types.Content(
            role="model",
            parts=[
                genai_types.Part(
                    text="申し訳ございません。処理中に問題が発生しました。もう一度ご指示いただけますでしょうか?\n\n例えば:\n- 「結果を教えてください」\n- 「再開してください」"
                )
            ],
        )
        
        # LlmResponseを作成して返す
        modified_response = LlmResponse(content=fallback_content)
        # usage_metadataをコピー
        if hasattr(llm_response, "usage_metadata"):
            modified_response.usage_metadata = llm_response.usage_metadata
    
        return modified_response
        
    return None  # そのまま次へ

root_agent = Agent(
    name="root_agent",
    model="gemini-2.5-flash",
    planner=BuiltInPlanner(
        thinking_config=types.ThinkingConfig(
            include_thoughts=False,
            thinking_budget=512,
        )
    ),
    instruction="..."
    tools=[...]
    after_model_callback=after_model_callback
)

after_model_callbackのようなコールバックはADKが用意している仕組みで、詳しくは以下のドキュメントを参照してください。

https://google.github.io/adk-docs/callbacks/types-of-callbacks/

3. コンテキスト消費状況を可視化

ユーザーにコンテキストを意識してもらうため & デバッグのしやすさのため、画面上にコンテキストの消費状況を表示するようにしています。

このために、前出のafter_model_callbackのなかで、total_token_countと使用モデルの最大入力トークンをStateにセットするようにしています。

AGENT_MODEL_MAP = { "root_agent": "gemini-2.5-flash" }
MODEL_MAX_INPUT_TOKENS = { "gemini-2.5-flash": 1_048_576 }

def after_model_callback(callback_context: CallbackContext, llm_response: LlmResponse):
    model_name = AGENT_MODEL_MAP.get(callback_context.agent_name)
    usage_metadata = llm_response.usage_metadata
    if usage_metadata:
        st = callback_context.state
        st["last_prompt_tokens"] = usage_metadata.prompt_token_count
        st["total_tokens"] = usage_metadata.total_token_count
        st["model_max_tokens"] = MODEL_MAX_INPUT_TOKENS.get(model_name)

4. ツール呼び出しの結果でコンテキストウィンドウが枯渇しないようにする

運用を続けるなかで、突然エージェントが何も応答してくれなくなることがありました。

ログを確認したところ、タイトルや説明文をまとめて取得するようなSQLの実行結果が10万以上のトークン量になっていて、以下のようなトークン超過エラーが出ていました。

400 INVALID_ARGUMENT. {'error': {'code': 400, 'message': 'The input token count (133898) exceeds the maximum number of tokens allowed (131072).', 'status': 'INVALID_ARGUMENT'}}

これを防ぐため、ツールごとにトークン数上限を設ける対策を行いました。Claude Code でもWebFetchのときに採用されているやり方です。

具体的には以下のような @with_token_guard デコレータを用意しました。

@with_token_guard(
    token_limit=10000,
    suggestion=lambda args: f"LIMIT {args.get('max_results', 1000) // 2} を使用してください"
)
def execute_query(sql_query: str, max_results: int = 1000) -> dict:
    ...

ツールの出力をLLMに渡す前に、内部的にトークン数を数えて既定の上限を超えていたら、成功レスポンスをエラーレスポンスに差し替えます。

@with_token_guard デコレータの実装例
def with_token_guard(token_limit: int, suggestion: str | Callable[[dict[str, Any]], str] = "出力を小さくするためにパラメータを調整してください"):
    def decorator(func: Callable[..., dict[str, Any]]) -> Callable[..., dict[str, Any]]:
        @wraps(func)
        def wrapper(*args, **kwargs) -> dict[str, Any]:
            # ツール関数を実行
            result = func(*args, **kwargs)

            # トークン数を計算
            token_count = count_tokens_for_tool_response(result)

            # 閾値チェック
            if token_count > token_limit:
                # 提案メッセージを生成
                if callable(suggestion):
                    suggestion_text = suggestion(kwargs)
                else:
                    suggestion_text = suggestion

                # エラーレスポンスを返す
                return {
                    "success": False,
                    "error": (
                        f"ツール '{func.__name__}' の出力が大きすぎます "
                        f"({token_count:,} トークン > 制限 {token_limit:,} トークン)。"
                    ),
                    "suggestion": suggestion_text,
                    "token_count": token_count,
                    "token_limit": token_limit,
                }
            return result
        return wrapper
    return decorator
count_tokens_for_tool_response の実装例
import json
from typing import Any
from vertexai.preview.tokenization import get_tokenizer_for_model

def count_tokens_for_tool_response(tool_response: dict[str, Any]) -> int:
    # 辞書をJSON文字列に変換
    response_text = json.dumps(tool_response, ensure_ascii=False, default=str)

    # gemini-2.5系はvertexai.preview.tokenizationでまだサポートされていないため、便宜上カウントにはgemini-1.5-flashのトークナイザーを使用
    tokenizer = get_tokenizer_for_model("gemini-1.5-flash")

    # トークナイザーでカウント
    result = tokenizer.count_tokens(response_text)
    return result.total_tokens

これにより、ツールの結果が大きすぎてもLLMのコンテキストウィンドウは守られ、軌道修正をユーザーに提案することができるようになりました。

5. データ基盤の品質も一緒に良くしていく

LLMにデータ基盤を使わせるようになると如実に見えてくるのが、データ基盤の品質です。

カラムの説明が不足していたり、古くなったカラムが残っていたりするとLLMはそれに翻弄されてしまいます。

普段データ分析するとき、いかに「自分の頭の中に入っているコンテキストに頼っているか」を実感します。

毎回何も知らない状態からスタートするLLMにとっては、品質の低いデータ基盤は鬱蒼としたジャングルに見えることでしょう。


翻弄されるめぐりちゃん

これは新しく入社してきたメンバーにとっても同じです。

LLMのため、将来の新入社員のためを思って、不足している定義を補強したり、要らないカラムを削除したりしましょう。

Tips

  • 「現実の業務で使われる言葉」を使って、テーブルやカラムの定義を説明する
  • カラムごとのデータ型を明示する(dbt docs generate を使えば簡単に取得できます)
  • Enum的なカラムには、どの値が意味するか説明をつけるようにする

各層のモデルに重複したカラム説明があるとメンテナンスが難しくなるので、dbt-osmosisというツールを導入して、上流モデルのメタデータを下流モデルに反映できるようにしました。

詳しくは以下の記事が参考になります。

https://tech.yappli.io/entry/improve-dbt-docs-description-with-dbt-osmosis

おわりに

本当はまだまだ項目を用意していたのですが、全部書いていたら担当の日を過ぎてしまいそうなので見出しだけ書いておきます。

  • モデル検索の選択肢を最初から与える
  • 大事なユースケースを選んで探索ステップを減らす
  • AI SDK Coreは使わなかった
  • Agent Engine + ADKに感じ始めた限界

詳しくはまた追加の記事で書くと思うので、気になる方はフォローなどお願いします。

この記事のPoC編をアドカレ11日目に公開する予定です。

明日は READYFOR Advent Calendar 2025 5日目、 pxfncさんによる記事が公開される予定です。

お楽しみに!

READYFORテックブログ

Discussion