多模态数字人导游:WebSocket流式交互的落地实践
去年做灵山胜境AI数字人导游项目时,有个场景让我印象深刻——测试阶段,游客对着屏幕问"大佛有多高",数字人导游停顿了整整4秒才开口回答。
4秒。在景区现场,游客已经转身走了。
这个项目用FastAPI做后端,前端是原生JS + Live2D数字人模型,知识库是结构化JSON。核心交互链路是:语音输入 → ASR转文字 → 知识库检索 → LLM生成回复 → TTS合成语音 → Live2D口型同步。链路长,如果每一步都等上一步完成再往下走,延迟就是灾难级的。
从HTTP轮询到WebSocket
第一版我用的是HTTP短轮询——前端每隔200ms发一次请求查状态。效果很糟糕:响应慢不说,服务器压力也大,六个并发就能把单核测试机CPU打满。
改成WebSocket后,整条链路变成了流式管道:
客户端 ──WebSocket──> 服务端
<── text_stream ── LLM流式文本
<── audio_stream ── TTS音频分片
<── lip_sync ── 口型参数
核心思路很简单:不要让任何一步等上一步全部完成。LLM吐一个字就推一个字给前端展示,同时送给TTS开始合成,TTS出一个音频分片就推给前端播放,Live2D拿到文本立刻算口型。
服务端实现
FastAPI原生支持WebSocket,代码相当直接:
from fastapi import WebSocket
import asyncio
import json
async def handle_tour_guide(ws: WebSocket, query: str):
await ws.accept()
# 1. 知识库检索(这一步没法流式,但很快,通常<100ms)
context = knowledge_base.search(query)
# 2. LLM流式生成
full_text = ""
async for token in llm.stream_chat(query, context):
full_text += token
# 文本流推给前端
await ws.send_json({"type": "text", "data": token})
# 每当凑够一个自然停顿点(标点符号),触发TTS
if token in "。!?\n" and len(full_text) > 10:
asyncio.create_task(stream_tts(ws, full_text[-50:]))
# 3. 发送结束标记
await ws.send_json({"type": "done"})
async def stream_tts(ws: WebSocket, sentence: str):
"""TTS分片流式推送"""
audio_chunks = tts_engine.synthesize_stream(sentence)
for chunk in audio_chunks:
await ws.send_json({
"type": "audio",
"data": chunk.hex() # 二进制用hex编码走JSON
})
有个细节值得说一下:TTS任务用 asyncio.create_task 异步启动,和主文本流并行跑。这样游客看到文字的同时,语音就已经开始播放了——体验上几乎是"秒回"。
前端流式消费
前端这边,原生JS处理WebSocket也很简洁:
const ws = new WebSocket('ws://localhost:8000/ws/guide');
const audioCtx = new AudioContext();
let audioQueue = [];
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'text':
// 打字机效果追加文本
appendText(msg.data);
// 同步驱动Live2D口型
live2dModel.setMouth(
calculateLipSync(msg.data)
);
break;
case 'audio':
// 解码base64音频分片,加入播放队列
const buffer = hexToArrayBuffer(msg.data);
audioCtx.decodeAudioData(buffer, (decoded) => {
audioQueue.push(decoded);
playNextInQueue();
});
break;
case 'done':
// 数字人恢复待机动画
live2dModel.setIdle();
break;
}
};
function playNextInQueue() {
if (audioQueue.length === 0) return;
const source = audioCtx.createBufferSource();
source.buffer = audioQueue.shift();
source.connect(audioCtx.destination);
source.start();
source.onended = playNextInQueue;
}
这里音频播放队列是个关键设计。TTS的分片到达顺序和播放顺序可能不一致(网络波动),用队列保证严格有序。AudioContext.decodeAudioData 是异步的,解码完才入队,避免阻塞主线程。
踩过的坑
坑一:WebSocket断线重连
景区WiFi不稳定是常态。第一次测试时,WebSocket断了就白屏,没有任何恢复机制。后来加了指数退避重连:
let retryCount = 0;
ws.onclose = () => {
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
retryCount++;
setTimeout(() => connect(), delay);
};
ws.onopen = () => { retryCount = 0; };
坑二:Live2D口型抖动
最初每个token都触发口型计算,结果数字人的嘴巴像在抽搐。后来改成60ms的平滑窗口,取区间内的平均值做口型参数,效果自然多了。
坑三:TTS与LLM的速率不匹配
DeepSeek生成很快,但TTS(我用的Edge TTS)合成速度跟不上。结果是文本早就在屏幕上显示完了,语音还在慢慢念。解决办法是加了一个水位控制——当TTS队列积压超过3个分片时,给LLM流加一个小的 asyncio.sleep(0.05) 限速。
效果
优化后,首字延迟从4秒降到了800ms左右——知识库检索占了大部分时间,但游客看到文字和听到声音几乎是同时开始的。在景区实测中,游客的反馈从"有点慢"变成了"挺快的"。
流式交互这件事,说穿了就是一句话:别让用户等一把抓完的结果,边生成边给。WebSocket是这个思路最自然的载体。
这个项目的完整介绍和架构图可以看之前写的灵山胜境AI数字人导游项目。

浙公网安备 33010602011771号