一问一答:复古理发厅数字人为什么最终走上了_端到端具身交互_
一问一答:复古理发厅数字人为什么最终走上了"端到端具身交互"
这篇不用"背景 / 接入 / CSS 拆解 / 行业知识"那一套骨架,而是把开发者在改
Theme04_Barber.vue过程中真正会冒出来的问题,一个个问出来、答下去。问的是链路取舍,答的是源码里真实发生的事——顺便把老周那张嘴背后的行业硬知识也交代清楚。
Q1:以前那套「前端自建 DeepSeek 流式 + 前端拆句 + 手写 SSML」的链路,为什么不要了?
因为它把三个不该由前端管的活儿全揽到了前端:
-
流式解析——前端要自己起请求、切 SSE、拼片段;
-
拆句——按标点把长回复切成播报单元,切早了语气断,切晚了等待长;
-
生成 SSML——把每段文本包成
<speak>,还要算停顿。

这三者只要有一个和语音侧不同步,就会出现"字幕到了、口型没到",或者"换了个模型,前端全得改"。更糟的是密钥落在浏览器里。
现在这条路被整体收进端到端 SDK:全局对象 window.XingyunAvatarAgent。前端只负责把一个字符串递出去,剩下的推理、合成、口型、表情、动作由 SDK 侧统一调度。前端不再调 LLM、不拆句、不手写 SSML——这就是迁移的全部动机。
// 加载入口也随之换成了端到端 SDK
loadSDK('https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar_e2e@latest.js')
await waitForSDK('xingyunAvatarAgent')
Q2:那"一条端到端"的对话,从用户按下发送到老周开口,到底经历了什么?
链路短得有点不像话。组件里所有"想让老周说话"的入口,最后都写同一个字段、同一个函数:
const speakText = (text: string) => {
isAvatarSpeaking.value = true;
subtitleText.value = text;
appState.ui.text = text;
appStore.sendMessage().then(() => {
setTimeout(() => {
isAvatarSpeaking.value = false;
}, text.length * 50);
});
};
appState.ui.text = text 是唯一"交接点",appStore.sendMessage() 内部转手给 ask():
async sendMessage(): Promise<string | undefined> {
const { ui, avatar } = appState
if (!ui.text || !avatar.instance) return
try {
// 端到端 SDK 内置对话:Brain 自动 LLM → 自动 speak
await avatar.instance.ask(ui.text)
return ui.text
} catch (error) {
console.error('发送消息失败:', error)
throw error
}
}
一句话概括:speakText**** 写文本 → sendMessage 调 ask → 一条链路直达"LLM 回复 + TTS + 口型 + 表情 + 动作"。
Q3:new window.XingyunAvatarAgent(...) 构造的时候,到底传了什么?
构造函数一次性把"身份 + 网关 + 一堆回调"交清:
const constructorOptions = {
container: containerEl,
appId: APP_ID,
appSecret: APP_SECRET,
enableDebugger: false,
gatewayServer: url.toString(),
onWidgetEvent: (event: any) => {
if (event.type === 'subtitle_on') onSubtitleOn(event.text)
else if (event.type === 'subtitle_off') onSubtitleOff()
},
onStateChange,
agentCallbacks: { onError, onConversationChange, onAgentStateChange },
}
const agent = new window.XingyunAvatarAgent(constructorOptions)
其中 gatewayServer 由常量拼出来:SDK_CONFIG.GATEWAY_URL = "https://nebula-agent.xingyun3d.com/user/v1/ttsa_v2/session",再补两个 query:data_source=2、custom_id=demo。
构造完还没完,真正建立连接要再等 init():
await agent.init({
onDownloadProgress: (progress: number) => { /* … */ },
onClose: () => { onStateChange('') }
})
onDownloadProgress 到位才 resolve 连接 Promise,onClose 负责把状态清空——一个管"进",一个管"出"。
Q4:ask() 到底替我们做了什么?
把上面那套旧链路的三个活儿,全部内置了。await avatar.instance.ask(text) 一次调用,等价于:
-
把文本交给智脑 → 智脑调 LLM 生成回复;
-
回复文本交给 TTS 合成语音;
-
语音驱动口型、表情、眼神、手势、身体动作同步生成(多模态表达)。
多模态表达的意思是:语言、口型、表情、眼神、头部动作、手势和身体动作统一实时生成——统一、连续、同步,老周不再是干巴巴的读表。同一句话会随内容换一副"身体":讲解低渐变时语气沉稳、手掌比出推剪高度;说到"圆脸要两侧推短"时头部微侧、掌心在脸侧比出轮廓;聊到烫染的化学原理时语速放慢、表情专注。
顺带一提,这条链路里"听"和"说"是两条并行的通道(多模态感知):老周正在剪发讲解时,你插一句"顺便帮我修下鬓角",他不需要等你把话听完、也不用把当前这句憋完——感知通道一直开着,插话能被即时接住。听(感知)与说(表达)不共享同一条独占锁。
所谓多模态感知,指的是持续知道理发厅正在发生什么:它不是简单把你说的那半句话转成文字,也不是只做一次关键词命中就完事。真正要解决的是——你一边翻左边悬挂价目牌一边嘀咕"这个多少钱"要被接住;你盯着右侧黄铜镜里"今日排号 27 号"发愣时,系统该主动问你要不要先预约;即使老周自己正在讲一整段渐变工艺,也能继续听见你的插话,让交流继续进行。核心优势:持续感知,而不是一次输入。
前端这边只需要保证一件事:appState.ui.text 里是"人话",不是 SSML、不是分句数组。把「前端自建 LLM 流式 + 前端按标点拆句 + 前端手写 SSML」的三段拼装,收成一次 avatar.instance.ask(text)——LLM 回复 → 语音合成 → 口型/表情/动作一次编排完成,这就是端到端(端对端)的定义:两端之间没有前端插手的环节。
Q5:字幕是怎么拿到的?为什么要监听 subtitle_on / subtitle_off?
字幕不由前端猜,是 SDK 通过 onWidgetEvent 主动推的——就是 Q3 构造参数里 subtitle_on / subtitle_off 那两个分支。store 把它落到全局状态:
onSubtitleOn: (text: string) => { appState.ui.subTitleText = text },
onSubtitleOff: () => { appState.ui.subTitleText = '' },
组件再用 watch 跟随,保证字幕和数字人的嘴同步:
watch(() => appState.ui.subTitleText, (val) => {
subtitleText.value = val || "";
});
字幕框的"正在说"状态靠一个 .speaking 类切换:
.barber-subtitle {
width: 100%;
max-width: 640px;
background: #f7f0e4;
border: 2px solid #c9a227;
border-radius: 6px;
padding: 10px 16px;
transition: all 0.3s ease;
}
.barber-subtitle.speaking {
border-color: #8e2f3c;
box-shadow: 0 0 0 4px rgba(142, 47, 60, 0.12);
}
.barber-subtitle-badge {
font-size: 12px;
font-weight: 600;
color: #8e2f3c;
letter-spacing: 1px;
}
黄铜边框在说话瞬间转成酒红——便宜,但足够传达"老周正在说"。
Q6:怎么知道"这一轮对话真的结束了"?
靠 agentCallbacks 里的真值,而不是前端估时:
agentCallbacks: {
onError(error: any) {
console.error('[AgentError]', error?.code, error?.message || error)
},
onConversationChange(event: any) {
if (event?.state === 'completed') {
console.log('[Conversation] 一轮对话结束')
}
},
onAgentStateChange(state: string) {
console.log('[AgentState]', state)
},
}
onConversationChange 里 event.state === 'completed' 就是一轮结束的官方信号;onAgentStateChange 是过程中态(思考 / 播报),可驱动按钮禁用等 UI;onError 兜底。那 speakText 里的 setTimeout(..., text.length * 50) 是什么?那只是接入层对"每个汉字约 50ms"的经验估时,用来让字幕高亮及时收尾。要做严谨状态机,请以 onConversationChange 为准。
Q7:问候语为什么不走 ask(),要单独用一个 speakGreeting()?
因为问候不需要推理,只需要"照本播报"——走 LLM 反而多一次往返、还可能被改写。所以 store 里单独开了一个直供播报的入口:
speakGreeting(text: string): void {
const avatar = appState.avatar.instance
if (!avatar || !text) return
try {
const ssml = generateSSML(text)
avatar.speak(ssml, true, true)
} catch (error) {
console.error('问候播报失败:', error)
}
}
其中 SSML 由工具函数现场包一层:
return `<speak pitch="${pitch}" speed="${speed}" volume="${volume}">${text}</speak>`
而组件的初始化顺序是"先连接、再问候":
const initAvatar = async () => {
const initContent = "你好,我是老周!在这行干了快 20 年了——经典精剪、渐变油头、修面烫染都拿得起。你今天想剪个什么发型?";
await appStore.connectAvatar(); // 先连接
isAvatarReady.value = true;
subtitleText.value = initContent;
appStore.speakGreeting(initContent); // 再问候
};
isAvatarReady 控制右上角"在岗 · 可服务"的金色状态灯,isLoading 控制"老周正在升起理发椅…"的加载遮罩。
Q8:组件里的 speakText() 和 store 里的 sendMessage() 是什么关系?
一句话:speakText 是组件侧唯一出口,sendMessage 是store 侧唯一实现,中间只靠 appState.ui.text 一根线连着。
-
点左侧悬挂木牌 →
selectQuickService→speakText -
点底部服务卡 →
playCaseContent→speakText -
发聊天消息 →
sendChatMsg→speakText
三个入口,一条出口。好处是:任何"让老周开口"的新场景,都只需要调 speakText,不会绕过链路去直连 SDK。
Q9:用户关页面、网络断了,数字人怎么收尾?
收尾分散在三个地方,形成闭环:
-
连接期出错:
onMessage里用getPromiseState(connectPromise)判断,若 Promise 还是 pending 就 reject,让connectAvatar抛错走 toast。 -
SDK 关闭:
init的onClose回调把onStateChange(''),状态灯熄灭。 -
组件卸载 / 关窗口:统一走
disconnect,内部就是一句 destroy:
disconnect(agent: any): void {
if (!agent) return
try {
agent.destroy('user_disconnect')
} catch (error) {
console.error('断开连接时出错:', error)
}
}
组件侧在挂载时注册 initAvatar 与关页兜底,卸载时再收一次尾:
onMounted(() => {
initAvatar();
window.addEventListener("beforeunload", () => {
avatarService.disconnect(appState.avatar.instance);
});
});
onUnmounted(() => {
if (appState.avatar.instance) {
avatarService.disconnect(appState.avatar.instance);
appState.avatar.instance = null;
}
});
destroy('user_disconnect') 明确带上断开原因,便于排查是"用户主动退"还是"异常掉线"。
Q10:全程说的"AI 端渲"是什么?为什么弱网环境下体验还能保住?
AI 端渲 = 把渲染下沉到终端侧完成,让实时交互真正变成终端基础能力。复古理发厅的顾客,常常是在店里信号不佳、甚至断网的角落里刷小程序。数字人画面在用户设备上算,而不是把每一帧从云端 GPU 推下来,同时降低云 GPU 成本、降低带宽占用、降低延迟、提升稳定性,让实时交互在弱网下也能跑起来。收益是三笔账:
-
成本:省掉云端 GPU 常驻开销;
-
带宽:不用逐帧传视频流,弱网下不会糊成一团;
-
延迟:本地渲染 + 只传文本,响应更快。
落到这个界面:canvas 就挂在理发椅构图中央,本地渲染:
.barber-canvas-chair {
position: absolute;
top: 38px;
left: 16px;
right: 16px;
bottom: 16px;
z-index: 3;
min-width: 200px;
min-height: 200px;
}
配一层带透视的棋盘格地面做舞台底:
.barber-checker-floor {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%) perspective(300px) rotateX(55deg);
width: 140%;
height: 100px;
background:
repeating-conic-gradient(#1f1f1f 0 25%, #ffffff 0 50%) 0 0 / 40px 40px;
opacity: 0.15;
filter: blur(0.3px);
}
opacity: 0.15 + blur(0.3px) 让地面只做氛围、不抢戏。最关键的是:就算网络抖动导致画面降级,走 subtitle_on/off 的字幕仍然在——信息不会因为渲染掉帧而丢。这是端渲 + 字幕事件组合出来的兜底。
一句话:省带宽、省机器、弱网也不掉链子——复古理发厅能用,才是真的能用。
Q11:端渲在本地跑,页面这么多复古 CSS 会不会跟它抢资源、或者互相打架?
不会,靠两件事管住:
一是 Vue 的 <style scoped> + barber- 前缀。所有类名都带主题前缀,即便十个主题组件同时存在也不会污染。转灯条纹就是典型:
.barber-pole-stripes {
position: absolute;
top: 10px;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 26px;
border-radius: 8px 8px 0 0;
background: repeating-linear-gradient(
45deg,
#8e2f3c 0 20px,
#ffffff 20px 40px,
#1f4738 40px 60px
);
animation: barber-pole-spin 8s linear infinite;
}
@keyframes barber-pole-spin {
0% { background-position: 0 0; }
100% { background-position: 0 120px; }
}
注意动画动的是 background-position 而不是 transform: rotate——旋转整个元素会形变,滚动背景位才是真转灯。
二是动画克制。悬挂木牌只在 hover 时摆 ±2°,镜子则靠 .barber-mirror-inner::before 用一个 linear-gradient 伪元素做静态高光——都不占用渲染主线程:
.barber-wood-sign:hover {
animation: barber-swing 1.2s ease-in-out infinite;
}
@keyframes barber-swing {
0%, 100% { transform: rotate(-2deg); }
50% { transform: rotate(2deg); }
}
黄铜镜框用 border-radius: 50% 50% 10% 10% 做拱顶、3px double 做双层框;票据 FAQ 的虚线段用一条 repeat 渐变:
.barber-mirror-frame {
width: 100px;
height: 115px;
border-radius: 50% 50% 10% 10%;
border: 3px double #c9a227;
padding: 5px;
box-shadow: 0 4px 12px rgba(201, 162, 39, 0.25);
}
.barber-receipt-perforation {
height: 6px;
margin: 8px 0;
background: repeating-linear-gradient(90deg, transparent 0 4px, #8e2f3c 4px 8px);
opacity: 0.45;
}
主角是数字人和对话,装饰只做氛围——这条纪律让端渲能拿到几乎全部算力。
Q12:链路再顺,老周也得"懂行"。一个靠谱的理发数字人得装进哪些行业硬知识?
以下是复古理发厅场景必须覆盖的五条专业事实,也是"专属智脑"里那套 SYSTEM_PROMPT("理发造型智能师")真正要兜住的内容。
先说清"专属智脑"到底指什么:它要让智能体知道「我是谁、知道什么、应该做什么」——它是谁,是那位干了快 20 年、语气老派有礼的老师傅老周;它知道什么,是下面这五条脸型匹配、卡尺毫米数、油头打理、烫染化学、修面刀工的美发领域硬知识;它要做什么,是推荐发型、讲解工艺、解答预约,非美发话题礼貌引导回来。而这颗脑不绑死任何一家大模型:豆包、DeepSeek、ChatGPT 等用惯的模型都可以接;没有现成模型时,可以直接用魔珐星云自研的智脑——低延迟、强逻辑、场景化理解、数据安全可控,专为具身交互场景优化。说到底,它把数字人从通用回答升级为面向具体身份和业务的智能体能力。
脸型与发型匹配原则。 圆脸(颧骨宽 + 下巴圆)目标是拉长脸型——两侧中高渐变推贴、顶部做蓬松,避免齐刘海与过宽侧分。方脸(下颌角硬朗)要柔化棱角——用软过渡渐变代替硬线、顶部做纹理,刘海遮额可缩短硬朗额头。长脸(纵向过长)要增加宽度——侧分 + 纹理烫增加两侧蓬松,避免头顶过高,留一些刘海。菱形脸(颧骨突出 + 下颌窄)要柔化颧骨——顶部中等蓬松、两侧圆润过渡。椭圆脸是唯一的"百搭脸型"。
推剪卡尺毫米数与渐变 fade 工艺。 卡尺(guard)标准刻度:#0.5 = 1.5mm(几乎光头)、#1 = 3mm、#2 = 6mm、#3 = 9mm、#4 = 13mm、#5 = 16mm、#6 = 19mm、#7 = 22mm、#8 = 25mm。渐变从脖子根部向上由短渐长,分三档:低渐变(Low Fade,脖子 2cm 处过渡,商务保守)、中渐变(Mid Fade,耳朵下方过渡,最百搭)、高渐变(High Fade,耳朵上方过渡,冲击力强)。关键是:用 #0(光推)打底 + #1 画基准线 + #2 混剪过渡 2-3cm,线条才自然不生硬。
油头 Pompador 吹风定型基础。 四步走:① 发油打底——豌豆大小,油基 pomade 或水基 clay,手心搓开从发根涂到发梢;② 圆梳逆风吹——从头顶中央向两侧逆发根方向吹,边吹边滚梳,制造根部蓬松;③ 顶部塑形——宽齿梳或手指把顶部向后梳起,额前形成约 3cm 的蓬起弧度;④ 定型喷雾——距头发 20cm 均匀喷强定型 hold,手按不塌即成。维持时间:水基发油 8-12 小时,油基 16-24 小时。
烫染化学原理与伤发机制。 烫发第一步二硫键还原断裂——碱性烫发液(pH 9-10)中的亚硫酸根离子打断角蛋白二硫键,头发变软可塑;第二步重新键合——卷发棒或纸卷固定形状后上氧化液(通常过氧化氢),二硫键在新位置重连;第三步锁型。染发类似:双氧水先打开毛鳞片、漂白天然色素,再让人工色素渗透皮层,最后锁色。伤发核心是 pH 9-10 的碱性让毛鳞片过度打开、角蛋白流失(纤维空洞化),半年内连续烫染 2 次以上会严重断发。建议烫染间隔至少 3 个月,烫后必做专业护理。
修面刀工角度与须后护理。 修面用两种刀:T 形安全剃刀(cartridge razor)适合新手与大面积区域(脸颊、颈部),刀片角度保持 30°,顺着胡须生长方向刮(先顺后逆)。直刃剃刀(straight razor)适合精细修型(鬓角、轮廓线、胡须修形),刀片与皮肤角度 15-20°,用手腕"弹腕"而非手臂发力,每刮 1-2 次换刀锋方向。修面前用热毛巾敷脸 2-3 分钟软化胡须并打开毛孔;修面后用冷水拍面收缩毛孔,再涂须后水(tonic,含酒精和薄荷,杀菌收缩)或须后乳(balsam,含乳木果油和维 E,滋润修护)。
Q13:这一串问答看下来,最值得抄走的是什么?
一、“端到端"的判据是"前端有没有插手中间环节”。 只要前端还在拆句、还在生成 SSML、还在选模型,它就不是端到端的。判断标准很简单:前端是否只递出一个字符串。
二、字幕与状态要分开对待。 字幕走事件(subtitle_on/off),一轮结束走回调(onConversationChange 的 completed),别拿估时当状态机。
三、问候与问答要分叉。speakGreeting 直供播报(generateSSML + speak),ask 走智脑推理——两条路各司其职,省往返也避改写。
四、端渲 + 纯 CSS 让弱网可用。 渲染下沉降低 GPU 与带宽开销,装饰零素材让页面更轻,字幕兜住信息。
五、行业硬知识必须硬编码进对话策略。 “圆脸要两侧推短”“渐变用 #1 到 #2 过渡 2-3cm”“烫发 pH 9-10 是碱性伤发”——这些细节不能交给通用模型自由发挥,数字人的专业度等于界面里嵌入的行业硬知识量。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)