我在教室 75 寸大屏上跑了一个超低成本 AI 数字人:系统设计 + 口型同步、拒答根治、实时化三线攻坚实录

演示前一晚,数字人还在对校长的一句「今天天气怎么样」礼貌拒绝:「抱歉,这个问题不在我目前掌握的资料范围内」。而屏幕上,她明明在说话,嘴巴却纹丝不动。

这篇文章分两部分:前半部分系统交代这套数字人是怎么搭起来的------它不是烧 GPU 的高保真方案,而是一个跑在教室既有大屏上、无独显、无专用硬件的低成本数字人;后半部分记录我们如何用一个周末,把口型、拒答、实时数据三个看似独立的 bug 追到根、砍到断。全文无注水,每一段都来自真实代码与真实故障,配图全部来自系统真实资产。


目录


一、先交代家底:低成本数字人是怎么搭起来的

先说清楚系统长什么样、为什么这么选型,后面的 bug 才有上下文。

这是一套部署在某小学大屏的校园 AI 数字人:前端是 Electron + React 的大屏界面,数字人渲染用自研的 2D DPK 位图方案(RigAvatar),语音与对话能力接火山引擎与多家廉价大模型;后端是 Python FastAPI,负责管线编排、RAG 检索、TTS、安全过滤,以及与火山 RTC 的信令交互。

1.1 为什么「低成本」是第一约束

学校现场只有教室既有的 75 寸大屏(内置盒子或一台普通迷你主机),没有独立显卡、没有专用硬件、没有专人运维。这个约束决定了所有技术选型------每一层我们都选了「够用且便宜」的方案,而不是「最好但贵」的方案:

高保真方案 我们的低成本选择 代价与收益
形象渲染 3D 建模 + GPU 实时渲染(Unreal/Unity) 2D DPK 位图贴片渲染 损失立体感,换来 CPU 可跑、零显卡依赖
唇同步 视频驱动数字人(MuseTalk 等,需 GPU 推理) 音素贴片 + 时间线驱动 损失真实感,换来纯 2D 合成、毫秒级编译
大模型 高端模型全量调用 DeepSeek / Qwen-flash 多路由 + 失败兜底 单条对话成本压到分级别
实时数据 付费搜索 API wttr.in 免费天气 + 中新网 RSS + 搜索引擎兜底 零 API 费用
存储 PostgreSQL + 独立向量库集群 SQLite + Milvus Lite(嵌入式,免 Docker) 单机部署,一键启动

低成本不是妥协,而是贯穿全文的设计约束。后面你会看到,很多「巧妙」的设计(比如实时快照抢在对话前注入、直达源并行赛跑)本质上都是在不花钱的前提下补齐能力

1.2 总体架构:三条对话链路 + 八节点管线

核心是三条对话链路 ,不同入口走不同链路------这一点极其重要,因为同一个问题在不同链路上,命运完全不同
#mermaid-svg-ltJd9Z96RZVjfV8e{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ltJd9Z96RZVjfV8e .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ltJd9Z96RZVjfV8e .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ltJd9Z96RZVjfV8e .error-icon{fill:#552222;}#mermaid-svg-ltJd9Z96RZVjfV8e .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ltJd9Z96RZVjfV8e .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ltJd9Z96RZVjfV8e .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ltJd9Z96RZVjfV8e .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ltJd9Z96RZVjfV8e .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ltJd9Z96RZVjfV8e .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ltJd9Z96RZVjfV8e .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ltJd9Z96RZVjfV8e .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ltJd9Z96RZVjfV8e .marker.cross{stroke:#333333;}#mermaid-svg-ltJd9Z96RZVjfV8e svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ltJd9Z96RZVjfV8e p{margin:0;}#mermaid-svg-ltJd9Z96RZVjfV8e .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ltJd9Z96RZVjfV8e .cluster-label text{fill:#333;}#mermaid-svg-ltJd9Z96RZVjfV8e .cluster-label span{color:#333;}#mermaid-svg-ltJd9Z96RZVjfV8e .cluster-label span p{background-color:transparent;}#mermaid-svg-ltJd9Z96RZVjfV8e .label text,#mermaid-svg-ltJd9Z96RZVjfV8e span{fill:#333;color:#333;}#mermaid-svg-ltJd9Z96RZVjfV8e .node rect,#mermaid-svg-ltJd9Z96RZVjfV8e .node circle,#mermaid-svg-ltJd9Z96RZVjfV8e .node ellipse,#mermaid-svg-ltJd9Z96RZVjfV8e .node polygon,#mermaid-svg-ltJd9Z96RZVjfV8e .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ltJd9Z96RZVjfV8e .rough-node .label text,#mermaid-svg-ltJd9Z96RZVjfV8e .node .label text,#mermaid-svg-ltJd9Z96RZVjfV8e .image-shape .label,#mermaid-svg-ltJd9Z96RZVjfV8e .icon-shape .label{text-anchor:middle;}#mermaid-svg-ltJd9Z96RZVjfV8e .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ltJd9Z96RZVjfV8e .rough-node .label,#mermaid-svg-ltJd9Z96RZVjfV8e .node .label,#mermaid-svg-ltJd9Z96RZVjfV8e .image-shape .label,#mermaid-svg-ltJd9Z96RZVjfV8e .icon-shape .label{text-align:center;}#mermaid-svg-ltJd9Z96RZVjfV8e .node.clickable{cursor:pointer;}#mermaid-svg-ltJd9Z96RZVjfV8e .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ltJd9Z96RZVjfV8e .arrowheadPath{fill:#333333;}#mermaid-svg-ltJd9Z96RZVjfV8e .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ltJd9Z96RZVjfV8e .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ltJd9Z96RZVjfV8e .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ltJd9Z96RZVjfV8e .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ltJd9Z96RZVjfV8e .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ltJd9Z96RZVjfV8e .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ltJd9Z96RZVjfV8e .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ltJd9Z96RZVjfV8e .cluster text{fill:#333;}#mermaid-svg-ltJd9Z96RZVjfV8e .cluster span{color:#333;}#mermaid-svg-ltJd9Z96RZVjfV8e div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ltJd9Z96RZVjfV8e .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ltJd9Z96RZVjfV8e rect.text{fill:none;stroke-width:0;}#mermaid-svg-ltJd9Z96RZVjfV8e .icon-shape,#mermaid-svg-ltJd9Z96RZVjfV8e .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ltJd9Z96RZVjfV8e .icon-shape p,#mermaid-svg-ltJd9Z96RZVjfV8e .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ltJd9Z96RZVjfV8e .icon-shape .label rect,#mermaid-svg-ltJd9Z96RZVjfV8e .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ltJd9Z96RZVjfV8e .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ltJd9Z96RZVjfV8e .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ltJd9Z96RZVjfV8e :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 火山引擎云端
FastAPI 后端 (Python)
Electron 大屏 (React)
自由聊天(语音)
文本提问
互动模式(实时对话)
对话面板
入口判定
AvatarStage

