コンテンツにスキップ

ElevenLabsとTwilioで20分でボイスエージェントを作成

公開日
最終更新日

聴くこの記事を聴く

ボイスエージェントは、着信通話に応答し、スピーチtoテキスト(STT)でリアルタイムに発話を文字起こしし、大規模言語モデル(LLM)で返答を生成し、テキスト読み上げ(TTS)モジュールで返答を話します。ElevenLabsとTwilioを使えば、約20分で実際の電話番号で動作するエージェントを作成できます。

デベロッパー向けに、今回使うフルスタックは、音声合成(Flash v2.5)と文字起こし(Scribe v2 Realtime)にElevenLabs、電話機能にTwilio、LLMにはOpenAIまたはAnthropicです。これらはすべて入れ替え可能なので、慣れているコンポーネントを選んで使うこともできます。

この記事では、Node.js とTypescriptを使って20分でボイスエージェントを作る方法を紹介します。ターン管理や割り込み、電話機能を自分で管理せずに済むマネージドな方法を探している場合は、ElevenAgentsをご覧ください。 

ボイスエージェントのアーキテクチャの仕組み

コードを書く前に、技術スタック内の3つのサービスがどのようにつながるかを理解しておくと役立ちます。

  • Twilio: 通話とオーディオ転送を担当します。
  • ElevenLabs: Scribe v2 RealtimeでSTT、Flash v2.5でTTSを担当します。
  • LLM: ツール呼び出しと返答の作成を担当します。

各ステージは薄いアダプターなので、他の部分に触れずに別のサービスに入れ替えることができます。例えば、OpenAIのLLMをAnthropicに切り替えても、他のコンポーネントを書き直す必要はありません。

電話がTwilio経由でサーバーに届きます。TwilioはPSTN通話に応答し、サーバーにWebSocketを開き、発信者の音声をbase64エンコードされたmu-lawフレームのストリームとして転送します。サーバーはカスケードを実行し、合成音声を同じWebSocket経由でストリームし、Twilioがそれを発信者に再生します。

Build a voice agent diagram of a call processing system using Twilio for speech-to-text conversion and LLM for response.

ボイスエージェントを作る際のフローは以下の通りです:

発信者がTwilio番号に電話をかけます。TwilioはWebhookからTwiMLドキュメントを取得します。TwiMLはTwilioにMedia StreamをWebSocketエンドポイントへ開くよう指示します。Twilioはbase64のmu-law(ulaw_8000)ペイロードを含むJSONイベントとして着信音声をストリームします。

サーバーは音声チャンクをScribe v2 リアルタイムに転送してストリーミング文字起こしを行います。発信者のターンが確定したら、トランスクリプトをLLMに送り、Flash v2.5でulaw_8000形式の返答を合成します。合成したmu-lawフレームをWebSocket経由でTwilioにbase64エンコードで送り、Twilioが発信者に再生します。

Scribe v2 Realtimeは約150msの遅延で部分的な文字起こしを出力し、Flash v2.5はモデル推論で約75msの速度です(ネットワークやアプリの遅延は除く)。LLMが最も時間がかかりやすく、遅延の大半を占めます。ギャップを短くするため、LLMの出力をトークンごとにストリームし、モデルが文を終える前に合成を始めます。

これらの選択のモデル上のトレードオフについては、モデル概要レイテンシーの理解.

ボイスエージェントを作る前に必要なもの

このガイドでは、4つの準備ができていることを前提としています。どれもすぐに用意できますが、1つでも欠けているとサーバーが動きません。

事前に確認しておくべきもの:

  1. 音声対応のTwilio電話番号:Twilioコンソールから番号、Account SID、Auth Tokenを控えておきます。
  2. ElevenLabs APIキー:ElevenLabsのダッシュボードで作成します。このキーはxi-api-keyヘッダーで送信され、秘密情報なので必ずサーバー側でのみ管理してください。詳しくは
  3. LLM APIキー:このチュートリアルではAnthropic ClaudeとOpenAIをどちらも使えるので、どちらかを選んでください。
  4. ローカル開発用のNgrok(または他のトンネル): Twilioがサーバーにアクセスするには公開HTTPSおよびWSSのURLが必要ですが、ngrokを使えばデプロイせずにそれが可能です。

秘密情報は環境変数として設定し、絶対にコミットしないでください。

export ELEVENLABS_API_KEY="..."
export ANTHROPIC_API_KEY="..."          # or OPENAI_API_KEY
export TWILIO_AUTH_TOKEN="..."          # used for webhook signature validation
export PUBLIC_HOST="your-subdomain.ngrok.app"

次に、サーバーが使うポートに向けてトンネルを開始します:

ngrok http 8080

Twilio Media Streamsプロトコルの理解

