演示前一晚,数字人还在对校长的一句「今天天气怎么样」礼貌拒绝:「抱歉,这个问题不在我目前掌握的资料范围内」。而屏幕上,她明明在说话,嘴巴却纹丝不动。
这篇文章分两部分:前半部分系统交代这套数字人是怎么搭起来的------它不是烧 GPU 的高保真方案,而是一个跑在教室既有大屏上、无独显、无专用硬件的低成本数字人;后半部分记录我们如何用一个周末,把口型、拒答、实时数据三个看似独立的 bug 追到根、砍到断。全文无注水,每一段都来自真实代码与真实故障,配图全部来自系统真实资产。

目录
- 一、先交代家底:低成本数字人是怎么搭起来的
- [1.1 为什么「低成本」是第一约束](#1.1 为什么「低成本」是第一约束)
- [1.2 总体架构:三条对话链路 + 八节点管线](#1.2 总体架构:三条对话链路 + 八节点管线)
- [1.3 2D DPK 渲染:一张底图 + 12 张口型贴片](#1.3 2D DPK 渲染:一张底图 + 12 张口型贴片)
- [1.4 L3 时间线唇同步:文本直接算出嘴型](#1.4 L3 时间线唇同步:文本直接算出嘴型)
- [1.5 五级降级:CPU 吃紧时丢车保帅](#1.5 五级降级:CPU 吃紧时丢车保帅)
- [1.6 六个人格与场景化管线](#1.6 六个人格与场景化管线)
- [1.7 校园安全网:双层危机干预](#1.7 校园安全网:双层危机干预)
- 二、第一战:口型同步之「有声不动」
- 三、第二战:拒答根治之「我不知道」
- [四、第三战:实时数据直达源------当 Bing 也靠不住](#四、第三战:实时数据直达源——当 Bing 也靠不住)
- 五、顺手收尾:大屏状态条的实时化
- 六、效果验收:从拒答到秒答
- 七、复盘:五条血泪工程心得
- 写在最后
一、先交代家底:低成本数字人是怎么搭起来的
先说清楚系统长什么样、为什么这么选型,后面的 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 时间线算法------一个确定性纯函数,同输入必同输出:
- 汉字 → 拼音:全量拼音字典(pypinyin 离线生成,2 万+ 字,语义匹配率 ≥95%),未收录回退内置子集;
- 拼音 → 音素单元:拆成「声母? + 韵母 + 鼻尾?」,各单元映射到 viseme 并赋时长权重(韵母 1.0 / 鼻尾 0.9 / 声母 0.35);
- 时长分配:按权重把总时长切给各单元,Σ 时长严格等于音频时长;
- RMS 精修:低能量单元锚定为 SIL,音节边界在 ±30% 窗内移向 RMS 梯度最小处;
- 整体前移 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 脖子上:
- 只给日期不给时刻------「现在几点了」无据可答;
- 「禁止使用资料外的数字」 ------这条约束的初衷是防止模型编造校情(比如把自己训练数据里的年份当校史),但它同时禁止了回答「今天几号」这种需要说出具体日期的问题------日期就是「资料外的数字」!
- 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.in 的 lang=zh 参数对 j1 格式经常不生效,返回的是 Smoky haze、Partly 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 没有 handler ,backend.* 模块的所有 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 安全