口型渲染 RigAvatar
MouthAudioBus

口型驱动总线
八节点管线编排

RAG + 安全 + TTS
dify_reuse 文本流

/api/chat/stream
volcano_rtc

RTC 信令
omni WebSocket

语音大模型一体
StartVoiceChat

云端 ASR + LLM + TTS

三条链路:

链路 触发方式 语音/大脑在哪 用途
文本流(dify_reuse) 用户打字或点快捷提问 后端八节点管线 + 云端 TTS 日常文本问答,能力最全
omni WS 自由聊天按钮 火山 omni 语音大模型一体 全双工语音对话
RTC 链路(火山 StartVoiceChat) 「实时对话」按钮 火山云端一体:ASR→LLM→TTS 全在云上 沉浸式互动,延迟最低

💡 为什么同一问题会「时灵时不灵」? 因为三条链路的「大脑」不一样。文本流走的是我们自己编排的管线(可以塞检索结果、塞时间基准、过安全过滤);而 RTC 链路的大脑在火山云端,云端 LLM 是黑盒,我们没有天然的联网检索能力------这正是后面拒答事故的祸根。

文本流链路内部是一条八节点顺序管线,每个节点可插拔、可单独测试:
#mermaid-svg-BqYTEcOJGL9lxBxi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BqYTEcOJGL9lxBxi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BqYTEcOJGL9lxBxi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BqYTEcOJGL9lxBxi .error-icon{fill:#552222;}#mermaid-svg-BqYTEcOJGL9lxBxi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BqYTEcOJGL9lxBxi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BqYTEcOJGL9lxBxi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BqYTEcOJGL9lxBxi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BqYTEcOJGL9lxBxi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BqYTEcOJGL9lxBxi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BqYTEcOJGL9lxBxi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BqYTEcOJGL9lxBxi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BqYTEcOJGL9lxBxi .marker.cross{stroke:#333333;}#mermaid-svg-BqYTEcOJGL9lxBxi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BqYTEcOJGL9lxBxi p{margin:0;}#mermaid-svg-BqYTEcOJGL9lxBxi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BqYTEcOJGL9lxBxi .cluster-label text{fill:#333;}#mermaid-svg-BqYTEcOJGL9lxBxi .cluster-label span{color:#333;}#mermaid-svg-BqYTEcOJGL9lxBxi .cluster-label span p{background-color:transparent;}#mermaid-svg-BqYTEcOJGL9lxBxi .label text,#mermaid-svg-BqYTEcOJGL9lxBxi span{fill:#333;color:#333;}#mermaid-svg-BqYTEcOJGL9lxBxi .node rect,#mermaid-svg-BqYTEcOJGL9lxBxi .node circle,#mermaid-svg-BqYTEcOJGL9lxBxi .node ellipse,#mermaid-svg-BqYTEcOJGL9lxBxi .node polygon,#mermaid-svg-BqYTEcOJGL9lxBxi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BqYTEcOJGL9lxBxi .rough-node .label text,#mermaid-svg-BqYTEcOJGL9lxBxi .node .label text,#mermaid-svg-BqYTEcOJGL9lxBxi .image-shape .label,#mermaid-svg-BqYTEcOJGL9lxBxi .icon-shape .label{text-anchor:middle;}#mermaid-svg-BqYTEcOJGL9lxBxi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BqYTEcOJGL9lxBxi .rough-node .label,#mermaid-svg-BqYTEcOJGL9lxBxi .node .label,#mermaid-svg-BqYTEcOJGL9lxBxi .image-shape .label,#mermaid-svg-BqYTEcOJGL9lxBxi .icon-shape .label{text-align:center;}#mermaid-svg-BqYTEcOJGL9lxBxi .node.clickable{cursor:pointer;}#mermaid-svg-BqYTEcOJGL9lxBxi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BqYTEcOJGL9lxBxi .arrowheadPath{fill:#333333;}#mermaid-svg-BqYTEcOJGL9lxBxi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BqYTEcOJGL9lxBxi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BqYTEcOJGL9lxBxi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BqYTEcOJGL9lxBxi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BqYTEcOJGL9lxBxi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BqYTEcOJGL9lxBxi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BqYTEcOJGL9lxBxi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BqYTEcOJGL9lxBxi .cluster text{fill:#333;}#mermaid-svg-BqYTEcOJGL9lxBxi .cluster span{color:#333;}#mermaid-svg-BqYTEcOJGL9lxBxi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BqYTEcOJGL9lxBxi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BqYTEcOJGL9lxBxi rect.text{fill:none;stroke-width:0;}#mermaid-svg-BqYTEcOJGL9lxBxi .icon-shape,#mermaid-svg-BqYTEcOJGL9lxBxi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BqYTEcOJGL9lxBxi .icon-shape p,#mermaid-svg-BqYTEcOJGL9lxBxi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BqYTEcOJGL9lxBxi .icon-shape .label rect,#mermaid-svg-BqYTEcOJGL9lxBxi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BqYTEcOJGL9lxBxi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BqYTEcOJGL9lxBxi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BqYTEcOJGL9lxBxi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ASR

语音转文本
唤醒门控

唤醒词判定
检索

RAG 批量召回
LLM

流式生成+句级TTS预取
安全过滤

内容+危机检测
TTS

文本转语音
Avatar

音素时间线
脚本

广播预置

检索节点有一个值得一提的低成本优化:6 个知识库数据集批量并行查询,串行 14.2 秒压到 0.4 秒;命中结果 Top-4 注入 prompt,每段截断 220 字,并把温度压到 0.1 防编造。

口型驱动则是一条独立的总线 (MouthAudioBus):谁有真实音频电平,谁就通过 setExternalLevel 接管口型;没有外部驱动时,Avatar 随 TTS 播放自驱动。设计初衷很美好,但它为后面「有声不动」埋了一颗雷。

1.3 2D DPK 渲染:一张底图 + 12 张口型贴片

数字人形象不是 3D 模型,而是一套叫 DPK 的位图资产:一张 base 底图 + 嘴部 ROI 区域的 12 张口型贴片 + 眉/眼表情贴片 。渲染时在嘴部 ROI 内做预乘 alpha 插值合成,ROI 之外字节级不变------这从物理上杜绝了整图重绘带来的闪烁。

下面是文文老师(传统文化人格)的 12 类音素口型真实资产,从静默到各发音口型:

12 类音素覆盖了汉语发音的全部口型族:MBP(双唇闭合 b/p/m)、FV(唇齿 f)、SIB(齿龈 s 族)、RETRO(翘舌 zh/ch/sh/r)、TDN、AA(大开口 a)、EE、IH、OO、UU、YU(撮口 ü 族)、SIL(静默)。

1.4 L3 时间线唇同步:文本直接算出嘴型

