1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GeminiのLive APIで音声チャットボットをSaaSに組み込んだ全記録

1
Last updated at Posted at 2026-10-14

Gemini Live API × Next.js でサポートチャット(文字+音声)をプロダクションに載せるまで

はじめに

自社 SaaS のサポート業務を一部自動化したくて、Google の Gemini Live API(音声リアルタイム対話)と Gemini Flash Lite(テキスト回答)を組み合わせたサポートチャットを実装しました。

チャットの仕様は次のとおりです。

  • ログイン後の画面の右下にポップアップで出る
  • テキスト入力と音声入力の両方に対応
  • 知識ベースはアプリのソースコードから自動生成(LP・利用規約・各画面の文言)
  • 日本語ユーザーには日本語、英語ユーザーには英語で回答
  • GDPR 的な観点から、初回利用時に同意確認を実施

この記事では、試作から本番投入まで約2週間のセッションで引っかかったポイントを中心にまとめます。


やったこと

全体アーキテクチャ

[ブラウザ] ─WebSocket─▶ [音声中継サーバー (Node, port 3102)]
                              │
                              ▼
                    [Gemini Live API (us-central1)]

[ブラウザ] ─HTTP─▶ [Next.js API Route]
                       │
                       ▼
             [Gemini Flash Lite (us リージョン)]

音声(Live API)はブラウザから直接 Google に投げるのが理想ですが、Google Cloud 版の Vertex AI には Ephemeral Token(一時トークン)機能がない(Gemini API のプレビュー機能のみ)ため、サービスアカウントの鍵を抱えた Node プロセスを中継サーバーとして別途立てる必要がありました。

知識ファイルの自動生成

RAG(ベクトル検索)は使わず、アプリのソースから文言をすべてテキストに展開して、システムプロンプトに丸ごと渡すシンプルな構成にしました。

// 各画面の文言定数からMarkdownを生成する例
import { STREAM_DETAIL_JA } from '../screen-ja'
import { STREAM_DETAIL_EN } from '../screen-en'

export function buildScreensKnowledge(locale: 'ja' | 'en') {
  const c = locale === 'ja' ? STREAM_DETAIL_JA : STREAM_DETAIL_EN
  return [
    `## 配信設定画面`,
    `### 配信の停止`,
    c.stopNote,   // ← 定数を直接参照するので文言変更を自動追従
  ].join('\n')
}

メリット: 画面文言が変われば知識も自動で変わり、ずれを検知するテストも書きやすい。
デメリット: トークン数が増える(本実装では日本語約2万字・英語約4.4万字)。コスト面は後述。

テキストチャット(Gemini Flash Lite)

import { GoogleGenAI } from '@google/genai'

const client = new GoogleGenAI({
  vertexai: true,
  project: process.env.SUPPORT_CHAT_GCP_PROJECT,
  location: 'us',
  // 専用鍵ファイルを明示的に渡す(ADC に暗黙フォールバックさせない)
  googleAuthOptions: {
    keyFilename: process.env.SUPPORT_CHAT_GCP_KEYFILE,
  },
})

const result = await client.models.generateContent({
  model: 'gemini-3.5-flash-lite',
  contents: [{ role: 'user', parts: [{ text: userMessage }] }],
  systemInstruction: buildSystemPrompt(locale, knowledge),
  generationConfig: {
    thinkingConfig: { thinkingBudget: 0 }, // 考える量を最小に(後述)
  },
})

音声チャット(Gemini Live API)

// 音声中継サーバーの核心部分
import { GoogleGenAI } from '@google/genai'

const session = await client.aio.live.connect({
  model: 'gemini-3.8-live',
  config: {
    systemInstruction: buildSystemPrompt('ja', knowledge),
    speechConfig: {
      voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceName } },
      languageCode: 'ja-JP',
    },
    generationConfig: { responseModalities: ['AUDIO', 'TEXT'] },
  },
})

