← デモに戻る

遺失物受付AI 実装詳細

Gemini 3.1 Flash Live API をベースとした、タスク指向リアルタイム音声対話システムの技術解説。 Function Calling による構造化状態管理を中心に、音声処理・相槌・電話番号リピートの実装を解説する。

Overview概要

このシステムは、遺失物受付窓口の対話をAIで代替するデモアプリケーションである。 単なる自由対話ではなく、受付票の5項目(落とした物・特徴・場所・日時・連絡先)を漏れなく収集し、完了を確実に検知する タスク指向対話(Task-Oriented Dialogue; TOD)として設計されている。

従来のTODシステムはルールベースの状態機械か、Fine-tuningされた専用モデルで実装されることが多かった。 本システムは Gemini の Function Calling(FC) を活用することで、 自由な雑談と構造化された情報収集を単一の大規模言語モデルで両立している。

項目技術選択理由
音声対話Gemini 3.1 Flash Live API低レイテンシ、バージイン対応、日本語品質
状態管理Function Calling(同期)構造化出力の確実性、外部DB不要
バックエンドFastAPI + asyncioWebSocket並列処理との親和性
フロントエンドバニラJS + Web Audio API依存ゼロ、AudioWorkletで16kHz PCM取得

Archシステム構成

ブラウザ (JS)
── PCM 16kHz バイナリ ──→
FastAPI WebSocket
──→
Gemini Live API
ブラウザ (JS)
←─ PCM 24kHz + JSON ──←
FastAPI WebSocket
←──
Gemini Live API
tool_call イベント
handle_tool() が状態を更新
send_tool_response()

通信プロトコルはステートフル WebSocket。 ブラウザは AudioWorklet(pcm-worklet.js)で16kHz PCMバイナリを連続送信し、 FastAPI は Gemini Live とのセッションを中継する。 Gemini からはPCM音声バイナリ・テキストトランスクリプト・Function Callの3種類のイベントが届く。

CoreFunction Calling(FC)

本システムの中核技術。FCを理解することがこのシステムのアーキテクチャ理解に直結する。

FCとは何か

Function Calling(ツール呼び出し)とは、LLMが応答を生成する代わりに 「この関数をこの引数で呼んでほしい」というJSONを出力する機能である。 OpenAI が2023年6月に GPT-4 向けに実装して以降、Gemini・Claude・Llama 等の主要モデル全てが対応する業界標準機能となった。

定義 FCはモデルに「自然言語を構造化データに変換させる」機能である。 モデルがコードを実行するわけではなく、JSONスキーマに従った呼び出し引数を生成するだけ。 実際の実行はアプリケーション側の責任となる。

仕組みの詳細

セッション開始時に、利用可能なツールをOpenAPIスキーマ形式で宣言する。 モデルは会話の文脈から「今ツールを呼ぶべきか」「どの引数で呼ぶか」を自律的に判断する。

# ツール宣言(セッション設定に含める)
UPDATE_REPORT_DECL = {
    "name": "update_report",
    "description": "遺失物受付票の1項目を記録する。confirmed=falseで仮記録、"
                  "confirmed=trueで確定記録。同フィールドの再呼び出しで上書き。",
    "parameters": {
        "type": "object",
        "properties": {
            "field": {
                "type": "string",
                "enum": ["item", "features", "location", "date", "contact"],
            },
            "value":     {"type": "string"},
            "confirmed": {"type": "boolean"},
        },
        "required": ["field", "value", "confirmed"],
    },
}

モデルが「利用者から情報が確定した」と判断すると、 テキスト応答の代わりに(または応答に加えて)tool_callイベントを発火する。 アプリ側はこれを受け取って処理し、send_tool_response()で結果を返す。

# send_loop 内での tool_call ハンドリング
if resp.tool_call and report_state is not None:
    fn_responses = []
    for fc in resp.tool_call.function_calls:
        result = handle_tool(fc.name, dict(fc.args), report_state)
        fn_responses.append(types.FunctionResponse(
            id=fc.id, name=fc.name, response=result,
        ))
    await session.send_tool_response(function_responses=fn_responses)
    # フロントに受付票の現在状態を通知
    await websocket.send_text(json.dumps({
        "type": "state_update", "state": report_state,
    }))
Live API の注意点 generateContent API と異なり、Live API はFCレスポンスの自動処理をサポートしない。 tool_callイベントを手動でハンドリングし、send_tool_response()を呼ぶ必要がある。 また gemini-3.1-flash-live-preview では同期FCのみサポート(NON_BLOCKINGは未対応)。

本システムでの実装:2つのツール

ツール名呼ばれるタイミング引数レスポンス
update_report 情報を1項目確認した時 / 訂正時 field, value, confirmed missing_fields, all_complete
complete_report 全5項目確定 + 利用者確認後 summary ticket_id, status

confirmedフラグの2段階設計が重要なポイントである。 利用者が情報を述べた段階では confirmed=false(仮記録)、 AIが読み上げて「よろしいですか?」と確認し利用者が同意した段階で confirmed=true(確定)となる。 missing_fieldsはconfirmed=trueの項目のみを「収集済み」とカウントするため、 確認をスキップした項目は必ず再確認が促される。

def handle_tool(name: str, args: dict, state: dict) -> dict:
    if name == "update_report":
        field     = args.get("field")
        value     = args.get("value", "")
        confirmed = bool(args.get("confirmed", False))
        if field == "contact":
            value = normalize_phone(value)  # 電話番号を正規化
        state[field] = {"value": value, "confirmed": confirmed}
        missing = [
            f for f in REQUIRED_FIELDS
            if state.get(f) is None or not state[f].get("confirmed", False)
        ]
        return {
            "result":        "確定記録" if confirmed else "仮記録",
            "missing_fields": missing,
            "all_complete":   len(missing) == 0,
        }

