以下の内容はhttps://tech.newmo.me/entry/2025/11/10/123509より取得しました。


Google ADK × DeepEvalで構築する音声AI Agentの会話評価基盤

newmoでは、GoogleのAgent Development Kit(ADK)を用いて音声配車AIエージェント「Maido」を開発しています。 これは従来オペレーターが電話で行っていた配車を、音声対話で自動化する取り組みです。Maidoについては以下のnoteで詳しく解説しているので、ぜひ併せてご覧ください。

note.com

本記事では、Maidoとユーザーの会話評価を自動化するために構築した「Conversational Evaluation」基盤の仕組みと運用について紹介します。

Agentのアーキテクチャ

評価の仕組みを説明する前に、Maidoのアーキテクチャを簡単に紹介します。

前述した通りAgentの開発ではGoogle ADKを利用しています。開発初期のPoCフェーズでは、LangChainやOpenAI Agents SDKなど他のLLM Agentフレームワークも検討しましたが、最終的には社内で広く利用されているGoogle Cloudサービス(Cloud Run, Speech-to-Text, Text-to-Speechなど)との親和性や、ADK自体の機能の充実性から、同じくGoogleが開発しているADKを採用しました。

実装言語はPythonを採用しています。newmoのバックエンド開発で中心的に利用されているGoや、昨今LLM Agent向けのフレームワークが勃興しているTypeScriptも選択肢になりましたが、将来的に音声関連の領域(VAD = Voice Activity Detectionなど)で何らかの機械学習的なアプローチが求められてくることを考慮して、その辺りのフレームワークが充実しているPythonに落ち着きました。(ADKが最初に提供された言語がPythonだったことも一因でした)

Maidoの最新アーキテクチャは以下の通りです。これに至るまでに複数世代の実装を試行錯誤してきましたが、その変遷についてはまた別の機会に紹介したいと思います。

Maidoのアーキテクチャ(Text based Multi-Agent × STT/TTS構成)

実装上の特徴として、Maidoの責務である「配車」という最終的なゴールに対して、各サブタスクを担当するSub Agentを配置するMulti-Agentな構成 *1 をとっている点があります。

タクシーの配車を達成するためには「今すぐの配車か予約配車かを聞き出す」「乗客の名前を聞き出す」「タクシーの配車位置を聞き出す」といった具体的なステップが必要です。これらサブタスクを専任のAgentに分離することで、プロンプトの短縮、振る舞いの安定化、不具合の局所化といったメリットを得ています。*2

それぞれのSub Agentの切り替えは状態遷移の管理を責務とするStateMachineAgentに一任されています。この仕組みには前述したような情報収集のステップが確実に遂行されるというメリットがあります。(そのため、Maidoは厳密にはAI AgentというよりはいわゆるAgentic Workflowと呼んだ方が正確かもしれません)

また、Maidoは音声による対話を前提に設計されたAgentであり、ユーザーとのインターフェースはTwilioを介した電話音声になります。そのため、Agentの入出力の前後ではGoogle Cloud Speech-to-Text / Text-to-Speech APIを用いた音声のテキストへの文字起こし / テキストからの音声合成が行われています。

なぜ評価が重要なのか

前提として、LLM Agentの開発を Eval-Driven(Eval-Centric)に行いたいというモチベーションがありました。

LLM AgentはLLMをコアなコンポーネントとして組み込んでいるため、振る舞いが非決定的になります。その結果、従来のように決定的なシナリオを前提としたE2Eテストの実施が難しくなるという課題があります。 また、LLMはモデルの性能が変わる可能性があるため、アプリケーションコードを変えていなくてもLLMに依存しているAgentの振る舞いは常に変わる可能性があります。

こうした課題に対して、LLM Agentが達成したい高レベルな目標に基づいた指標を定義して、それを基準に評価(Eval)を行うことでAgentの品質を担保する考え方が重要になってきます。そして、「評価」を開発プロセスの中心に置いて継続的に実施・改善していくことでAgentの品質を高めようというのが、Eval-Driven(Eval-Centric)な開発です。

Eval-Driven(Eval-Centric)なAgentの設計・開発については以下の記事が詳しいので、併せて参照いただければ幸いです。

評価基盤の概要