有了口型贴片,问题变成「什么时刻贴哪张嘴」。TTS 链路(文本在我们手里)用的是L3 时间线算法------一个确定性纯函数,同输入必同输出:

  1. 汉字 → 拼音:全量拼音字典(pypinyin 离线生成,2 万+ 字,语义匹配率 ≥95%),未收录回退内置子集;
  2. 拼音 → 音素单元:拆成「声母? + 韵母 + 鼻尾?」,各单元映射到 viseme 并赋时长权重(韵母 1.0 / 鼻尾 0.9 / 声母 0.35);
  3. 时长分配:按权重把总时长切给各单元,Σ 时长严格等于音频时长;
  4. RMS 精修:低能量单元锚定为 SIL,音节边界在 ±30% 窗内移向 RMS 梯度最小处;
  5. 整体前移 30ms:嘴先动、声后到(anticipatory offset),把感知延迟压在 50ms 预算内。

非汉字走 g2v 多语言规则,覆盖中/英/日/俄/西/法/德/意/葡/韩 10 种语言。整条时间线单句编译平均 <25ms,唇音互相关 ≥0.85。

但 RTC 链路没有文本时间线 ------大脑和语音全在云端,本地只有一条音频轨。这条链路的口型只能靠实测 RMS 电平驱动(Web Audio AnalyserNode 每 20ms 算一次均方根)。两条口型轨道各管各的链路,而第一战的 bug,就出在 RTC 这条轨道上。

1.5 五级降级:CPU 吃紧时丢车保帅

教室大屏的盒子算力有限,课间高峰 CPU 会波动。我们做了一个五级降级控制器,CPU 负载升高时逐级「丢车保帅」:

级别 触发(CPU 负载) 动作
L0 < 0.45 全开:填充微表演 + 视线扫视 + 头部微摆
L1 > 0.55 关填充粒子(「嗯...」预备嘴脉冲等装饰)
L2 > 0.70 关视线扫视
L3 > 0.82 关头部微摆/呼吸
L4 > 0.92 锁 30fps 渲染节流

两条铁律:口型时间线和表情时间线永不降级(再卡也要保证说话张嘴);升降级带 1500ms 迟滞,防止在阈值边界抖动。控制器是确定性的------CPU 负载由外部注入,内部无随机、无定时器,可以直接拿恒定负载做单测。

1.6 六个人格与场景化管线

校园里不是一个数字人打天下,而是六个人格各司其职,每个人格有独立的外观、角色定位、LLM/TTS 选型和安全配置:

人格 角色 说明
王老师 咨询陪伴(默认主形象)
李老师 校史讲解
小葵 心理辅导(唯一开启危机干预)
文文老师 传统文化(诗词/书法/民俗)
百科知识助手 科普讲解
活动讲解员 活动接待(严格唤醒词门控)

人格之上是场景化管线:接待场景(严格唤醒词、温度 0.0、检索必过)、自由场景(宽松唤醒、温度 0.7)、广播场景(只播预置脚本,不过 LLM)。同一套节点,按场景 JSON 配置组合出不同行为------这是「一个系统服务全校」的关键。

1.7 校园安全网:双层危机干预

面向小学生的系统,安全是硬需求。心理辅导人格「小葵」开启了双层危机干预

  • 第一层:关键词检测。覆盖自伤/轻生/欺凌/家暴四类,50+ 短语,优先短语而非单字防误报;输入先做归一化(去空格/标点/换行),「我 想 自 杀」这类拆字绕过无效;
  • 第二层:LLM 语义判定 。关键词没命中时,用一个只回答 YES/NO 的初筛 prompt 兜住隐晦表述(如「有时候真想一觉睡过去再也不醒来」)。LLM 不可用时放行------宁可漏判交给老师,不可误拦正常倾诉。

命中即 block_and_notify:拦截当前回答、播放安抚话术、大屏弹出 12355 心理援助热线卡片、后台通知管理员。此外还有独立于人格配置的全局危机检测和内容过滤五分类(涉政/色情/暴力/辱骂/儿童不宜),流式输出也能拦住被 token 切断的敏感词。

2025 年一项针对 29 个心理健康聊天机器人的研究里,没有一个通过危机应对评估。校园场景的安全机制不能靠「模型自觉」,必须工程化兜底------这是我们做双层的动机。

家底交代完,下面进入三线攻坚。


二、第一战:口型同步之「有声不动」

2.1 现象:最伤自尊的 bug

大屏状态栏有一个我自研的口型异常自检 :实时监测 audioLevel > 0.02 而嘴型值 ≤ 0.03 持续超过 5 秒,就亮出红色徽章------「口型异常:有声不动」

它精准地出现在了最尴尬的时刻:互动模式下,数字人声音清晰流畅,脸却面无表情,像一个假装在说话的哑剧演员。

2.2 排查:两套「真值系统」打架

如 1.4 节所说,RTC 链路的口型靠实测 RMS 驱动。但深挖代码后发现,互动模式下口型电平其实有两个来源

来源 A:SDK 音量上报 ------火山 RTC SDK 的 onRemoteAudioPropertiesReport 回调,按 uid 上报每个用户的音量。我们根据 AI 用户的 uid 找到她的音量,除以 150 归一化:

ts 复制代码
// 旧逻辑:SDK 音量上报驱动 speaking + 音量
e.on(VERTC.events.onRemoteAudioPropertiesReport, (reports: any) => {
  for (const r of reports) {
    if (r.userId === this.agentUserId) {
      const vol = r.linearVolume ?? 0
      const speaking = vol > 50
      if (speaking !== this.speaking) {
        this.speaking = speaking
        this.cb.onAgentSpeaking?.(speaking)
      }
      this.cb.onAgentVolume?.(Math.min(1, vol / 150))
    }
  }
})

来源 B:AnalyserNode 实测 RMS------拿到 AI 的远端音频轨后,接 Web Audio 的 AnalyserNode,每 20ms 算一次 RMS(均方根电平),经过低通滤波和迟滞阈值(>0.12 判定开口 / <0.05 判定闭合)输出平滑的口型电平。

问题来了。来源 B 的输出被来源 A 翻转的 this.speaking 标志门控

ts 复制代码
// 旧逻辑:实测电平输出前,先检查 speaking(由 SDK 上报翻转)
this.rmsTimer = window.setInterval(() => {
  const rms = /* 计算 RMS */
  const open = smooth > 0.12 ? true : smooth < 0.05 ? false : open
  if (!this.speaking) {
    this.cb.onAgentVolume?.(0)   // ← speaking 为 false,实测电平直接清零!
    return
  }
  this.cb.onAgentVolume?.(smooth)
}, 20)

