百度 RTC 苏州话接入实录:18 秒踢线、第 1 句后全哑,四轮排障的完整真相

2026-10-11 · 语音技术 / 数字人 · 全篇数据来自我们线上数字人的生产链路实测

我们给一台数字人换方言音色,用的是百度智能云「大模型实时互动 RTC」。上线后遇到的现象很典型也很折磨人:第一句能答,第二句开始全哑,日志里还循环出现 每 18~19 秒一次的会话断开。前后排查了四轮,每一轮的"根因"都被下一轮推翻过。

这篇把四个阶段的实测数字、判读方法、以及被推翻的错误结论全部摊开写。如果你正在做方言数字人、或在用 WebSocket 接实时语音,这篇能省你几天。

一、先说最容易走错的:产品线选错,等于问错了人

我们最初用「语音技术」的 REST 接口试苏州话,结论是"百度不支持苏州话"。这个结论是错的——错在产品线。方言能力只在 RTC 这条线上。

维度语音技术(REST API)大模型实时互动 RTC ← 苏州话走这条
苏州话 lang=zh_su无(方言仅粤语/四川话)官方明确支持
苏州话音色(如芊芊)调不通有
控制台入口speech/app/listrtc/interaction
教训:"对端不支持"这种结论,先问一句"我问的是对端的产品 A,还是产品 B?"。我们在错误的产品线上耗掉的时间,比后面所有技术难题加起来还多。

二、纯服务端接入骨架(不需要麦克风和浏览器)

RTC 支持纯服务端 WebSocket 接入,很适合"数字人后端直连"这种架构:

wss://rtc-aiotgw.exp.bcelive.com/v1/realtime
  ?a=APPID&ak=AK&sk=SK&ac=raw16k&cfg=<urlencoded json>

关键点:

另外有个反直觉的点:连接就绪后只发 [T]:文本、不发音频,会立刻收到会话断开。可靠的主触发是发音频 PCM 帧,云端在同一会话内做 ASR→LLM→TTS 流式回传。

三、致命坑 1:一个线程 send、另一个线程 recv,二进制帧会被静默丢掉

这是最阴险的一类 bug,因为它不报错。我们的桥接原本在一条线程发音频 PCM、另一条线程收 TTS 二进制,共用一个连接对象。结果:

收到的 TTS 音频
默认(多线程共用连接)掉到 4 帧,wav 仅 684 字节,数字人几乎失声
加 enable_multithread=True稳定 290~314 帧 / 185~200KB / 5.8~6.3 秒完整音频
websocket.create_connection(url, timeout=60, enable_multithread=True)

云端其实把完整音频发下来了,是我们的客户端在读侧丢帧。所以症状会表现为"云端日志一切正常、客户端没声音"——如果只看服务端日志,会一路查错方向。

四、致命坑 2:devId 用随机值,等于每次连接烧掉一个 License

百度按 licenseKey + appId + deviceId 唯一标识一次激活。用 uuid4() 生成 devId,等于每次连接都算一台新设备,每次烧掉一个激活额度。额度烧完不会自动释放。

它抛出来的报错极具误导性,很容易被当成"License 没绑定":

[E]:[LIC]:[RES]:[FAILED]:激活数量已达上限,当前已激活2台,该应用绑定上限为2台

正确做法是把 deviceId 固定成硬件序列号或主机名,并在收到 [E]:[LIC]:[MUST] 时立刻回激活:

DEVICE_ID = os.environ.get("DEVICE_ID", "node1-digital-human-01")
msg = '[E]:[LIC]:[ACTIVE]:{"devId":"%s","uId":"%s","licKey":"%s"}' % (DEVICE_ID, DEVICE_ID, LIC_KEY)

成功会回 [E]:[LIC]:[RES]:[PASS]:。

顺带算一笔商业账(卖设备的人必看)

量产别把 AK/SK 烧进设备。用 ?a=APPID&ak=AK&sk=SK 是最省事的验证方式,但固件里带着 AK/SK,等于设备卖给客户 = 密钥交给客户,而主账号 AK 有全账号权限、无法做权限控制。正确架构是设备向自己的服务端要 token,服务端用 AK/SK 调「创建大模型互动实例」签发(token 24 小时有效,且实例创建后端侧须 30 秒内连上)。

