跳至内容

用 ElevenLabs 和 Twilio 20 分钟搭建语音智能体

发布时间
最近更新

收听收听本文

语音智能体可以接听呼入电话,通过 语音转文本 (STT)实时转录来电者语音,使用大语言模型(LLM)生成回复,再通过 文本转语音 (TTS)模块回复。使用 ElevenLabs 和 Twilio,大约 20 分钟即可让智能体在真实电话号码上运行。

开发所用的完整技术栈包括:ElevenLabs 负责语音合成(Flash v2.5)和转录(Scribe v2 Realtime),Twilio 负责电话通信,OpenAI 或 Anthropic 作为 LLM。不过这些组件都可替换,你可以选择最熟悉的组件。

本文介绍如何使用 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 将呼入音频作为 JSON 事件流传输,其中包含 base64 编码的 mu-law(ulaw_8000)负载。 

服务器将音频块转发给 Scribe v2 Realtime 进行流式转录。来电者一轮发言结束后,将转录文本发送给 LLM,再使用 Flash v2.5 以 ulaw_8000 合成回复。随后通过 WebSocket 将 base64 编码的合成 mu-law 帧发回 Twilio,由其播放给来电者。

Scribe v2 Realtime 输出部分转录的延迟约为 150ms,Flash v2.5 的模型推理约为 75ms,不含网络和应用延迟。LLM 是首段音频生成时间中最大且最难预测的影响因素,占据了大部分延迟预算。为缩短等待时间,我们逐 token 流式传输 LLM 输出,并在模型完成整句前开始合成。

如需了解这些选择背后的模型权衡,请参阅 模型概览延迟解析

构建语音智能体前需要准备什么

本指南假设已准备好 4 项内容。每项都能快速完成设置,但缺少任何一项都会导致服务器无法运行。

请检查以下前提条件:

  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 不会提供原始音频 socket,而是通过 WebSocket 使用结构化 JSON 协议封装所有内容。了解 4 种事件类型和发送格式后,就能在编写前完全理解步骤 2 中的 WebSocket 处理器。

Twilio 连接到 WebSocket 后,会发送一系列 JSON 文本消息,共有 4 种事件类型。

connected 事件最先到达,确认 WebSocket 已建立。媒体流开始时会发送一次 start 事件;其中包含必须保存的 streamSid,以便回传音频,还包含位于 start.customParameters 和 start.callSid 下的通话元数据。 

media 事件会持续出现:media.payload 是一段 base64 编码的 8kHz mu-law 音频,每帧 20ms;来电者音频的 media.track 为 inbound。最后,媒体流结束时会发送 stop,通常是因为通话已挂断。

要播放回传音频,需发送类型为 media 的消息,其中包含相同的 streamSid 和 base64 mu-law 负载。要打断已排队的音频,需发送带有 streamSid 的 clear 消息,以清空 Twilio 的出站缓冲区。

呼入和呼出编码相同(ulaw_8000)。我们向 ElevenLabs 文本转语音请求 ulaw_8000,并直接将字节转发给 Twilio,无需中间重采样。

步骤 1:提供 TwiML webhook

来电时,Twilio 会向 webhook 发起 HTTP 请求,而你需要返回 TwiML,将通话连接到 Media Stream。<Connect><Stream> 指令会建立双向 WebSocket。这里应使用 <Connect> 而非 <Start>:它会在流持续期间保持通话,并允许回传音频,这正是此设置的目的。

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 控制台中,将号码的“有来电时” webhook 设置为 https://your-subdomain.ngrok.app/incoming-call,并使用 HTTP POST。

步骤 2:接收 Media Stream WebSocket

WebSocket 处理器读取 Twilio 事件、驱动级联链路并回传音频。

我们会保存少量每通电话状态:streamSid、STT 连接,以及标记智能体是否正在说话的标志。处理器会将每个呼入媒体帧从 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 驱动的分段通常是正确选择,因为自然停顿是判断来电者一轮发言结束的最可靠信号。

具体步骤如下:通话开始时打开 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,可缩短来电者说完到智能体开始回应之间的感知间隔。如需了解批量对应功能和完整功能集,请参阅 语音转文本文档实时语音转文本 产品页面。

步骤 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:使用 ulaw_8000 通过 Flash TTS 合成

现在需要将文本转为 Twilio 可播放的音频。请求 Flash v2.5 时使用 outputFormat: "ulaw_8000",使字节匹配 Twilio 所需编码,然后流式传输音频,并通过 WebSocket 将每个块作为 media 事件回传。

将 LLM token 累积成句子大小的片段,每完成一个片段就进行合成,而非等待完整回复。这样能缩短首段音频生成时间:模型还在生成第二句时,来电者已能听到第一句。如需更精细地控制增量合成,实时 TTS WebSocket 指南介绍了如何将文本输入单个已打开的合成 socket;下方 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 信号在智能体播放期间检测来电者语音。检测到后执行两项操作:首先,停止转发 TTS 块;speak 中的 agentSpeaking 标志已通过跳出循环实现此功能。其次,向 Twilio 发送 clear 消息,清空其端已排队的音频。

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

如果跳过 clear,即使停止发送音频,Twilio 仍会继续播放已缓冲的音频,因此智能体看起来会盖过来电者说话。

记录、监控并优雅地处理失败

为每个阶段添加监测,以便在通话感觉缓慢时定位延迟来源。记录从最终转录到第一个 LLM token、从第一个 LLM token 到第一个 TTS 字节,以及从第一个 TTS 字节到发送至 Twilio 的帧之间的时间。大部分可变延迟通常出现在 LLM 阶段;STT 和 TTS 阶段则相对稳定。

还要为局部失败做好准备。LLM 可能超时,STT 流可能中断;根据地理位置不同,经公共互联网到 ElevenLabs 的网络往返时间约为 20 至 200ms。服务器应部署在靠近来电者的位置,而不只是靠近 ElevenLabs,因为 ElevenLabs 已会路由至北美、欧洲和东南亚集群中最近的节点。 

某一阶段失败时,不要让来电者陷入静默:合成一句简短的回退提示(“抱歉,请再说一遍好吗?”),并保持通话。为每个阶段设置超时和 try/catch,避免单轮失败关闭整个 WebSocket。

上线前,还应设置以下默认项:

  • 如上所示,在系统提示词中限制回复长度,使每轮简短且可打断。 
  • 限制对话历史,避免长时间通话导致 LLM 上下文无限增长。 
  • 设置最大通话时长,防止卡住的会话持续占用并发。

如需继续优化可控部分,延迟文档 说明了首段音频生成时间的来源,模型概览 介绍速度与质量的权衡,而 实时 TTS WebSocket 指南 则展示如何通过增量文本输入进一步降低合成延迟。

使用 ElevenAPI 构建生产级语音智能体

20 分钟后,你已具备生产级 语音智能体 的每一层能力。Twilio 负责电话通信,Scribe v2 Realtime 负责转录,LLM 生成回复,Flash v2.5 则通过同一个 WebSocket 进行语音回复。 

如果不想自行维护级联链路,ElevenAgents 可提供托管服务,包括轮次管理、打断处理和电话通信集成,底层使用的正是刚才手动连接的相同模型。 

如需继续优化可控的技术栈,可查看 ElevenAPI 产品页面,了解计划、并发限制和 声音库。或者,注册,立即开始拨打第一通电话。

使用 Twilio 和 ElevenLabs 构建语音智能体常见问题

相关内容

用高质量 AI 音频创作