newmoではタクシーの音声配車AIエージェントの「Maido」を開発しています。前回の記事では、LLM評価フレームワークのDeepEvalを用いたMaidoの会話評価基盤について紹介しました。
この記事では、その基盤を支えるもう一つの主役である「UserSimulator」の実装にフォーカスして、詳細を解説します。
なぜSimulatorが必要か
MaidoのようなMulti-AgentなアーキテクチャのLLM Agentを開発・運用する上で、Agentの評価は不可欠です。
Simulatorが存在しない状態でAgentの評価を行う場合、初歩的な例ではAgentとユーザーのやり取りを「AgentがAという質問をしてきたら、Xと回答する」といった形でルールベースでシナリオ化する方法が考えられます。しかし、実際にはLLM Agentがこなせる会話は多岐に渡り、むしろそれが強みです。そのため、ルールベースで全てのパターンを網羅することは現実的ではありません。また、LLM Agentが発話するテキストは確率的に変化し常に一定とは限らないという点も、シナリオ定義の難しさを助長しています。
そのため、LLM Agentによるバリエーションに富んだ発話内容を自然言語のまま解釈して、適切に応答する機構が必要です。さらに、評価という観点では応答の内容から言葉の使い回しに至るまで、多様なパターンの振る舞いができると理想的です。
こうしたニーズに対して、ユーザーの属性や回答して欲しい情報をシナリオという形で定義し、それをプロンプトとして動作するLLM Agent = UserSimulatorを構築することは有効なアプローチです。
UserSimulatorの全体像
UserSimulatorは、Maidoを利用するユーザーの振る舞いをシミュレーションするAI Agentです。Maidoがタクシーの配車に必要な情報(名前、配車場所、予約配車か即時配車か、etc…)を1ステップずつ質問するのに対して、UserSimulatorが「名前は?→田中です」「場所は?→大阪城まで」「今すぐの配車か?→そうです」といった形で回答していきます。
会話の流れはYAMLベースのシナリオで定義されており、以下のような形でMaidoとユーザーとのやり取りをシミュレーションすることができます。
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分ほどで アパホテル大阪なんば駅前タワー に到着予定です。ご利用ありがとうございました。またのご利用をお待ちしております。
UserSimulatorの実装はMaido本体と同様にGoogle Agent Development Kitで行われており、以下の2つの動作モードが備わっています。
- independentモード
- 独立したAI Agentとして振る舞うモード
- 音声はText-to-Speechで生成する
- 質問への回答をLLMが考えて発話するため、柔軟なやり取りが可能
- with_audioモード
- LLMが次に発話すべきカテゴリの判断のみを行って、対応する音声ファイルを再生するモード
- 音声はカテゴリごとに事前定義されたフレーズ単位の音声ファイル(AudioClip)を利用
- 現実の配車音声から抜粋したフレーズを利用するため、より実環境に近い音声品質でやり取りが可能
2つのモードを実装している理由としては、MaidoのAgentの評価では多様なシナリオを元に柔軟なやり取りをして動作の網羅性を担保したいのに対して、E2Eテストでは電話の音声品質が性能に与える影響を見たい、といったニーズの違いがあるためです。
こうした機能を、AI Agentとしての実装以外にも複数のコンポーネントを組み合わせて実現しています。以下にUserSimulatorを取り巻くシステムのアーキテクチャの全体像を示します。