本節では、Maidoがどのように「会話全体の品質」を評価する仕組みを構築しているかを紹介します。

前提として、個別のSub Agentの振る舞いに対する評価はADKのEvalの機能を利用することで比較的簡単に実現可能です。例えば住所の聞き取りというタスクであれば、様々な住所のパターンを含んだセリフと正解住所のペアをリストとして用意してあげれば、そのタスクを担当するAgentがどれくらいの正答率を出せるかは計測できます。*3

しかし、プロダクトの品質管理の観点で知りたいのはやはり最終的にMaidoがタクシーの配車を正確に行うことができるか?です。この視点での評価には、Sub Agent単体の評価だけでは不十分で、配車に至るまでのプロセス全体を評価対象とする必要がありました。

その際に、本番環境でMaidoとユーザーがやり取りしている様子を記録して評価するというアプローチも考えられますが、それだとリリース前の機能の評価やリグレッションテスト的な目的での評価は難しくなってしまいます。

こうした課題を解決するために、あらかじめ定義されたシナリオに沿ってユーザーのように振る舞うAI AgentであるUserSimulatorとMaidoに会話をさせて、その会話を評価対象とするConversational Evaluationの仕組みを構築しています。

Conversational Evaluationの概念モデル

ここでコアとなるコンポーネントがUserSimulator, Executor, Evaluatorの3つです。

UserSimulatorによるシナリオ再現

UserSimulatorはタクシーの配車を依頼するユーザーを再現するAI Agentで、与えられたシナリオを模倣してMaidoと会話することでオフラインでの会話品質の評価を可能にしています。

Maido本体と同じくADKベースで実装されていて、シナリオ定義は以下のようなYAMLファイルで記述します。

scenarios:
  - name: "田中太郎"
    age: 25
    gender: "male"
    phone_customer_context:
      phone_number: "+815012345678"
      dispatch_histories: []
    pickup_location: "アパホテル大阪なんば駅前タワー"
    pickup_location_type: "hotel"
    destination: "新大阪駅"
    preferred_time: "今すぐ"
    is_reservation: false
    is_talkative: true
    is_impatient: false
    ...

ここでは、名前や乗車地といった配車に必要な情報の他に、再現したいユーザーのキャラクター情報が定義されています。これにより、以下のようなやり取りが再現できます。

Maido    未来都タクシーです。今すぐの配車でしょうか?
User    はい、今すぐお願いします。
Maido   お迎えに上がる住所または場所を教えてください
User    アパホテル大阪なんば駅前タワーでお願いします。
Maido   アパホテル大阪なんば駅前タワーにお迎えでよろしいでしょうか?
User    はい、そちらで間違いございません。ホテルの正面でお願いいたします。
Maido   承知いたしました。アパホテル大阪なんば駅前タワーの正面にお迎えに上がります。 ご乗車になる代表者の苗字をお伺いできますでしょうか?
User    はい、田中です。
Maido   すぐに手配いたします。お電話を切らずそのままお待ちください。
User    はい、承知いたしました。ありがとうございます。
Maido   お待たせいたしました。無線番号 123 が 1分ほどで アパホテル大阪なんば駅前タワー に到着予定です。ご利用ありがとうございました。またのご利用をお待ちしております。

キャラクター情報を変えるとユーザーの振る舞いも大きく変わるため、様々なパターンのシミュレーションが可能になっています。以下は別のシナリオで、 is_impatient = true を設定することで、短気なユーザーを再現したやり取りの例です。

Maido    未来都タクシーです。今すぐの配車でしょうか?
User    うん、今すぐ。梅田スカイビルにタクシー呼んでくれる?
Maido   お迎えに上がる住所または場所を教えてください
User    梅田スカイビル!わかるでしょ?タクシー待機所ね、そこ。
Maido   梅田スカイビルにお迎えに上がります。タクシー待機場所でよろしいでしょうか?
User    うん、それでいい。で、行き先は関西国際空港ね。早くしてほしいんだけど。
Maido   承知いたしました。梅田スカイビル タクシー待機所にお迎えに上がります。ご乗車になる代表者の苗字をお伺いできますでしょうか?
User    高橋ね、高橋健一。早く、時間ないから。
Maido   すぐに手配いたします。お電話を切らずそのままお待ちください。
User    うん、わかった。まだ?早くしてよ。
Maido   お待たせいたしました。無線番号 123 が 1分ほどで 梅田スカイビル に到着予定です。ご利用ありがとうございました。またのご利用をお待ちしております。