于是形成了一条完美的死亡连锁:
#mermaid-svg-cr1CkCgtL5ypLqSb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cr1CkCgtL5ypLqSb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cr1CkCgtL5ypLqSb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cr1CkCgtL5ypLqSb .error-icon{fill:#552222;}#mermaid-svg-cr1CkCgtL5ypLqSb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cr1CkCgtL5ypLqSb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cr1CkCgtL5ypLqSb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cr1CkCgtL5ypLqSb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cr1CkCgtL5ypLqSb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cr1CkCgtL5ypLqSb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cr1CkCgtL5ypLqSb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cr1CkCgtL5ypLqSb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cr1CkCgtL5ypLqSb .marker.cross{stroke:#333333;}#mermaid-svg-cr1CkCgtL5ypLqSb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cr1CkCgtL5ypLqSb p{margin:0;}#mermaid-svg-cr1CkCgtL5ypLqSb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-cr1CkCgtL5ypLqSb .cluster-label text{fill:#333;}#mermaid-svg-cr1CkCgtL5ypLqSb .cluster-label span{color:#333;}#mermaid-svg-cr1CkCgtL5ypLqSb .cluster-label span p{background-color:transparent;}#mermaid-svg-cr1CkCgtL5ypLqSb .label text,#mermaid-svg-cr1CkCgtL5ypLqSb span{fill:#333;color:#333;}#mermaid-svg-cr1CkCgtL5ypLqSb .node rect,#mermaid-svg-cr1CkCgtL5ypLqSb .node circle,#mermaid-svg-cr1CkCgtL5ypLqSb .node ellipse,#mermaid-svg-cr1CkCgtL5ypLqSb .node polygon,#mermaid-svg-cr1CkCgtL5ypLqSb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cr1CkCgtL5ypLqSb .rough-node .label text,#mermaid-svg-cr1CkCgtL5ypLqSb .node .label text,#mermaid-svg-cr1CkCgtL5ypLqSb .image-shape .label,#mermaid-svg-cr1CkCgtL5ypLqSb .icon-shape .label{text-anchor:middle;}#mermaid-svg-cr1CkCgtL5ypLqSb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cr1CkCgtL5ypLqSb .rough-node .label,#mermaid-svg-cr1CkCgtL5ypLqSb .node .label,#mermaid-svg-cr1CkCgtL5ypLqSb .image-shape .label,#mermaid-svg-cr1CkCgtL5ypLqSb .icon-shape .label{text-align:center;}#mermaid-svg-cr1CkCgtL5ypLqSb .node.clickable{cursor:pointer;}#mermaid-svg-cr1CkCgtL5ypLqSb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cr1CkCgtL5ypLqSb .arrowheadPath{fill:#333333;}#mermaid-svg-cr1CkCgtL5ypLqSb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cr1CkCgtL5ypLqSb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cr1CkCgtL5ypLqSb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cr1CkCgtL5ypLqSb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cr1CkCgtL5ypLqSb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cr1CkCgtL5ypLqSb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cr1CkCgtL5ypLqSb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cr1CkCgtL5ypLqSb .cluster text{fill:#333;}#mermaid-svg-cr1CkCgtL5ypLqSb .cluster span{color:#333;}#mermaid-svg-cr1CkCgtL5ypLqSb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-cr1CkCgtL5ypLqSb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cr1CkCgtL5ypLqSb rect.text{fill:none;stroke-width:0;}#mermaid-svg-cr1CkCgtL5ypLqSb .icon-shape,#mermaid-svg-cr1CkCgtL5ypLqSb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cr1CkCgtL5ypLqSb .icon-shape p,#mermaid-svg-cr1CkCgtL5ypLqSb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cr1CkCgtL5ypLqSb .icon-shape .label rect,#mermaid-svg-cr1CkCgtL5ypLqSb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cr1CkCgtL5ypLqSb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cr1CkCgtL5ypLqSb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cr1CkCgtL5ypLqSb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 恒为 0
门控清零
SDK 音量上报

uid 不匹配 / 标度不符
speaking 恒 false
AnalyserNode 实测电平被丢弃
口型电平恒 0
有声不动
onAgentSpeaking 不翻转
AvatarStage 不切 speaking 态

SDK 的音量上报为什么不可靠? 因为它依赖两个我们无法完全掌控的约定:一是 userId 匹配(云端合流模式下的 uid 和我们 join 时注册的 agentUserId 可能对不上);二是音量标度(linearVolume 的取值范围和阈值 50 在不同版本/模式下表现不一)。任何一个环节失配,speaking 就永远是 false------而它一 false,唯一可靠的实测电平也被连带清零。

更隐蔽的是渲染端的第二重门控。RigAvatar 里有这样一行:

tsx 复制代码
// 旧逻辑:avatar state 不是 speaking 时,嘴型硬归零
const finalMouthOpen = isSpeaking ? mouthSmooth : 0

即便电平总线把 RTC 的电平送到了 Avatar,只要 avatar state ≠ speaking(React 状态经 rtcSpeaking → App.tsx → AvatarStage props 一路传播,任何一环竞态断链),嘴型照样被归零。两重门控叠加,断链概率翻倍。

2.3 修复:让「实测」成为唯一真值

原则很简单:音频轨本身就是说话与否的最强真值。AnalyserNode 挂的就是 AI 的远端音频轨,实测 RMS 高 = 她在出声,这是物理事实,不需要任何 uid 约定。

修复分三刀:

第一刀:speaking 由实测电平直接翻转,SDK 上报降级为纯兜底:

ts 复制代码
// volcRtc.ts ------ 新逻辑
this.rmsTimer = window.setInterval(() => {
  const rms = /* 计算 RMS */
  const lvl = Math.min(1, Math.max(0, (rms - 0.008) * 6))
  smooth += (lvl - smooth) * 0.4                    // 低通滤波
  const open = smooth > 0.12 ? true : smooth < 0.05 ? this.open : true
  // ★ speaking 改由实测电平直接翻转:不再依赖 SDK 上报的 uid/标度
  if (open !== this.speaking) {
    this.speaking = open
    this.cb.onAgentSpeaking?.(open)
  }
  this.cb.onAgentVolume?.(open ? smooth : 0)
}, 20)
ts 复制代码
// SDK 音量上报:仅作兜底链(AnalyserNode 接线失败时才生效)
e.on(VERTC.events.onRemoteAudioPropertiesReport, (reports: any) => {
  if (this.usingRealAudio) return   // ← 真音频链下短路,两源不再打架
  /* 原有 speaking 翻转 + onAgentVolume 逻辑保留 */
})

第二刀:渲染端门控放宽------有真实驱动源时,即使 avatar state 传播断链,也不硬归零:

tsx 复制代码
// RigAvatar.tsx ------ 新逻辑
// 有真实驱动源(TTS 接线 / RTC 外部电平)时,state≠speaking 也允许驱动:
// 状态传播断链(如 rtcSpeaking 竞态)不再造成「有声不动」
if (isSpeaking || hasRealDriver) { /* ... 驱动骨骼 ... */ }