アーキテクチャの要素を分解すると大きく以下の3つのパートに大別できるので、以降の章ではそれぞれについて詳しく解説していきます。
- UserSimulatorの実装
- BigQuery上で行う配車音声の文字起こし
- AudioClipの生成
UserSimulatorの実装
前述の通り、UserSimulatorには2つの動作モードがあり、それぞれの動作モードでは別々のプロンプトをAgentに与えています。
independentモード
まず、independentモードでは以下のようなプロンプトを実装しています。(部分的に実際の実装からは簡略化しています)シナリオに基づいて振る舞いを制御できるように、シナリオ内に含まれる属性に応じて動的にプロンプトが変わるのが特徴です。
@dataclass class UserScenario: name: str age: int gender: Gender pickup_location: str ... # その他のシナリオに紐づく属性情報 def to_prompt(self) -> str: """ Convert scenario to prompt text for LLM. """ prompt = f"""You are a user trying to book a taxi with the following profile. When mentioning addresses or place names, use locations in Osaka Prefecture. ## Basic Information: - Living in Osaka - Name: {self.name} - Age: {self.age} years old ({AgeGroup.from_age(self.age)}) - Gender: {self.gender}""" prompt += f""" ## Ride Information: - Pickup location: {self.pickup_location} - Pickup location type: {self.pickup_location_type}""" prompt += f""" ## Conversation Style: - Style: {self.conversation_style} - Speaking speed: {self.speaking_speed}""" prompt += "\\n\\n## Behavioral Characteristics:" if self.is_talkative: prompt += "\\n- Talkative and tends to give detailed explanations" if self.is_impatient: prompt += "\\n- Impatient and wants to complete the booking quickly" prompt += "\\n- May express frustration if the system doesn't understand immediately" prompt += "\\n- Uses short, direct phrases" (...) prompt += f""" ## Response Guidelines: - Respond naturally according to your profile and conversation style - If polite, use phrases like 「お願いします」「ありがとうございます」 - If casual, use shorter forms like 「すぐです」「いいよ」 - If impatient, show urgency with phrases like 「早くして」「まだ?」 - If regular user, mention familiarity like 「いつものところで」「いつもありがとう」 Remember to maintain consistency with your age, occupation, and speaking style throughout the conversation.""" prompt += f""" ## Output * `text`: The text of the user's response * `should_end_conversation`: Whether the user wants to end the conversation * `end_reason`: The reason why the user wants to end the conversation Always reply in JSON matching this schema: {json.dumps(UserResponse.model_json_schema(), ensure_ascii=False, indent=2)} """
この中で、genderやspeaking_speedといったいくつかの属性は、発話内容をText-to-Speech (TTS) APIで音声化する際にTTSのmodelや発話速度などの設定を調整するのに用いられます。
with_audioモード
次に、with_audioモードではまずタクシー配車のやり取りにおける会話の受け答えを以下のようなカテゴリに分類します。(以下はあくまでも一例で、実際の実装ではもっと細かい粒度でカテゴリを管理しています)
class AudioCategory(StrEnum): """ Audio clip category classification. """ NAME = "name" PICKUP_LOCATION = "pickup_location" CONFIRMATION = "confirmation" TIME_REQUEST = "time_request"
そして、この分類に基づいてLLMには会話の文脈に応じた次に発話すべき内容のカテゴリの決定のみを委ねます。
prompt = f"""あなたはタクシー配車システムのユーザーシミュレータです。 以下の会話履歴を見て、次にユーザーがどのカテゴリの発話をすべきか判断してください。 ## 会話履歴: {"\n".join(conversation_history)} ## エージェントの最後の発話: {self.last_agent_response} ## シナリオ情報: {self.scenario.to_prompt()} ## 判断基準: 1. エージェントの最後の発話に対して自然な応答を選ぶ 2. 会話の流れと文脈を考慮する 3. シナリオの設定に従った応答を選ぶ 4. タクシー配車の会話として適切な流れを維持する 次の発話カテゴリを1つだけ選んで、カテゴリ名のみを小文字で返してください。 カテゴリ名: {", ".join(list(AudioCategory))}"""
scenarios: - name: "田中太郎" age: 25 gender: "male" ... audio_clips: time_request: "今すぐでお願いします_123456789.wav" confirmation: "はいそうです_234567891.wav" name: "田中でお願いします_345678912.wav" pickup_location: "大阪城公園でお願いします_456789123.wav"
with_audioモードを利用する場合、シナリオにはAudioClipのカテゴリごとにどの音声ファイルを再生すべきかを記載する作りになっています。
これにより、前述のLLMが決定したカテゴリの音声ファイルを確実に再生することができるような仕組みを実現しています。
BigQuery上で行う配車音声の文字起こし
続いて、UserSimulatorのwith_audioモードで利用しているAudioClipを生成するのに必要となる、タクシー配車音声の文字起こしをどのように用意しているかについて深堀りしていきます。
AudioClipの元になっているのは、Maidoとユーザーのやり取りの音声です。タクシーの配車音声は1配車につき約1~2分程度の会話のやり取りが発生します。この中からAudioClipのような数秒単位の短いフレーズを適切に切り抜くためには、まずは配車音声全体の文字起こしが必要です。
単純に音声の文字起こしをするだけであれば、音声ファイルをSpeech-to-Text APIに渡して結果のテキストを得るスクリプトを書くのが手軽でしょう。しかし、配車音声の文字起こしデータはUserSimulatorの実装に限らず幅広い領域で活用されるべきデータ資産です。
newmoではプロダクトのデータ基盤の実装にBigQueryを採用しています。そのため、同じ基盤上にデータを蓄積できるよう、今回はBigQuery上で完結する形で音声ファイルの文字起こしを行う仕組みを構築しました。
実装においては以下の記事を全面的に参考にさせていただきました。各種クラウド上のリソースの設定や作成方法の詳細についてはこの記事では省略するので、気になる方はこちらも併せてご参照ください。
冒頭に提示したアーキテクチャの全体像のうち、文字起こしに関わる部分を抜粋して更に細分化した図が以下です。

