跳到主内容
快讯直播
AI智模界
教程

Realtime API 实战:能被打断的语音助手

做完是什么效果

先描述终点,你才知道每一步在干什么。

打开一个网页,点一下「开始通话」,对着麦克风说:「帮我查一下 A301 会议室现在空不空。」助手用语音答你:「A301 空闲,能坐八个人。」你话锋一转,中途插一句:「等等,换成 B105。」它立刻闭嘴,转去查 B105,再重新开口——不会坚持把上一句说完,也不会把你说的话当成耳旁风。

这个「随时插话」的体验,背后就是四件事:

1. 浏览器和模型之间有一条 WebRTC 音频双向管道;

2. 一条数据通道负责发控制指令、收状态事件;

3. 服务端 VAD(语音活动检测)发现你开口,就掐掉正在生成的回复,客户端同时把正在放的声音停掉;

4. 模型需要外部数据时触发函数调用,你本地执行完把结果塞回去,它接着说话。

四件事拆开都不难,难的是把它们接成一条闭环。下面挨个来。

前置条件清单

  • Node.js 18 以上,自带 fetch,省得装 axios。
  • 一个 Realtime API 的 API Key,只放在服务端环境变量里(例如 OPENAI_API_KEY)。
  • 一个当前可用的 Realtime 模型名和一个音色名。具体型号与音色列表以官方文档为准,本文代码里用占位符。
  • 浏览器要求:支持 WebRTC 的现代浏览器。麦克风权限只在 localhost 或 HTTPS 下才给。
  • 一个耳机。外放时麦克风会捡到扬声器的声音,模型会以为你在说话,自己打断自己——这是新手最常见的翻车点,后面还会提。

第 1 步:为什么走 WebRTC 而不是 WebSocket

Realtime API 一般提供两条接入路径:WebSocket 和 WebRTC。

WebSocket 路径下,音频要你自己转成规定的格式、自己切片、自己发 input_audio_buffer.append,播放端也要自己做抖动缓冲。写起来可控,但延迟和卡顿都得你自己扛。

WebRTC 路径下,麦克风音轨直接塞进 PeerConnection,模型的声音以音轨形式回来,回声消除、丢包重传、抖动缓冲都交给浏览器。代价是信令交换多了几步,但延迟更稳,工程上也更省事。

所以建议:浏览器端一律优先 WebRTC;服务端对服务端、或者客户端不方便用 WebRTC 的场景,再考虑 WebSocket。

整体结构是:

```

浏览器 ──1. GET /session──▶ 你的后端 ──用长期 Key 换临时密钥──▶ Realtime API

浏览器 ──2. POST SDP(offer)──▶ Realtime API(带上临时密钥)

浏览器 ◀─3. SDP(answer)────── Realtime API

音频 走媒体轨,控制事件走名为 oai-events 的数据通道

```

长期 API Key 永远不出你的服务器,浏览器只拿一个短时效的临时密钥。这不是可选项,是底线。

第 2 步:后端签发临时密钥

一个最小 Express 服务,只做两件事:托管静态页、签发临时密钥。

```javascript

// server.js

import express from "express";

const app = express();

app.use(express.static("public"));

app.get("/session", async (req, res) => {

// 用长期 Key 换一个临时密钥,绝不能把长期 Key 发到浏览器

const r = await fetch("https://api.openai.com/v1/realtime/sessions", {

method: "POST",

headers: {

Authorization: Bearer ${process.env.OPENAI_API_KEY},

"Content-Type": "application/json",

},

body: JSON.stringify({

model: "REALTIME_MODEL_NAME", // 占位,填当前可用模型,以官方文档为准

voice: "VOICE_NAME", // 占位,音色名以官方文档为准

}),

});

if (!r.ok) {

return res.status(500).json({ error: await r.text() });

}

const data = await r.json();

// 返回体里通常有个短时效的 client secret,字段路径以官方文档为准

res.json(data);

});

app.listen(3000, () => console.log("http://localhost:3000"));

```

注意两点:临时密钥是短时效的,页面每次开新会话都应该重新取;另外签发接口的路径、请求体字段、返回体结构都可能调整,一切以官方文档为准,代码里留个注释,将来改起来不至于找不到地方。

第 3 步:前端建连,把麦克风接进去

页面就三样东西:一个按钮、一个用于播放远端声音的 audio 元素、一个日志区。

```html

<!-- public/index.html -->

<!doctype html>

<html lang="zh">

<body>

<button id="start">开始通话</button>

<audio id="remote" autoplay></audio>

<pre id="log"></pre>

<script src="app.js"></script>

</body>

</html>

```