const finalMouthOpen = isSpeaking || hasRealDriver ? mouthSmooth : 0

第三刀:onAgentSpeaking 的回调链打通 ------RealtimeDialogPanel → useRealtimeDialog → App.tsx → AvatarStage 一路把实测翻转的 speaking 传上去,保证大屏的状态机(说话/聆听/待机)与口型同源。

2.4 验证

重启大屏后进入互动模式:数字人开口瞬间口型同步开合,音量起伏与嘴型开度肉眼可见地一致;「口型异常」徽章再未亮起。迟滞阈值(0.12/0.05)+ 20ms 采样 + 0.4 低通的组合,让嘴型既不抖动也不迟滞。

🔑 这一战的教训 :当你有两条「真值」来源时,必须明确谁是主、谁是从,并且从源绝不允许门控主源。我们最初的代码让一个不可靠的约定(uid 匹配)门控了物理事实(实测电平),这是典型的「信任错位」。


三、第二战:拒答根治之「我不知道」

3.1 现象:礼貌而顽固的拒绝

口型修好了,新的问题更显眼了。看这两段真实对话(摘自后端日志):

用户 :今天几月几号?

王老师:我没法查看今天的日期哦,你可以看看手机或问问老师~
用户 :今天济宁的天气如何?

王老师:抱歉,这个问题不在我目前掌握的资料范围内,我不能随意回答哦。

一个连日期都说不了天的数字人,在校园场景基本等于宣判死刑。而且注意:这两条对话都发生在互动模式(RTC 链路下)

3.2 排查:一个「过度尽责」的 prompt

RTC 链路的大脑是火山云端的 StartVoiceChat(云端 ASR→LLM→TTS 一体)。我们能控制的只有启动任务时下发的 LLMConfig.SystemMessages。翻出旧版构造函数:

python 复制代码
# 旧版 build_llm_system_messages(节选)
seg.append(
    f"今天是{now:%Y年%m月%d日}。"                      # ← 只有日期,没有星期和时刻
    "学校年份、人数、社团等校情一律以「校园资料」为准,"
    "禁止使用资料外的年份或数字,禁止编造。"            # ← 一刀切的保守约束
)

再看 LLMConfig:Temperature: 0.1(极低,鼓励保守复读)。

三重绞索套在云端 LLM 脖子上:

  1. 只给日期不给时刻------「现在几点了」无据可答;
  2. 「禁止使用资料外的数字」 ------这条约束的初衷是防止模型编造校情(比如把自己训练数据里的年份当校史),但它同时禁止了回答「今天几号」这种需要说出具体日期的问题------日期就是「资料外的数字」!
  3. Temperature 0.1------模型宁可复读拒答模板,也不冒险发挥。

而云端 LLM 没有联网能力,「今天天气」对它来说就是既无工具、又无数据、还被约束吓着的三无问题。拒答,是它在规则内的最优解。

3.3 修复:三分手术

第一刀:时间基准给足 + 约束精准化 。新版 build_llm_system_messages

python 复制代码
seg.append(
    "# 实时基准与约束\n"
    f"今天是 {now.strftime('%Y年%m月%d日')}(星期{weekdays[now.weekday()]}),"
    f"当前时间约 {now.strftime('%H:%M')}。"
    "日期、星期、时间类问题直接按本基准回答,不要说无法查询。\n"
    "学校年份、人数、社团等校情一律以「校园资料」为准,禁止编造校情数字"
    "(尤其不得自报 2026)。\n"                       # ← 保守约束收窄到校情数字
    "天气、新闻、常识等日常问题直接回答,不要拒答,不要说「无法查询」"
    "「不在资料范围内」;若无实时数据,大方给出常识性回答并说明可能不是最新。"
    "回答简洁友好,面向小学生口播。"
)

注意约束的精确收窄 :从「禁止资料外的一切数字」收窄为「禁止编造校情数字」。防编造的目标不变,但不再误伤日期时间。

第二刀:温度放宽,允许模型自然表达而不是复读模板。

第三刀(最关键):给云端 LLM 补上「联网能力」------用 ExternalPromptsForLLM 把实时快照推过去。

火山 RTC 提供了一个 Command:UpdateVoiceChat + ExternalPromptsForLLM,可以给下一轮对话 注入上下文。于是我们的策略是:任务启动后 2 秒,后台异步去联网抓天气+热点快照,抓到就注入

python 复制代码
def schedule_live_context(self, room_id: str, task_id: str, delay_s: float = 2.0) -> None:
    """任务启动后后台拉联网快照(天气/热点)并经 inject_context 注入下一轮。

    云端 LLM 无联网能力,等用户开口再查就晚了------首句延迟会多出整个
    搜索耗时。所以抢在对话开始前把快照推到云端,模型答「今天天气」
    时直接引用,零额外延迟。
    """
    loop = asyncio.get_running_loop()
    loop.create_task(self._push_live_context(room_id, task_id, delay_s))

async def _push_live_context(self, room_id: str, task_id: str, delay_s: float = 2.0):
    if delay_s > 0:
        await asyncio.sleep(delay_s)
    try:
        from backend.services.web_search import web_search
        city = _school_city()   # 从校名「济宁市东门小学」抠出「济宁市」
        weather, news = await asyncio.gather(
            asyncio.wait_for(web_search(f"{city}今天天气", top_k=2), timeout=14.0),
            asyncio.wait_for(web_search("今天新闻热点", top_k=3), timeout=14.0),
            return_exceptions=True,
        )
        lines: list[str] = []
        if isinstance(weather, list) and weather:
            lines += [f"- 天气:{it['title']}:{it['snippet']}" for it in weather]
        if isinstance(news, list) and news:
            lines += [f"- 热点:{it['title']}:{it['snippet']}" for it in news]
        if not lines:
            return
        stamp = datetime.datetime.now().strftime("%H:%M")
        msg = (
            f"【实时快照 {stamp} 更新】以下是刚刚联网查到的实时信息,"
            "用户问天气/新闻时优先采用:\n" + "\n".join(lines)
        )
        await asyncio.to_thread(self.inject_context, room_id, task_id, msg)
        log.info("[rtc] live context 注入 %d 条快照 task=%s", len(lines), task_id)
    except Exception as e:
        log.warning("[rtc] live context 注入失败(忽略): %s", e)

在后端日志里看到的真实验证结果:

text 复制代码
16:59:21 [INFO] StartVoiceChat 200 OK
16:59:26 [INFO] UpdateVoiceChat 200 OK        ← 快照到达火山云端
[rtc] live context 注入 5 条快照 task=a7e8d301...

