多模态数字人导游: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数字人导游项目

posted @ 2026-06-30 14:57  C(5,3)  阅读(37)  评论(0)    收藏  举报