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 外の部分に多かった印象です。
同じ構成を試す方の参考になれば幸いです。