カスタムLLMインテグレーション

Speech Engine SDKを使用して、独自のLLMでTwilio電話エージェントを動かします。

概要

ElevenAgentsのネイティブTwilio連携は、ElevenLabsがLLMをホストするケースに対応しています。独自モデル、RAGパイプライン、関数呼び出しのルーティング、その他のサーバーサイド推論など、LLMの頭脳を自前のサーバーで完全に制御する必要があり、かつエージェントをTwilioの電話番号で運用したい場合は、このガイドを使用してください。

カスタムLLM側はSpeech Engine SDKで実現します。これはElevenLabsとサーバーの間にWebSocketを開き、通話の進行に合わせてLLMが応答をストリーミングで返せるようにします。Twilio側では、Media Streamsを使用して通話オーディオをエージェントへ中継します。

アーキテクチャ

Speech Engine SDKは、エージェントの会話システム内で2つのWebSocketエンドポイントを公開します。

  • brain WebSocketはサーバー上で動作します。ElevenLabsはこれに接続し、文字起こしを受け渡してLLM生成テキストを受信します。
  • conversation WebSocketはElevenLabs上で動作します。クライアントはこれに接続してオーディオを送信し、合成オーディオを受信します。Twilioブリッジは署名付きURL経由で接続し、μ-lawオーディオを双方向に中継します。

Twilio Media StreamsとSpeech Engineはいずれもulaw_8000を使用するため、ブリッジはトランスコードなしでbase64エンコードされたオーディオを中継します。

loop [Conversation] Dial number POST /incoming-call TwiML <Connect><Stream> WebSocket /media-stream Open conversation WebSocket (signed URL) Speak media event (μ-law base64) user_audio_chunk user_transcript agent_response (streamed) audio event (μ-law base64) media event Play audio Caller Twilio Bridge Server ElevenLabs (conversation WS) Brain Server

必要に応じて、ブリッジとbrainサーバーを同じプロセスで実行できます。以下の例では両者を統合しています。

このパターンを使う場合

このガイドとネイティブTwilio連携は、どちらもエージェントをTwilioの電話番号に割り当てます。違いはLLMを誰が管理するかです。

  • ネイティブ連携:ElevenLabsがLLMをホストし、エージェントを通じて設定します。よりシンプルです。
  • Speech Engine SDK経由のカスタムLLM(このガイド):LLMを自前のサーバーでホストします。モデル、RAG、関数呼び出し、ビジネスロジックを完全に制御できます。構成要素は増えます。

LLMロジックが標準のエージェント設定に収まる場合は、ネイティブ連携をおすすめします。brainで自社インフラ上のコードを実行する必要がある場合は、このガイドを使用してください。

このパターンでは、サーバーとElevenLabs APIの間の通信にWebSocket接続を使うSpeech Engine SDKを使用します。Speech Engine SDKの代わりにOpenAI互換HTTPエンドポイントを使用するカスタムLLMガイドも利用できます。

主な違いは、WebSocketかHTTPリクエストかです。WebSocketではターンごとに新しいHTTP接続を確立するのではなく、単一の接続を維持するため、レイテンシーが改善する場合があります。

前提条件

  • Twilioアカウントと、音声通話対応の電話番号。
  • Speech Engineリソース。Speech Engineクイックスタートに従って作成し、brainサーバーのパターンを確認してください。
  • 公開HTTPSトンネル(例:ngrok)。Twilioは公開インターネット経由でブリッジに接続します。
  • Python 3.9以降またはNode.js 18以降。

μ-lawオーディオ用にエージェントを設定する

Twilio Media Streamsは8 kHzのμ-lawオーディオを使用します。ブリッジでトランスコードする必要がないよう、Speech Engineが同じ形式を受信・出力するように設定してください。

import asyncio
import os
from elevenlabs import AsyncElevenLabs
elevenlabs = AsyncElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
async def update_engine():
await elevenlabs.speech_engine.update(
speech_engine_id="seng_8k3m9xr4hjnfg983brhmhkd98n6",
asr={"user_input_audio_format": "ulaw_8000"},
tts={
"model_id": "eleven_flash_v2",
"agent_output_audio_format": "ulaw_8000",
},
speech_engine={
"request_headers": {"x-api-key": os.environ["SHARED_SECRET"]},
},
)
asyncio.run(update_engine())

eleven_flash_v2はテキスト読み上げのレイテンシーを低く保つため、電話通話で重要です。request_headersブロックは、すべてのbrain WebSocket接続にx-api-key: <shared-secret>を含めるようElevenLabsに指示します。brainサーバーはこのヘッダーを確認し、自分のSpeech Engineだけが接続できるようにします。

