从"打字提问"到"张嘴就聊",聊一聊怎么让 Agent 听懂人话、再用嗓子回你
现在的 Agent,光会打字已经不够了
你去看但凡像样点的 Agent 产品,基本都有一个共同技能:能听、会说。
你说一句话,它转成文字(这就是 ASR ,自动语音识别);它想出来的答案,再用语音念给你听(这就是 TTS,语音合成),而且音色还能随便换。笔记里那句话总结得很到位:
语音输入会转成文本(ASR),大模型的回答通过语音朗读(TTS),可以切换音色。Agent 开发必备技术。
所以今天这篇,我们就来把 Agent 的"耳朵"和"嘴巴"装上去。先理清楚整条链路长什么样:
用户点击录音 → 输入一段语音 → 服务端接口把语音转成文字(ASR)→ 大模型生成回答 → 流式 SSE 返回文字 ,同时 WebSocket 返回流式语音。
一句话说人话:耳朵归 ASR,嘴巴归 TTS,文字走 SSE,语音走 WebSocket。
先回答一个灵魂拷问:为啥不干脆用 SSE 传音频?
既然文字都能用 SSE 流式返回,那音频为什么不行?
笔记里给了答案,而且一针见血:
因为 SSE 是基于 HTTP 的文本协议,需要转 base64 才行,传这种二进制数据还是 WebSocket 更合适。SSE 是基于 GET 请求。
翻译一下:
- SSE 本质是"文本协议" 。它只能老老实实传纯文本,你想塞一段二进制音频,得先
base64编码成字符串------体积直接膨胀约三分之一,纯属花钱买罪受。 - SSE 还是"单工"的 (基于 GET 请求,服务器往客户端单向推)。语音这种事,天生更适合 WebSocket 这种双向、能传二进制的通道。
所以整个项目里就有了一条清清楚楚的分工线:
| 数据 | 走哪条路 | 原因 |
|---|---|---|
| 流式文字 | Server-Sent Events (SSE) | 本来就是文本,天生合适 |
| 流式语音 | WebSocket | 二进制数据,双向通道更合适 |
前端这边:录音其实没你想的那么难
语音功能的第一步,是在浏览器里把声音录下来。笔记里把前端要做的事列得明明白白:
- 静态服务器用
@nestjs/serve-static; - 用户交互页面是
asr.html; - 调
navigator.mediaDevices.getUserMedia申请麦克风权限; - 用
MediaRecorder这个录音 API,把音频流(stream)切成一块块(chunk)。
核心就是一句话:getUserMedia 拿到麦克风音频流,MediaRecorder 把它录下来切成 chunk,再把这些二进制音频扔给 NestJS 服务端。
服务端拿到这段音频后,转手去调腾讯云的 ASR,换回文字,再拿去喂给大模型。剩下的,就是"流式返回"的戏份了------那部分是下一篇的主角,这里先按下不表。
ASR 实战:把 mp3 变成一行文字
先看最朴素的版本:给一个音频文件,拿回识别结果。完整代码长这样:
javascript
// 自动语音识别 Automatic Speech Recognition
// 知识库,语音先转文字,文本向量 匹配 知识库中的文本向量,找到最相似的文本,作为识别结果。
import "dotenv/config"
import tencentcloud from "tencentcloud-sdk-nodejs";// 腾讯云 sdk
import fs from "fs";// 读写文件fs";
const SECRET_ID = process.env.SECRET_ID;
const SECRET_KEY = process.env.SECRET_KEY;
const AsrClient = tencentcloud.asr.v20190614.Client;
const AUDIO_FILE = './output.mp3';
先把几个要素交代清楚:
tencentcloud-sdk-nodejs:腾讯云全家桶 SDK;v20190614:ASR 产品的版本号,写错了直接报错;- 密钥走
.env(SECRET_ID/SECRET_KEY),千万别硬编码进代码------这是底线。
顺便说一句:代码顶上那行注释写着"语音先转文字,再拿文本向量去匹配知识库"------这只是笔记里对"知识库问答"场景的联想。就这段代码本身而言,腾讯云的
SentenceRecognition是直接把语音识别成文字返回给你的,不需要你自己再去做向量匹配。
接着初始化客户端:
javascript
const client = new AsrClient({
credential: {
secretId: SECRET_ID,
secretKey: SECRET_KEY,
},
region: "ap-shanghai",
profile: {
httpProfile: {
reqMethod: "POST",
reqTimeout: 30,
}
}
});
注意 region: "ap-shanghai"(上海地域)和 reqTimeout: 30(30 秒超时)------音频识别有时比较慢,超时给足一点。
然后是真正干活的部分:
javascript
async function run() {
// mp3 二进制 -> base64
const audioBase64 = fs.readFileSync(AUDIO_FILE).toString("base64");
const params = {
EngSerViceType: "16k_zh", // 16k 16bit 1ch 中文识别
SourceType:1, // 1: base64 编码的音频数据 2: url
Data: audioBase64,
DataLen: Buffer.byteLength(audioBase64),
VoiceFormat: "mp3",
}
try {
const data = await client.SentenceRecognition(params);
console.log("识别结果", data.Result);
} catch (error) {
console.error("识别失败", error);
}
}
run()
.catch(console.error);
这几个参数,个个都是"不写就报错"的主:
EngSerViceType: "16k_zh":16k 采样率、中文识别。名字里的 "S" 大写小写这事儿别纠结,照抄就对了(历史遗留的拼写)。SourceType: 1:意思是"我传的是 base64 编码的音频数据";如果改成2,那就是传一个音频 URL 让它自己去下载。Data+DataLen:音频的 base64 内容,和它的字节长度(Buffer.byteLength)。VoiceFormat: "mp3":音频格式,跟你的文件对上。
关键点 :音频是二进制,SDK 要的是 base64 字符串,所以第一步 fs.readFileSync(...).toString("base64") 把 mp3 整个读进来编码。识别成功,结果就在 data.Result 里躺着了。
TTS 基础版:让文字出声
会听之后,还得会说。先看非流式版本------给它一段文字,它给你一个完整音频文件:
javascript
import "dotenv/config";
import tencentcloud from "tencentcloud-sdk-nodejs-tts";
import fs from "node:fs";
const secretId = process.env.SECRET_ID;
const secretKey = process.env.SECRET_KEY;
const TtsClient = tencentcloud.tts.v20190823.Client;
const client = new TtsClient({
credential: {
secretId,
secretKey,
},
region: "ap-beijing",
profile: {
httpProfile: {
endpoint: "tts.tencentcloudapi.com",
},
},
});
注意这里用的是另一个 SDK tencentcloud-sdk-nodejs-tts,版本 v20190823,地域换成了 ap-beijing。
参数和调用:
javascript
const params = {
// Text: `下班路上,我还在为晚霞开心。突然电话响起:系统崩了。我的心一下揪紧,
// 冲进办公室时几乎是绝望。可当大家一起排查、重启、屏幕终于恢复正常的,我长长松了口气,
// 笑着说:还好,我们没放弃。`,
Text: `Hello`,
SessionId: 'session-001',
VoiceType: 502006, // 女声
Codec: "mp3"
}
这里有个有趣的细节:源码里被注释掉的那段 Text,是一小段"晚霞 → 电话铃响 → 系统崩了 → 冲回办公室 → 终于恢复 "的戏剧小作文。写 demo 的人是真的懂生活------而正式跑的是简简单单一句 Hello,先跑通再说。
VoiceType: 502006 就是音色编号(标注是女声)。想换声音?改这个号就行,这就是"可切换音色"的实现方式。
最后是保存音频:
javascript
client
.TextToVoice(params)
.then((data) => {
// console.log(data); // base64字符串
// 二进制缓冲区
const audioBuffer = Buffer.from(data.Audio, 'base64');// 二进制缓冲区
const outputPath = './output.mp3';
fs.writeFileSync(outputPath, audioBuffer, (err) => {
if (err) {
console.error('保存文件失败:', err);
} else {
console.log(`文件已成功保存: ${outputPath}`);
}
});
})
流程是:调 TextToVoice → 返回的 data.Audio 是 base64 字符串 → 用 Buffer.from(data.Audio, 'base64') 还原成二进制 → 写进 ./output.mp3。
一来一回正好对称:ASR 是"文件二进制 → base64 → 喂给接口",TTS 是"接口返回 base64 → 还原二进制 → 存成文件"。二进制和 base64 之间来回倒腾,是跟云服务打交道的基本功。
TTS 进阶版:流式语音,边生成边播放
基础版有个体验问题:它得等整段音频全部合成完,才一次性丢给你。文字长一点,你就得干等着。
而真正救体验的,是流式合成------文字一句一句送进去,音频一块一块吐出来,理论上可以做到"边说边播"。这段代码信息量最大,我们慢慢拆。
第一步:自己算一个签名(腾讯云 WebSocket 的规矩)
流式 TTS 走的是 WebSocket,地址长这样:wss://tts.cloud.tencent.com/stream_wsv2。而这个 URL 不是随便拼的,得签名:
javascript
const SECRET_ID = process.env.SECRET_ID;
const SECRET_KEY = process.env.SECRET_KEY;
const APP_ID = process.env.APP_ID;
const VOICE_TYPE = 101001;
const OUTPUT_FILE = 'output.mp3';
const TEXT_INTERVAL_MS = 3000;
const TEXTS = [
"傍晚我还在为晚霞开心",
"突然电话响起:系统崩了",
"我心里一沉冲回办公室",
"好在大家一起排查后终于恢复",
"我长长松了口气"
];
// 等待指定时间 await sleep(1000);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
眼熟吗?又是那段"晚霞与系统崩了"的小剧场,只不过这次被切成了五句,一句一句喂进去。
再看签名函数:
javascript
function buildWsUrl() {
const now = Math.floor(Date.now() / 1000); // 当前时间秒数向下取整
const sessionId = `session_${now}_${Math.random().toString(36).slice(2)}`;
const params = {
Action: "TextToStreamAudioWSv2", // 文本到流式声音 基于WebSocket 协议
AppId: parseInt(APP_ID),
Codec: "mp3",
Expired: now + 3600, // 过期时间 1 小时
SampleRate: 16000, // 采样率 16000Hz
SecretId: SECRET_ID,
SessionId: sessionId,
Speed: 0,
Timestamp: now,
VoiceType: VOICE_TYPE,
Volume: 5,
}
const sortedKeys = Object.keys(params).sort();
const signStr = sortedKeys.map((k) => `${k}=${params[k]}`).join("&");
const rawStr = `GETtts.cloud.tencent.com/stream_wsv2?${signStr}`;
const signature = crypto
.createHmac("sha1", SECRET_KEY)
.update(rawStr)
.digest("base64");
// ?a=1&b=2
const searchParams = new URLSearchParams({
...params,
Signature: signature,
});
return {
sessionId,
url:`wss://tts.cloud.tencent.com/stream_wsv2?${searchParams.toString()}`,
}
}
签名规则本身很简单,就三步:
- 把参数按键名排序 (
Object.keys(params).sort()),拼成key=value用&连起来; - 前面拼上
GETtts.cloud.tencent.com/stream_wsv2?(注意是GET开头,不是 POST); - 用
SECRET_KEY做 HMAC-SHA1 加密,再base64编码,得到Signature。
几个参数值得记一下:Action 是接口名 TextToStreamAudioWSv2,SampleRate: 16000 采样率,VoiceType: 101001 音色,Expired: now + 3600 表示一小时后过期。
第二步:连上去,然后"接水管"
签名换到 URL,就可以连了:
javascript
function streamTTS() {
if (!SECRET_ID || !SECRET_KEY || !APP_ID) {
throw new Error("请先在.env文件中配置 SECRET_ID, SECRET_KEY, APP_ID");
}
const {url,sessionId} = buildWsUrl();
const ws = new WebSocket(url); // 链接 ws 服务器
// 水管子 一头扎到目标文件 一头联向 ws 服务器,用于写入音频数据,bundle,写入文件
const writeStream = fs.createWriteStream(OUTPUT_FILE, {flags: 'w'});
let totalBytes = 0; // 已写入的字节数
let closed = false;
let sent = false;
那句注释我特别喜欢,复述一遍:
水管子:一头扎到目标文件,一头联向 ws 服务器,用于写入音频数据。
fs.createWriteStream 就是这根"水管子"。服务器那边每吐出一块音频,这边就顺着管子"哗"地流进文件里,不用等全部合成完。另外还准备了三个小旗子:totalBytes 记字节数、closed 防重复关闭、sent 防止文本被重复发送。
第三步:处理收到的消息(这段最妙)
javascript
// 有消息了
ws.on("message", async (data, isBinary) => {
if (isBinary) {
writeStream.write(data);
totalBytes += data.length;
return;
}
// 发送完了,消息,报错
try {
const msg = JSON.parse(data.toString());
console.log("[消息]", JSON.stringify(msg));
// 服务器就绪,可以发送文本了
if (msg.ready === 1 && !sent) {
sent = true;
await sendTexts(ws, sessionId); // 发送文本
}
if (msg.code && msg.code !== 0) {
console.error(`[错误] code=${msg.code}, message=${msg.message}`);
closeAll();
} else if (msg.final === 1) {
console.log('[完成] 合成结束');
}
} catch (e) {
console.error("[解析错误]", e.message);
}
});
这里有个极其重要的判断 :isBinary。
- 是二进制 (
isBinary === true)→ 那就是音频数据,直接writeStream.write(data)灌进文件; - 不是二进制 → 那就是控制消息 (JSON 文本),得
JSON.parse解析。
服务器返回的 JSON 里,有三个关键字段,构成了整个握手流程:
ready === 1:服务器说"我准备好了,你可以发文本了"。所以这里用sent标志位守着,只在第一次 ready 时调用sendTexts,避免重复发送。code !== 0:出错了,打印错误并收工。final === 1:合成结束了。
再回答一次那个"为什么不用 SSE"的问题 :正是因为 WebSocket 能通过 isBinary 直接区分"这块是二进制音频",所以音频可以顺着同一条连接一坨坨地流出来,不用 base64 编码成文本。这就是它比 SSE 适合传音频的根本原因。
第四步:把五句话一句句喂进去
javascript
async function sendTexts(ws, sessionId) {
for (let i = 0; i < TEXTS.length; i++) {
ws.send(JSON.stringify({session_id:sessionId, message_id: `msg_${i}`,
action:"ACTION_SYNTHESIS", data: TEXTS[i]}))
console.log(`[文本]已发送:${TEXTS[i]}`)
if (i < TEXTS.length - 1) await sleep(TEXT_INTERVAL_MS);
}
ws.send(JSON.stringify({ session_id:sessionId, action:"ACTION_COMPLETE" }))
console.log("[文本]已发送ACTION_COMPLETE")
}
这段就是"把文字一句句递进去"的动作:
- 每句话用一个
action: "ACTION_SYNTHESIS"的消息发出去,带上session_id和message_id; - 句与句之间
sleep(3000)(TEXT_INTERVAL_MS = 3000)隔 3 秒------模拟"慢慢说"的效果; - 全部发完,最后补一条
action: "ACTION_COMPLETE",等于告诉服务器"我说完了,你收尾吧"。
第五步:优雅收尾
javascript
const closeAll = () => {
if (closed) return;
closed = true;
writeStream.end(() => {
console.log(`[保存]音频已保存到 ${OUTPUT_FILE},共 ${totalBytes} 字节`);
});
if (ws.readyState < WebSocket.CLOSING) ws.close();
}
closeAll 用 closed 标志位做幂等保护 :不管你是正常结束、还是出错、还是连接断了,最后都走这一个出口------关掉"水管子"、打印总字节数、关掉 WebSocket。不写这个,很可能在多个回调里把连接重复关闭,然后收获一堆莫名其妙的报错。
open / error / close 三个事件也都挂了对应的日志处理,整段逻辑才算完整闭环。
小结
到这儿,Agent 的"耳朵"和"嘴巴"就装好了:
- 耳朵(ASR) :mp3 二进制 → base64 → 腾讯云
SentenceRecognition→ 一行文字; - 嘴巴(TTS 基础版) :文字 →
TextToVoice→ base64 → 还原二进制 → 存成 mp3; - 嘴巴(TTS 流式版) :自己算 HMAC-SHA1 签名 → 连 WebSocket →
ACTION_SYNTHESIS逐句送文字 → 二进制音频从isBinary通道直接流进文件 →ACTION_COMPLETE收尾。
但有没有发现,这里面其实藏着一个还没解开的关键角色------"流式返回"。文字怎么一段段推给前端?生成器、RxJS、SSE 又是怎么配合的?
这正是下一篇要拆的:NestJS 的 AI 模块 + SSE + RxJS 流式输出 。我们不仅要把 @Sse 这个装饰器聊透,还要看看"生成器(Generator)"是怎么让答案一段一段"吐"出来的。
(小声说:那个"晚霞与系统崩了"的小剧场,大概率还有返场。)