数字人迎宾总在重播、还打不断:两个状态位和一个字段的错

2026-10-26 · AI智工坊 · 数字人现场运维实录

给一台落地式数字人加「有人靠近就欢迎」的功能,代码量不到一百行,但真正上线后,客户提的投诉往往只有两句:「它一直重复说同一句话」和「我想让它停,它不听」。

这两个症状看起来像一个毛病,其实是三个互相独立的问题叠在一起:一个状态位写错了复位条件,一个布尔字段被复用成了两种含义,一组语音打断门槛按「正常对话」标定、却没考虑「四十秒的整段播报」。下面按现场真实排查顺序拆开讲,参数与改法都可直接照抄。

本文所有阈值、文件名、判定条件均来自实际部署的服务端代码与真机验收记录,不涉及客户账号与设备凭据。

一、症状:人站着不动,欢迎词一遍接一遍

触发逻辑本身很朴素:前端每 600ms 抓一帧摄像头画面,用帧差判断画面里有没有人;判定有人,就向语音接口发一次播报请求。

问题出在「防重复」的那个状态位上。旧写法用的是一面旗子,语义是「现在允许触发吗」:

let armed = true;              // 允许触发
let lastTrigger = 0;
const COOLDOWN = 30000;        // 30 秒冷却

// 每 600ms 一帧
if (!hasPerson) { armed = true; return; }        // ← 病灶:无人一帧就复位
if (armed && Date.now() - lastTrigger > COOLDOWN) {
  armed = false;
  lastTrigger = Date.now();
  speak(WELCOME_TEXT);
}

这行 armed = true 是致命的。摄像头帧差检测在光照变化、人影晃动、压缩噪声下本来就会偶尔漏一帧,漏一帧就等于「人走了」,旗子被重新举起;而 COOLDOWN 只有 30 秒,比一段完整欢迎词还短。

于是形成闭环:播到第 28 秒,检测抖了一帧 → 旗子复位 → 冷却早已过 → 再次触发 → 从头再播。人不动,它就一直播。

改法:把「冷却」换成「一次到访只欢迎一遍」

不要用时间冷却来描述「去过重来」,要用到访这个语义。三个变量足够:

let welcomed  = false;     // 本次到访是否已欢迎过
let leftSince = null;      // 最近一次「无人」的时间戳
const LEAVE_HOLD = 8000;   // 连续无人满 8 秒,才算真的走了

if (hasPerson) {
  leftSince = null;                                   // 有人:清空离开计时
  if (!welcomed && Date.now() - lastTrigger > COOLDOWN) {
    welcomed = true; lastTrigger = Date.now(); speak(WELCOME_TEXT);
  }
} else {
  if (leftSince === null) leftSince = Date.now();
  if (Date.now() - leftSince > LEAVE_HOLD) welcomed = false;   // 只有久走才重置
}

关键差别是复位条件从「一帧」变成「连续 8 秒」。抖动最多丢几百毫秒的检测,够不到 8 秒;而一个真实的访客离开再回来(去看展板、接个电话)通常超过 8 秒,仍会被重新欢迎。这个 8 秒不是拍脑袋——它是「最短真实离场时间」与「最长连续漏检时间」之间的中位数,实测在展厅光照下稳定。

二、症状:开口说话,它照样念自己的

第二个问题更隐蔽,因为打断功能本身是好的——正常对话时用户插话能立刻切断回复。唯独欢迎词不行。

顺着链路查下去,卡在一个布尔字段上。后端播报接口向所有客户端广播一段 JSON,其中 noBarge 表示「本条播报禁止打断」。当时为了区分「欢迎词要用专门的欢迎视频」,代码把两个含义塞进了同一个字段:

// 旧:一个字段干两件事
{ type: "audio", url: "...", noBarge: welcome }   // welcome=true → noBarge=true

前端拿到之后:

// display.js
const item = { ..., welcome: !!data.noBarge };     // 用 noBarge 判断「是不是欢迎词」
if (!data.noBarge) this.setMicActive(true);        // 而 noBarge 又决定「开不开麦」

结果就是:欢迎词一播,麦克风被关掉。麦克风关着,语音活动检测(VAD)拿不到任何输入,用户说破喉咙也不会触发打断——这不是打断逻辑坏了,是输入被物理掐断了。