対話フロー:FCによるスロット充填

利用者「昨日の夕方、渋谷の改札で財布を落としました」
Gemini が3項目を一度に認識
update_report(item="財布", confirmed=false)
update_report(location="渋谷駅改札", confirmed=false)
update_report(date="4月26日夕方", confirmed=false)
レスポンス: missing_fields=["features","contact"]
Gemini「使い込まれた財布を、昨日の夕方、渋谷駅の改札付近でなくされたということでよろしいですか?」
利用者「はい、そうです」
update_report(item="財布", confirmed=true) × 3項目
↓ 残り2項目を自然に聞く → 全項目確定 →
complete_report(summary="...") → 受付番号発行

FCの他の活用例

FCは「自然言語 → 構造化アクション」の汎用変換機構であり、対話システム以外でも広く応用できる。

ユースケースツール例特徴
外部API呼び出し get_weather(location) モデルが必要と判断した時だけ呼ぶ
DBクエリ生成 search_inventory(category, date_range) 自然言語をSQLに変換せず構造化引数で渡す
カレンダー操作 create_event(title, start, end, attendees) 「来週の月曜14時に〜」を正確にパース
フォーム入力補完 fill_form(field, value) 本システムと同じスロット充填パターン
IOTデバイス制御 set_light(brightness, color) Google公式サンプルのユースケース
エージェント中断 request_human_handoff(reason) 複雑なケースを人間にエスカレーション
設計のポイント FCのツール設計で最も重要なのはdescriptionの質である。 モデルはdescriptionだけを手がかりにツールを呼ぶタイミングと引数を決定する。 「いつ呼ぶか」「呼んではいけない条件」を明示的に書くことで信頼性が大幅に向上する。

Sessionセッション管理

本システムはマルチセッション対応のステートフルWebSocket APIとして動作する。 「マルチテナント」(複数組織のデータ分離)とは異なる概念であることに注意。

# ws_endpoint はリクエストごとに独立したコルーチンとして動作
async def ws_endpoint(websocket: WebSocket, ...):
    report_state = make_report_state()  # ← ローカル変数: 接続ごとに独立
    #   グローバル共有ストレージは一切使わない
    async with client.aio.live.connect(...) as session:
        rt = asyncio.create_task(recv_loop())  # マイク受信タスク
        st = asyncio.create_task(send_loop())  # Gemini→ブラウザ送信タスク
        await asyncio.wait([rt, st], return_when=asyncio.FIRST_COMPLETED)

各接続で recv_loop(マイク音声受信)と send_loop(Gemini応答配信)が 非同期タスクとして並走する。一方が切断されると他方もキャンセルされる設計。 uvicorn の asyncio イベントループが多数の接続を1スレッドで並列処理するため、 CPUバウンドでない限りスケールする。

Audio音声パイプライン

マイク
MediaStream
AudioWorklet (16kHz PCM)
WebSocket binary
Gemini Live
スピーカー
AudioContext (24kHz)
WebSocket binary
Gemini Live

入力は AudioWorkletpcm-worklet.js)で16kHz・16bit PCMとして抽出し、 WebSocket バイナリフレームで連続送信する。 AudioContext のサンプルレートを16kHzに固定することで再サンプリングコストを排除している。

出力は Gemini から返る24kHz Int16LE PCMをFloat32に変換し、 AudioBufferSourceNodeをチェーンして継ぎ目のない再生を実現。 playbackTime を累積することで音声チャンク間のギャップを防いでいる。

UX相槌(あいづち)システム

AIが応答を生成・送信する前の数百ミリ秒の無音を埋めるため、 事前生成した短い相槌音声(「はい」「なるほど」等)をフロントで即時再生する。 これにより会話のリズムが自然になる。

相槌は2つのトリガーで発火する:

トリガー条件確率
発話終了後 input_transcription 受信 & AI音声未着 75%(25%はスキップで単調さ回避)
発話中無音 RMS < 0.008 が 300ms 継続 65%(1ターン1回まで)

発話の文脈(疑問形・共感・挨拶・汎用)を正規表現で分類し、 カテゴリに合った相槌クリップを選択する。 電話番号収集フェーズでは相槌を完全に抑制し、 AIの即時オウム返しとの干渉を防ぐ設計としている。

Feature電話番号リアルタイムリピート

NTTのデモで確認された「電話番号を区切りながら言うと即座にオウム返しする」体験を実装。 技術的には Gemini Live のターン検出(300ms無音でターン終了)を活用することで、 特別な実装なく自然に実現できる。

# システムプロンプトによる制御
"""
▼ 利用者が番号の途中で止まった場合:
  その部分だけをすぐそのまま繰り返す。余計な言葉を足さない。
  例)利用者「080の」→ AI「080の、」

▼ 全桁揃ったら区切りを付けて確認:
  携帯なら 3-4-4: 「080-1234-5678 でよろしいですか?」
  固定電話なら市外局番ルールで区切り
"""

バックエンドでは normalize_phone() が電話番号の桁数に応じて 自動的にハイフン区切りにフォーマットする。 11桁携帯(090/080/070)は3-4-4、 10桁固定電話(03/06など)は2-4-4のルールを適用する。

def normalize_phone(raw: str) -> str:
    digits = re.sub(r"\D", "", raw)
    if len(digits) == 11:
        if digits[:2] in ("09", "08", "07"):
            return f"{digits[:3]}-{digits[3:7]}-{digits[7:]}"
    if len(digits) == 10 and digits[:2] in ("03", "06", ...):
        return f"{digits[:2]}-{digits[2:6]}-{digits[6:]}"
    return raw