在做大语言模型(LLM)开发的时候,我们经常会遇到流式输出的场景。也就是说,LLM不是一次性把所有内容都生成完,而是像打字机一样一段一段往外吐词。这种方式在聊天机器人、语音交互、智能助理等场景下特别常见,响应更快,用户体验也更自然。
但问题来了——当你想让模型生成的文字被朗读出来,比如用微软的 Azure Speech Service(或者别的 TTS 服务)把文字变成声音时,很多 TTS API 是设计来处理整段文本的:你得先把完整的一段话传给它,然后它才开始合成语音并朗读。这就带来了延迟,用户要等一段时间才听到声音,体验打了折扣。
一、LLM 的流式输出是怎么回事
流式输出,英文叫 streaming generation,是指模型边生成内容边把它传给前端。比如你问了一个问题:
“介绍一下太阳系的行星”
LLM 会先输出“太阳系有八大行星”,然后接着输出“包括水星、金星、地球……”等等,一边生成一边发给你,而不是等它一口气写完 500 字才返回。
在技术实现上,这通常是基于 Server-Sent Events (SSE) 或 WebSocket,模型在后台生成一个 token(一个单词或符号),就立刻推给前端,形成连续不断的文字流。
二、Speech 的“整段式”朗读
而语音合成(TTS)这边的逻辑就不太一样。以微软的 Azure Speech 为例,它通常接受完整的一段文字,比如:
“太阳系有八大行星,包括水星、金星、地球、火星、木星、土星、天王星和海王星。”
然后它才开始合成音频,并朗读出来。整个过程是 批处理式的,所以你必须等文字生成完、音频生成完,才能播放。
三、那我们怎么把两者结合起来呢?
目标是这样的:LLM边生成、Speech边朗读,让人感觉像是在“思考着说话”。
解决思路
我们需要搭建一个“中间件”机制:
监听 LLM 的流式输出,比如每接收到一个新 token 或一段文字,就进行处理;
设定一个阈值或句子终止符(比如遇到句号、逗号、段落符),就把这部分送去语音合成;
TTS 使用流式接口(如果有),或者模拟成分段播放;
播放音频流,一边接收一边播,避免卡顿。
如果你用的是 Azure Speech,它其实支持一个叫 “Push audio stream” 的 API 接口。也就是说,我们可以边把文本送进去,它边朗读。虽然这个功能对文本长度和格式有一定要求,但在实验性开发中是可行的。
四、实际开发中要注意的点
分段策略:不要一个 token 就扔给 TTS,会导致频繁中断。可以按“句子结尾”“标点符号”或者“每 n 个词”分批。
TTS 合成时间:即使是流式朗读,TTS 本身也要一点时间生成音频,要做好缓存和排队机制。
音频拼接或缓冲:为了让朗读不中断,最好有一个 buffer(播放队列),提前塞好下一段音频。
多线程处理:文字生成、语音合成和音频播放可以用异步任务或多线程分开跑,防止阻塞。
下面是实现的JS代码,后端需要授权Token
const SpeechSDK = window.SpeechSDK;
let synthesizer = null;
let ttsQueue = [];
let isSpeaking = false;
let sentenceBuffer = “”; // 聚合缓存
let messageDiv = document.getElementById("message");
let chatMessageTxt = document.getElementById("chatMessage");
let starBut=document.getElementById("btnStart");
// 标点正则,遇到这些符号就认为一句结束(可以根据场景扩展)
const SENTENCE_END_RE = /[\u3002\uFF1F\uFF01\.?!;;\n\r]/;
starBut.onclick = async function () {
synthesizer = null;
ttsQueue = [];
isSpeaking = false;
sentenceBuffer="";
messageDiv.innerText ="";
this.disabled = true;
console.log("开始流式朗读...");
// 获取 token 和 region
const resp = await fetch("/token");
const { token, region } = await resp.json();
// 初始化 Speech SDK(用 token方式)
const speechConfig = SpeechSDK.SpeechConfig.fromAuthorizationToken(token, region);
speechConfig.speechSynthesisVoiceName = "zh-CN-XiaoxiaoNeural";
const audioConfig = SpeechSDK.AudioConfig.fromDefaultSpeakerOutput();
synthesizer = new SpeechSDK.SpeechSynthesizer(speechConfig, audioConfig);
console.log("等待 SSE 文本流...");
// 监听 SSE 文本流
const evtSource = new EventSource("/chat?historyID=1234567890&message="+chatMessageTxt.value);
evtSource.onmessage = (event) => {
const text = event.data;
messageDiv.innerText += text;
console.log(`收到片段: ${text}`);
onFragmentReceived(text);
};
evtSource.onerror = (e) => {
console.error("SSE 连接断开", e);
starBut.disabled=false;
evtSource.close();
};
};
// 片段聚合为一句完整话后入队
function onFragmentReceived(text) {
sentenceBuffer += text;
// 可以一次收到多句,所以用while
while (true) {
const match = SENTENCE_END_RE.exec(sentenceBuffer);
if (match) {
// 取出一句完整内容(包括结尾标点)
const idx = match.index + match[0].length;
const sentence = sentenceBuffer.slice(0, idx);
enqueueText(sentence.trim());
// 缓存剩余部分
sentenceBuffer = sentenceBuffer.slice(idx);
} else {
break;
}
}
}
// 队列机制
function enqueueText(text) {
if (!text) return;
ttsQueue.push(text);
playNext();
}
function playNext() {
if (isSpeaking) return;
if (ttsQueue.length === 0) return;
const text = ttsQueue.shift();
isSpeaking = true;
console.log(`开始朗读: ${text}`);
synthesizer.speakTextAsync(
text,
(result) => {
isSpeaking = false;
if (result.reason === SpeechSDK.ResultReason.SynthesizingAudioCompleted) {
console.log(`朗读完成: ${text}`);
} else {
console.error(`朗读未成功,Reason: ${result.reason}`);
}
playNext();
},
(error) => {
isSpeaking = false;
console.error("合成出错: ", error);
playNext();
}
);
}
window.addEventListener("beforeunload", () => {
if (synthesizer) synthesizer.close();
});
声明:来自硅基-桂迹,仅代表创作者观点。链接:https://eyangzhen.com/2060.html