大まかな流れとしては、Twilioに保存されている配車音声ファイルをサーバーがダウンロードしてGCSにアップロードし、それを外部テーブルとして参照したBigQueryのデータセットを経由した後、BigQuery MLの ML.TRANSCRIBE 関数を用いてSTTの音声認識モデルを動かして文字起こしを行った結果を別のテーブルに書き込んで利用する、というステップを経ています。
BigQuery MLを利用した文字起こしの実施
外部テーブル → 文字起こし結果を保存するテーブルへのデータの連携は、以下のようなクエリをScheduled Queryとして実行することで実現しています。
参考: クエリのスケジューリング | BigQuery | Google Cloud Documentation
INSERT INTO `PROJECT.DATASET.twilio_recordings_transcription` SELECT * FROM ML.TRANSCRIBE( MODEL `PROJECT.DATASET.ride_dispatch_recordings_transcribe_model`, ( SELECT * FROM `PROJECT.DATASET.twilio_recordings` WHERE uri NOT IN ( SELECT uri FROM `PROJECT.DATASET.twilio_recordings_transcription` ) AND EXISTS ( SELECT 1 FROM UNNEST(metadata) AS m WHERE m.name = "format" AND m.value = "monoral" ) ) );
クエリ内で利用しているBigQuery MLは、BigQuery上でSQLを介してVertex AIの各種モデルやAPIを利用できる機能です。 ML.TRANSCRIBE 関数はその中でも音声の文字起こしにフォーカスした関数で、STTの認識ツールを指定することで柔軟な構成に基づいた文字起こしを実現できます。
参考:
- BigQuery の ML の概要 | Google Cloud Documentation
- 認識ツール | Cloud Speech-to-Text | Google Cloud Documentation
今回は以下のような設定で認識ツールを作成しました。実装時点でSTTの認識ツールはTerraformでのリソース作成に対応していなかったため、コンソール上から作成しています。

注意点として、実装時点ではSTTの新しいモデル(Chirp 2 / 3)に言語を ja-JP で指定すると、認識ツールのlocationに asia-northeast1(東京) リージョンを選べないという制約がありました。そのため、今回は asia-southeast1(シンガポール) リージョンを選択しています。
(この辺りはGoogle Cloudが日進月歩で進化している領域のため、この記事を参考に実装される際は最新の対応状況を確認することをおすすめします)
作成した認識ツールを ML.TRANSCRIBE 関数から参照するためには、BigQuery上で音声認識用のモデルを作成する必要があります。こちらもTerraformでのリソースの作成には未対応だったため、BigQueryのコンソール上で以下のクエリを実行して作成しています。
参考: ML.TRANSCRIBE 関数を使用して音声ファイルを音声文字変換する | BigQuery | Google Cloud Documentation
CREATE OR REPLACE MODEL `PROJECT.DATASET.ride_dispatch_recordings_transcribe_model` REMOTE WITH CONNECTION `PROJECT.DATASET.ride-dispatch-recordings-connector` # 専用のBigQueryのconnectionリソースを作成していることを想定 OPTIONS ( REMOTE_SERVICE_TYPE = 'CLOUD_AI_SPEECH_TO_TEXT_V2', SPEECH_RECOGNIZER = 'projects/PROJECT_NUMBER/locations/asia-southeast1/recognizers/ride-dispatch-recordings-transcriber' # 先ほど作成した認識ツールを指定 );
アプリケーションから参照するデータセットの作成
前述した認識ツールのリージョン問題に起因して、文字起こし結果を保存するテーブルも一次的には同じ asia-southeast1(シンガポール) リージョンに保存することになりました。対してその他のデータ基盤のデータセットは asia-northeast1(東京) リージョンで作成されているため、リージョンを揃えるためにBigQueryのData Transfer Serviceを使ってデータセットのリージョン間コピーを行っています。
参考: データセットを管理する | BigQuery | Google Cloud Documentation
そして ML.TRANSCRIBE の結果保存される文字起こしのデータを各種アプリケーションから利用しやすい形にモデリングしたデータマートのデータセットをdbtで作成して、後述するAudioClipの生成時はそちらを参照するようにしています。
こうして最終的に出来上がったデータセットとデータの一例が以下の画像のものです。