改法:把「用哪段视频」和「能不能打断」拆成两个字段

// 后端:解耦
broadcast({ type: "audio", url, noBarge: false, welcome: welcome });

// 前端:各取所需
const item = { ..., welcome: !!data.welcome, noBarge: !!data.noBarge };
if (!data.noBarge) this.setMicActive(true);   // 欢迎期间麦克风保持开启

改动只有两行,但性质完全不同:欢迎词仍然用欢迎专用视频(视觉不乱),同时麦克风保持开启(可以被打断)。这类「一个字段两种语义」的债,越早拆越便宜——它不会报错,只会在某个特定场景下表现为「功能不存在」。

三、麦克风开了,为什么还是打断不了

解耦之后依然反应迟钝,于是要看打断判定本身。VAD 打断的判据是三个参数的组合:

参数含义正常回复取值欢迎词场景为何失效
thresh音量阈值5.0整段 mp3 连续播放,环境噪声底噪被自身回声抬高,正常人声突破不了 5.0
needFrames需连续满足的帧数12(约 1.2 秒)用户开口第一两个字常被当成噪声丢掉,累计不到 12 帧
noiseFloor自适应噪声底alpha = 0.3跟随太快,播报回声把底噪顶上去,阈值水涨船高

这套参数是为「正常回复」标定的——那种场景下播报短、间歇多,底噪有机会回落。欢迎词是四十到六十秒不间断的整段音频,底噪一直高位,等于自己把自己的耳朵堵住了。

改法:只给欢迎态单独放宽,别动正常回复

const _welcomePl = this.audioPlaying && this.currentItem && this.currentItem.welcome;

const thresh    = _welcomePl ? 2.0 : 5.0;   // 5.0 → 2.0
const minFrames = _welcomePl ? 2   : 3;     // 3   → 2
const needFrames= _welcomePl ? 6   : 12;    // 12  → 6(约 0.6 秒)

为什么只改欢迎态?因为正常回复如果也放宽到 2.0,自己的回声就会把自己打断,出现「说半句就停」的新毛病。已有的回声抑制是三件套:自适应回声系数 _echoCoeff、首音 800ms 保护窗、连续真人才计数的帧门控。放宽门槛必须限定在整段播报场景,这是边界。

四、最可靠的一层:让屏幕可点

语音打断再调,也总有失效场景:环境嘈杂、访客声音小、离麦克风远、方言口音被 VAD 判成噪声。展厅里最不该出现的情况是「客户想让它停,只能等它说完」。

所以加了一层不依赖任何语音判定的兜底:播报中点击屏幕任意位置即打断。

document.addEventListener('click', (e) => {
  if (this.audioPlaying) this.bargeIn();   // 只判是否在播,不限定欢迎词
});

注意条件里不要写 && this.currentItem.welcome。写成只判欢迎词,正常回复播放中点屏就停不下来,提示语「轻触屏幕可随时打断讲解」就成了假话。而界面上的控制按钮自带 stopPropagation,点它们不会误触这层兜底。

五、功能上线了,客户不知道,等于没上

这层最容易被工程师忽略。功能上线当天,现场反馈是「还是停不下来」——因为没人知道屏幕能点。

解决办法是在访客视线自然落点处写明。这台设备的右下角有一个二维码盒子(「扫码用手机对话」+ 副标题「嘈杂环境也听清」),就在二维码图正下方加一行金色虚线分隔的提示:

<div style="border-top:1px dashed #ffd479;color:#ffd479;margin-top:8px;padding-top:6px">
  👆 轻触屏幕任意处<br>可随时打断讲解
</div>

用金色 #ffd479 与原有蓝灰副标题区分开,是因为它不是说明文字,是操作指引。这条改动没动一行逻辑,却是当天体验提升最明显的一次——产品能力的可发现性,和产品能力本身同等重要。

六、部署阶段的三个坑(每个都让人误判过一次)

1. 端口是 HTTPS,用 http 探活会得到「服务已死」

curl -sk https://127.0.0.1:8002/healthz     # 正确
curl -s  http://127.0.0.1:8002/healthz      # 报 Empty reply from server,误判

健康检查返回里还要看 mode: real(真实引擎)而不是 mock,否则你以为在验收真机,其实跑的是占位实现。

