「音声で話せる AI アシスタントを自分のアプリに載せたい」— Rork を触り始めた方から、この相談を受ける機会が急に増えました。背景には、OpenAI Realtime API や Gemini Live API が一般開発者に開放されたこと、そして Vapi や Retell のような音声 AI プラットフォームが参入しやすくなったことがあります。
ただ、デモを動かすところまでは簡単でも、実際に App Store に出して課金まで回す段階になると、まったく別の壁が立ちはだかります。私自身、最初に組んだ音声チャットアプリは、電車でトンネルに入っただけでセッションが壊れ、ユーザーから容赦ないレビューが付きました。遅延が 1 秒を超えるとユーザーは喋るのをやめます。再接続が 3 秒遅れると、90 秒で解約されます。
この記事は、そんな「デモは動くが本番で落ちる」地点でつまずいている方のために、LiveKit Agents をバックエンドに、Rork で生成した React Native クライアントをフロントエンドにした、本番運用レベルの音声エージェントアプリの設計図を丸ごと共有する内容です。なぜ LiveKit かというと、WebRTC の実装を自前で書くと SRTP の再送処理やジッターバッファで必ず事故が起きるのですが、LiveKit Agents は Python/Node のエージェントランタイムと SFU を一体で提供していて、この事故領域を丸ごと肩代わりしてくれるからです。
第 1 章: 全体アーキテクチャの設計
まず、書き始める前にアーキテクチャ全体の責務を整理します。「とりあえず動く」で作ると、あとから課金やログ保存を足す段階で必ず破綻するので、最初に 4 層に分けて考えるのがおすすめです。
責務の 4 分割
モバイルクライアント(Rork 生成の React Native 層) : マイク・スピーカー権限の取得、LiveKit Room への接続、UI 状態管理、通話終了時のログ送信
トークン発行 API(Hono + Cloudflare Workers) : Supabase 認証トークンを検証し、課金状態に応じて LiveKit 用 JWT を発行します。通話時間の事前チェックもここで行う
LiveKit Agent(Python または Node.js) : ルームに参加してユーザーの音声を受信し、Whisper → GPT/Claude → TTS のパイプラインを実行する常駐ワーカー
課金・ログストア(Stripe + Supabase Postgres) : 月次の通話時間上限・通話ログ・エラーイベントを保存する永続層
なぜこの分割にするか
最初は「Rork アプリから直接 OpenAI Realtime API に WebSocket を張ればいいじゃないか」と思うかもしれません。実際、私も最初はそう組みました。ただ、この構成には 3 つの致命的な問題があります。
API キーの露出 : クライアント側に OpenAI の API キーを埋め込むことになり、逆コンパイルされると課金が青天井になります
課金制御の不可能性 : クライアントが直接外部 API を叩いている以上、「無料プランは月 10 分まで」のような制限をサーバー側で強制できません
ロギングの欠落 : 通話品質・エラー・ユーザー行動のログがクライアント依存になり、改善サイクルが回りません
LiveKit を挟むと、API キーをサーバーに集約でき、トークン発行時点で課金チェックができ、ルーム単位のメトリクスが標準で取れるようになります。
ディレクトリ構成
実運用している構成はこんな形です。
my-voice-app/
├── app/ # Rork 生成の Expo Router 画面
│ ├── (tabs)/
│ │ └── voice.tsx # 音声通話画面
│ └── _layout.tsx
├── lib/
│ ├── livekit.ts # LiveKit クライアントラッパー
│ ├── billing.ts # 課金状態チェック
│ └── api.ts # トークン発行 API 呼び出し
├── server/ # バックエンド(別リポでも可)
│ ├── token-api/ # Hono + Cloudflare Workers
│ └── agent/ # LiveKit Agent (Python)
└── app.config.ts
第 2 章: LiveKit Agent サーバーの実装
まずはバックエンドから作ります。クライアントから作ると、接続先がないまま UI だけ作ることになり、結局何も動かないまま時間を溶かすことになるからです。
必要な環境変数
LiveKit Cloud のダッシュボードで新規プロジェクトを作成すると、以下 3 つが発行されます。
LIVEKIT_URL: wss://your-project.livekit.cloud
LIVEKIT_API_KEY: APIxxxxxxxxxxxxxxxx
LIVEKIT_API_SECRET: シークレット
さらに、AI プロバイダー側のキーも必要です。今回は OpenAI を使う前提で進めます。
# .env.agent
LIVEKIT_URL = wss://your-project.livekit.cloud
LIVEKIT_API_KEY = APIxxxxxxxxxxxxxxxx
LIVEKIT_API_SECRET = your-secret
OPENAI_API_KEY = YOUR_OPENAI_API_KEY
エージェント本体(Python)
LiveKit Agents の Python SDK を使うと、VAD(音声区間検出)→ STT → LLM → TTS のパイプラインが数十行で書けます。下記は「フレンドリーな日本語アシスタント」として振る舞わせる最小実装です。
# server/agent/main.py
import asyncio
import logging
from livekit.agents import AutoSubscribe, JobContext, WorkerOptions, cli, llm
from livekit.agents.voice_assistant import VoiceAssistant
from livekit.plugins import openai, silero
logger = logging.getLogger( "voice-agent" )
async def entrypoint (ctx: JobContext):
# 初期プロンプト: エージェントの人格・禁止事項を定義する
initial_ctx = llm.ChatContext().append(
role = "system" ,
text = (
"あなたは日本語で応答するフレンドリーなアシスタントです。"
"返答は 1 〜 2 文に収め、句読点で間を取ってください。"
"医療・法律・投資の個別助言はせず、専門家への相談を促してください。"
),
)
# ユーザーの音声トラックのみ購読する(コスト削減のため映像はスキップ)
await ctx.connect( auto_subscribe = AutoSubscribe. AUDIO_ONLY )
assistant = VoiceAssistant(
vad = silero. VAD .load(),
stt = openai.STT( model = "whisper-1" , language = "ja" ),
llm = openai.LLM( model = "gpt-4o-mini" ),
tts = openai.TTS( voice = "nova" ),
chat_ctx = initial_ctx,
)
assistant.start(ctx.room)
# 接続直後の挨拶
await asyncio.sleep( 0.5 )
await assistant.say( "こんにちは、何かお手伝いできることはありますか?" , allow_interruptions = True )
if __name__ == "__main__" :
cli.run_app(WorkerOptions( entrypoint_fnc = entrypoint))
実行すると、ワーカープロセスが LiveKit Cloud に常駐し、ルームに人間が参加するたびに自動で呼び出されます。期待する出力はログに以下のように表示されます。
voice-agent - INFO - worker started, id=AW_xxxxxxx
voice-agent - INFO - accepting job, room=voice-user-123
voice-agent - INFO - agent connected to room
なぜ VoiceAssistant を使うか
自分で STT → LLM → TTS を繋ぐと、「ユーザーが話している途中で LLM を走らせてしまう」「TTS 中にユーザーが割り込んだら止める」といった制御を全部書く羽目になります。VoiceAssistant クラスはこの割り込み制御を内部で実装済みで、allow_interruptions=True を付けると自然な会話のやり取りが実現できます。
私は最初 LangChain のエージェントを直結しようとして、「返答が終わってからしか次の発話を受けない」状態に陥り、ユーザーからクレームの嵐でした。VoiceAssistant を経由するだけでこの問題は消えます。
デプロイ先の選定
エージェントは常駐プロセスなので、Cloudflare Workers には乗りません。推奨は以下の 3 択です。
Fly.io : Python コンテナをそのままデプロイでき、$5/月 から。個人開発ならこれ
LiveKit Cloud Agents(有料オプション) : サーバー運用不要。ただし月額コストは高め
Railway / Render : Fly.io と同等の使い勝手
私は Fly.io で fly launch --dockerfile で立ち上げ、fly scale count 2 で冗長化しています。1 エージェントあたりの同時通話は 10 〜 20 まで捌けるので、MRR が伸びたら台数を増やせば良いだけです。
第 3 章: トークン発行 API の実装
クライアントから LiveKit に直接繋ぐには JWT が必要です。この JWT に「どのルームに入れるか」「何秒まで喋れるか」を埋め込むのがポイントです。
なぜ課金チェックをこの層に置くか
「エージェント側で課金チェックすればいいのでは」と思うかもしれませんが、それだと「ルームには繋がるが AI は応答しない」という意味不明な状態になります。ユーザー体験としては「接続できたのに何も返ってこないアプリ」と認識され、App Store レビューで低評価が付きます。接続自体をトークン発行時点で拒否するのが正解です。
Hono + Cloudflare Workers の実装
// server/token-api/src/index.ts
import { Hono } from "hono" ;
import { cors } from "hono/cors" ;
import { AccessToken } from "livekit-server-sdk" ;
import { createClient } from "@supabase/supabase-js" ;
type Env = {
LIVEKIT_API_KEY : string ;
LIVEKIT_API_SECRET : string ;
SUPABASE_URL : string ;
SUPABASE_SERVICE_KEY : string ;
};
const app = new Hono <{ Bindings : Env }>();
app. use ( "*" , cors ({ origin: [ "https://your-app.example" ] }));
app. post ( "/token" , async ( c ) => {
const { authorization } = c.req. header ();
if ( ! authorization?. startsWith ( "Bearer " )) {
return c. json ({ error: "unauthorized" }, 401 );
}
// ① Supabase でユーザー検証
const supabase = createClient (c.env. SUPABASE_URL , c.env. SUPABASE_SERVICE_KEY );
const {
data : { user },
error ,
} = await supabase.auth. getUser (authorization. replace ( "Bearer " , "" ));
if (error || ! user) return c. json ({ error: "invalid token" }, 401 );
// ② 今月の残通話時間をチェック
const { data : usage } = await supabase
. from ( "voice_usage" )
. select ( "seconds_used, seconds_limit" )
. eq ( "user_id" , user.id)
. eq ( "period" , new Date (). toISOString (). slice ( 0 , 7 )) // YYYY-MM
. single ();
const remaining = (usage?.seconds_limit ?? 600 ) - (usage?.seconds_used ?? 0 );
if (remaining <= 30 ) {
return c. json ({ error: "quota_exceeded" , remaining }, 402 );
}
// ③ LiveKit JWT 発行
const roomName = `voice-${ user . id }-${ Date . now () }` ;
const at = new AccessToken (c.env. LIVEKIT_API_KEY , c.env. LIVEKIT_API_SECRET , {
identity: user.id,
ttl: Math. min (remaining, 1800 ), // 最大 30 分か残り時間の短い方
metadata: JSON . stringify ({ plan: user.app_metadata?.plan ?? "free" }),
});
at. addGrant ({
roomJoin: true ,
room: roomName,
canPublish: true ,
canSubscribe: true ,
});
return c. json ({
token: await at. toJwt (),
url: "wss://your-project.livekit.cloud" ,
room: roomName,
secondsRemaining: remaining,
});
});
export default app;
期待するレスポンス(成功時)はこの形です。
{
"token" : "eyJhbGci..." ,
"url" : "wss://your-project.livekit.cloud" ,
"room" : "voice-abc-1713775200000" ,
"secondsRemaining" : 480
}
よくある間違い: TTL を長くしすぎる
初期実装で ttl: 86400(1 日)のように設定してしまうと、一度発行されたトークンを使い回されて課金制限をすり抜けられます。通話 1 回につき 1 トークン発行 を徹底し、TTL は 30 分上限にするのが安全です。
第 4 章: Rork 側の React Native クライアント
いよいよクライアント側です。Rork でプロジェクトを作成する際、プロンプトで「LiveKit React Native を使った音声通話アプリ。録音ボタンを押すと会話が始まり、ストップで終了する」のように明確に指示すると、画面の土台が自動で生成されます。
依存関係のインストール
Rork が生成したプロジェクトに以下を追加します。
npx expo install @livekit/react-native @livekit/react-native-webrtc
npx expo install expo-av expo-permissions
app.config.ts にマイク権限の記述も忘れずに。これを忘れると iOS で audio セッションが取れず、ずっと無音になります。
// app.config.ts(抜粋)
export default {
expo: {
// ...
ios: {
infoPlist: {
NSMicrophoneUsageDescription: "AI アシスタントと会話するためにマイクを使用します。" ,
},
},
android: {
permissions: [ "RECORD_AUDIO" , "MODIFY_AUDIO_SETTINGS" ],
},
plugins: [ "@livekit/react-native-expo-plugin" , "@config-plugins/react-native-webrtc" ],
} ,
} ;
LiveKit クライアントラッパー
接続ロジックを UI から分離すると、再接続処理を共通化でき、テストも書きやすくなります。
// lib/livekit.ts
import { Room, RoomEvent, ConnectionState, Track, LocalAudioTrack } from "livekit-client" ;
export type VoiceConnectionState = "idle" | "connecting" | "connected" | "disconnected" | "error" ;
export class VoiceClient {
private room : Room ;
private listeners = new Set <( s : VoiceConnectionState ) => void >();
private state : VoiceConnectionState = "idle" ;
constructor () {
this .room = new Room ({
adaptiveStream: true ,
dynacast: true ,
publishDefaults: {
audioPreset: {
maxBitrate: 32_000 , // 音声のみなので 32kbps で十分
},
},
});
// 状態変化のフック
this .room. on (RoomEvent.ConnectionStateChanged, ( cs ) => {
if (cs === ConnectionState.Connected) this . setState ( "connected" );
if (cs === ConnectionState.Disconnected) this . setState ( "disconnected" );
if (cs === ConnectionState.Reconnecting) this . setState ( "connecting" );
});
// ネットワーク劣化時の通知
this .room. on (RoomEvent.ConnectionQualityChanged, ( quality , participant ) => {
if (quality === "poor" ) {
console. warn ( "connection quality degraded" , participant.identity);
}
});
}
async connect ( url : string , token : string ) {
try {
this . setState ( "connecting" );
await this .room. connect (url, token);
const track = await LocalAudioTrack. createMicrophoneTrack ({
echoCancellation: true ,
noiseSuppression: true ,
autoGainControl: true ,
});
await this .room.localParticipant. publishTrack (track);
} catch (e) {
this . setState ( "error" );
throw e;
}
}
async disconnect () {
await this .room. disconnect ();
this . setState ( "idle" );
}
onState ( cb : ( s : VoiceConnectionState ) => void ) {
this .listeners. add (cb);
return () => this .listeners. delete (cb);
}
private setState ( s : VoiceConnectionState ) {
this .state = s;
this .listeners. forEach (( l ) => l (s));
}
}
通話画面の UI
Rork に「接続中・会話中・エラー状態を大きなボタンで表現して」と指示すると、アニメーション付きの状態表示を作ってくれます。以下は最小限の例です。
// app/(tabs)/voice.tsx
import { useEffect, useRef, useState } from "react" ;
import { View, Text, Pressable, ActivityIndicator, StyleSheet } from "react-native" ;
import { registerGlobals } from "@livekit/react-native" ;
import { VoiceClient, VoiceConnectionState } from "../../lib/livekit" ;
import { requestToken } from "../../lib/api" ;
registerGlobals ();
export default function VoiceScreen () {
const [ state , setState ] = useState < VoiceConnectionState >( "idle" );
const [ remaining , setRemaining ] = useState < number | null >( null );
const [ error , setError ] = useState < string | null >( null );
const clientRef = useRef < VoiceClient | null >( null );
useEffect (() => {
const client = new VoiceClient ();
clientRef.current = client;
const unsub = client. onState (setState);
return () => {
unsub ();
client. disconnect ();
};
}, []);
const handleStart = async () => {
try {
setError ( null );
const token = await requestToken ();
if ( "error" in token) {
if (token.error === "quota_exceeded" ) {
setError ( "今月の無料枠を使い切りました。プランをアップグレードしてください。" );
} else {
setError ( "接続に失敗しました。しばらくしてからお試しください。" );
}
return ;
}
setRemaining (token.secondsRemaining);
await clientRef.current?. connect (token.url, token.token);
} catch (e) {
setError ((e as Error ).message);
}
};
const handleStop = async () => {
await clientRef.current?. disconnect ();
};
const isActive = state === "connected" || state === "connecting" ;
return (
< View style = {styles.container} >
{ state === " connecting " && < ActivityIndicator size = "large" /> }
{ state === " connected " && (
< Text style = {styles.status} > 通話中 — 残り {remaining}秒 </ Text >
)}
{ error && < Text style = {styles.error} > {error} </ Text > }
< Pressable
onPress = {isActive ? handleStop : handleStart}
style = {[styles.button, isActive && styles.buttonActive]}
>
<Text style={styles.buttonText}>{isActive ? "通話を終了" : "通話を開始" } </ Text >
</ Pressable >
</ View >
);
}
const styles = StyleSheet. create ({
container: { flex: 1 , justifyContent: "center" , alignItems: "center" , padding: 24 },
status: { fontSize: 18 , marginBottom: 24 },
error: { color: "#c00" , marginBottom: 12 , textAlign: "center" },
button: {
width: 200 ,
height: 200 ,
borderRadius: 100 ,
backgroundColor: "#4a90e2" ,
justifyContent: "center" ,
alignItems: "center" ,
},
buttonActive: { backgroundColor: "#e2574a" },
buttonText: { color: "white" , fontSize: 20 , fontWeight: "600" },
});
期待する挙動としては、ボタンをタップすると connecting → connected と状態が変わり、エージェントから挨拶の音声が流れてきます。
第 5 章: よくある落とし穴と対処
ここからは、私が実際に詰まった箇所を時系列で共有します。同じ罠にハマらないための地図だと思ってください。
落とし穴 1: iOS で音声セッションが取れない
症状: 接続はできるのに、エージェントの音声が聞こえありません。マイクも反応しありません。
原因: iOS の AVAudioSession のカテゴリが playAndRecord になっていないと、LiveKit がマイクを開けません。@livekit/react-native は起動時に registerGlobals() を呼ぶと自動で設定しますが、これを忘れるとサイレントに失敗します。
対処: ルートコンポーネントのトップレベルで registerGlobals() を呼ぶこと。Rork が生成したコードに含まれていない場合は手動で追加してください。
落とし穴 2: TestFlight では動くが本番で落ちる
症状: 開発ビルドや TestFlight では問題ないのに、App Store 配信版で接続直後に落ちる。
原因: Hermes エンジンの最適化で、LiveKit の内部で使う WebAssembly 関連コードが最適化バグに当たることがあります。
対処: metro.config.js で以下を設定して、LiveKit の該当モジュールを最適化対象から外します。
// metro.config.js
const { getDefaultConfig } = require ( "expo/metro-config" );
const config = getDefaultConfig (__dirname);
config.transformer.minifierConfig = {
keep_fnames: true ,
mangle: { keep_fnames: true },
};
module . exports = config;
落とし穴 3: 通話時間が正しくカウントされない
症状: ユーザーは 5 分話したのに、DB には 30 秒しか記録されありません。
原因: 通話終了イベントをクライアント側で送っていると、アプリが強制終了された場合にイベントが届きません。
対処: LiveKit Webhook を使い、サーバー側の room_finished イベントで通話時間を記録します。
// server/token-api/src/webhook.ts
app. post ( "/livekit-webhook" , async ( c ) => {
const event = await c.req. json ();
if (event.event === "room_finished" ) {
const userId = event.room.name. split ( "-" )[ 1 ];
const duration = event.room.duration; // 秒
const supabase = createClient (c.env. SUPABASE_URL , c.env. SUPABASE_SERVICE_KEY );
await supabase. rpc ( "add_voice_seconds" , { uid: userId, secs: duration });
}
return c. json ({ ok: true });
});
クライアント依存ではなくサーバー側の真実のイベントで記録することで、アプリがクラッシュしても正確に課金できます。
落とし穴 4: エージェントのレスポンスが遅い
症状: ユーザーが話し終わってから 2 〜 3 秒経ってようやく返答が始まる。
原因: STT → LLM → TTS を直列で実行すると、各段階のレイテンシが加算されます。特に gpt-4o のフルモデルは最初のトークンが出るまで 800ms かかることがあります。
対処:
STT は whisper-1 ではなく、より高速な gpt-4o-transcribe を検討
LLM は gpt-4o-mini もしくは Groq 経由の Llama 3.3 を検討
TTS はストリーミング対応のプロバイダー(Cartesia・ElevenLabs Turbo v2)を使い、最初の音声チャンクが 200ms 以内に出るようにする
さらに攻めるなら、OpenAI Realtime API や Gemini Live API を直接 LiveKit Agents に統合する(プラグインあり)
落とし穴 5: App Store 審査でリジェクト
症状: 「マイクの用途が不明瞭」で 4.0 リジェクト。
原因: NSMicrophoneUsageDescription が「マイクを使用します」のような抽象的な文言になっていた。
対処: 何のために使うかを具体的に書く 。「AI アシスタントと音声で会話するためにマイクにアクセスします」のように目的を明示します。Apple は目的が曖昧な権限取得を嫌います。
第 6 章: 収益化の設計
本番で運用するからには、通話時間に紐づく課金モデルが必要です。個人開発で月 30 万円以上を狙う場合、私は以下 3 層のプラン設計を推奨しています。
プラン設計の例
Free : 月 10 分(600 秒)まで。離脱を減らすための体験枠
Standard : ¥980/月、月 3 時間(10,800 秒)まで。軽いユーザーのスイートスポット
Pro : ¥2,480/月、月 15 時間(54,000 秒)まで。ヘビーユーザー向け
Stripe の Metered billing は過剰設計なので、初期は「月定額 + 上限到達で追加購入」のシンプルな仕組みで十分です。
サーバー側の使用量更新
-- Supabase 側のストアドプロシージャ
CREATE OR REPLACE FUNCTION add_voice_seconds ( uid uuid, secs int )
RETURNS void AS $$
DECLARE
cur_period text : = to_char( now (), 'YYYY-MM' );
user_limit int ;
BEGIN
SELECT CASE
WHEN plan = 'pro' THEN 54000
WHEN plan = 'standard' THEN 10800
ELSE 600
END INTO user_limit
FROM users WHERE id = uid ;
INSERT INTO voice_usage (user_id, period , seconds_used, seconds_limit)
VALUES ( uid , cur_period, secs, user_limit)
ON CONFLICT (user_id, period )
DO UPDATE SET seconds_used = voice_usage . seconds_used + EXCLUDED . seconds_used ;
END ;
$$ LANGUAGE plpgsql;
離脱率を下げる再接続 UX
ネットワークが切れた瞬間に「通話が切断されました」とだけ出すアプリは解約されます。代わりに:
切断直後に「再接続しています…」を表示し、LiveKit の自動再接続(5 秒タイムアウト)を待つ
5 秒経っても復旧しない場合、「ネットワーク状態を確認してもう一度お試しください」と優しく案内
直近の会話履歴(直前 3 〜 4 ターン)をローカルに保存し、再接続時に同じコンテキストで継続できるようにする
この 3 点をやるだけで、私のアプリでは 30 日継続率が 18% から 34% に跳ね上がりました。
第 7 章: コスト試算と損益分岐
本番運用で怖いのは「ユーザーが増えるほど赤字が膨らむ」パターンです。最初にコスト構造を把握しておきましょう。
1 分あたりの原価(2026 年 4 月時点の概算)
STT (gpt-4o-transcribe): 約 $0.006/分
LLM (gpt-4o-mini, 入出力合計 2K トークン): 約 $0.003/分
TTS (OpenAI tts-1): 約 $0.015/分
LiveKit Cloud(音声のみ): 約 $0.002/分
合計: 約 $0.026/分(約 ¥4/分)
Pro プラン(¥2,480/月、15 時間 = 900 分上限)のヘビーユーザーが上限いっぱい使うと、原価は ¥3,600/月になり赤字です。そのため:
上限を設計で守る : Pro でも 15 時間が上限で、追加は別料金
LLM を軽量化 : gpt-4o-mini → Gemini 2.5 Flash-Lite で 3 分の 1 に圧縮
TTS を乗り換え : Cartesia Sonic の $0.005/分プランに切り替え
これで 1 分あたり ¥2 程度まで下がり、Pro プランでも粗利率 60% 以上を維持できます。
モニタリング
Supabase に以下のビューを作っておくと、日次で「原価超過ユーザー」が見えます。
CREATE VIEW voice_cost_monitor AS
SELECT
u . email ,
vu . seconds_used ,
ROUND ( vu . seconds_used * 0 . 0000667 :: numeric , 2 ) AS cost_usd,
p . monthly_revenue_usd ,
CASE WHEN vu . seconds_used * 0 . 0000667 > p . monthly_revenue_usd
THEN 'LOSS' ELSE 'OK' END AS status
FROM voice_usage vu
JOIN users u ON u . id = vu . user_id
JOIN plans p ON p . id = u . plan_id
WHERE vu . period = to_char( now (), 'YYYY-MM' );
status = 'LOSS' のユーザーを毎日チェックして、異常利用者には個別アプローチします。大半は意図せず上限近くまで使っているだけなので、メールで「使い方のコツ」を送るだけで収まります。
第 8 章: 次の一歩
この記事で手に入れたのは、「音声エージェントを本番で動かすための最低限の地図」です。ここから先の成長は、トピックに応じて方向が分岐します。
もし「会話の質」を高めたいなら、LiveKit Agents の function_calling を使って、カレンダー予約や RAG 検索を会話中に差し込む実装に進むのがおすすめです。もし「スケール」を優先したいなら、Fly.io のリージョンを東京・シンガポール・バージニアに分散させ、ユーザーの地理的近接性を活かす構成を検討してください。もし「自社 LLM」に置き換えたいなら、LiveKit Agents は任意の LLM プラグインをサポートしているので、独自のバックエンドを差し込むことも可能です。
私自身、この構成で音声メモ × AI 要約アプリを作り、リリースから 3 か月で MRR が 40 万円を超えました。技術的な壁より、UX とコスト設計の壁のほうが高かったというのが実感です。もしこの記事の構成で詰まった箇所があれば、X(@dolice )でお気軽に声をかけてください。一緒に詰まるポイントを潰していきましょう。
音声 AI の設計パターンや最新のエージェント技術
関連する実装ノウハウとしては、Rork × OpenAI Realtime API で音声アシスタントアプリを作る完全ガイドやRork アプリの月次 ARR を伸ばす収益化戦略の完全ガイド も併せてどうぞ。