🐕

対話ベースでフォーム埋められるエージェントを作ってみる

に公開

今までエージェント周りを開発したかったのですが、API料金がなあ。。と思い断念していました。(最近 OpenRouter という存在を知って、個人的な開発なら無料である程度できそうなことに気づきました)
ハッカソンに参加するとクレジットをいただけるということで(ありがとうございます!)、思い切って参加してみました。この記事では、そこで何を作ったのか・どういうエージェントのロジックになっているのかを主に説明していこうと思います。

つくったものとモチベーション

生活していると何かとフォームと出くわすことがありますが、毎回UIが違っていてものによってはわかりづらいなと思うことがありました。今ならLLMがチャットベースで進めていくことで、

  • インターフェースが基本1つのみ(チャット)
  • 抽象的な質問もステップバイステップで進められる。

みたいなメリットがあるかもと思い、今回はフォームをチャット形式で入力できるエージェントを作ってみました。

エージェントの構成

エージェントは以下の5つです

エージェント 役割 モデル
Orchestrator まとめ役・進行 gemini-3-flash-preview
Quick Checker 質問文のレビュー gemini-2.5-flash-lite
Reviewer 回答の不足等の確認 gemini-3-flash-preview
Auditor 回答全体のレビュー gemini-2.5-flash
Designer フォームの設計を行う gemini-3-flash-preview

基本的に全てのエージェントでツールの呼び出しを強制しています。gemini では、toolConfig のオプションで ANY を設定することで、ツールを呼び出すように強制することができます(2.5-flash-lite などで稀に呼び出さないことがあるかも?気のせいかもですが)

Gemini API を使用すると、モデルが提供されたツール(関数宣言)を使用する方法を制御できます。具体的には、.function_calling_config 内でモードを設定できます。

AUTO (Default):
モデルは、プロンプトとコンテキストに基づいて、自然言語によるレスポンスを生成するか、関数呼び出しを提案するかを決定します。これは最も柔軟なモードであり、ほとんどのシナリオにおすすめです。

ANY:
モデルは常に関数呼び出しを予測するように制約され、関数スキーマの準拠が保証されます。allowed_function_names が指定されていない場合、モデルは指定された関数宣言のいずれかを選択できます。allowed_function_names がリストとして指定されている場合、モデルはそのリスト内の関数からのみ選択できます。すべてのプロンプトに関数呼び出しのレスポンスが必要な場合は、このモードを使用します(該当する場合)。

NONE:
モデルは関数呼び出しを行うことが禁止されています。これは、関数宣言なしでリクエストを送信するのと同じです。これを使用すると、ツール定義を削除せずに、関数呼び出しを一時的に無効にできます。

VALIDATED(プレビュー):
モデルは、関数呼び出しまたは自然言語のいずれかを予測するように制約され、関数スキーマの準拠が保証されます。allowed_function_names が指定されていない場合、モデルは使用可能なすべての関数宣言から選択します。allowed_function_names が指定されている場合、モデルは許可された関数のセットから選択します。

https://ai.google.dev/gemini-api/docs/function-calling?hl=ja&example=meeting

const response = await this.client.models.generateContent({
    config: {
      toolConfig: options?.forceToolCall
        ? { functionCallingConfig: { mode: FunctionCallingConfigMode.ANY } }
        : undefined,
    },
  })

なのでユーザーに対してレスポンスを返す場合でも、askask_options という2つのツール経由でないとコミュニケーションが取れないような設計になっています。
各タイミングのレビューエージェントも ツール経由でないと終了できず、「このツール使って!」というフィードバックをユーザープロンプトとして返却します。
一方で無限ループの可能性はあるので、最大のイテレーション数を設定してフォールバック用のUIを見せる必要があります。

フォーム作成

もともとフォーム入力のみサポートしようと思っていたのですが、フォーム作成時の叩き台も LLM に作ってもらったらいい感じかも?と思い作ってみました。
構造はすごいシンプルです。最初にフォームのタイトルと概要を入力したら、生成開始します。不足点があれば質問してきて、必要な情報が全て集まったとLLMが判断したらフォームの雛形を生成します。

例えば

  • タイトル: イタリアーノ テーブル予約
  • 目的: 当レストランのテーブル予約を承ります。ご希望の日時、人数、アレルギー情報などをご記入ください。

みたいな入力で作り始めると、以下の感じで質問してきます。内部では ask_options というツールを gemini に呼んでもらって作っています。

一通り質問し終わると、フォームの項目をばっと作ります。生成内容には、ラベル名や「なんでそのフィールドが必要か」などの内容があります。一方でフォーム入力の対話中に失礼な発言があるとまずいので、念の為禁止項目を設けています。

フォーム入力

フォームは基本対話ベースで行い、フォールバックとして通常のフォームUIを表示するような作りになっています。
3-flash-preview が賢くて、雑な回答をすると聞き直してくれます。ただ、レスポンスが遅かったり固まったりすることがあるので、本格的に使うならちょっと検証しないとなと思いました(同じような話題が上がってそう)

全体的なフローは以下のような感じになっています。

いちばん最初は言語の設定からです。ブラウザの設定言語をもとに初回のメッセージを生成します。

フランス語で話してくれたりします。

3-flash-lite の Orchestrator がメインで動作します。Orchestrator は

  • ask: 自由入力の質問
  • ask_options: 選択肢付きの質問

の2つのツールを持っていて、このうちのいずれかを必ず呼ぶようになっています。

それぞれのツールは内部で質問文のレビューを QuickCheck に投げて、問題ないかチェックします。
問題がある場合は、ユーザープロンプトとしてフィードバックを Orchestrator に投げ、再度質問文を生成し直すループ構造になっています。

ユーザーが質問を回答した際は、その内容が要件を満たしているか Reviewer が判断。これも要件を満たしていなかったらユーザープロンプトとしてフィードバックを Orchestrator に投げて、再度質問します。満たしていれば、次の質問に移ります。

質問対象は Orchestrator がシステムプロンプトに埋め込んでおり、Reviewer の結果によってその切り替えを行なっています。

QuickCheck や Reviewer などのサブエージェントは別セッションで行うようになっており(Claude Code の agent 的な)、Orchestrator のコンテキストを汚さないようにしています。

ちなみにLLMでエラーが出た場合は、以下の感じでフォームが出ます


そのほかエージェント周り以外は以下の感じです。

インフラ

以下を使用しています。

  • Firebase Hosting: アプリのホスティング
  • Cloud Functions: API
  • Firestore: チャットのキャッシュ用
  • Turso(DB): 無料で使える sqlite ベースのDB
  • Google AI Studio: モデルのログ見たり

基本的な LLM のセッションの情報は Firestore に保存されます。TTL を24時間に設定しているので、フォーム入力中に途中離脱したりした場合でも、明日にはデータは残らない作りになっています。
一方で会話のログは欲しいので、ユーザーからの入力メッセージ・LLMのレスポンスメッセージの2つは Turso に永続的に保存しています。

おわり

作っては見たものの、作成したフォームをテストする環境がないとなかなか実運用まで持ってくのは難しそうだなと思いました。
体験としては個人的にいいなと思っていて、

  • チャットに向き合うだけで良い
  • 気づいたら勝手に終わってる感
    が「お〜」と言う感じでした。こういったサービスだけじゃなく、ちょっとしたCLIとかでもエージェントのループ周りの知見は応用できそうだなと思ったので、また何か作ろうと思います。

https://youtu.be/wSVrsLuJdD4

Discussion