Twilioは生のオーディオソケットを提供しません。代わりに、すべてをWebSocket上の構造化されたJSONプロトコルでラップします。4つのイベントタイプと送信フォーマットを理解しておくと、Step 2のWebSocketハンドラーがすぐに理解できます。

TwilioがWebSocketに接続すると、4つのイベントタイプを持つJSONテキストメッセージのシーケンスを送信します。

最初にconnectedイベントが届き、WebSocketが有効であることを確認します。startイベントはメディアストリーム開始時に1回送信され、音声を返すために必要なstreamSidを含み、start.customParametersやstart.callSidに通話メタデータも含まれます。

mediaイベントは繰り返し発生します:media.payloadはbase64エンコードされた8kHz mu-lawオーディオのチャンク(1フレーム20ms)で、media.trackは発信者音声用です。最後に、ストリーム終了時(通常は通話が切れたとき)にstopが送信されます。

音声を再生するには、同じstreamSidとbase64のmu-lawペイロードでmediaタイプのメッセージを送信します。すでにキューに入っている音声を中断するには、streamSid付きでclearメッセージを送信し、Twilioの送信バッファをクリアします。

着信・送信のエンコーディングは同じ(ulaw_8000)です。ElevenLabsテキスト読み上げからulaw_8000をリクエストし、そのままTwilioにバイトを転送するので、途中でリサンプリングは不要です。

Step 1:TwiML Webhookの提供

通話が着信すると、TwilioはWebhookにHTTPリクエストを送り、TwiMLでMedia Streamに接続するよう返答します。<Connect><Stream>動詞で双方向WebSocketを開きます。ここでは<Start>ではなく<Connect>を使ってください:ストリームの間通話を維持し、音声を返せるようにするためです。

Webhookが返すTwiMLは以下の通りです:

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="wss://your-subdomain.ngrok.app/media" />
  </Connect>
</Response>

Expressの場合、ホストを埋め込んでドキュメントを返すだけのPOSTハンドラーになります:

// ... imports and app setup
app.post("/incoming-call", (_req, res) => {
  const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="wss://${process.env.PUBLIC_HOST}/media" />
  </Connect>
</Response>`;
  res.type("application/xml").send(twiml);
});

Twilioコンソールで、番号の「A call comes in」Webhookをhttps://your-subdomain.ngrok.app/incoming-callにHTTP POSTで設定します。

Step 2:Media Stream WebSocketの受け入れ

WebSocketハンドラーはTwilioイベントを読み取り、カスケードを動かし、音声を返します。

通話ごとに少量の状態(streamSid、STT接続、エージェントが話しているかのフラグ)を保持します。ハンドラーは受信した各mediaフレームをbase64からデコードし、生のmu-lawバイトをSTTに転送します:

import { WebSocketServer } from "ws";
// ... http server bound to the same port as Express

const wss = new WebSocketServer({ server, path: "/media" });

wss.on("connection", (ws) => {
  const state = { streamSid: null as string | null, agentSpeaking: false };

  ws.on("message", async (raw) => {
    const event = JSON.parse(raw.toString());
    switch (event.event) {
      case "start":
        state.streamSid = event.start.streamSid;
        await startSttSession(ws, state);
        break;
      case "media":
        await forwardToStt(Buffer.from(event.media.payload, "base64"), state);
        break;
      case "stop":
        await teardown(state);
        ws.close();
        break;
    }
  });
});

Step 3:Scribe v2 Realtimeで文字起こし

Scribe v2 Realtimeはストリーミング音声チャンクを受け取り、部分・最終文字起こしを返します。mu-lawエンコーディングも直接サポートしているので、Twilioのフレームをそのまま渡せます。

また、無音区切りのVoice Activity Detection(VAD)や、手動コミットでセグメントを確定する機能もあります。電話エージェントの場合、VADによる区切りが最適です。自然な間が発信者ターン終了の最も確実なサインだからです。

手順:通話開始時にSTTストリームを開く。すべてのmu-lawチャンクを送る。確定したトランスクリプトを受け取ったらLLMを呼び出す。

リアルタイムSTTクライアントの仕様はまだ進化中なので、下記の形は小さなTypeScriptアダプター(openRealtimeStt)でラップし、APIのライブ仕様に合わせて実装します。onFinalを、完了した発信者ターンを次のステージに渡すフックとして扱ってください。

async function startSttSession(ws, state) {
  // openRealtimeStt is a thin adapter over the realtime STT API:
  // model_id="scribe_v2_realtime", mu-law encoding, 8kHz, VAD on
  // so turns finalize on silence.
  const session = await openRealtimeStt({
    modelId: "scribe_v2_realtime",
    encoding: "ulaw",
    sampleRate: 8000,
  });
  state.stt = session;

  session.onFinal(async (text: string) => {
    if (text.trim()) await handleTurn(ws, state, text);
  });
}

async function forwardToStt(audioBytes, state) {
  if (state.stt) await state.stt.sendAudio(audioBytes);
}

リアルタイム認識の遅延は部分文字起こしで約150msなので、発信者が話し終えてからエージェントが話し始めるまでのギャップが短くなります。バッチ版や全機能については、スピーチtoテキストのドキュメントリアルタイムスピーチtoテキストのプロダクトページをご覧ください。

Step 4:LLMで返答を生成

このステージで返答を作成します。LLMは会話履歴を受け取り、アシスタントのテキストを返します。返答はストリームで受け取り、最初の文から合成を始めましょう。

ここではOpenAIを使っています:

// ... client init: new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const SYSTEM_PROMPT =
  "You are a concise phone assistant. Keep replies to one or two sentences.";

async function llmReply(history) {
  const stream = await llm.chat.completions.create({
    model: "gpt-4.1-mini",
    stream: true,
    messages: [{ role: "system", content: SYSTEM_PROMPT }, ...history],
  });
  for await (const part of stream) {
    const token = part.choices[0]?.delta?.content;
    if (token) yield token; // incremental tokens
  }
}

上記のモデルID(gpt-4.1-mini)は低遅延の例です。Anthropicならclaude-haiku-4-5が同等です。どちらのプロバイダーでも同じllmReply契約で動作します。関数の本体を入れ替えるだけで、他の部分はそのままです。

システムプロンプトで返答の長さを制限します。電話では長い返答は遅く感じ、自然な割り込みもしづらくなります。

Step 5:Flash TTSでulaw_8000形式に合成

テキストをTwilioが再生できる音声に変換します。Flash v2.5にoutputFormat: "ulaw_8000"でリクエストし、Twilioの期待するエンコーディングに合わせます。音声をストリームし、各チャンクをmediaイベントとしてWebSocket経由で返します。

LLMトークンを文単位のフラグメントにまとめ、フラグメントごとに合成します。全体の返答を待たずに合成することで、最初の文をモデルが次の文を生成中に発信者に聞かせることができ、time-to-first-audioが短縮されます。インクリメンタル合成をより細かく制御したい場合は、リアルタイムTTS WebSocketガイドを参照してください。下記のHTTPストリーミング方式は短い会話には十分シンプルです。

import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";

const eleven = new ElevenLabsClient(); // reads ELEVENLABS_API_KEY
const VOICE_ID = "JBFqnCBsd6RMkjVDRZzb"; // George, a default voice

async function speak(ws, state, text: string) {
  state.agentSpeaking = true;
  const stream = await eleven.textToSpeech.stream(VOICE_ID, {
    text,
    modelId: "eleven_flash_v2_5",
    outputFormat: "ulaw_8000",
  });
  for await (const chunk of stream) {
    if (!state.agentSpeaking) break; // interrupted by barge-in
    ws.send(
      JSON.stringify({
        event: "media",
        streamSid: state.streamSid,
        media: { payload: Buffer.from(chunk).toString("base64") },
      })
    );
  }
  state.agentSpeaking = false;
}

AIボイスエージェントを本番運用向けに強化する

上記5ステップで動作するエージェントが完成しますが、これは本番運用とは異なります。

実際の電話回線でエージェントを使う前に注意すべき点がいくつかあります。

Twilio Webhook署名の検証

WebhookのURLを知っていれば誰でもPOSTできるため、まずリクエストが本当にTwilioから来たかを確認しましょう。TwilioはすべてのリクエストにAuth Tokenで署名し、X-Twilio-Signatureヘッダーに付与します。検証に失敗したものは拒否してください。署名はフルURLとPOSTパラメータを使って計算されるので、Twilioと同じ方法で計算する必要があります。

Twilioのヘルパーを使えば簡単です:

import twilio from "twilio";

app.post("/incoming-call", express.urlencoded({ extended: false }), (req, res) => {
  const url = `https://${process.env.PUBLIC_HOST}/incoming-call`;
  const valid = twilio.validateRequest(
    process.env.TWILIO_AUTH_TOKEN!,
    req.header("X-Twilio-Signature") || "",
    url,
    req.body
  );
  if (!valid) return res.sendStatus(403);
  // ... return TwiML as before
});

秘密情報の適切な管理

ELEVENLABS_API_KEY、LLMキー、TWILIO_AUTH_TOKENはソースや平文のenvファイルに保存せず、シークレットマネージャーで管理してください。ElevenLabsキーは必要なエンドポイントだけにスコープし、クレジット上限も設定しておくと、漏洩時の被害を最小限にできます。