五、致命坑 3:lang=zh_su 会自己混进上海话

只设 cfg={"lang":"zh_su"} 时,云端 LLM 会往上海话上靠。实测抓到的原声是 "阿拉一淘讲讲苏州葛老街小巷啊"——"阿拉"是上海话,苏州话第一人称应是我 / 伲。

方言点上海话(污染)苏州话(正确)
第一人称阿拉 / 我伲混用我 / 伲
第二人称侬倷
第三人称伊俚
否定/疑问伐勿 / 阿好

解法是把人设写进 cfg["sceneRoleCfg"]["prompt"]——不是 cfg 顶层。顶层放 prompt 会造成更诡异的结果:License 通过、但没有任何 [A]: 返回,会话僵死。我们据此一度误判"WebSocket 模式不支持人设"。

SUZHOU_ROLE_PROMPT = (
    "你是苏州姑娘小苏,请全程用纯正苏州话回答。严格要求:"
    "一、禁止使用上海话词汇,尤其禁止用“阿拉”自称,第一人称必须用“我”或“伲”;"
    "禁止“侬”要用“倷”,禁止“伊”要用“俚”,禁止“伐”要用“勿”。"
    "二、多用苏州话特征词:哉、格/搿、今朝、明朝、啥、蛮、交关、晓得、困觉、铜钿、覅、阿好。"
    "三、回答简短自然,每句不超过四十字。"
)
CFG = {"audiocodec":"raw16k","dfda":True,"lang":"zh_su","disable_voice_auto_int":True,
       "sceneRoleCfg":{"name":"小苏","prompt":SUZHOU_ROLE_PROMPT,"model":"DEFAULT"}}

A/B 对比(同一句提问"介绍一下苏州有啥好玩的地方"):无人设时回复偏普通话;加人设后回复是"今朝我带倷去逛平江路,格是老苏州,小桥流水哉……交关漂亮……阿好?"——特征词全中,无"阿拉"无"侬"。

验证方言纯度的通用手法:把 TTS 产出的 wav 再喂给 ASR 回听,看转出来的字。出现"阿拉/侬/伊/伐"= 污染;出现"哉/格/搿/倷/今朝/交关/阿好/困觉/勿"= 正确。这个"文本→TTS→wav→ASR→文本"的自闭环,不需要任何外部录音素材,能同时验证两侧。

六、18 秒踢线:我们两轮都猜错了,真相在客户端

第一轮结论(错的)

日志里会话被精确地、无限循环地踢:

17:46:13 看门狗:检测到百度会话已断开/被踢 -> 重建
17:46:31 断开 -> 17:46:32 重建   (间隔 18s)
17:46:50 断开 -> 17:46:50 重建   (18s)
17:47:08 -> 17:47:09              (18s)

当时以为是"websocket-client 默认不发 ping",加了 ping_interval=10 后观察到"50 秒内 0 次重建",据此下了结论。后来确认:ping_interval=10 确实在生产代码里,但百度照样每 18~19 秒踢一次。那次"50 秒 0 重建"只是刚重启后的相位偶然——单次短窗口观察不可信,必须看 ≥3 分钟的连续日志。

第二轮:真凶是我们自己的 socket timeout

加了关闭事件日志后抓到铁证:

[CLOSE_EVT] source=recv_fail code=None reason=Connection timed out
            exc_type=WebSocketTimeoutException

因果链非常干净:

  1. 百度静默期(用户没说、TTS 已发完)不发任何业务帧,这是正常的;
  2. 我们客户端 timeout=15,socket 15 秒没数据 → ws.recv() 抛 WebSocketTimeoutException;
  3. 接收循环进入 except,置"已关闭"标志;
  4. 看门狗 3 秒后检测到标志 → 主动 close(),发出 close 1000;
  5. 百度服务端把它记成"端上进行了断开 / close 1000 (normal)"——看起来像对端踢我们。

18s = 15s(recv 超时)+ 3s(看门狗延迟)。规律性极强,反而成了定位钥匙。

