탐색으로 건너뛰기

사용자 지정 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는 에이전트의 대화 시스템에서 두 개의 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 로직이 표준 에이전트 구성 안에서 작동한다면 네이티브 통합을 사용하세요. 두뇌가 자체 인프라에서 코드를 실행해야 할 때 이 가이드를 사용하세요.

이 패턴은 서버와 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는 8kHz μ-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만 연결할 수 있도록 합니다.

브리지 서버 구축

브리지는 세 가지 라우트를 제공합니다.

  • POST /incoming-call — Twilio 웹훅입니다. 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가 이 URL로 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 번호는 트렁크 또는 웹훅 중 하나로만 라우팅할 수 있으며 둘 다 사용할 수는 없습니다.

4

번호로 전화

아무 전화기에서나 해당 번호로 전화하세요. 에이전트가 응답합니다. 통화 중 말하면 에이전트의 응답을 들을 수 있습니다. 디버그 로깅을 활성화하면 브리지는 각 턴의 통화 SID, 대화 ID, 오디오 형식을 기록합니다.

프로덕션 고려 사항

  • 웹훅 유효성 검사: /incoming-call에서 항상 X-Twilio-Signature를 검증하세요. 위 예시는 Twilio의 헬퍼 라이브러리를 사용합니다. 이 단계를 건너뛰지 마세요.
  • 공유 시크릿: brain WebSocket에서 공유 시크릿을 적용하세요. 그렇지 않으면 ngrok URL을 추측한 누구나 연결하여 ElevenLabs를 사칭할 수 있습니다.
  • 안정적인 호스트: ngrok 무료 티어 URL은 재시작할 때마다 변경됩니다. 재시작할 때마다 Speech Engine ws_url과 Twilio 웹훅을 업데이트하지 않도록 예약된 ngrok 도메인 또는 실제 호스트 이름을 사용하세요.
  • 지연 시간: 각 통화는 LLM의 첫 토큰 생성 시간에 더해 두 번의 네트워크 홉을 추가합니다. 지연 시간이 짧은 모델을 사용하고 응답을 스트리밍하여 체감 지연 시간을 낮게 유지하세요.
  • 하나의 프로세스 또는 두 개: 예시에서는 단일 ngrok 터널이 모든 것을 처리하도록 브리지와 brain을 같은 포트에 배치합니다. 프로덕션 환경에서는 각각 공개 URL이 있는 한 두 서비스로 분리할 수 있습니다.
  • 프롬프트 인젝션: 전화 통화의 음성 입력은 신뢰할 수 없는 사용자 입력입니다. 도구 호출이나 데이터베이스 쓰기에 영향을 주기 전에 트랜스크립트를 검증하세요.

다음 단계