目标读者:写过一点 Node/前端,想让自己的 Agent 能"听懂人话、张嘴说话"的中级开发者。 读完你会拿到一条能跑的链路:浏览器录音 → 服务端转文字 → 大模型流式回答 → SSE 推字 + WebSocket 推音频。
一、为什么 Agent 一定要有语音
先讲场景,别讲技术。
用户在地铁上、在开车、在厨房手上都是油。这时候他不想打字,他想说一句:
"帮我看看今天还有哪些待办没做完。"
一条完整的语音交互链路应该是这样的:
- ASR:录音上传 → 服务端转成文本
- LLM :文本进大模型,流式吐字
- TTS :文字边生成边合成语音,流式播放
注意这里有两个"流式",不是修辞,是硬需求。用户说完一句话之后,等待超过 2 秒就会觉得"这 AI 卡了"。流式的本质不是"快",而是"不停"------首字节来得早,后面的就不用等。
二、架构:文字走 SSE,音频走 WebSocket
先看我最终落地的链路:
bash
┌──────────┐ 录音(ogg/opus) ┌──────────────┐
│ Browser │ ─────────────────▶ │ NestJS 服务端 │
│ asr.html │ POST /speech/asr │ │
└──────────┘ └──────┬───────┘
▲ │ base64 + SDK
│ ▼
│ ┌──────────────┐
│ │ 腾讯云 一句话ASR │
│ └──────┬───────┘
│ │ 文本
│ ▼
│ ┌──────────────┐
├──── SSE 文本流 ─────────│ 大模型(qwen) │
│ GET /ai/chat/stream └──────┬───────┘
│ │ 每个 chunk
│ ▼
│ ┌──────────────┐
└──── WS 音频流 ──────────│ 腾讯云 流式TTS │
wss://.../stream_wsv2└──────────────┘
为什么音频不走 SSE? 这是笔记里最值钱的一条结论,值得单独说。
SSE 全称 Server-Sent Events,本质是基于 HTTP 的纯文本协议:
http
Content-Type: text/event-stream
data: hello\n\n
它只能传文本。你要塞 mp3 进去,就得 base64 编码,而 base64 会带来两个确定性的损耗:
- 体积膨胀约 33%(每 3 字节变 4 字符)
- 编解码 CPU 开销,尤其在音频这种每秒几十 KB 的连续数据上,白白多烧一遍
再加上 SSE 只能是 GET 请求,没法像 POST 那样带 body。所以:
音频是二进制的事,别让文本协议替它扛。
WebSocket 天然支持二进制帧(isBinary),服务端收到就直接 writeStream.write(data),一行转码都不用写。这就是我选 WS 的唯一理由------不是因为它"更潮"。
三、第一站:ASR,浏览器录音的那 250 毫秒
前端要做三件事:拿权限、录音、上传。核心 API 只有两个。
3.1 getUserMedia + MediaRecorder
js
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 优先用 ogg/opus:体积小、腾讯云支持好
const preferredMimeType = 'audio/ogg;codecs=opus';
const mediaRecorder = MediaRecorder.isTypeSupported(preferredMimeType)
? new MediaRecorder(stream, { mimeType: preferredMimeType })
: new MediaRecorder(stream); // 浏览器兜底
mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
chunks.push(event.data);
}
};
mediaRecorder.start(250); // 每 250ms 切一个分片
navigator 是 BOM 里的浏览器内置对象,属于 window 的只读属性,不需要 import 任何东西。它能拿设备信息、媒体能力、定位,录音就是它的媒体能力之一。
mediaRecorder.start(250) 里的 250 是时间片 ,不是时长上限。它的作用是:每 250ms 自动触发一次 ondataavailable,给你一块音频分片。你不写这个参数,就只会在 stop() 时一次性吐出全部数据------录音较长时容易内存抖动,也做不了"边说边传"。
3.2 停止 → 合并 → 上传
js
mediaRecorder.onstop = async () => {
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
if (!blob.size) {
throw new Error("录音数据为空,请至少录制 1 秒再上传");
}
const formData = new FormData();
formData.append('audio', blob, 'record.ogg');
const response = await fetch("/speech/asr", { method: "POST", body: formData });
const data = await response.json();
resultEl.textContent = data.text || "(空结果)";
};
这里有三个真实会咬人的点:
| 现象 | 原因 | 处理 |
|---|---|---|
| 上传后是空文件 | 用户点得太快,chunks 还是空数组 |
用 blob.size 兜底校验 |
| 识别结果乱码/失败 | 文件名写死 record.ogg,但浏览器实际兜底用的是 webm |
用 mediaRecorder.mimeType 反推后缀 |
| 麦克风指示灯一直亮 | 忘了关流,录音轨常驻 | finally 里 stream.getTracks().forEach(t => t.stop()) |
第三条最容易被忽略。Blob 只是数据,麦克风是硬件资源 ,不 stop() 它会一直占着------用户第二次点"开始录音"就会失败。
四、第二站:把 base64 丢给腾讯云
腾讯云「一句话识别」走的是同步 HTTP 接口,SDK 用 tencentcloud-sdk-nodejs:
js
import tencentcloud from "tencentcloud-sdk-nodejs";
import fs from "fs";
const AsrClient = tencentcloud.asr.v20190614.Client;
const client = new AsrClient({
credential: { secretId: process.env.SECRET_ID, secretKey: process.env.SECRET_KEY },
region: "ap-shanghai", // 地域要和账号开通的地域一致
profile: { httpProfile: { reqMethod: "POST", reqTimeout: 30 } }
});
const audioBase64 = fs.readFileSync('./output.mp3').toString("base64");
const params = {
EngSerViceType: "16k_zh", // 16kHz / 中文
SourceType: 1, // 1 = base64, 2 = url
Data: audioBase64,
DataLen: Buffer.byteLength(audioBase64), // 注意是 base64 串的字节数
VoiceFormat: "mp3"
};
const data = await client.SentenceRecognition(params);
console.log("识别结果:", data.Result);
几个必须对齐的参数:
EngSerViceType要和音频采样率匹配。你录的是 16k 就填16k_zh,填8k_zh会识别率暴跌。VoiceFormat要跟真实文件格式一致。前端说的ogg/opus就填ogg,别填mp3。DataLen是 base64 串的字节数,不是原始音频长度。用Buffer.byteLength算最稳(纯 ASCII 虽然也等于.length,但别赌)。- 一句话识别有 60s 上限。超过就得换「录音文件识别」或流式 ASR,别硬怼。
说白了,ASR 就是一次"二进制 → base64 → 云端 → 文本"的搬运。它不难,难在参数对齐。
五、第三站:TTS,从一次调用到一条 WebSocket
5.1 先跑通最简单的:非流式
js
import tencentcloud from "tencentcloud-sdk-nodejs-tts";
const TtsClient = tencentcloud.tts.v20190823.Client;
const client = new TtsClient({
credential: { secretId, secretKey },
region: "ap-beijing",
profile: { httpProfile: { endpoint: "tts.tencentcloudapi.com" } }
});
const params = {
Text: "下班路上,我还在为晚霞开心。",
SessionId: "session-001",
VoiceType: 502006, // 音色,换号就是换人
Codec: "mp3"
};
const data = await client.TextToVoice(params);
const audioBuffer = Buffer.from(data.Audio, "base64"); // 云端给的是 base64
fs.writeFileSync("./output.mp3", audioBuffer);
这版能出声,但有个致命问题:它必须等你把整段文字都攒完才能开始合成。用户要听一句 30 秒的回答,就得干等 30 秒------流式就白做了。
而且这里我踩了一个很蠢的坑:
js
const secretId = process.env.SECRETID; // ❌ 少了一个下划线
.env 里写的是 SECRET_ID,代码里读的是 SECRETID,process.env 不报错,只给你 undefined。SDK 拿着 undefined 去签名,你只会看到一句含糊的鉴权失败。
环境变量名是字符串,拼错不会报错,只会静默失败。 上线前一定 console.log 一次关键配置是否存在。
5.2 流式 TTS:TextToStreamAudioWSv2
腾讯云的流式 TTS 走 WebSocket,URL 里带一整套签名参数 。签名的规则很明确:把所有参数按键名排序,拼成 k=v&k=v,再和请求行拼起来做 HMAC-SHA1。
js
function buildWsUrl() {
const now = Math.floor(Date.now() / 1000);
const sessionId = `session_${now}_${Math.random().toString(36).slice(2)}`;
const params = {
Action: "TextToStreamAudioWSv2",
AppId: APP_ID,
Codec: "mp3",
Expired: now + 3600,
SampleRate: 16000,
SecretId: SECRET_ID,
SessionId: sessionId,
Speed: 0,
Timestamp: now,
VoiceType: 101001,
Volume: 5,
};
// 1. 按键名排序
const sortedKeys = Object.keys(params).sort();
const signStr = sortedKeys.map(k => `${k}=${params[k]}`).join("&");
// 2. 拼接请求行,必须是 "GET + host + path + ?" + 参数
const rawStr = `GETtts.cloud.tencent.com/stream_wsv2?${signStr}`;
// 3. HMAC-SHA1 + base64
const signature = crypto.createHmac("sha1", SECRET_KEY).update(rawStr).digest("base64");
const searchParams = new URLSearchParams({ ...params, Signature: signature });
return { sessionId, url: `wss://tts.cloud.tencent.com/stream_wsv2?${searchParams.toString()}` };
}
这一步有 3 个一个字符都不能错的细节:
rawStr里的GET和域名之间没有空格,这是腾讯云的既定格式,不是笔误。- 参数必须排序后拼接,顺序错了签名就对不上。
Signature是最后追加的,不能参与签名计算。
签名过了,接下来才是真正有意思的地方:状态机。
5.3 服务端说 ready,你才能喂文本
js
ws.on("message", async (data, isBinary) => {
// 二进制帧 = 音频,直接落盘,不转码
if (isBinary) {
writeStream.write(data);
totalBytes += data.length;
return;
}
const msg = JSON.parse(data.toString());
if (msg.ready === 1 && !sent) {
sent = true;
await sendTexts(ws, sessionId); // 连接就绪后才发文本
}
if (msg.code && msg.code !== 0) {
closeAll(); // 有错误码,收尾
} else if (msg.final === 1) {
closeAll(); // 合成结束
}
});
文本帧的协议也很简单,只有两个动作:
js
// 每段文字一次 ACTION_SYNTHESIS
ws.send(JSON.stringify({
session_id: sessionId,
message_id: `msg_${i}`,
action: "ACTION_SYNTHESIS",
data: TEXTS[i]
}));
// 全部发完后,补一个 ACTION_COMPLETE
ws.send(JSON.stringify({
session_id: sessionId,
message_id: `msg_${TEXTS.length}`,
action: "ACTION_COMPLETE"
}));
我这里用 TEXT_INTERVAL_MS = 3000 模拟大模型"一句一句吐字"的节奏。真实接入时,这个 sleep 要删掉 ,换成把 LLM 的每个 chunk 直接 send 出去------这才是流式 TTS 的正当用法:
流式 TTS 的杀手锏不是"合成得快",而是**"不用等全文"**。大模型吐第一个句子,音频就开始播了。
收尾必须幂等 ,因为 final、error、close 三个事件可能同时来:
js
let closed = false;
const closeAll = () => {
if (closed) return; // 幂等保护
closed = true;
writeStream.end(() => console.log(`音频已保存,共 ${totalBytes} 字节`));
if (ws.readyState < WebSocket.CLOSING) ws.close();
};
漏了这个 closed 标记,你会看到 write after end 报错,或者文件写了两遍。
六、第四站:大模型流式输出,SSE + RxJS + 生成器
服务端用 NestJS,流式接口靠 @Sse() 装饰器:
ts
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Sse('chat/stream')
chatStream(@Query('query') query: string) {
return from(this.aiService.streamChain(query)).pipe(
map((chunk) => ({ data: chunk })) // 和前端约定的 SSE 格式
);
}
}
底层是一个 async generator:
ts
async *streamChain(query: string) {
const stream = await this.chain.stream({ query });
for await (const chunk of stream) {
yield chunk; // 吐一次,前端就收到一次
}
}
为什么要绕这么一圈?因为 async/await 一次只返回一个值,而 SSE 需要"多次返回"。生成器(Generator)就是为"多次返回"而生的东西:
js
async function* produce() {
yield "第一段";
await new Promise(r => setTimeout(r, 1000));
yield "第二段";
}
for await (const item of produce()) console.log(item); // 一段一段地拿
for await 是消费生成器的语法糖,yield 是生产它的语法糖。而 from() 是 RxJS 的桥:它能把数组、Promise、可迭代对象、异步可迭代对象 统统转成 Observable,这样你就能接 map、filter 这些操作符。
RxJS 的价值一句话概括:把网络流、定时器、鼠标事件统一成"流",然后用同一套操作符去处理。
模型这一层还做了 DI 解耦,换模型不用改业务代码:
ts
{
provide: 'CHAT_MODEL',
useFactory: (configService: ConfigService) =>
new ChatOpenAI({
modelName: configService.get('MODEL_NAME'),
apiKey: configService.get('OPENAI_API_KEY'),
configuration: { baseURL: configService.get('OPENAI_BASE_URL') },
}),
inject: [ConfigService],
}
一行 baseURL 换成 DashScope 的兼容地址,qwen-plus 就跑起来了;换成别家同理。模型是配置,不是代码。
这里还有两个 SSE 的暗坑
@Sse是 GET 。你把整个 prompt 塞进 query string,长了就会被 URL 长度限制干掉。生产环境应该改成 POST +fetch读ReadableStream。data:里不能有裸换行 。SSE 用换行分隔帧,模型吐出的多行文本会把一帧拆成好几帧。要么前端按\n\n重组,要么服务端把\n转义成\\n。
七、组装:四个轮子装到一辆车上
把上面的东西串起来,服务端的骨架应该是这样(现状说明 :录音页 asr.html 已经在 fetch("/speech/asr"),但 app.controller.ts 里目前只有 GET /,也就是说这个接口还缺着):
ts
@Controller('speech')
export class SpeechController {
@Post('asr')
@UseInterceptors(FileInterceptor('audio'))
async asr(@UploadedFile() file: Express.Multer.File) {
const base64 = file.buffer.toString('base64');
const res = await asrClient.SentenceRecognition({
EngSerViceType: '16k_zh',
SourceType: 1,
Data: base64,
DataLen: Buffer.byteLength(base64),
VoiceFormat: 'ogg',
});
return { text: res.Result };
}
}
再往上加一个 WebSocket 网关(@nestjs/websockets + ws 适配器),把 LLM 的 chunk 直接中转给腾讯云流式 TTS,音频帧再推给前端。
整条链路的数据流方向记住一句话就够了:
录音 → 文本(一次请求)
文本 → 文字流(SSE,长连接)
文字流 → 音频帧(WS,长连接)
八、踩坑速查表
| # | 坑 | 表现 | 解法 |
|---|---|---|---|
| 1 | process.env.SECRETID 拼错 |
鉴权失败,报错含糊 | 启动时校验必填 env |
| 2 | 文件名写死 .ogg |
识别失败 | 用 mediaRecorder.mimeType 定格式 |
| 3 | 忘了 tracks.stop() |
麦克风被占,二次录音失败 | finally 里关流 |
| 4 | blob.size === 0 |
上传空文件 | 前置校验 + 提示"至少 1 秒" |
| 5 | WS 连上就发文本 | 丢包/无音频 | 等 ready === 1 |
| 6 | 收尾没做幂等 | write after end |
closed 标记 |
| 7 | 音频走 SSE | 体积膨胀、CPU 白烧 | 二进制一律走 WS |
| 8 | SSE data 含换行 |
前端拼接断裂 | 转义或按空行重组 |
| 9 | 长 prompt 塞 GET | 被 URL 长度截断 | 改 POST 流式响应 |
九、写在最后
这套东西拆开看每一步都不复杂:录音是 30 行,ASR 是一次 SDK 调用,TTS 是一条 WebSocket,SSE 是一个装饰器。
但它组合起来之后,Agent 的性质变了------从"一个需要你敲字的工具",变成"一个能听你说话、也能回你话的对话者"。
如果只让我留三条经验给你:
- 协议选型跟着数据类型走。 文本走 SSE,二进制走 WS。别为了一致性牺牲效率。
- 流式的关键在"触发时机",不在"速度快"。 ASR 一句话一传、TTS 一句一合、SSE 一段一推,用户的等待感是被"节奏"消掉的。
- 所有跨系统的参数对齐,都要当成契约来对待。 采样率、格式、签名顺序、env 名字------错一个,整条链路静默失败。
代码在 asr_tts 目录 下,四个独立可跑的验证脚本:
- asr_test.mjs --- 一句话识别
- tts-test.mjs --- 非流式 TTS
- streaming-tts-test.mjs --- 流式 TTS(本文重点)
- ai.service.ts --- 大模型流式输出
建议的验证顺序:先把 output.mp3 单独跑通(确认鉴权没问题),再测 ASR(确认格式没问题),最后才拼链路。单点不通就拼链路,你只会得到一堆互相掩盖的错误。