self.ws = websocket.create_connection(
    url,
    timeout=60,              # 15 -> 60:避免静默期 ws.recv() 误抛超时
    enable_multithread=True,
    ping_interval=10,        # TCP 层保活交给 ping,不依赖 socket timeout
    ping_timeout=5,
)

修复后进程连续跑 144 秒(10:51:18 → 10:53:56)零 recv_fail、零重建;此前是每 18 秒一次。这一条也直接解释了"第一句能答、后面没声"——一次问答往返约 7~9 秒,会话只有 18 秒寿命,请求落在后段就必然被踢丢。

诊断坑:桥接里的日志全是 print,而 systemd 下从未 flush 的 stdout 是块缓冲,不落盘 → "看不到日志"会被误读成"代码没生效"。要么 print(msg, flush=True),要么直接走 logging。我们因此白排查了两天。

七、"第 1 句能答、后面全哑":四轮回溯,每轮根因都不同

踢线修好后,真人连续对话仍然是第一句有回复、后面哑。这件事我们追了四轮,根因一层套一层。

第 1 轮:自己加的"整句重喂"把共享会话污染死了

日志铁证:

11:10:29.800 收到 eos:本句共 12 块 / 56374 字节
11:10:31.303 [WARNING] 本句 7s 未收到回复,尝试第 2 次整句重喂(56374 字节)  <- eos 才过 1.5s!
11:10:32.919 收到音频第 40 块,累计 193050 字节                              <- 百度其实在正常回
11:10:43.500 第 1 次重喂(424727 字节)  11:10:43.972 第 2 次重喂(424727 字节)

三个 bug 叠加:①"本次是否已回复"的判据变量写成了自赋值(x = getattr(x, ...)),永远保留上一句的时间戳,判断全错;②每次 eos 都起一个重试任务,7 秒内说多句就并发多个,同时往共享会话灌音频;③桥接是全局单例,任何一个连接重喂都会污染所有用户。

ws._last_audio_ts = 0.0                      # 真正重置,不是 getattr(自己, 自己)
_prev = getattr(ws, "_retry_task", None)
if _prev is not None and not _prev.done(): _prev.cancel()
_REFEED_STATE = {"last_ts": 0.0, "min_gap": 25.0}   # 共享单例必须全局节流

第 2 轮:VAD 把一句话切成碎片,云端会话"假死"

每 2~4 秒一个 eos,且大量发生在静音状态:11:25:06 eos 29块/138768字节(才开始 0.5 秒就收句)。三个参数问题:静音结束判定 600ms 太短、底噪只钳上限没钳下限(实测震荡在 0.0044~0.012)、绝对阈值 0.006 太低。

更关键的是发现了一种"假死"态:WebSocket 连着、健康检查返回 True,但不再产出任何回复,重建会话立刻恢复。连接层健康 ≠ 服务层健康——看门狗只能发现断开,发现不了假死。所以健康判据必须覆盖业务产出(连续 N 次无产出 → 重建,N≥2 避免误伤偶发未识别)。

第 3 轮:TTS 播报的回声让 VAD 永远收不了句

现场出现 19 秒不收句、340 块 1.6MB 的录音。根因链:数字人播放 TTS → 扬声器回声进麦克风 → rms 抬到 0.01~0.07 → VAD 判定"在说话" → 静音计时永远归零 → 永不收句 → 用户的下一句永远提交不上去。而罪魁是那个"或"条件的绝对阈值:

var speaking = (rms > this.noiseFloor * thresh) || (rms > ABS_MIN);   // ABS_MIN 一旦低于回声水平,前面所有调参作废

修法是三条:播放/讲话态禁用绝对阈值短路、播放期完全冻结底噪、以及加一条硬最大句长(12 秒强制收句)当兜底阀门。getUserMedia 的 echoCancellation:true 在这种"HDMI 出 + USB 麦"的跨设备组合里基本失效,压不住回声。

第 4 轮:单例共享下,转码缓冲必须按连接分槽

最后一个是并发问题。同一个进程里,百度会话可以是单例(License 限制),PCM 发送队列可以是全局(它本来就要混流),但转码缓冲是连接级语义。我们把"新录音流开始就清空缓冲"的流边界检测(本身是对的)作用在了唯一的全局缓冲上,于是两个客户端同时录音时,任何一端开始新流都会抹掉另一端那一句:

