コンテンツへ移動

ElevenLabsとTwilioで20分でボイスエージェントを構築する

公開日
最終更新日

聴くこの記事を聴く

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

デベロッパー向けの構成は、音声合成にElevenLabs(Flash v2.5)、文字起こしにElevenLabs(Scribe v2 Realtime)、電話機能に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に対し、WebSocketエンドポイントへのMedia Streamを開くよう指示します。Twilioは、base64のmu-law(ulaw_8000)ペイロードを含むJSONイベントとして受信音声をストリーミングします。

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

Scribe v2 Realtimeは約150msのレイテンシで部分文字起こしを出力し、Flash v2.5のモデル推論はネットワークとアプリケーションのレイテンシを除いて約75msです。最初の音声が出るまでの時間に最も大きく、予測しにくい影響を与えるのはLLMであり、レイテンシ予算の大半を占めます。間を短く保つため、LLMの出力をトークンごとにストリーミングし、モデルが文を生成し終える前に合成を開始します。

こうした選択におけるモデルのトレードオフについては、モデルの概要レイテンシの解説をご覧ください。

ボイスエージェントの構築前に必要なもの

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

次の前提条件を確認してください。

  1. Voice機能に対応したTwilioの電話番号:Twilioコンソールで、電話番号、Account SID、Auth Tokenを確認してください。
  2. ElevenLabs APIキー:ElevenLabsダッシュボードで作成します。キーはxi-api-keyヘッダーで送信される機密情報のため、サーバー側だけで管理してください。API認証をご覧ください。
  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つのイベントタイプと送信形式を理解すれば、書く前からステップ2のWebSocketハンドラーを完全に理解できます。

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

最初にconnectedイベントが届き、WebSocketが接続済みであることを確認します。media streamが開始されるとstartイベントが1回送信されます。ここには、音声を返す際に必要となるため保存しておくstreamSidが含まれます。また、start.customParametersとstart.callSidには通話メタデータも含まれます。

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

音声を再生するには、同じstreamSidとbase64エンコードしたmu-lawペイロードを含むmediaタイプのメッセージを送信します。すでにキューに入れた音声を中断するには、streamSidを含むclearメッセージを送信します。これによりTwilioの送信バッファーがフラッシュされます。

受信・送信のエンコードは同一です(ulaw_8000)。ElevenLabsのテキスト読み上げにulaw_8000をリクエストし、間でリサンプリングすることなくバイト列をTwilioへ直接転送します。

ステップ1:TwiML Webhookを提供する

着信時、TwilioはWebhookにHTTPリクエストを送信します。これに対し、通話をMedia Streamへ接続するTwiMLを返します。<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ハンドラーを1つ作成します。

// ... 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を使用してください。

ステップ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;
    }
  });
});

ステップ3:Scribe v2 Realtimeで文字起こしする

Scribe v2 Realtimeはストリーミングオーディオチャンクを受け取り、部分文字起こしと確定文字起こしを返します。mu-lawエンコードにも直接対応しているため、Twilioのフレームを変更せずに渡せます。

また、無音ベースのセグメンテーション用の音声アクティビティ検出(VAD)と、セグメントを確定する手動コミット制御も提供します。電話エージェントでは、自然な間が発信者の発話ターン終了を示す最も信頼できるシグナルであるため、通常はVAD駆動のセグメンテーションが適しています。

手順は次のとおりです。通話開始時にSTTストリームを開きます。受信したすべてのmu-lawチャンクを送信します。確定した文字起こしに応じてLLMを呼び出します。

リアルタイムSTTクライアントのインターフェースは現在も進化しているため、以下の形式は固定されたフィールド名ではなく、ライブAPIに対して実装する小さなTypeScriptアダプター(openRealtimeStt)の背後に置いています。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テキストのプロダクトページをご覧ください。

ステップ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の契約で利用できます。関数本体を入れ替えても、エージェントの残りの部分は変更不要です。

システムプロンプトで応答の長さを制限します。これは電話では重要です。長い応答は遅く感じられ、自然に中断しにくくなります。

ステップ5:Flash TTSでulaw_8000形式に合成する

テキストを、Twilioが再生できるオーディオに変換する必要があります。バイト列がTwilioの想定するエンコードに一致するよう、outputFormat: "ulaw_8000"を指定してFlash v2.5をリクエストします。その後、オーディオをストリーミングし、各チャンクをmediaイベントとしてWebSocket経由で返送します。

応答全体を待つのではなく、LLMトークンを文単位の断片にまとめ、各断片が完成した時点で合成します。これにより、モデルが2文目を生成している間に発信者は1文目を聞けるため、最初の音声が出るまでの時間を短縮できます。増分合成をより細かく制御する方法については、リアルタイム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はすべてのリクエストに対して、X-Twilio-SignatureヘッダーでAuth Tokenを使った署名を付与します。検証に失敗したものは拒否します。署名は完全な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つの処理を行います。1つ目はTTSチャンクの転送を停止することです。これはspeak内のagentSpeakingフラグがループを抜けることで処理します。2つ目は、Twilio側ですでにキューに入れたオーディオをフラッシュするため、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の近くだけでなく、発信者の近くにも配置してください。

ステージで障害が起きても、発信者を無音のままにしないでください。短いフォールバック文(「すみません、もう一度おっしゃっていただけますか?」)を合成し、通話を維持します。1回のターンの失敗でWebSocket全体が切断されないよう、各ステージをタイムアウトとtry/catchで囲んでください。

リリース前に、さらにいくつかのデフォルト設定をしておくことをおすすめします。

  • 上記のようにシステムプロンプトで応答の長さを制限し、ターンを短く中断しやすい状態に保ちます。
  • 長時間の通話でLLMコンテキストが無制限に増えないよう、会話履歴に上限を設けます。
  • 停止したセッションが同時実行枠を静かに消費し続けるのを防ぐため、通話時間の上限を設定します。

制御可能な部分をさらに調整するには、レイテンシのドキュメントで最初の音声が出るまでの時間の要因を確認し、モデルの概要で速度と品質のトレードオフを確認してください。また、リアルタイムTTS WebSocketガイドでは、増分テキスト入力で合成レイテンシをさらに下げる方法を紹介しています。

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

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

カスケードを自分で保守したくない場合、ElevenAgentsは、手作業で接続したのと同じモデルを基盤とするマネージドサービスとして、ターンテイキング、割り込み処理、電話機能のインテグレーションを提供します。

自分で管理するスタックをさらに調整したい場合は、プラン、同時実行数の上限、ElevenAPIのプロダクトページとボイスライブラリをご覧ください。または、登録して、今すぐ最初の通話を始めましょう。

TwilioとElevenLabsでボイスエージェントを構築する:よくある質問

関連記事

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