```javascript

// public/app.js

let pc, dc, audioEl;

let ephemeralKey = null;

let lastResponseItemId = null; // 打断时用来告诉服务端"我播到哪儿了"

const log = (...a) => (document.getElementById("log").textContent += a.join(" ") + "\n");

audioEl = document.getElementById("remote");

document.getElementById("start").addEventListener("click", start);

async function start() {

// 1) 麦克风必须在用户手势里申请,否则浏览器直接拒绝

const stream = await navigator.mediaDevices.getUserMedia({

audio: {

echoCancellation: true, // 关键:压掉扬声器串进来的自己的声音

noiseSuppression: true,

autoGainControl: true,

},

});

// 2) 向后端要临时密钥

const session = await (await fetch("/session")).json();

ephemeralKey = session.client_secret?.value; // 字段路径以官方文档为准

if (!ephemeralKey) throw new Error("没拿到临时密钥");

// 3) 建 PeerConnection

pc = new RTCPeerConnection();

pc.ontrack = (e) => {

audioEl.srcObject = e.streams[0];

audioEl.play(); // 用户已经点过按钮,这里不会被自动播放策略拦

};

stream.getTracks().forEach((t) => pc.addTrack(t, stream));

// 4) 控制通道:名字必须是 oai-events,所有事件都从这里收发

dc = pc.createDataChannel("oai-events");

dc.onmessage = onEvent;

dc.onopen = () => log("[dc] open");

// 5) 交换 SDP:浏览器出 offer,服务端回 answer

const offer = await pc.createOffer();

await pc.setLocalDescription(offer);

const answerResp = await fetch(

"https://api.openai.com/v1/realtime?model=REALTIME_MODEL_NAME", // 模型名以官方文档为准

{

method: "POST",

headers: {

Authorization: Bearer ${ephemeralKey},

"Content-Type": "application/sdp",

},

body: offer.sdp,

}

);

if (!answerResp.ok) throw new Error(await answerResp.text());

const answerSdp = await answerResp.text();

await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });

log("[rtc] connected");

}

```

如果遇到 ICE 相关的报错,先别怀疑代码,按官方文档的示例核对一遍 SDP 交换顺序;真实网络环境下可能还需要配置 ICE 服务器,具体做法以官方文档为准

第 4 步:配置会话(VAD、人设、工具)

连接建立后会收到 session.created,这是推送配置的最佳时机。配置里三块最重要:说话人设、是否要转写文本、以及 VAD 用什么策略。

```javascript

function send(obj) {

if (dc && dc.readyState === "open") dc.send(JSON.stringify(obj));

}

const tools = [

{

type: "function",

name: "get_room_status",

description: "查询某个会议室当前是否空闲,以及能容纳多少人",

parameters: {

type: "object",

properties: {

room: { type: "string", description: "会议室名称,例如 A301" },

},

required: ["room"],

},

},

];

function configureSession() {

send({

type: "session.update",

session: {

instructions:

"你是会议室助手。回答要口语化、简短,最多两句话。不确定的信息不要编。",

voice: "VOICE_NAME", // 以官方文档为准

// 输入输出音频格式:WebRTC 路径下一般用默认值即可,以官方文档为准

turn_detection: {

type: "server_vad", // 类型与可选值以官方文档为准

threshold: 0.5, // 越大越不敏感,防误触发

prefix_padding_ms: 300, // 保留说话前的一小段,避免吃掉开头

silence_duration_ms: 500, // 判定"说完了"的静音时长,越小越灵敏

create_response: true, // 检测到说完就自动回复

interrupt_response: true, // 用户插话时自动取消正在进行的回复

},

tools,

tool_choice: "auto",

},

});

}

```

instructions 里那句「最多两句话」不是客套——回复越短,延迟越低、成本越低,被打断的概率也越小。

第 5 步:打断处理,两个动作

打断是这套系统里最容易做错的地方。关键认识是:打断是两件事,服务端一件,客户端一件。

服务端那件由 interrupt_response: true 加上 VAD 完成:模型一听到你开口,就停止生成。

客户端那件是:你耳机里可能还残留着几百毫秒已经缓冲好的旧语音,必须立刻丢掉,否则体验就是你打断了它,它还在自顾自说半句。

```javascript

function onEvent(e) {

const ev = JSON.parse(e.data);

log("[ev]", ev.type);

switch (ev.type) {

case "session.created":

configureSession();

break;

case "input_audio_buffer.speech_started":

// 你开口了。立刻本地静音,服务端也会同步取消进行中的回复

stopPlayback();

break;

case "response.created":

// 新一轮回复开始,恢复出声

resumePlayback();

break;

case "response.output_item.done":

// 一个输出项完成。函数调用会在这里出现

if (ev.item?.type === "function_call") handleFunctionCall(ev.item);

if (ev.item?.type === "message") lastResponseItemId = ev.item.id;

break;

case "response.done":

log("[turn] finished");

break;

case "error":

log("[error]", JSON.stringify(ev.error));

break;

}

}

function stopPlayback() {

// 最简做法:直接静音。WebRTC 音频是持续流,恢复时不会重播旧内容

audioEl.muted = true;

// 兜底再发一次取消,服务端 VAD 通常已经处理过了

send({ type: "response.cancel" });

}

function resumePlayback() {

audioEl.muted = false;

}

```

想要更顺滑的效果,可以不用 muted 硬切,而是在 AudioContext 里插一个 GainNode:打断时把增益瞬间拉到 0,新回复开始时再回到 1,听感上少一点「啪」的断点。注意 AudioContext 需要在用户手势之后 resume(),否则会一直挂起。