12:11:47 探针收到音频第1块(带 EBML 头) -> 清空共享缓冲
12:11:48 展示机也收到第1块(也带 EBML 头) -> 又清空,把探针那 751 字节抹掉
12:11:50 探针 eos -> 缓冲里只剩别人的续块、没有 EBML 头 -> ffmpeg 整句解不出 -> pcm=0B
self._webm_bufs = {}   # owner -> bytearray   单槽改按连接分槽
self._sent_pcms = {}   # owner -> bytes
def _own(ws): return str(id(ws))               # 用 id 不用对象,避免断开后无法回收

同一轮还踩了一个极典型的坑:给四个方法加 owner 参数时,两个改了 def 行、两个只改了函数体漏改签名。py_compile 不报错,运行时才炸,而且看起来功能正常(音频已实时喂出去,百度照样回答,只是 eos 后补不上静音尾)——差点当没问题交付。所以批量改签名必须机器校验调用点,尤其是 asyncio.to_thread(b.method, owner) 这种"方法对象被当参数传递"的间接调用,肉眼 grep 会漏。

本轮最终实测:e2e 成功率 1/3 → 3/3;"说完话→听到回音" 13.39s → 1.00 / 1.05 / 1.09 秒;噪声误提交从"每 2~3 秒一句"降到 60 秒零提交;pcm=0B 从每隔几秒一次降到 0 次。

八、"反应慢":把 TTS 从"攒够整段"改成流式分段

链路全通后,体验瓶颈变成一个纯工程问题:首字要等 10.5~14.6 秒。原因是旧实现要等收到 TTS 结束事件才把整段音频推给前端,而百度是1x 实时下发的(12.26 秒音频花 12.26 秒才收完),等于用户白等一个完整音频时长。

改成"边收边推"后有个必须算对的账:首段缓冲 B 必须 ≥ 后续段长 L,否则会断档——第 0 段在 t=B 推送、播到 t=2B 结束,第 1 段要 t=B+L 才推出,不断档要求 B+L ≤ 2B,即 L ≤ B,还要留约 0.5 秒余量吸收抖动。

我们先按直觉取了小值 B=0.6s / L=1.8s,结果每播 0.6 秒卡 1.2 秒,说话一顿一顿;改成 B=2.0s / L=1.5s 后无缝。同时播放端的"句末"判定要从句末改成流末,否则每个分段都被当成一句结束,待机/讲话视频会来回闪。

指标改造前改造后
首段出声(用户感知的反应速度)10.5 ~ 14.6s3.3 ~ 3.5s
普通话字幕出现与音频同时3.3s(比声音还早)
整句推完10.5 ~ 14.6s10.5 ~ 12.5s(受对端下发速度限制)

字幕还有一个额外设计:语音保持苏州话(lang=zh_su),但推给前端的字幕译成普通话——方言文字大多数人看不懂。做法是在收到回答文本时(此时 TTS 才刚开始播)就并行启动翻译,音频播完再取结果,音频通常播 5~10 秒、翻译约 1 秒,用户完全无感知。翻译失败必须回退原文,绝不能阻塞主链路。

九、一条被真机数据逼出来的通用铁律

上线后真机出现过一句录音解出 650880 字节 = 20.3 秒,而设计上限是 12 秒。前端诊断里字段组合很反常:状态=idle 录音=recording——状态机说闲着,录音器说在录。

根因是 onstop 是异步回调:句 A 停止录音 → onstop 还在事件队列里没执行 → VAD 立刻开始录句 B → 句 A 的 onstop 才执行,把句 B 的状态覆盖成 idle → 句 B 的 12 秒硬上限和静音收句判断都进不去 → 无限录下去。

修法两条:

修前修后
最长句20.3 秒(650880 字节)12.0 秒(384000 字节,正好卡上限)
其余句长—1.3~3.8 秒(上限只在异常时触发)

验算方法很实用:PCM 字节数 ÷ 2 ÷ 16000 = 秒(16bit / 16kHz / mono),直接读日志就能算出每句实际时长。