整个链路时序:
wttr.in / 中新网RSS 火山云端(StartVoiceChat) FastAPI 后端 用户(大屏) wttr.in / 中新网RSS 火山云端(StartVoiceChat) FastAPI 后端 用户(大屏) #mermaid-svg-5uSWtmbJV5oTVKzs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5uSWtmbJV5oTVKzs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5uSWtmbJV5oTVKzs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5uSWtmbJV5oTVKzs .error-icon{fill:#552222;}#mermaid-svg-5uSWtmbJV5oTVKzs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5uSWtmbJV5oTVKzs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5uSWtmbJV5oTVKzs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5uSWtmbJV5oTVKzs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5uSWtmbJV5oTVKzs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5uSWtmbJV5oTVKzs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5uSWtmbJV5oTVKzs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5uSWtmbJV5oTVKzs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5uSWtmbJV5oTVKzs .marker.cross{stroke:#333333;}#mermaid-svg-5uSWtmbJV5oTVKzs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5uSWtmbJV5oTVKzs p{margin:0;}#mermaid-svg-5uSWtmbJV5oTVKzs .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5uSWtmbJV5oTVKzs text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-5uSWtmbJV5oTVKzs .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5uSWtmbJV5oTVKzs .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-5uSWtmbJV5oTVKzs .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-5uSWtmbJV5oTVKzs .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-5uSWtmbJV5oTVKzs #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-5uSWtmbJV5oTVKzs .sequenceNumber{fill:white;}#mermaid-svg-5uSWtmbJV5oTVKzs #sequencenumber{fill:#333;}#mermaid-svg-5uSWtmbJV5oTVKzs #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-5uSWtmbJV5oTVKzs .messageText{fill:#333;stroke:none;}#mermaid-svg-5uSWtmbJV5oTVKzs .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5uSWtmbJV5oTVKzs .labelText,#mermaid-svg-5uSWtmbJV5oTVKzs .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-5uSWtmbJV5oTVKzs .loopText,#mermaid-svg-5uSWtmbJV5oTVKzs .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-5uSWtmbJV5oTVKzs .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5uSWtmbJV5oTVKzs .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-5uSWtmbJV5oTVKzs .noteText,#mermaid-svg-5uSWtmbJV5oTVKzs .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-5uSWtmbJV5oTVKzs .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5uSWtmbJV5oTVKzs .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5uSWtmbJV5oTVKzs .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5uSWtmbJV5oTVKzs .actorPopupMenu{position:absolute;}#mermaid-svg-5uSWtmbJV5oTVKzs .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-5uSWtmbJV5oTVKzs .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5uSWtmbJV5oTVKzs .actor-man circle,#mermaid-svg-5uSWtmbJV5oTVKzs line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-5uSWtmbJV5oTVKzs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 2 秒后,后台异步执行 ↓(不阻塞对话) 下一轮对话起,模型带着实时快照答题 POST /rtc/start (点击「实时对话」) StartVoiceChat + 新版 SystemMessages (完整时间基准+软化的约束) 200 OK 会话建立,可以开口说话 并行抓取 天气快照 + 热点快照 结构化实时数据 UpdateVoiceChat(ExternalPromptsForLLM) 「今天济宁的天气如何?」 「济宁天气晴朗,22~35°C,当前31°C,湿度44%...」

🔑 设计亮点 :注入发生在对话开始前的空窗期,用户开口提问时快照已经在云端等着------问答延迟零增加。如果等用户问「今天天气」再现场搜索,首句延迟会凭空多出好几秒。

3.4 文本链路的同款手术

文本流链路有个对称 bug:编排管线的 RetrievalNode 在构建最终 prompt 时,用 _build_prompt(...) 的返回值整体覆盖ctx.system_prompt,把 dispatch 阶段注入的时间基准和联网检索结果全部抹掉。修复成追加式合并后,文本链路同样实现了时间直答。

这个 bug 教会我们:在多阶段编排系统里,任何「赋值」都可能是「覆盖」,下游永远要假设上游给的东西是有限的,合并优于替换。


四、第三战:实时数据直达源------当 Bing 也靠不住

4.1 新问题:搜索回来了,但搜了个寂寞

第二战修复后我们跑了一轮实测,「今天济宁的天气如何?」确实不拒答了------但回答是:

「济宁九月通常晴天为主,气温在 22 到 30 度之间......建议大扫除时注意通风防尘哦。」

一本正经的气候常识,不是今天的天气。翻调试日志发现,Bing 搜索对这类 query 的返回是:

  • 搜「天气」→ 百度百科、政府门户网站首页------没有一条有实时温度
  • 搜「今天新闻热点」→ 今日热榜/央视网/新浪网的门户首页描述------没有一条是具体新闻。

通用搜索引擎的 SEO 排序逻辑,对「实时性」这个维度几乎是盲的。

4.2 方案:直达源并行抢答

思路:识别到天气类问题就直连免费天气 API,识别到新闻类问题就直连新闻源 RSS,直达结果与搜索引擎结果并行赛跑,直达的置顶(这也是 1.1 节低成本哲学的延续------wttr.in 免 key、RSS 免费,零 API 费用):
#mermaid-svg-5QpeUmz0HGJMA0nv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5QpeUmz0HGJMA0nv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5QpeUmz0HGJMA0nv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5QpeUmz0HGJMA0nv .error-icon{fill:#552222;}#mermaid-svg-5QpeUmz0HGJMA0nv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5QpeUmz0HGJMA0nv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5QpeUmz0HGJMA0nv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5QpeUmz0HGJMA0nv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5QpeUmz0HGJMA0nv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5QpeUmz0HGJMA0nv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5QpeUmz0HGJMA0nv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5QpeUmz0HGJMA0nv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5QpeUmz0HGJMA0nv .marker.cross{stroke:#333333;}#mermaid-svg-5QpeUmz0HGJMA0nv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5QpeUmz0HGJMA0nv p{margin:0;}#mermaid-svg-5QpeUmz0HGJMA0nv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5QpeUmz0HGJMA0nv .cluster-label text{fill:#333;}#mermaid-svg-5QpeUmz0HGJMA0nv .cluster-label span{color:#333;}#mermaid-svg-5QpeUmz0HGJMA0nv .cluster-label span p{background-color:transparent;}#mermaid-svg-5QpeUmz0HGJMA0nv .label text,#mermaid-svg-5QpeUmz0HGJMA0nv span{fill:#333;color:#333;}#mermaid-svg-5QpeUmz0HGJMA0nv .node rect,#mermaid-svg-5QpeUmz0HGJMA0nv .node circle,#mermaid-svg-5QpeUmz0HGJMA0nv .node ellipse,#mermaid-svg-5QpeUmz0HGJMA0nv .node polygon,#mermaid-svg-5QpeUmz0HGJMA0nv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5QpeUmz0HGJMA0nv .rough-node .label text,#mermaid-svg-5QpeUmz0HGJMA0nv .node .label text,#mermaid-svg-5QpeUmz0HGJMA0nv .image-shape .label,#mermaid-svg-5QpeUmz0HGJMA0nv .icon-shape .label{text-anchor:middle;}#mermaid-svg-5QpeUmz0HGJMA0nv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5QpeUmz0HGJMA0nv .rough-node .label,#mermaid-svg-5QpeUmz0HGJMA0nv .node .label,#mermaid-svg-5QpeUmz0HGJMA0nv .image-shape .label,#mermaid-svg-5QpeUmz0HGJMA0nv .icon-shape .label{text-align:center;}#mermaid-svg-5QpeUmz0HGJMA0nv .node.clickable{cursor:pointer;}#mermaid-svg-5QpeUmz0HGJMA0nv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5QpeUmz0HGJMA0nv .arrowheadPath{fill:#333333;}#mermaid-svg-5QpeUmz0HGJMA0nv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5QpeUmz0HGJMA0nv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5QpeUmz0HGJMA0nv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5QpeUmz0HGJMA0nv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5QpeUmz0HGJMA0nv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5QpeUmz0HGJMA0nv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5QpeUmz0HGJMA0nv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5QpeUmz0HGJMA0nv .cluster text{fill:#333;}#mermaid-svg-5QpeUmz0HGJMA0nv .cluster span{color:#333;}#mermaid-svg-5QpeUmz0HGJMA0nv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5QpeUmz0HGJMA0nv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5QpeUmz0HGJMA0nv rect.text{fill:none;stroke-width:0;}#mermaid-svg-5QpeUmz0HGJMA0nv .icon-shape,#mermaid-svg-5QpeUmz0HGJMA0nv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5QpeUmz0HGJMA0nv .icon-shape p,#mermaid-svg-5QpeUmz0HGJMA0nv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5QpeUmz0HGJMA0nv .icon-shape .label rect,#mermaid-svg-5QpeUmz0HGJMA0nv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5QpeUmz0HGJMA0nv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5QpeUmz0HGJMA0nv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5QpeUmz0HGJMA0nv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是