ブリッジサーバーを構築する

ブリッジは3つのルートを提供します。

  • POST /incoming-call — Twilio webhook。Twilioに/media-streamへのMedia Streamを開くよう指示するTwiMLを返します。
  • GET /media-stream — Twilio Media Streams WebSocket。Speech Engine conversation WebSocketとの間でオーディオを中継します。
  • GET /ws — Brain WebSocket。会話開始時にElevenLabsがここへ接続します。標準のengine.serve() / engine.attach()サーバーを実行します。
1

依存関係をインストールする

pip install "elevenlabs" "aiohttp" "twilio" "python-dotenv"
2

Speech Engine用の署名付きURLを生成する

ブリッジは新しい通話が着信するたびに署名付きURLをリクエストします。このURLにはSpeech Engine IDと一回限りの署名が埋め込まれるため、ブリッジで生のAPIキーを使用する必要はありません。

from elevenlabs import AsyncElevenLabs
elevenlabs = AsyncElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
async def signed_url() -> str:
response = await elevenlabs.conversational_ai.conversations.get_signed_url(
agent_id=os.environ["SPEECH_ENGINE_ID"],
)
return response.signed_url
3

TwiMLレスポンスを返す

通話が着信すると、Twilioは/incoming-callへPOSTします。レスポンスは、ブリッジ自身の/media-stream WebSocketへのMedia Streamを開くTwiMLです。

from aiohttp import web
from twilio.request_validator import RequestValidator
validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])
async def incoming_call(request: web.Request) -> web.Response:
form = await request.post()
signature = request.headers.get("X-Twilio-Signature", "")
url = str(request.url)
if not validator.validate(url, dict(form), signature):
return web.Response(status=403, text="forbidden")
host = request.headers.get("X-Forwarded-Host") or request.host
twiml = (
'<?xml version="1.0" encoding="UTF-8"?>'
"<Response><Connect>"
f'<Stream url="wss://{host}/media-stream"/>'
"</Connect></Response>"
)
return web.Response(text=twiml, content_type="text/xml")

RequestValidator(Python)とtwilio.webhook({ validate: true })(Node)は、X-Twilio-SignatureヘッダーをTWILIO_AUTH_TOKENと照合します。検証しない場合、公開インターネット上の誰でも/incoming-callにPOSTでき、アカウントに通話料金が請求される可能性があります。

4

Media Streamをブリッジする

Media Streamは、connected、start、media(オーディオペイロード)、stopという一連のJSONイベントを送信するWebSocketです。ブリッジはstart時にSpeech Engine conversation WebSocketを開き、ストリームが閉じるまでオーディオを双方向に中継します。

import asyncio
import json
import aiohttp
from aiohttp import web
async def media_stream(request: web.Request) -> web.WebSocketResponse:
twilio_ws = web.WebSocketResponse()
await twilio_ws.prepare(request)
stream_sid: str | None = None
el_session: aiohttp.ClientSession | None = None
el_ws: aiohttp.ClientWebSocketResponse | None = None
pump_task: asyncio.Task | None = None
async def pump_el_to_twilio(el: aiohttp.ClientWebSocketResponse):
async for msg in el:
if msg.type != aiohttp.WSMsgType.TEXT:
continue
event = json.loads(msg.data)
etype = event.get("type")
if etype == "audio":
await twilio_ws.send_str(json.dumps({
"event": "media",
"streamSid": stream_sid,
"media": {"payload": event["audio_event"]["audio_base_64"]},
}))
elif etype == "interruption":
await twilio_ws.send_str(json.dumps({
"event": "clear",
"streamSid": stream_sid,
}))
elif etype == "ping":
event_id = event.get("ping_event", {}).get("event_id")
await el.send_str(json.dumps({
"type": "pong", "event_id": event_id,
}))
try:
async for msg in twilio_ws:
if msg.type != aiohttp.WSMsgType.TEXT:
continue
event = json.loads(msg.data)
if event["event"] == "start":
stream_sid = event["start"]["streamSid"]
el_session = aiohttp.ClientSession()
el_ws = await el_session.ws_connect(await signed_url())
await el_ws.send_str(json.dumps({
"type": "conversation_initiation_client_data",
}))
pump_task = asyncio.create_task(pump_el_to_twilio(el_ws))
elif event["event"] == "media" and el_ws is not None:
await el_ws.send_str(json.dumps({
"user_audio_chunk": event["media"]["payload"],
}))
elif event["event"] == "stop":
break
finally:
if pump_task:
pump_task.cancel()
if el_ws and not el_ws.closed:
await el_ws.close()
if el_session and not el_session.closed:
await el_session.close()
return twilio_ws