ExecutorによるAgentオーケストレーション

Executorは実態としてはCloud Run jobsで実行されるPythonスクリプトです。内部ではMaidoのAgentのRunnerとUserSimulatorのAgentのRunnerを並行に呼び出して、それぞれのAgentが発話した内容を時系列に保存する役割を担います。

記録された会話の内容は以下のような ConversationResult 型に保存され、Evaluatorによる評価が行われます。

@dataclass
class ConversationResult:
    """会話の結果"""
    user_scenario: UserScenario  # 会話の元になったシナリオ定義
    turns: list[Turn]  # Maido, Userの発話結果のリスト
    total_turns: int  # 総ターン数(Maido, Userの往復で1ターンと計算)

@dataclass
class ScenarioExecutor:
    maido_agent_model: str
    user_simulator_model: str
    turns = field(default_factory=list)
    total_turns = 0
    conversation_ended = False

    async def run_conversation(self, scenario, max_turns):
        maido_agent_runner = MaidoAgentRunner.new(
            model=self.maido_agent_model,
        )
        user_simulator_runner = UserSimulatorRunner.new(
            model=self.user_simulator_model,
            user_scenario=scenario,
        )

        maido_events = maido_agent_runner.listen_events()
        user_events = user_simulator_runner.listen_events()

        async def handle_maido_events():
            for event in maido_events:
                if event.is_text():
                    self.turns.append(Turn(role="assistant", content=event.text))
                    **user_simulator_runner.add_audio_content(event.audio)**
                if event.is_terminal():
                    self.conversation_ended = True
                    return

        async def handle_user_events():
            for event in user_events:
                if self.conversation_ended:
                    return
                if event.is_text():
                    self.turns.append(Turn(role="user", content=event.text))
                    **maido_agent_runner.add_audio_content(event.audio)**
                    self.total_turns += 1
                    if self.total_turns >= max_turns:
                        self.conversation_ended = True
                        return
                if event.is_terminal():
                    self.conversation_ended = True
                    return

        try:
            await asyncio.wait_for(
                asyncio.gather(handle_maido_events(), handle_user_events()),
                timeout=_CONVERSATION_TIMEOUT_SECONDS,
            )
        finally:
            maido_agent_runner.stop()
            user_simulator_runner.stop()

        return ConversationResult(scenario, self.turns, self.total_turns)

ここで、 ConversationResult として記録された情報はテキストベースですが、MaidoとUserSimulatorの情報のやり取りは音声ベースで行われている点にご注目ください。これにより、単なるテキストベースの会話のキャッチボールにとどまらず、リアルな会話の間を含めたシミュレーションが可能になっています。(複雑になるため上記イメージでは省略していますが、実際の実装では両者の発話タイミングを細かく調整する silence_monitor のような非同期タスクも存在しています)

DeepEvalによる評価の実施

Evaluatorでは、LLM評価フレームワークであるDeepEvalのConversational G-Eval metricsを使ったLLM-as-a-Judgeが行われます。*4

Conversational G-Eval metricsとは、LLM-as-a-Judgeの評価指標として広く用いられるG-Eval metricsを、評価対象を会話全体に拡張したものです。G-Eval metricsをはじめとするLLM-as-a-Judgeの評価指標についてはDeepEvalの開発元であるConfident AIが公開している以下の記事に詳しいので、併せて参照ください。

https://www.confident-ai.com/blog/llm-evaluation-metrics-everything-you-need-for-llm-evaluation

DeepEvalの evaluate 関数は以下のようなシグネチャになっており、ここに ConversationalTestCaseBaseConversationalMetric を継承した ConversationalGEval クラスの評価指標をそれぞれリストで渡すことで、評価が実行されます。