用户 query
15 分钟

查询缓存命中?
直接返回
含天气词?

天气/气温/下雨/风力
含热点词?

新闻/热点/头条
wttr.in 直达

结构化实时天气
中新网滚动新闻 RSS

最新标题
asyncio.gather 并行赛跑
Bing → DDG 引擎搜索
直达源置顶 + 引擎结果垫后
写回查询缓存 15min
返回 top_k

天气直达源wttr.in,免 key,j1 JSON 格式带当前气温/湿度/今日温度区间:

python 复制代码
async def _weather_direct(city: str) -> list[dict[str, Any]]:
    """wttr.in 免费天气(免 key)。返回 0..1 条结构化结果;失败静默 []。"""
    url = f"https://wttr.in/{quote_plus(city)}?format=j1&lang=zh"
    async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as c:
        r = await c.get(url, headers={"User-Agent": _UA, "Accept-Language": "zh-CN,zh;q=0.9"})
        r.raise_for_status()
        data = r.json()
    cur = (data.get("current_condition") or [{}])[0]
    # ... 解析 temp_C / humidity / 今日 mintempC~maxtempC ...
    return [{
        "title": f"{place}实时天气", "snippet": snippet, "url": url,
        "temp_c": temp_c, "desc": desc or "天气",
    }]

新闻直达源:中新网滚动新闻 RSS,CDATA 标题直接就是可播报的短句,全站共享 10 分钟缓存(不可能每问一次都去拉)。

4.3 两个魔鬼细节

细节一:城市抠取。 用户问「今天济宁的天气如何?」------注意,「济宁」后面没有「市」。我们第一版正则要求城市必须带行政区后缀,结果没抠到城市,wttr.in 按 IP 定位回了「本地天气」(学校机房在济宁,歪打正着但不可控)。最终用四个模式依序尝试:

python 复制代码
_CITY_PATTERNS = (
    re.compile(r"([\u4e00-\u9fa5]{2,6}?(?:市|县|区|旗))"),                                   # 济宁市
    re.compile(r"(?:今天|今日|现在|明天)([\u4e00-\u9fa5]{2,4}?)(?:的)?(?:天气|气温|温度|下雨)"), # 今天济宁的天气
    re.compile(r"([\u4e00-\u9fa5]{2,4})(?=今天|今日|现在)"),                                  # 济宁今天天气
    re.compile(r"^(?!今天|今日|现在|明天)([\u4e00-\u9fa5]{2,4}?)(?:的)?(?:天气|气温|温度|下雨)"), # 上海天气怎么样
)

第二个模式必须排在第四个之前------否则「今天济宁的天气」会被句首模式抠出「今天济宁」,wttr.in 直接解析失败。

细节二:英文天气词中文化。 wttr.inlang=zh 参数对 j1 格式经常不生效,返回的是 Smoky hazePartly cloudy 这种英文词。直接透传给前端,大屏就会显示「27°C · Smoky haze」。我们做了一张 40 条的 WMO 词表映射,外加最长关键词包含兜底

python 复制代码
def _zh_weather_desc(en: str) -> str:
    """先精确匹配,未命中再做关键词包含兜底。

    wttr.in 的描述存在大量组合词(Smoky haze / Patchy light drizzle 等),
    精确枚举覆盖不全;包含匹配按最长关键词优先(「light rain」不会被
    「rain」截胡),仍无命中时原样返回。
    """
    d = en.strip().lower()
    hit = _WEATHER_DESC_ZH.get(d)
    if hit:
        return hit
    for key in sorted(_WEATHER_DESC_ZH, key=len, reverse=True):
        if key in d:
            return _WEATHER_DESC_ZH[key]
    return en

从此不管 wttr.in 冒出什么新组合词,Light freezing fog 也能自动命中「冻雾」。

4.4 验证

同一个问题,修复后数字人的回答:

「济宁天气晴朗,22 到 35 度,当前 31 度,湿度 44%,今天可以放心洗晒衣物哦~」

------温度、湿度全部来自 wttr.in真实实时数据(上线当天恰逢济宁转阴,第二天卡片自动从「晴」变成「阴」再到「烟霾」,全程无人工干预)。

新闻问答同样质变:

「为你找到了几条今日要闻:第一条,徐景颜......第二条,汪侃......第三条,关于可控核聚变研究的新进展......」

------每一条都是中新网 RSS 的真实今日标题


五、顺手收尾:大屏状态条的实时化

三战打完,回看大屏顶部的状态条:天气卡片显示的是写死的 mock(26°C 多云),「今日咨询 128」也是假的。这不巧了吗------第三战攒下的直达源正好复用。

后端加一个 kiosk 公开端点:

python 复制代码
@app.get("/api/dashboard/status")
async def dashboard_status():
    """天气走 wttr.in 直达源(进程内 10 分钟缓存,免费服务限频),
    今日咨询取 conversation_logs 当日计数(60 秒缓存,避免轮询打库)。
    天气抓取失败返回 null(前端保持上一次显示),绝不 500。"""

前端 CampusStatus 组件挂载即拉、每 5 分钟轮询,时钟 30 秒刷新,失败保持上次显示:

tsx 复制代码
// 挂载即拉,此后每 5 分钟轮询;失败保留上次显示
useEffect(() => {
  let alive = true
  const load = async () => {
    try {
      const s = await getDashboardStatus()
      if (!alive) return
      if (s.weather && s.weather.temp_c !== null) setTemp(s.weather.temp_c)
      if (s.weather?.desc) setDesc(s.weather.desc)
      if (s.consultations !== null) setConsult(s.consultations)
    } catch { /* 后端不可达:保持当前显示,下一轮再试 */ }
  }
  void load()
  const t = window.setInterval(load, 1000 * 300)
  return () => { alive = false; window.clearInterval(t) }
}, [])

现在「今日咨询」显示的是 conversation_logs 表的当日真实计数,每场对话结束自动 +1;天气每 5 分钟自动刷新。


六、效果验收:从拒答到秒答

三线修复全部落地后,我们用固定问题集做了回归验证(后端真实日志):

提问 修复前 修复后
今天几月几号? ❌「我没法查看今天的日期哦...」 ✅「今天是2026年9月7日,星期一。」
现在几点了? ❌ 拒答 ✅「现在是2026年9月7日(星期一)下午4点54分。」
今天济宁的天气如何? ❌「不在我目前掌握的资料范围内」 ✅「济宁天气晴朗,22~35°C,当前31°C,湿度44%...」
今天有什么新闻热点? ❌ 门户首页描述或拒答 ✅ 播报中新网 3 条真实今日新闻
互动模式口型 ❌「口型异常:有声不动」 ✅ 实时同步,异常徽章再未触发
RTC 实时快照 ❌ 无 ✅ 任务启动即注入 5 条(天气2+热点3),UpdateVoiceChat 200

系统层面的关键指标(来自验收测试与性能分析):

指标 数值
唇音互相关(口型 vs 音频) ≥ 0.85
口型方差断言(6/6 人格通过) ≈ 0.062
打断响应(用户插话→闭嘴) ≤ 150ms
口型-音频启动延迟 ≤ 200ms
首句响应(优化后目标) p50 ≤ 2.5s / p90 ≤ 3.0s
8 小时浸泡(内存增长/崩溃) ≤ 10% / 0 次
渲染资源占用 CPU ≤ 30%,GPU ≤ 50%

七、复盘:五条血泪工程心得

心得一:双真值必打架,先定主从

口型之战的本质是两个数据源都想当真值,而弱的那个还在门控强的那个 。任何系统里出现两套说话机制(SDK 上报 vs 实测信号),第一件事不是修 bug,是明确主从关系,把从源降级为兜底,物理上杜绝互相覆盖。

心得二:保守约束是一把散弹枪

「禁止使用资料外的数字」这条 prompt 约束,成功防住了校情编造,也成功防住了日期回答。LLM 的约束必须像手术刀一样窄------你要防的到底是什么?「禁止编造校情数字」才是你要的,「禁止资料外数字」是你过度收缩后误伤自己的散弹枪。

心得三:为延迟设计,而不是为正确性设计

实时快照注入的精髓不在「能查到」,而在查的时机------对话开始前的空窗期。用户开口的瞬间,数据已经在云端待命。正确但慢的系统在演示场景里约等于错误。

心得四:可观测性是修 bug 的前置条件

这次攻坚中我们还顺手修了一个日志黑洞:uvicorn 默认 log config 的 root logger 没有 handlerbackend.* 模块的所有 log.info 全被静默丢弃------排查 RTC 注入问题时「日志里什么都没有」一度让我们怀疑代码没被执行。两行 logging.basicConfig 让所有关键链路日志重见天日:

python 复制代码
# uvicorn 默认 log_config 的 root logger 无 handler,backend.* 的日志全被丢弃
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")

没有可观测性的系统,bug 不是不存在,只是你看不见。

心得五:低成本是设计约束,不是降配清单

回看整个系统,最满意的不是哪个单点优化,而是「低成本」作为约束倒逼出的一系列设计:2D 贴片渲染倒逼出 ROI 零闪烁合成和五级降级;免费数据源倒逼出直达源并行赛跑;廉价模型倒逼出 RAG 精准注入和温度分档。约束越紧,设计越巧------这大概是低成本系统唯一的红利。


写在最后

这三个 bug,单看每一个都不难,但它们共同的特点是:都在「跨系统边界」上 ------前端渲染与音频链的边界、编排多阶段之间的边界、云端黑盒与我们信令的边界、搜索引擎与实时数据的边界。边界上的 bug 之所以难查,是因为没有任何一个单点组件是「错的」,错的是组件间的假设

而比修 bug 更值得分享的,是这套系统的定位:不是又一个烧钱的数字人 Demo,而是一个能真正放进教室、长期无人值守运行的低成本方案。如果你也在做教育场景的 AI 落地,希望「低成本不等于低体验」这个命题,能给你一点信心。

如果这篇文对你有一点点启发,欢迎点赞收藏;如果你也在做数字人/大模型应用,欢迎评论区聊聊你踩过的坑------尤其是「AI 有声不动」这种看着像玄学的 bug,很可能只是某个 uid 没对上。


技术栈:FastAPI · WebRTC (火山 RTC/VERTC) · Web Audio API (AnalyserNode/RMS) · React 18 · Electron 33 · 2D DPK 位图渲染 · asyncio · httpx · SQLite + Milvus Lite

关键词:低成本数字人 · AI 数字人 · 口型同步 · 音素时间线 · LLM 拒答 · RTC · 实时上下文注入 · wttr.in · RAG 编排 · 校园 AI 安全

相关推荐
tianxuanjg1 小时前
工业/协作机器人不锈钢精密零件批量加工难点与量产解决方案
人工智能·经验分享·机器人·无人机·制造
LeapMay1 小时前
中国科学院深圳先进技术研究院-深圳职业技术大学联合培养博士后招聘(具身智能/多模态感知/视觉检测方向)
人工智能·计算机视觉·3d·机器人
飞奔的猫1 小时前
锈了儿—素材工作流简化游戏场景元素的生成(三)
人工智能·游戏·个人开发
开源量化GO1 小时前
看到“2026年 Python 与 API”时,用示例和拆解看清关系
人工智能·python
晚安code1 小时前
GPT-Image-2.5 凌晨发布,速度精度交互三升级,Flare 与 Sunburst 怎么选
人工智能·gpt·chatgpt
科技林总1 小时前
OpenSpec是什么
人工智能
LuTshoes2 小时前
spring ai 实战-MCP
java·人工智能·spring
百度Geek说2 小时前
DeepSeek Harness:重新设计 Agent 运行时
人工智能
Ming_studying2 小时前
近期AI热点010|ChatGPT Images 2.5 加入 Sketch:图像生成从文字提示走向草图交互
人工智能·chatgpt·openai·ai绘图·sketch