做完是什么效果
先描述终点,你才知道每一步在干什么。
打开一个网页,点一下「开始通话」,对着麦克风说:「帮我查一下 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,带上 name、arguments、call_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.onclose 和 pc.onconnectionstatechange 加上重连与提示,否则用户只会觉得「卡死了」。
延迟与成本调优要点
延迟方面:
- 能动用 WebRTC 就别用 WebSocket,省掉自己实现抖动缓冲的麻烦。
silence_duration_ms直接决定「说完到你听到回应」的间隔,调小能明显变快,代价是误打断变多,需要按场景权衡。instructions里明确要求短回答。回复短一半,时间和音频成本都少一半。- 工具函数本地执行要快;慢查询就在调用前先让模型说句话垫一下。
- 服务端和你的用户尽量在相近的网络区域,这一项常常比改代码更有效。
成本方面:
- 音频输入输出通常都按量计费,长连接挂着不讲话也会持续占用会话。不用时主动断开,不要让它开一整天。
- 如果不需要把用户的话转成文字,就别开输入转写相关的选项,能省一笔。
- 用
max_response_output_tokens之类的参数给回复长度封顶,防止模型偶尔长篇大论。 - 前缀稳定有利于缓存命中,从而降低成本。把固定的
instructions和工具定义放在前面,别每次都改。 - 具体计费方式、缓存规则、单价,一律以官方定价页和文档为准,别照着博客里的数字做预算。
下一步建议
跑通之后,按这个顺序往下加:
1. 加一个「打断统计」:记录每次 speech_started 到 response.done 的时间差,你会立刻知道自己的 VAD 参数调得对不对。
2. 把工具接到真实业务上,比如查工单、查日程、查库存,注意给每个工具写好 description,模型选工具靠的就是它。
3. 多轮上下文管理:长时间会话要考虑截断或摘要,别让上下文无限涨。
4. 换 VAD 策略试试:除了基于音量的判断,还可以了解语义层面的断句方案,看哪个更贴合你的场景,可选值以官方文档为准。
5. 做降级路径:WebRTC 建立失败时退回到文字输入,用户至少不会对着一个死掉的页面干瞪眼。
先把最小闭环跑起来,再逐项加料。能被打断这件事本身,就已经让语音助手从「对讲机」变成了「对话」。