def evaluate(
    test_cases: Union[
        List[LLMTestCase], List[ConversationalTestCase], List[MLLMTestCase]
    ],
    metrics: Optional[
        Union[
            List[BaseMetric],
            List[BaseConversationalMetric],
            List[BaseMultimodalMetric],
        ]
    ] = None,
    # Evals on Confident AI
    metric_collection: Optional[str] = None,
    hyperparameters: Optional[Dict[str, Union[str, int, float, Prompt]]] = None,
    # agnostic
    identifier: Optional[str] = None,
    # Configs
    async_config: Optional[AsyncConfig] = AsyncConfig(),
    display_config: Optional[DisplayConfig] = DisplayConfig(),
    cache_config: Optional[CacheConfig] = CacheConfig(),
    error_config: Optional[ErrorConfig] = ErrorConfig(),
) -> EvaluationResult:

ここで ConversationalTestCase は実態としては list[Turn] なので、先ほど解説したExecutorの結果の ConversationResult を変換することで得られます。そして、評価指標としては以下のようなものを定義しています。

DISPATCH_INFORMATION_COMPLETENESS_METRIC="""
Evaluate whether the assistant successfully collected all required information based on the customer type provided in the test case context.

1. Customer Type A
   - aaa
   - bbb
   - ccc

2. Customer Type B
   - xxx
   - yyy
   - zzz

(etc...)

Score based on:
- Were all required fields collected based on the customer type?
- Was unnecessary information avoided for known customers?
- Were ambiguous locations properly clarified?
- Was the dispatch timing confirmed?
- Did the conversation flow match the expected pattern for the customer type?
"""

def create_dispatch_completeness_metric(model_name: str) -> ConversationalGEval:
    return ConversationalGEval(
        name="Dispatch Information Completeness",
        evaluation_params=[TurnParams.CONTENT, TurnParams.ROLE, TurnParams.SCENARIO],
        criteria=DISPATCH_INFORMATION_COMPLETENESS_METRIC,
        model=GeminiModel(model_name=model_name),
    )

これは「配車に必要な情報が正しく収集できているか」というMaidoの最重要事項に対するダイレクトな指標です。具体的には以下のような観点での評価を行っています。

評価観点 内容
必要な情報の収集 配車地点・乗客名・配車時刻が取得できたか
不要な確認の抑制 お得意様に対して冗長な確認がないか
収集した情報の明瞭性 曖昧な発話を適切に確認できたか

この他にも、言葉遣いやコミュニケーションの明瞭さ、想定外のシチュエーションに対するハンドリングができているかなど、厳格な定義が難しい領域での評価を行うための指標が複数定義されています。

評価の結果は、それぞれの指標ごとに 0〜1 のスケールでのスコアとして得られるため、これらをまとめた内容をスプレッドシートに書き込んだり、Slackの専用チャンネルに通知したりして活用しています。

Full Example

最後に、これまで説明してきた一連の流れを示した疑似コードを添付しておきます。

import asyncio
from dataclasses import dataclass
from deepeval.test_case.conversational_test_case import Turn
from deepeval.evaluate.configs import AsyncConfig

_MAIDO_AGENT_MODEL = "gemini-2.5-flash"
_USER_SIMULATOR_MODEL = "gemini-2.5-flash"
_CONVERSATION_TIMEOUT_SECONDS = 180.0

@dataclass
class ConversationResult:
    user_scenario: UserScenario
    turns: list[Turn]
    total_turns: int

@dataclass
class ScenarioExecutor:
    maido_agent_model: str
    user_simulator_model: str
    turns = field(default_factory=list)
    total_turns = 0
    conversation_ended = False

    async def run_conversation(self, scenario, max_turns):
        maido_agent_runner = MaidoAgentRunner.new(
            model=self.maido_agent_model,
        )
        user_simulator_runner = UserSimulatorRunner.new(
            model=self.user_simulator_model,
            user_scenario=scenario,
        )

        maido_events = maido_agent_runner.listen_events()
        user_events = user_simulator_runner.listen_events()

        async def handle_maido_events():
            for event in maido_events:
                if event.is_text():
                    self.turns.append(Turn(role="assistant", content=event.text))
                    user_simulator_runner.add_audio_content(event.audio)
                if event.is_terminal():
                    self.conversation_ended = True
                    return

        async def handle_user_events():
            for event in user_events:
                if self.conversation_ended:
                    return
                if event.is_text():
                    self.turns.append(Turn(role="user", content=event.text))
                    maido_agent_runner.add_audio_content(event.audio)
                    self.total_turns += 1
                    if self.total_turns >= max_turns:
                        self.conversation_ended = True
                        return
                if event.is_terminal():
                    self.conversation_ended = True
                    return

        try:
            await asyncio.wait_for(
                            asyncio.gather(
                                handle_maido_events(),
                                handle_user_events(),
                            ),
                            timeout=_CONVERSATION_TIMEOUT_SECONDS,
                        )
        finally:
            maido_agent_runner.stop()
            user_simulator_runner.stop()

        return ConversationResult(scenario, self.turns, self.total_turns)