Speech EngineのinterruptionイベントはTwilioストリームでclearイベントをトリガーし、バッファリングされたオーディオを破棄するため、割り込み発話がスムーズに機能します。pingイベントにはpongで応答し、conversation WebSocketを維持します。

5

brainサーバーも同時に実行する

brainサーバーは、クイックスタートで示されている標準のSpeech Engineサーバーです。追加するのはWebSocketアップグレード時の共有シークレットチェックだけです。x-api-keyがSpeech Engineに設定した値と一致する場合にのみ、接続を受け入れてください。

import os
from elevenlabs import AsyncElevenLabs
elevenlabs = AsyncElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
SHARED_SECRET = os.environ["SHARED_SECRET"]
async def brain_ws(request: web.Request) -> web.WebSocketResponse:
if request.headers.get("x-api-key") != SHARED_SECRET:
return web.Response(status=401, text="unauthorized")
ws = web.WebSocketResponse()
await ws.prepare(request)
engine = await elevenlabs.speech_engine.get(os.environ["SPEECH_ENGINE_ID"])
session = engine.create_session(ws)
async def on_transcript(transcript):
# Replace this with your own LLM call; see the quickstart.
await session.send_response("Hello, you've reached the demo.")
session.on("user_transcript", on_transcript)
await session.run()
return ws
def make_app() -> web.Application:
app = web.Application()
app.router.add_post("/incoming-call", incoming_call)
app.router.add_get("/media-stream", media_stream)
app.router.add_get("/ws", brain_ws)
return app
if __name__ == "__main__":
web.run_app(make_app(), port=3001)

LLM呼び出しとストリーミング応答を含む完全なon_transcript実装については、Speech Engineクイックスタートを参照してください。

Twilioをブリッジに接続する

1

ブリッジと公開トンネルを開始する

ngrok http 3001
python bridge.py

ngrokが表示するhttps:// URLを控えてください。TwilioはそこにPOSTします。

2

Speech Engineのws_urlを更新する

ElevenLabsが接続先を認識できるよう、speech_engine.ws_urlをbrainエンドポイントの公開WebSocket URLに設定してください。

await elevenlabs.speech_engine.update(
speech_engine_id="seng_8k3m9xr4hjnfg983brhmhkd98n6",
speech_engine={"ws_url": "wss://abc123.ngrok.io/ws"},
)
3

Twilio番号を設定する

Twilioコンソールで、電話番号のVoice Configurationを開きます。

  • A call comes in:Webhook
  • URL:https://abc123.ngrok.io/incoming-call
  • HTTP method:POST

番号がElastic SIP Trunkに接続されている場合は、先に切断してください。Twilio番号はトランクまたはwebhookのどちらか一方にのみルーティングでき、両方には設定できません。

4

番号に電話をかける

任意の電話から番号に発信してください。エージェントが応答します。通話中に話しかけると、エージェントの応答が聞こえるはずです。デバッグログを有効にすると、ブリッジは各ターンの通話SID、会話ID、オーディオ形式を記録します。

本番環境での考慮事項

  • Webhookの検証:/incoming-callでは必ずX-Twilio-Signatureを検証してください。上記の例ではTwilioのヘルパーライブラリを使用しています。この手順は省略しないでください。
  • 共有シークレット:brain WebSocketで共有シークレットを必ず適用してください。適用しないと、ngrok URLを推測した第三者が接続し、ElevenLabsになりすます可能性があります。
  • 安定したホスト:ngrok無料プランのURLは再起動のたびに変わります。再起動ごとにSpeech Engineのws_urlとTwilio Webhookを更新しなくて済むよう、予約済みのngrokドメインまたは実際のホスト名を使用してください。
  • レイテンシー:各通話では、LLMの最初のトークンが生成されるまでの時間に加えて、ネットワークホップが2回発生します。低レイテンシーのモデルを使用し、レスポンスをストリーミングして体感レイテンシーを抑えてください。
  • 1プロセスまたは2プロセス:この例では、1つのngrokトンネルですべてをカバーできるよう、bridgeとbrainを同じポートに配置しています。本番環境では、それぞれにパブリックURLがあれば、2つのサービスに分割できます。
  • プロンプトインジェクション:電話で話された入力は、信頼できないユーザー入力です。ツール呼び出しやデータベースへの書き込みに影響する前に、文字起こしを検証してください。

次のステップ