还有一个容易被忽略的配套动作:如果走的是客户端手动打断(比如你按了静音键,或者自己做了 VAD),需要额外发一条 conversation.item.truncate,把 audio_end_ms 设为实际播到的毫秒数,告诉服务端「这句话我其实只放了这么多」。否则模型的对话记忆里以为自己讲完了整句。字段与语义以官方文档为准,服务端 VAD 自动打断的场景下通常不用手动发。

第 6 步:函数调用闭环

函数调用是四步走,缺一步模型就会「卡住不说话」:

1. 模型输出一个 function_call,带上 nameargumentscall_id

2. 你在本地执行这个函数;

3. 用 conversation.item.create 把结果包成 function_call_output 发回去;

4. 再发一次 response.create,模型才会开口把结果讲出来。

```javascript

async function handleFunctionCall(item) {

const args = JSON.parse(item.arguments || "{}");

const result = await getRoomStatus(args.room);

send({

type: "conversation.item.create",

item: {

type: "function_call_output",

call_id: item.call_id,

output: JSON.stringify(result),

},

});

// 关键:不补这一句,模型拿到结果也不会继续说话

send({ type: "response.create" });

}

async function getRoomStatus(room) {

// 真实项目里换成查数据库或内部接口

const fake = {

A301: "空闲,可容纳 8 人",

B105: "占用中,10:30 之后空出来",

};

return { room, status: fake[room] ?? "查不到这个会议室的信息" };

}

```

这里有个实用技巧:查数据库可能要几百毫秒,用户会觉得「它怎么不理我」。可以让 instructions 要求模型在调用工具前先说一句「我看一下」,体感上就顺了。

常见坑与排错

1. 页面里出现长期 API Key。 唯一正确做法是后端签发临时密钥,前端拿到的必须是短时效的临时凭证。

2. 数据通道名字写错。 必须叫 oai-events,写成别的名字事件全收不到,而且不报错,最难查。

3. 没有声音。 先看 pc.ontrack 有没有触发,再看 audioEl.play() 是不是返回了被拒绝的 Promise——自动播放策略要求用户手势。另外确认 audioEl 没被静音,上一轮打断时 muted = true 之后忘了恢复。

4. 麦克风拿不到。 页面不是 localhost 也不是 HTTPS,浏览器会直接拒绝 getUserMedia

5. 助手疯狂自己打断自己。 外放导致麦克风听到了扬声器的声音。先戴耳机验证,再回来检查 echoCancellation: true 有没有生效。

6. 打断不生效,或者说完了才断。 检查 turn_detection 里的 interrupt_response 是否开启,silence_duration_ms 是否设得太大。设太小又会频繁误触发,500 毫秒左右是比较常见的起点,具体调参看你的场景。

7. 状态竞争。 刚连上就往里发 response.create,可能配置还没应用。稳妥做法是收到 session.created(或配置更新后的确认事件)再开始交互。

8. 函数调用后没下文。 九成是忘了补 response.create

9. 断线没兜底。dc.onclosepc.onconnectionstatechange 加上重连与提示,否则用户只会觉得「卡死了」。

延迟与成本调优要点

延迟方面:

  • 能动用 WebRTC 就别用 WebSocket,省掉自己实现抖动缓冲的麻烦。
  • silence_duration_ms 直接决定「说完到你听到回应」的间隔,调小能明显变快,代价是误打断变多,需要按场景权衡。
  • instructions 里明确要求短回答。回复短一半,时间和音频成本都少一半。
  • 工具函数本地执行要快;慢查询就在调用前先让模型说句话垫一下。
  • 服务端和你的用户尽量在相近的网络区域,这一项常常比改代码更有效。

成本方面:

  • 音频输入输出通常都按量计费,长连接挂着不讲话也会持续占用会话。不用时主动断开,不要让它开一整天。
  • 如果不需要把用户的话转成文字,就别开输入转写相关的选项,能省一笔。
  • max_response_output_tokens 之类的参数给回复长度封顶,防止模型偶尔长篇大论。
  • 前缀稳定有利于缓存命中,从而降低成本。把固定的 instructions 和工具定义放在前面,别每次都改。
  • 具体计费方式、缓存规则、单价,一律以官方定价页和文档为准,别照着博客里的数字做预算。

下一步建议

跑通之后,按这个顺序往下加:

1. 加一个「打断统计」:记录每次 speech_startedresponse.done 的时间差,你会立刻知道自己的 VAD 参数调得对不对。

2. 把工具接到真实业务上,比如查工单、查日程、查库存,注意给每个工具写好 description,模型选工具靠的就是它。

3. 多轮上下文管理:长时间会话要考虑截断或摘要,别让上下文无限涨。

4. 换 VAD 策略试试:除了基于音量的判断,还可以了解语义层面的断句方案,看哪个更贴合你的场景,可选值以官方文档为准

5. 做降级路径:WebRTC 建立失败时退回到文字输入,用户至少不会对着一个死掉的页面干瞪眼。

先把最小闭环跑起来,再逐项加料。能被打断这件事本身,就已经让语音助手从「对讲机」变成了「对话」。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。