AudioClipの生成
最後に、タクシー配車音声の文字起こしからAudioClipをどのように生成しているかについて説明します。AudioClipの生成は以下の3ステップで構成されます。
- 文字起こしを分析してフレーズを切り抜く
- 切り抜いたフレーズの音声をバリデーションする
- 切り抜いた音声ファイルに属性情報を付与する
各ステップの処理はLLMに全面的に頼っており、それぞれ適切なプロンプトを定義して統合するPythonスクリプトを実装しています。(LLMのモデルは gemini-2.5-flash を利用しています)
まず、文字起こしの分析では以下のようなプロンプトを定義しています。特徴としては、文字起こしの全文に加えて単語ごとの発話タイミングの情報をコンテキストに与えることで、切り抜きを行うべき音声ファイル内の区間を判定させている点があります。このプロンプトで体感的には9割以上の正確さでフレーズの切り抜きに成功しています。
TRANSCRIPT_ANALYSIS_PROMPT = """ あなたはタクシー配車予約の電話会話における発話内容を分析し、**顧客(利用者)の発話のみ**を対象に音声クリップとして有用なフレーズを抽出するAIアシスタントです。 ## 重要な制約: **この文字起こしには、顧客(利用者)とAI音声エージェントの両方の発話が含まれています。** **必ず顧客の発話のみを抽出対象とし、AI音声エージェントの発話は絶対に除外してください。** ## 顧客発話の対象カテゴリ: {categories_description} ## タスク: 以下の会話文字起こしと単語別タイミング情報を分析し、**顧客の発話のみ**から上記カテゴリに該当するフレーズを抽出してください。 ## 入力データ: **文字起こし:** {transcript} **単語別タイミング情報:** {words_info} ## 出力形式: 以下のJSON形式で、信頼度0.8以上の顧客発話のみを返してください: {schema} **顧客の自然な発話のみ抽出し、エージェントの発話は除外してください。** """
次に、前段の処理で一部ユーザーの発話以外のフレーズが含まれてしまうことがあるため、そうしたデータを除外するためにバリデーションを行います。実装をしていてプロンプトのチューニングが最も求められたのはこのステップで、判定基準に含まれる例示を細かく指定することでバリデーションの精度を向上させることに成功しました。
AUDIO_VALIDATION_PROMPT = """ あなたは音声ファイルを分析し、人間の自然な発話か機械生成音声かを判別する専門AIです。 ## タスク: 提供された音声ファイルを分析し、以下を判定してください: 1. 人間の自然な音声かどうか 2. AI・TTS(Text-to-Speech)などの機械生成音声かどうか 3. 音声クリップとして保存すべきかどうか ## 判定基準: ### 人間の自然な音声の特徴: - 自然な間(ポーズ)がある - 感情の抑揚や自然なイントネーションがある - 発話速度に自然な変化がある - 咳払い、「えー」「あのー」などの自然な言い淀みがある - 関西弁や方言などの地域的特徴がある ### 機械生成音声(除外対象)の特徴: - 遅めかつ一定のテンポで機械的な発話パターン - 不自然に滑らかで感情の起伏が少ない - 定型的で丁寧すぎる発音 - 人工的な抑揚やアクセント - TTS特有の音響特性 ## 音声情報: - カテゴリ: {category} - テキスト: "{text_content}" 以下のJSON形式で結果を返してください: {schema} 確実に人間の自然な発話で、内容も適切と判断できる場合のみ "keep" を推奨してください。 """
最後に、以下のプロンプトで切り抜いた音声ファイルに発話内容のカテゴリやユーザーの性別、声色、方言の有無などの属性情報を付与します。
ここで付与する属性情報は評価・E2Eテストのシナリオで用いる属性情報と揃えており、AudioClipのファイルをGCSに保存する際にメタデータとして保存することで、シナリオに対応するAudioClipを検索しやすくしています。
AUDIO_ATTRIBUTE_ANALYSIS_PROMPT = """ あなたは音声ファイルから話者の属性を分析し、詳細な属性情報を抽出する専門AIです。 ## タスク: 提供された音声ファイルを分析し、話者の以下の属性を特定してください: ## 分析対象属性: {attributes_description} ## 分析対象: - **カテゴリ**: {category} - **テキスト内容**: "{text_content}" ## 出力形式: 以下のJSONスキーマに従って分析結果を返してください: {schema} **重要**: 確信を持って判定できない属性は null を設定してください。推測に過度に依存しないでください。 """
構築したシステムの応用
当初はUserSimulator用に構築した文字起こしやAudioClipの仕組みですが、運用を続ける中で別の領域への応用も進んでいます。
例えば、MaidoはAIがタクシーの配車に失敗した際にフォールバック先として人間のオペレーターが配車を引き継ぐ仕組みになっています。
そのオペレーターが利用するための管理画面も存在するのですが、画面上にMaidoとユーザーが行ったやり取りを文字起こしベースで表示することで、配車業務をAIから人にスムーズに引き継げる仕組みを構築しようとしています。
また、AudioClipの音声ファイルはSTTのモデルのファインチューニングに活用することを検討しています。Cloud Speech-to-Textにはユーザーが用意した短い音声ファイルをベースに、モデルをファインチューニングするカスタム音声モデルの作成機能が存在します。
参考: カスタム音声モデルの概要 | Cloud Speech-to-Text | Google Cloud Documentation
Maidoでは地名や名前といったドメイン固有の単語の聞き取り精度に課題がありSTTのファインチューニングを検討していますが、ここでAudioClipの音声ファイルは地名や名前といったカテゴリごとに分類された短い音声のデータセットであるという点で、ユースケースにマッチしています。
一方で、AudioClipの生成過程で得られるフレーズのテキストは文字起こしの過程でSTTを通して得られるテキストの精度に依存しているため、ここで課題になっているドメイン固有の単語の認識精度は高くないという課題が存在しています。このギャップをいかに埋めるか(人が1フレーズずつ確認してアノテーションをやり直すのか、あるいは別のLLMに補正を行わせるのか、etc…)が応用を進めていく上での課題です。
おわりに
今回の記事では、タクシーの音声配車AIエージェントMaidoの会話評価基盤で利用されているUserSimulatorの実装について詳細に解説しました。
UserSimulatorは独立したAI Agentとして振る舞うモードと、事前定義されたフレーズごとの音声ファイル(AudioClip)の文脈に応じた再生に特化したモードの2つを兼ね備えており、特に後者のモードを実現するために様々な周辺システムを構築しています。
前回の記事とあわせて、Maidoの会話評価基盤とその裏側を支えるSimulator技術の全体像を理解いただければ幸いです。
これらの仕組みが、音声ベースのAI Agentの実装をしている皆さんにとって何かしらの参考になることを願っています。
ところで、newmoでも開発を行っている自動運転のシステムでは、Simulatorがコアな役割を果たします。
タクシーの音声配車と自動運転という全く異なるように思えるドメインでも、LLMのような確率的な振る舞いをするシステムを評価・運用していく中では、何かしらの形でシステムが作用する先の世界(今回の例であればMaidoの利用者であるユーザー、自動運転であれば車が走る道路を含めた周辺環境)をシミュレーションする必要があるという共通点があるのは面白いですね。
自動運転チームではそのようなSimulatorの開発も行うMLエンジニアの募集をしているので、面白そうだと思った方はぜひお話ししましょう!
書いた人: genkey6