音声と同時にテキスト書き起こしも受け取れるので、チャット画面への表示に活用しています。

同時接続の制御

音声セッションは重いので、制限を設けました。

// グローバルで同時接続数を管理
const activeVoiceSessions = new Map<string, WebSocket>()
const MAX_GLOBAL_SESSIONS = 3

export function tryAcquireVoiceSlot(userId: string): boolean {
  if (activeVoiceSessions.size >= MAX_GLOBAL_SESSIONS) return false
  if (activeVoiceSessions.has(userId)) return false
  return true
}

1ユーザーあたり同時1接続、全体で同時3接続まで。超過した場合は即座にエラーレスポンスを返します。


ハマったポイント

1. モデルが global リージョンをサポートしていない

指示書に global と書いてあったのですが、gemini-3.8-live の対応リージョンは us・eu・us-central1 のみでした。

- location: 'global'
+ location: 'us-central1'  // Live API はこちら

ドキュメントは公式のモデル一覧ページを確認してから実装するのが安全です。

2. thinkingBudget: 0 にしないと空回答が返る

Flash Lite をデフォルト設定で呼ぶと、料金プランの質問で稀に空のレスポンスが返ることがありました。thinkingBudget を 0(最小)にしたら安定しました。

generationConfig: {
  thinkingConfig: { thinkingBudget: 0 },
},

(個人の感想)Flash Lite はもともと「考える」ステップが短いモデルなので、知識ファイルが大きい場合は明示的に 0 にしておくのが無難そうです。

3. GOOGLE_APPLICATION_CREDENTIALS を使ってはいけない

最初は環境変数 GOOGLE_APPLICATION_CREDENTIALS に鍵ファイルのパスを指定する一般的な方法を書きました。しかしこの環境変数は google-auth-library が ADC(Application Default Credentials)として自動的に参照するため、将来 YouTube Data API などほかの Google 系ライブラリを追加した際に、意図せずサポートチャット用の鍵で認証される可能性があります。

対策として、専用の環境変数名を用意して @google/genai に明示的に渡しました。

// ❌ 汎用の環境変数は使わない
// GOOGLE_APPLICATION_CREDENTIALS=/etc/loopcast/gcp-support-chat.json

// ✅ サポートチャット専用の変数名を使う
// SUPPORT_CHAT_GCP_KEYFILE=/etc/loopcast/gcp-support-chat.json

const client = new GoogleGenAI({
  googleAuthOptions: {
    keyFilename: process.env.SUPPORT_CHAT_GCP_KEYFILE,
  },
})

本番環境で SUPPORT_CHAT_GCP_KEYFILE が未設定なら起動失敗させ、ADC へのサイレントフォールバックを防止しています。

4. 音声セッション終了後に「使用中」ロックが残る

音声セッションを終了してすぐ「話す」を押すと、前セッションの後片付け(WebSocket のクローズ処理)が終わっていないためロックが残り、「使用中です」と断られることがありました。

// セッション終了後、最大3秒待ってから判定する
async function waitForSessionCleanup(userId: string): Promise<void> {
  const deadline = Date.now() + 3000
  while (Date.now() < deadline) {
    if (!activeVoiceSessions.has(userId)) return
    await new Promise(r => setTimeout(r, 100))
  }
}

5. 配布の自動化ツールがデフォルト宛先に外部 IP を使っていた

本番サーバーへの SSH 接続を WireGuard VPN 経由に変更した後、デプロイスクリプトのデフォルト宛先(deploy@<外部IP>)が疎通不可になっていました。

# 既存の deploy-cp.sh はデフォルト宛先が外部IP
CP_HOST=conoha-new scripts/deploy/deploy-cp.sh

CP_HOST 環境変数で内側の VPN 宛先を明示するだけで動くので、スクリプト自体は修正不要でした。ただし、同じデフォルト宛先に依存するスクリプトが多数あったため、一覧化して runbook に残しておきました。

