はじめに
以前から気になっていたCloudflareの製品。今回、初めて触れる機会があったので、中でも特に興味があったサーバーレス機能 Cloudflare Workers を使って、Twilio Media Streams と連携するリアルタイムAIアプリケーションを構築してみました。
前回のAIボットと電話を繋ぐ - Twilio Voice - ConversationRelayで作ったWebsocketサーバの機能をCloudflare Workersで実装しているイメージです。
本記事では、Cloudflare Workers上にWebSocketサーバーを実装し、電話の音声をリアルタイムでAIが処理・応答する「AI Agent」や「AIボイスボット」と連携する手順と、そのコードのポイントを解説します。
Twilioにも Functions & Assets という便利なサーバーレス機能がありますが、リアルタイムの双方向通信が必要なWebSocketをネイティブサポートしていないため、今回のようなユースケースは実現が困難でした。Cloudflare Workersなら、この課題を驚くほどシンプルに解決できます。
手探りで始めたプロジェクトですが、無事に動作させることができました。この記事が、同じようにリアルタイムAIアプリケーション開発に挑戦する方の参考になれば幸いです。
なぜCloudflare Workersなのか? Twilio Functionsとの違い
今回のアーキテクチャでCloudflare Workersを選んだ理由は、WebSocketの双方向通信を標準でサポートしている点に尽きます。
機能 | Cloudflare Workers | Twilio Functions & Assets |
---|---|---|
WebSocket | ネイティブサポート | 限定的(サポート外) |
通信モデル | HTTPリクエスト/レスポンス + 持続的な双方向通信 | 主にHTTPリクエスト/レスポンス |
実行環境 | グローバルなエッジネットワーク(低遅延) | Twilioインフラ内 |
状態管理 | WebSocket接続中はメモリで状態を保持可能 | 基本的にステートレス |
Twilio Media Streamsは、通話中の音声をリアルタイムにWebSocket経由でストリーミングしてくれるサービスです。これを受け取って即座に応答を返すには、サーバー側でWebSocket接続を維持し、双方向でデータをやり取りする必要があります。
Cloudflare Workersはこの要件に完璧にマッチしており、エッジで実行されることによる低遅延というメリットも享受できます。
アーキテクチャ概要
今回構築するシステムの処理の流れは以下の通りです。
- ユーザーがTwilioの電話番号に発信
- TwilioはCloudflare WorkersのTwiML用エンドポイントにHTTPリクエストを送信
- Workersは、Media Streamsの接続先(自身のWebSocketエンドポイント)を指定したTwiMLをTwilioに応答
- Twilio Media Streamsが、WorkersのWebSocketエンドポイントに接続を開始
- ユーザーが話した音声はテキスト化され、WebSocketを通じてリアルタイムでWorkersに送信
- Workersは受け取ったテキストをOpenAIのAPIに送信し、ストリーミングモードで応答を受け取る
- WorkersはOpenAIからの応答を文章の区切り(句読点など)で分割し、完成した文章から順にWebSocket経由でTwilioへ返送
- Twilioは受け取ったテキストを音声に合成し、ユーザーの電話口で再生
このアーキテクチャの鍵は、ステップ⑦です。AIの応答を最後まで待つのではなく、生成された部分から少しずつ返すことで、ユーザーはあたかも人間と会話しているかのような自然な対話体験を得られます。
完全なコードはこちらにあります。
手順
1. 環境準備
まず、Cloudflare WorkersのCLIツールであるwrangler
をインストールし、ご自身のCloudflareアカウントでログインします。
# Wranglerのインストール
npm install -g wrangler
# Cloudflareにログイン
wrangler login
2. プロジェクト作成
wrangler
を使って、新しいWorkerプロジェクトの雛形を作成します。
# "websocket-server"という名前でプロジェクトを初期化
wrangler init websocket-server
# 作成されたディレクトリに移動
cd websocket-server
対話形式でいくつか質問されますが、今回は基本的な「Hello World」テンプレート(JavaScript)を選択し、デプロイは後ほど手動で行う設定として進めます。
3. 実装のポイント解説
ここからは、src/index.js
とwrangler.jsonc
のコードの重要な部分を抜粋し、その役割を解説します。
a. リクエストの振り分け役:fetch
ハンドラ
Workerにリクエストが届くと、最初にfetch
イベントハンドラが実行されます。ここでリクエストの種類を判別し、適切な処理に振り分けます。
-
WebSocketアップグレードリクエスト:
Upgrade
ヘッダーにwebsocket
が含まれていれば、WebSocket接続を確立する処理(handleWebSocketUpgrade
)を呼び出します -
TwiMLリクエスト: Twilioから通話開始時にかかってくるリクエスト(例:
/webhook/twiml
)であれば、TwiMLを生成する処理(handleTwimlRequest
)を呼び出します -
その他GETリクエスト: ブラウザでの疎通確認用です
// src/index.js
// Cloudflare Workers用エントリーポイント
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname;
// WebSocketへのアップグレードリクエストか?
// Twilio Media Streamsが接続してくる際にこのヘッダーが付与される
const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader === 'websocket') {
return handleWebSocketUpgrade(request, env);
}
// TwilioからのTwiMLリクエストか?
if (path.startsWith('/webhook/twiml')) {
return handleTwimlRequest(request, env);
}
// ブラウザ確認用のエンドポイント
if (request.method === 'GET' && (path === '/faq' || path === '/translator-en-jp')) {
return new Response(`WebSocket endpoint active at: ${path}`);
}
return new Response('Not Found', { status: 404 });
}
};
b. 通話の開始を指示:動的なTwiMLの生成
TwilioはTwiMLというXML形式の命令セットに従って動作します。通話が始まると、Twilioは私たちのWorkerに「次は何をすればいい?」と尋ねてきます。これに対し、私たちは<ConversationRelay>
というTwiMLタグを使って「このWebSocketサーバーに接続して、ストリーミングを始めてください」と指示します。
url
属性に、これから待ち受けるWebSocketエンドポイントのURLを動的に設定しているのがポイントです。
// src/index.js
// TwiML XMLレスポンスを生成する関数
function createTwimlResponse(config) {
// <ConversationRelay> を使ってMedia Streamsの開始を指示
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<ConversationRelay
url="${config.url}"
language="${config.language}"
welcomeGreeting="${config.welcomeGreeting}"
ttsLanguage="${config.ttsLanguage}"
voice="${config.voice}" />
</Connect>
</Response>`;
return new Response(twiml, {
headers: { 'Content-Type': 'text/xml' },
});
}
c. WebSocket接続の心臓部:WebSocketPair
Upgrade: websocket
ヘッダー付きのリクエストを受け取ったら、いよいよWebSocket接続を確立します。ここで登場するのが、Cloudflare Workers特有の WebSocketPair
オブジェクトです。
これは、client
とserver
という2つのWebSocketオブジェクトのペアを生成します。
-
client
: 接続元(今回はTwilio)に返すためのソケット。 -
server
: Worker内部でメッセージの送受信を行うためのソケット。
server.accept()
を呼び出して接続を有効にし、101 Switching Protocols
レスポンスと共にclient
ソケットを返すことで、HTTP接続からWebSocket接続へのプロトコル切り替えが完了します。
// src/index.js
// WebSocketアップグレードを処理するハンドラ
async function handleWebSocketUpgrade(request, env) {
// Cloudflare特有のWebSocketPairを生成
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
// サーバー側のWebSocket接続を有効化
server.accept();
// メインのWebSocket処理ロジックを起動
handleWebSocket(server, /* ... */);
// プロトコル切り替えのため、101レスポンスとクライアントソケットを返す
return new Response(null, {
status: 101,
webSocket: client, // このclientをTwilioに渡す
});
}
d. リアルタイム性の鍵:ストリーミング処理
handleWebSocket
関数では、OpenAI APIへのリクエストにstream: true
オプションを指定しています。これにより、AIの応答を一度にすべて受け取るのではなく、生成されたトークン(単語や文の一部)ごとに細切れで受信できます。
受信したデータはTextDecoder
で文字列に変換し、句読点(。
、、
、?
)を区切り文字として文章を分割します。そして、完成した文章ができた時点ですぐにws.send()
でTwilioに送信します。
// src/index.jsのhandleWebSocket関数内
// ... OpenAIへのfetchリクエスト部分
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { /* ... */ },
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: conversationHistory,
stream: true, // ★ストリーミングを有効化
// ...
}),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = ''; // 受信したテキストを一時的に溜めるバッファ
let fullResponse = '';
const delimiter = /(?<=[。、?])/; // 日本語の句読点で区切る正規表現
// ストリーミングデータを読み取るループ
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// 区切り文字で文章を分割
const sentences = buffer.split(delimiter);
// 最後の要素は不完全な文の可能性があるため、次のループまでバッファに残す
buffer = sentences.pop() || '';
// 完成した文章を順次送信
for (const sentence of sentences) {
if (sentence) {
console.log("DEBUG: Sending sentence to client:", sentence);
ws.send(JSON.stringify({ type: 'text', token: sentence, last: false }));
}
}
}
// 最後にバッファに残ったテキストがあれば送信
if (buffer) {
ws.send(JSON.stringify({ type: 'text', token: buffer, last: true }));
}
e. AIの役割を簡単切り替え:wrangler.jsonc
AIにどのような役割を担わせるか(通訳、FAQボット、予約受付など)は、「システムプロンプト」で定義します。これらのプロンプトをコード内にハードコーディングするのではなく、設定ファイルwrangler.jsonc
のvars
オブジェクトに記述することで、コードを変更することなくAIの挙動を柔軟に変更できます。
// wrangler.jsonc
{
"name": "conversation-relay-wss",
"main": "src/index.js",
"compatibility_date": "2024-07-12",
"vars": {
"SYSTEM_PROMPT_TRANSLATOR_EN_JP": "あなたは丁寧で親しみやすい電話オペレーターです。入力が英語でない場合は空文字を返してください。英語の場合のみ日本語に翻訳してください...",
"SYSTEM_PROMPT_TRANSLATOR_JP_EN": "You are a polite and friendly phone operator...",
"SYSTEM_PROMPT_FAQ": "あなたはホテルの電話コンシェルジュです。お客様からの質問に対して、わかりやすく丁寧に日本語で、口語で回答してください...",
"SYSTEM_PROMPT_ORDER": "あなたはホテルの予約サポートAIです...",
"SYSTEM_PROMPT_BOOKING": "あなたはホテルの予約受付AIアシスタントです..."
},
"env": {
"production": {
"name": "conversation-relay-wss-prod",
"vars": {
// 本番環境用のプロンプトも同様に設定
"SYSTEM_PROMPT_TRANSLATOR_EN_JP": "あなたは丁寧で親しみやすい電話オペレーターです...",
"SYSTEM_PROMPT_FAQ": "あなたはホテルの電話コンシェルジュです..."
}
}
}
}
Workerのコードからはenv
オブジェクトを通じてこれらの値にアクセスできます。これにより、/faq
エンドポイントに接続が来たらSYSTEM_PROMPT_FAQ
を、/translator-en-jp
ならSYSTEM_PROMPT_TRANSLATOR_EN_JP
を読み込む、といった動的な切り替えが可能になります。
また、OpenAIのAPIキーのような機密情報は、wrangler.jsonc
に直接書くのではなく、以下のコマンドで暗号化されたSecretとして設定するのがベストプラクティスです。
# 暗号化されたSecretとしてAPIキーを設定
wrangler secret put OPENAI_API_KEY
4. デプロイと動作確認
コードと設定が完成したら、いよいよデプロイです。
# Cloudflareネットワークにデプロイ
wrangler deploy
デプロイが成功すると、https://<プロジェクト名>.<あなたのサブドメイン>.workers.dev
という形式のURLが発行されます。
デプロイ後の動作確認には、wrangler tail
コマンドが非常に便利です。実行中のWorkerが吐き出すconsole.log
をリアルタイムで確認できます。
# リアルタイムでログをストリーミング表示
wrangler tail
Twilioの電話番号設定で、通話がかかってきた際のWebhook URLに、デプロイしたWorkerのTwiMLエンドポイント(例: https://.../webhook/twiml/faq
)を指定すれば、準備完了です。
まとめ
今回は、Cloudflare WorkersとTwilio Media Streamsを組み合わせ、リアルタイムのAI音声対話アプリケーションを構築する方法を解説しました。
- Cloudflare Workers のネイティブな WebSocket サポートが、リアルタイム双方向通信の鍵
- Cloudflare特有の
WebSocketPair
オブジェクトで接続を確立 - OpenAI APIの ストリーミング機能 と句読点での分割送信により、自然な対話応答を実現
-
wrangler.jsonc
でシステムプロンプトや環境変数を管理することで、柔軟でメンテナンス性の高い構成が可能
Twilio Functionsでは難しかったリアルタイム処理が、Cloudflare Workersを使うことで非常にシンプルに実装できることを実感しました。今後は、Cloudflare D1(SQLデータベース)やDurable Objectsと組み合わせて会話履歴を永続化したり、AI Gatewayを導入してプロンプト管理や分析を強化したりと、さらに多くの可能性が広がりそうですが、またの機会にチャレンジしてみたいと思います。
ソースコード
Githubにアップしたコードはこちら