2. 改了 JS 不 bump 版本号,等于没改

入口页引脚本时带 ?v=:display.js?v=20260918tapstop。每次改 display.js 或 welcome.js 都要把这个串换掉,否则浏览器继续用缓存里的旧文件,服务端代码是新的、用户跑的是旧的,排查会怀疑人生。

3. 编辑器报「保存成功」不等于文件真的变了

这是最贵的一条教训:批量替换后,编辑工具返回成功,但远端文件内容未变(跨行匹配的替换尤其容易假成功)。如果直接重启服务,你会把旧代码当新代码上线,然后疑惑「改了怎么没效果」。

铁律:改完先在本地 grep 确认新符号存在,再上传;上传后再在服务端 grep 一次。两次命中才算闭环。

另外两个现场细节:改完 systemctl restart 后要等冷启动(加载推理框架约 20~40 秒)再探活,太早访问必然失败;而展厅那台独立展示机除了重启服务,还要在屏幕上手动刷新一次页面,否则页面 JS 不会重新拉取,服务端已是新版、屏幕还是旧版。

七、上线验收清单

#验收项通过判据
1单次到访只欢迎一次站在摄像头前 3 分钟、期间正常小幅走动,播报只发生 1 次
2抖动不复位用手在镜头边缘快速晃过(制造单帧漏检),不触发重播
3真实离场可重欢迎离开画面超过 8 秒后返回,重新欢迎 1 次
4语音可打断欢迎词播报中说一句完整短句,600ms 内停播并进入聆听
5正常回复不被误断静默不发声时,回复不出现中途自停
6触控可打断播报中点击屏幕空白处,立即停播
7按钮不误触播放中点音量/关闭按钮,不触发打断逻辑
8提示可见屏幕右下角可见「轻触屏幕任意处 / 可随时打断讲解」

八、这类问题的通用解法

回头看,三个病灶有一个共同特征:它们都不会报错。状态位复位太激进,只是「多说了几遍」;字段语义复用,只是「某个场景功能不存在」;门槛标定不适配,只是「反应慢一点」。没有任何一条会进日志、会亮红灯。

所以做现场类交互产品,判断标准不能是「代码跑通了」,而必须是「把症状翻译成状态,再给每个状态一个可观测的判据」:

这四句翻译做完,验收清单自己就长出来了。

我们在做数字人现场交付时,把这类「不报错但看得见」的问题整理成了逐项验收表。如果你正在布一台迎宾/讲解类数字人,欢迎看看我们的现场部署服务,或直接下载 《数字人现场故障自查清单》。

常见问题

欢迎词应该多长?

展厅场景建议 15~25 秒。超过 30 秒,访客大概率听不完就会走开或试图打断,而打不断的体验比不欢迎更差。如果业务一定要讲长内容,正确做法是拆成「短欢迎 + 按需触发的分段讲解」,而不是一段四十秒的整段播报。

为什么不能用「时间冷却」防止重复欢迎?

冷却只能表达「多久之后可以再说」,无法表达「这个人是不是新来的」。一旦冷却时长比播报时长短,检测抖动就会造成循环重播;如果把冷却拉到很长,又会出现「同一人离开十分钟回来不被欢迎」。用「到访」状态机才能同时满足两个方向的需求。

关掉麦克风再播报,不是更防回声吗?

防回声应该由回声抑制算法做,而不是靠关麦。关麦的代价是彻底丧失打断能力——用户说什么系统都听不见。正确姿势是麦克风常开 + 自适应回声系数 + 首音保护窗,让系统能区分「自己的声音」和「用户的声音」。

语音打断和触控打断要都做吗?

要。两者失效场景不重叠:语音打断在嘈杂、远距离、小声时失效;触控打断在用户双手拿着东西、或不知道能点时失效。展厅这类公共场所,触控是保底,语音是体验上限,缺一层就会收到「停不下来」的投诉。

怎么判断改完之后是否真的生效了?

不要看部署日志,要看设备行为。最短的验证路径是三步:改完在服务端 grep 到新符号、探活返回 real 引擎、然后站在镜头前做一次「晃动—离开 8 秒—返回」的完整动作。三步都过才算上线。

看看数字人现场部署服务 →