6. サービスユニットを配布したのに照合台帳に登録しなかった

本番に systemd のサービスユニット(loopcast-support-voice.service)を手動で置いたところ、毎日の構成ドリフト検査(設定ファイルと repo の差分チェック)に「表に無いもの=1」として引っかかりました。

対策として照合台帳(config-pairs.tsv)に追加しただけでなく、「scripts/deploy/ 配下に unit ファイルを置いたら、同じ回に台帳へも追加しなければテストが落ちる」 という再発防止テストを実装しました。

// config-pairs-units.test.mjs(抜粋)
const unitFiles = glob.sync('scripts/deploy/**/*.service')
const pairedPaths = parsePairs('scripts/cp/config-pairs.tsv').map(r => r.right)

for (const unit of unitFiles) {
  if (!pairedPaths.some(p => p.endsWith(path.basename(unit)))) {
    throw new Error(`${unit} が config-pairs.tsv に登録されていません`)
  }
}

7. プライバシーポリシーの「Google 側での保持期間」をどう書くか

音声・テキストを Google に送る以上、プライバシーポリシーへの記載が必要です。専門家レビューで指摘されたのが「保持日数を断言するな」という点でした。

公式ドキュメント(Abuse monitoring)を確認すると:

  • 学習への利用:事前同意なし → なし
  • キャッシュ:デフォルトで有効、24時間以内に削除
  • 不正利用監視の記録:最長90日(ただしモデルの区分に「公式記載なし」の部分あり)
  • 音声が記録に含まれるか:公式に記載なし

「公式に記載なし」の項目は「公式に記載なし」とそのまま専門家への資料に書き、最終的にプライバシーポリシーには「一定期間保持される場合があります」という表現に落ち着きました。断言しないことが重要です。


学び

コスト感覚

実測値から割り出した1ユーザー1日あたりの費用の目安(上限いっぱいで使った場合):

利用方法 上限 費用/日(概算)
テキストのみ(30回) 30回 ≒ $0.03
音声(5分 × 5回) 5回 ≒ $0.50〜0.75
合計 — ≒ $0.80

テキストはほぼコストゼロ、音声が支配的です。知識ファイルが大きくてもキャッシュが効くため(Vertex AI はデフォルトでキャッシュ有効)、テキスト側は想定より安く抑えられました。

「知識を自動生成してずれをテストで検知」の設計が便利

ソースの文言定数から知識 JSON を生成し、その JSON のハッシュをテストに埋め込んでおくと、文言を変えた人が知識の再生成を忘れてもテストが落ちます。RAG を導入しなくてもこの方法でかなりカバーできます。

音声の文字起こし置換で「声はカタカナ・画面はメールアドレス」を両立

「サポート アット 」と読ませて、画面表示時に support@<your-domain> へ置換する方法を採りました。システムプロンプトだけでは揃えられないケースへの現実的な対処です。

// 書き起こしテキストを表示前に変換
function normalizeTranscript(text: string): string {
  return text
    .replace(/サポート\s*アット\s*<サービスドメイン読み>/g, 'support@<your-domain>')
}

本番投入の順番が重要

DB のマイグレーション(同意テーブル・利用回数テーブル)を先に当てておかないと、コードデプロイ後にサービスが起動した瞬間からエラーになります。今回は ②鍵 → ③設定 → ④テーブル → ①main マージ→ push → ⑤画面デプロイ の順序で進め、「main に入った後はテーブルが当たっていないと他チームのデプロイも止まる」という事態を回避しました。


まとめ

Gemini Live API は、WebSocket の中継サーバーさえ用意できれば Next.js への組み込みは比較的シンプルでした。一方でハマりどころは「リージョン」「ADC の汚染」「プライバシーポリシーの書き方」「デプロイ順序」など、API 外の部分に多かった印象です。

同じ構成を試す方の参考になれば幸いです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?