给 Agent 装上耳朵和嘴巴:ASR + TTS 双通道流式实战(腾讯云 × NestJS)

目标读者:写过一点 Node/前端,想让自己的 Agent 能"听懂人话、张嘴说话"的中级开发者。 读完你会拿到一条能跑的链路:浏览器录音 → 服务端转文字 → 大模型流式回答 → SSE 推字 + WebSocket 推音频。


一、为什么 Agent 一定要有语音

先讲场景,别讲技术。

用户在地铁上、在开车、在厨房手上都是油。这时候他不想打字,他想说一句:

"帮我看看今天还有哪些待办没做完。"

一条完整的语音交互链路应该是这样的:

  1. ASR:录音上传 → 服务端转成文本
  2. LLM :文本进大模型,流式吐字
  3. 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 个一个字符都不能错的细节:

  1. rawStr 里的 GET 和域名之间没有空格,这是腾讯云的既定格式,不是笔误。
  2. 参数必须排序后拼接,顺序错了签名就对不上。
  3. 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 的暗坑

  1. @Sse 是 GET 。你把整个 prompt 塞进 query string,长了就会被 URL 长度限制干掉。生产环境应该改成 POST + fetch 读 ReadableStream。
  2. 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 的性质变了------从"一个需要你敲字的工具",变成"一个能听你说话、也能回你话的对话者"。

如果只让我留三条经验给你:

  1. 协议选型跟着数据类型走。 文本走 SSE,二进制走 WS。别为了一致性牺牲效率。
  2. 流式的关键在"触发时机",不在"速度快"。 ASR 一句话一传、TTS 一句一合、SSE 一段一推,用户的等待感是被"节奏"消掉的。
  3. 所有跨系统的参数对齐,都要当成契约来对待。 采样率、格式、签名顺序、env 名字------错一个,整条链路静默失败。

代码在 asr_tts 目录 下,四个独立可跑的验证脚本:

建议的验证顺序:先把 output.mp3 单独跑通(确认鉴权没问题),再测 ASR(确认格式没问题),最后才拼链路。单点不通就拼链路,你只会得到一堆互相掩盖的错误。

相关推荐
壹米饭21 分钟前
手搓 MCP 到最简 Agent,我都做了什么?
agent·ai编程·mcp
the局外人1 小时前
LangChain ToolRuntime 入门:context、state 与 store 的实际用法
后端·langchain·agent
七夜zippoe2 小时前
Agent 数据安全实战:输入校验、PII 脱敏与输出过滤的三道防线
ai·agent·数据安全·校验·过滤·pii脱敏
愤怒的代码3 小时前
上下文窗口:模型的短期记忆
人工智能·llm·agent
lucas_AI3 小时前
450 个不说话的 AI agent:SwarmEvolve 把进化搜索提速 5-13 倍
llm·agent
ZhengEnCi4 小时前
Agent 上下文太长怎么办:四类压缩策略、最近上下文怎么传与记忆保持的分层架构
agent
风雨中的小七4 小时前
解密Prompt系列73. 爆改对话框:揭秘 Intelligent UI 的动态界面魔法
llm·agent·mcp