エンタープライズプランではIPホワイトリストで特定IP範囲にキーを制限できます。このサーバーはAPIキーを直接使いますが、キーがバックエンドから出ないためです。もし音声処理をブラウザやモバイルクライアントに移す場合は、使い捨てトークンに切り替えてキーがクライアント側に漏れないようにしましょう。

同時実行数の上限を理解する

各プランにはモデルごとに異なる同時実行数の上限があり、同時に音声を生成しているリクエスト数でカウントされます。

電話エージェントの場合、この仕組みは有利に働きます。音声生成は再生より速いので、各通話は返答合成中の短い間だけTTS同時実行数を消費し、通話全体ではありません。目安として、同時実行数5程度で約100件の会話型放送をサポートできます。生成が再生より早く終わるためです。

とはいえ、推測ではなく余裕を監視しましょう。ElevenLabsのレスポンスにはcurrent-concurrent-requestsとmaximum-concurrent-requestsヘッダーが含まれるので、ログに記録し上限に近づいたらアラートを出してください。上限を超えるとリクエストは優先度順にキューされ、通常50ms程度の遅延が発生し、過負荷が続くとHTTP 429が返されます。

HTTP 429が返された場合は短いバックオフで再試行してください。継続する場合は、料金ページで上限を引き上げるか、エンタープライズのお客様はアカウントマネージャーにご相談ください。

バージイン(割り込み)や中断への対応

エージェントが話している最中に発信者が話し始めた場合、エージェントが止まることを期待します。これがバージインで、正しく対応することで自然なエージェント体験になります。

STTのVAD信号でエージェント再生中の発信者発話を検知します。検知したら2つのことを行います。まず、TTSチャンクの転送を止めます(speak内のagentSpeakingフラグでループを抜けます)。次に、Twilioにclearメッセージを送り、すでにキューに入っている音声をクリアします。

function interrupt(ws, state) {
  state.agentSpeaking = false;
  ws.send(JSON.stringify({ event: "clear", streamSid: state.streamSid }));
}

clearを送らないと、Twilioはバッファされた音声を再生し続けるため、エージェントが発信者の上から話しているように見えてしまいます。

ログ・監視・障害時の優雅な対応

各ステージに計測を入れて、通話が遅く感じる場合にどこで遅延が発生しているか特定できるようにしましょう。最終トランスクリプトから最初のLLMトークンまで、最初のLLMトークンから最初のTTSバイトまで、最初のTTSバイトからTwilioへの送信までのギャップを計測します。遅延の大半はLLMステージに集中し、STTとTTSは比較的安定しています。

部分的な障害も想定しておきましょう。LLMのタイムアウト、STTストリームの切断、ElevenLabsへのネットワーク往復は地理によって20~200ms程度変動します。サーバーはElevenLabsだけでなく発信者の近くに配置しましょう。ElevenLabsは北米・欧州・東南アジアの最寄りクラスタに自動ルーティングされます。

どこかのステージで失敗しても発信者を無音にしないでください:「すみません、もう一度お願いできますか?」のような短いフォールバックを合成し、通話を維持します。各ステージをタイムアウトとtry/catchでラップし、1回の失敗でWebSocket全体が落ちないようにしましょう。

出荷前に設定しておくべきデフォルトもいくつかあります:

  • システムプロンプトで返答の長さを制限し、ターンが短く割り込みやすくなるようにします。
  • 会話履歴に上限を設け、長時間通話でLLMのコンテキストが無制限に増えないようにします。
  • 最大通話時間を設定し、セッションが停止せずに同時実行数を消費し続けるのを防ぎます。

自分で制御できる部分をさらに調整したい場合は、レイテンシードキュメントでtime-to-first-audioの仕組みを解説しています。モデル概要では速度と品質のトレードオフを説明し、リアルタイムTTS WebSocketガイドではインクリメンタルなテキスト入力で合成遅延をさらに短縮する方法を紹介しています。

ElevenAPIで本番対応のボイスエージェントを構築

20分経過した今、すべてのレイヤーが揃った本番対応のボイスエージェントが完成しています。Twilioが電話機能を、Scribe v2 Realtimeが文字起こしを、LLMが返答生成を、Flash v2.5が同じWebSocketで返答を話します。

カスケードを自分で管理したくない場合は、ElevenAgentsがターン管理・割り込み対応・電話インテグレーションをマネージドサービスとして提供します。今回手動で組み合わせたのと同じモデルが使われています。

自分で制御できるスタックをさらに調整したい場合は、ElevenAPIのプロダクトページでプラン、同時実行数、ボイスライブラリなどを確認できます。もしくは、サインアップして、今日から最初の通話を始めましょう。

TwilioとElevenLabsでボイスエージェントを作るFAQ

関連記事

最高品質のAIオーディオで創造する