十、可复用的五条,与方言无关

  1. 单例共享的资源要逐项问"这是全局语义还是连接语义"。我们同一份设计里三种语义并存:会话是全局的(License 限制)、PCM 队列是全局的(本来就要混流)、转码缓冲是连接级的。一刀切必出事。
  2. 健康检查要覆盖业务产出,且判定时机要匹配响应时延。连接层健康 ≠ 服务层健康;在请求发出的瞬间判定"超时未响应"等于永远误报。
  3. "我改了但没生效"先怀疑客户端缓存。前端资源用版本号协商(前端上报版本 → 服务端比对 → 不一致就推刷新),比反复改服务端快得多。但要检查这个"自动刷新"动作本身会不会弄挂别的链路——我们就出现过自动刷新修好旧 JS、却顺手把麦克风弄成挂起状态(异步上下文在无用户手势时不跑)。
  4. 给共享资源加重试/补偿前先想三件事:判据变量每次新请求真的重置了吗?重试任务会并发堆积吗?重试落在共享资源上要不要全局节流?否则"兜底逻辑"会变成"故障放大器"。
  5. 兜底写了必须验证它真的命中过。我们有个空录音兜底写完"一条命中日志都没有",查下去才发现是 ffmpeg 静默失败返回空、不抛异常。写兜底时顺手打一条日志,别让它变成心理安慰。

做方言数字人的门槛不在模型,而在这些"连接层—会话层—业务层"的缝合处。我们站内在售的苏州话标准词库(3550 条,含释义、拼音、IPA 与示例代码)和数字人素材,就是按"先定基线、再验证"的方式做出来的——如果你正在做方言语音或数字人落地,可以直接拿去用,也可以带着具体链路问题找我们。

看看苏州话标准词库(含免费样本)→ 方言语音/数字人定制服务 →

常见问题

百度支持苏州话吗?为什么我在语音技术里调不通?

支持,但不在「语音技术」那条产品线——那条线的方言只有粤语和四川话。苏州话要走「大模型实时互动 RTC」,在 cfg 里设 lang=zh_su。用错产品线会得到"不支持"的错误结论。

为什么我的 RTC 会话每 18 秒左右断一次?

先别怪对端。常见真凶是客户端 websocket.create_connection 的 timeout 太短:百度在静默期不发任何业务帧,ws.recv() 超时抛异常 → 你的看门狗把它当成"会话已死"并主动 close。18s ≈ 15s(socket 超时) + 3s(看门狗延迟)。把 timeout 提到 60,TCP 保活交给 ping_interval。

苏州话音色里混进了"阿拉""侬",怎么去掉?

这是 zh_su 往上海话靠的表现。把人设写进 cfg["sceneRoleCfg"]["prompt"](注意不是 cfg 顶层,放顶层会导致会话僵死),显式禁用上海话词汇并指定第一人称用"我/伲"、第二人称用"倷"。验证方法:把 TTS 音频再喂给 ASR 回读,看转写的字里有没有"阿拉/侬/伊/伐"。

为什么第一句有回复、后面就没声音了?

这条我们追了四轮,根因各不相同且会叠加:①自己加的"整句重喂"把共享单例会话灌成垃圾;②VAD 把句子切太碎导致云端会话"假死";③TTS 播放回声让 VAD 永远收不了句;④单例共享下转码缓冲被并发连接互相清空。判断顺序:先看后端有没有音频产物文件(有=后端正常,问题在前端 VAD),再看有没有重建日志。

数字人"反应慢",先优化哪里?

先拆时间线,别急着调阈值。我们最大的收益来自一件事:把 TTS 从"收完整段才推"改成流式分段推,首段出声从 10.5~14.6 秒降到 3.3~3.5 秒。注意首段缓冲必须 ≥ 后续段长,否则会比不分段更卡。

卖硬件设备时 License 要注意什么?

三件事:①deviceId 必须用硬件序列号,绝不能用随机值(每次连接烧一个激活次数);②购买时选清"一批一码/一机一码",事后不可改;③问清"资源包到期后已激活设备是否失效",并注意"已绑定不可撤回"——买之前就要按应用把额度分配估好。