async def conversational_evaluation():
    scenarios = load_scenarios_from_yaml("scenarios.yaml")

    async def run_single_scenario(scenario):
        executor = ScenarioExecutor(
            maido_agent_model=_MAIDO_AGENT_MODEL,
            user_simulator_model=_USER_SIMULATOR_MODEL,
        )
        return await executor.run_conversation(
            scenario=scenario,
            max_turns=30,
        )

    conversations = await asyncio.gather(*[run_single_scenario(s) for s in scenarios])
    test_cases = [conversation_to_test_case(conversation) for conversation in conversations]
    metrics=create_all_metrics()

    scores = deepeval.evaluate(test_cases, metrics, async_config=AsyncConfig(run_async=True))

    notifier = EvaluationNotifier.new()
    notifier.notify_evaluation_results(scores)

if __name__ == "__main__":
    asyncio.run(conversational_evaluation())

評価基盤の活用

こうして実装されたConversational Evaluationは開発環境で日次実行されることで、スナップショットテスト的な役割を果たしています。

また、Cloud Run jobsで実装されていることでGoogle Cloudのコンソール上から簡単に手動実行できるため、開発者個々人がPull RequestベースでCloud Run jobsをブランチデプロイしてConversational Evaluationを実行することで、幅広いシナリオによるテストを行う目的でも活用しています。実際に、大きめな新機能の開発時の結合テストやリアーキテクチャ後のリグレッションテストとして利用して、バグを事前検知したことも少なくありません。

さらに、一連の実装のEvaluator部分を決定的な動作面だけに焦点を当てた判定ロジック(例: 配車完了の状態に至れたかどうかのみをOK or NGで判定する)に置き換えることで、プロダクト全体のE2Eテストも同様の仕組みで実現しています。こうした再利用性もこの仕組みの大きな利点です。

以上のように、Conversational Evaluationの仕組みはMaidoの開発・品質保証における中核的な役割を担っています。

限界と発展

LLM Agentの評価の方法としては、あらかじめ用意したシミュレーションやベンチマークをもとに性能を計測するオフライン評価と、本物のユーザーとのインタラクションを通じて性能を計測するオンライン評価が存在します。Conversational Evaluationは同じ条件で再現性を保って継続的にテストするために、オフライン評価にフォーカスしています。

しかし、UserSimulatorによるユーザーの振る舞いの模倣には限界があります。一例として、現状のLLMは与えられたユーザーの台本に対して「お行儀よく」振る舞う傾向があるため、顕在化していないユーザー起因の課題をConversational Evaluationの仕組みで特定することは困難です。

そのため、Maidoでは各Agentに対してLLMによるガードレールを実装して、異常や過度に冗長なやり取りを検知した際に強制的に人間のオペレーターにfallbackを行うオンライン評価の仕組みの充実も両輪で進めています。

まとめ

今回の記事ではタクシーの音声配車を自動化するAI AgentのMaidoの開発において、UserSimulatorとの会話をLLM-as-a-Judgeの評価対象とするConversational Evaluationの仕組みについて紹介しました。

評価にはLLM評価フレームワークのDeepEvalを用いることで、フレームワーク側が実装してくれているConversational G-Eval metricsの機能を活用して、ビジネス上の重要な観点を評価指標に簡単に落とし込めることもわかりました。

Conversational Evaluationの仕組みのおかげで、チームはEval-DrivenにAI Agentを開発することができています。

次回の記事ではUserSimulatorの実装の詳細について書こうと思うのでご期待ください。

書いた人: genkey6




以上の内容はhttps://tech.newmo.me/entry/2025/11/10/123509より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14