《G1234 一路正点:出行管家阿程的端到端链路巡检手记》
这不是一篇从"背景---接入---CSS"顺下来的拆解。我们换一个视角:把高铁出行主题的后端链路,当成一趟真实车次来巡检。车次 G1234,北京南 → 上海虹桥,07:15 发车,12:48 到站,沿途停靠若干个中途站。每到一个站,我们就下车检查一个技术环节------这样走完全程,端到端的链路也就一并验完了。
需要先讲清楚一件事:这个项目已经把"前端自己搭链路"的整体方案换掉了 。早期做法是前端自建 DeepSeek 流式接口、前端按标点拆句、前端手写 SSML 再喂给渲染层------三段拼装,任何一段抖动都会在画面上暴露成口型与声音不同步。现在走的是端到端 SDK:全局对象 window.XingyunAvatarAgent,一次 ask() 就把「大模型回复 + 语音合成 + 口型 + 表情 + 动作」串起来。这趟车换了新车底,我们一段段验。
配色与版式先给个底:整个界面是四色骨架------高铁白 #f5f7fa 铺站台,站台蓝 #1f5fa8 做边框与次级标题,中国红 #c8102e 落在车次号与关键状态上,炭灰 #33393f 撑起信息屏与数据板;警示黄 #f7c948 贯穿左右,既是翻牌屏的轴分割线,也是站台安全线与侧边装饰。

始发站 · 北京南|检票进站:一次 init 让阿程"上车"
检票口的动作只有两个:验票、放行。SDK 侧对应的正是构造 + 初始化。
组件先向服务对象要一个容器:avatarService.getContainerId() 返回唯一 DOM ID,同时赋给模板里的 :id="containerId" 和 store 里的挂载目标,确保 SDK 知道把数字人画在哪里:
ts
const containerId = ref(avatarService.getContainerId());
真正的检票发生在 services/avatar.ts 的 connect() 里。它先拼网关地址(把 data_source 与 custom_id 作为查询参数追加),再等容器完成布局(waitForContainerReady 用 requestAnimationFrame 轮询,避免 canvas 被渲染成 0×0),然后才构造 SDK:
ts
const url = new URL(SDK_CONFIG.GATEWAY_URL);
url.searchParams.append('data_source', SDK_CONFIG.DATA_SOURCE);
url.searchParams.append('custom_id', SDK_CONFIG.CUSTOM_ID);
const agent = new window.XingyunAvatarAgent({
container: containerEl,
appId: APP_ID,
appSecret: APP_SECRET,
enableDebugger: false,
gatewayServer: url.toString(),
onWidgetEvent, onStateChange, onMessage, agentCallbacks,
});
网关三件套是硬编码在 constants/index.ts 里的:GATEWAY_URL = "https://nebula-agent.xingyun3d.com/user/v1/ttsa_v2/session"、DATA_SOURCE: "2"、CUSTOM_ID: "demo"。
构造完不等于上车,还得等资产下载 。init() 的 onDownloadProgress 是这趟车最直观的仪表盘------进度摸到 100% 才把"连接成功"这个 Promise resolve 掉:
ts
await agent.init({
onDownloadProgress: (progress: number) => {
console.log(`初始化进度: ${progress}%`);
if (progress >= 100) resolve(true);
},
onClose: () => { onStateChange(''); console.log('SDK连接关闭'); },
});
onClose是"关门信号":SDK 连接一旦关闭,就把onStateChange('')推回去,组件里的.rail-avatar-status随之失去online类,绿灯熄灭、rail-pulse动画停下。
组件侧的 initAvatar() 负责把这套流程接到 UI 上:await appStore.connectAvatar() 成功后 isAvatarReady 置真,ElMessage.success("出行管家阿程已就绪") 弹出,并立刻 appStore.speakGreeting(initContent) 播一句开场白。数据库加载期间 isLoading 为真,.rail-loading-overlay 遮罩显示,.rail-loading-spinner 转着 rail-spin,直到 finally 里把它放掉。
中途站 01 · 天津南|车门口的耳朵:多模态感知,插话也要听得见
车刚停稳,我们要验的是数字人的耳朵 。多模态感知 要做的事,是让阿程持续知道站台和车厢里正在发生什么------听觉(现场人声与环境)、视觉(画面里的用户与场景)、业务数据(车次、余票、会员信息)同时在喂给智脑,而不是排着队一条条处理。
它的目标,不是简单把一段录音转成文字,也不是把一张车票截图识别成几个字段。真正要解决的是:旅客一句"我这趟 G1234 到虹桥还要不要换乘",阿程要同时听清这句话、看懂屏幕上那张车票的到达站与"换乘 0 次"、再调出这趟车的实时正晚点;旅客伸手改签了另一张票,她的话题也能立刻跟过去;即使 AI 自己正在说话,也能继续听见旅客的插话,让交流继续进行------核心优势:持续感知,而不是一次输入。
这里有个容易做砸的点:AI 自己在说话的时候,还能不能听见用户插话? 很多方案在播报期间直接把麦克风通道闸死了------你说你的,它念它的,等它念完才轮到你这句。这套 SDK 不是这样:通话状态由回调组统一上报,一轮对话什么时候算结束、什么时候能被新输入打断,都有明确信号。
ts
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 则报告智能体自身的状态迁移。前端据此知道此刻该不该抢话、能不能接新输入。
静默判定与打断节奏由常量控制:DEFAULT_VAD_SILENCE_TIME: 300(300ms 静音即判定说话结束,这是"低延迟"的第一道闸)、SPEAK_INTERRUPT_DELAY: 2000(播报中被打断时的缓冲窗口)。这两个数值决定了插话体验是"抢得上话"还是"插不进去"。
组件的入口设计也配合了这一点:speakText() 里从不加"正在说话则忽略"的判断------用户点一张新车票、或者敲回车发一句新消息,都会被正常受理,由 SDK 去做抢占与重排。
中途站 02 · 徐州东|换乘枢纽:业务数据通道里的铁路常识
多模态感知的第三个通道是业务数据。这趟车到了徐州东------京沪线的心脏级换乘枢纽,正好把铁路出行的硬知识过一遍。这些内容不是装饰,它们最终都会成为智脑"我知道什么"的一部分。
① 车次命名规则 :G 字头是高速动车,设计时速 250km/h 以上(复兴号最高 350km/h);D 字头是普通动车,时速 200km/h 以内;C 字头是城际铁路,跑在城市群之间;Z 直达、T 特快、K 快速都属普速,时速分别约 160 / 140 / 120km/h。这套命名是识别列车等级的快速线索------界面里 G1234 用中国红 #c8102e 加粗突出,正是 G 字头在高铁场景里的"头等席位"。
② 席别物理差异 :二等座一排 3+2 共 5 座,座宽 47cm,靠背固定不可调;一等座 2+2 共 4 座,座宽 50cm,带脚踏板与可调靠背;商务座(仅复兴号)1+1 共 2 座,座宽 75cm,可平躺,配小桌板、阅读灯、独立空调口与免费餐食。车票卡的 metrics 字段把"席别"单列一项,就是为了让人一眼比价选座。
③ 购票预售期与候补:12306 预售期 15 天,热门线路(京沪、京广)整点放票常在数秒内售罄。候补购票允许无票时提交候补订单,系统按提交时间排队,一有票依次发放------等于给没抢到的人留了条"自动排队"通道。
④ 中转换乘要点:同站换乘(走站内换乘通道)最小建议间隔 15--20 分钟;跨站换乘(需出闸机换地铁/出租)建议 30--45 分钟,取决于中转站规模与地铁接驳距离;城际换乘(如广州南→深圳北→福田)还要算口岸通关时间。产品卡里"换乘 0 次"被特意高亮,因为直达本身就是卖点。
⑤ 禁限带物品量化标准 :充电宝 ≤ 100Wh 可随身(100--160Wh 需车站批准);酒精饮料散装一律禁带、瓶装限 50ml 以下;打火机限 2 个;菜刀、剪刀等管制刀具严禁带入。行李长宽高之和不超过 160cm、重量不超 20kg(儿童 10kg)。这些硬规定直接写进了 faqList 第 2 条。
⑥ 12306 积分与常旅客:乘车积分按票价 5 倍累积(1 元 = 5 分),100 积分 = 1 元,可兑换车票、酒店、接送机等,有效期 12 个月;会员分"常旅客"与"银卡"两档,年消费满 1000 元升银卡,享优先候补与专属客服。
faqList 里的五条问答(提前到站、行李限制、儿童票、改签规则、充电插座)与 quickServices 的六个入口(车次查询、购票指引、中转方案、改签退票、遗失物品、车站导航)就是这些常识在界面上的落点,点击后统一走 selectQuickService()。
中途站 03 · 南京南|一条到站广播:ask() 把三段链路压成一段
南京南是大站,广播一响全场安静。这里要验的是这趟车最关键的车底------端到端(端对端) 。它把「前端自建 LLM 流式 + 前端按标点拆句 + 前端手写 SSML」的三段拼装,收成一次 avatar.instance.ask(text):LLM 回复 → 语音合成 → 口型/表情/动作一次编排完成,前端不再插手中间环节。一条 ask() 打通整条链路,这才是"端到端"三个字的分量。
旧链路长这样:前端调 LLM 拿流式文本 → 前端按标点拆句 → 前端为每句生成 SSML → 逐句喂渲染层。三个环节串行,光"拆句等 LLM、SSML 等拆句"就叠了几百毫秒,而且口型与音频各算各的,很容易错位。
新链路只剩一句:
ts
// stores/app.ts
async sendMessage(): Promise<string | undefined> {
const { ui, avatar } = appState;
if (!ui.text || !avatar.instance) return;
await avatar.instance.ask(ui.text); // 一条链路直达 LLM+TTS+口型+表情+动作
return ui.text;
}
avatar.instance.ask(text) 之后,LLM 回复、语音合成、口型、表情、动作全部由 SDK 内部一次编排完成。这里的关键不是"多调了几个接口",而是多模态表达 ------实时生成统一、连续、同步的具身表达。报站时语气平稳、口型与音频严丝合缝;说到"这趟车晚点 6 分钟"时眉头微收、语速放慢;指引"检票口在 12 号"时抬手做一个指向手势------语气、表情、手势全都随内容变。语言、口型、表情、眼神、头部动作、手势和身体动作,是同一套时间轴上统一实时生成的,而不是各条链路各算各的再拼起来------统一、连续、同步,高铁站台不再是干巴巴的读表。 旧方案那种"音频到了、口型还在路上"的错位,从根上被消掉。前端不再自己调 LLM、不再拆句、不再手写 SSML------它只负责把一句话递进去,然后听回传。
组件层的唯一出口是 speakText(),所有交互(点车票卡 playCaseContent、点检票闸机 selectQuickService、聊天框 sendChatMsg)最终都汇到它:
ts
const speakText = (text: string) => {
isAvatarSpeaking.value = true;
subtitleText.value = text;
appState.ui.text = text; // 写进 store,由 sendMessage 读取
appStore.sendMessage().then(() => {
setTimeout(() => { isAvatarSpeaking.value = false; }, text.length * 50);
});
};
低延迟是这趟车体感层面的胜负手:ask() 少掉了前端串行拼装的等待,链路短、抖动小;再加上 300ms 的 VAD 静默判定,用户一句话说完,几乎是"话音落、画面动"。而边说话边被插话也能即时响应,靠的正是中途站 01 验过的那套对话状态回调------它不是把播放当成不可打断的黑盒,而是一直在听。
中途站 04 · 蚌埠南|广播喇叭:字幕是一次"回传",不是自己算的
站台广播和车厢广播是两套东西------一个往外播,一个往回来。字幕也同理:它不是前端自己切出来的,而是 SDK 把生成结果回传上来的。
回传的入口是 onWidgetEvent:
ts
onWidgetEvent: (event: any) => {
if (event.type === 'subtitle_on') {
onSubtitleOn(event.text);
} else if (event.type === 'subtitle_off') {
onSubtitleOff();
}
},
store 收到后又把它塞回全局状态:onSubtitleOn 写 appState.ui.subTitleText = text,onSubtitleOff 清空它。组件不直接接 SDK,而是订阅这个状态:
ts
watch(() => appState.ui.subTitleText, (val) => {
subtitleText.value = val || "";
});
于是字幕卡的显隐与内容全部由节点信号驱动:subtitleText 有值才渲染 .rail-subtitle-wrapper,isAvatarSpeaking 为真时给 .rail-subtitle-card 挂上 speaking 类,让左边框由站台蓝翻成中国红,并跑起呼吸式的 rail-glow:
css
.rail-subtitle-card.speaking {
border-left-color: #c8102e;
animation: rail-glow 2s ease-in-out infinite;
}
@keyframes rail-glow {
0%, 100% { box-shadow: 0 4px 14px rgba(31, 95, 168, 0.12); }
50% { box-shadow: 0 6px 22px rgba(200, 16, 46, 0.25); }
}
subtitle_on / subtitle_off 这对开关的价值在于:字幕的节奏天然跟着语音走,不需要前端估算时长去对表------虽然组件在 speakText() 里仍留了一个 text.length * 50ms 的兜底计时用来收尾动画。
中途站 05 · 苏州北|智脑调度室:不绑死任何一家大模型
苏州北站台不大,但旁边就是调度中心------这站验的是脑子。
专属智脑 要做的是让智能体知道"我是谁、我知道什么、应该做什么"。这颗脑不绑死任何一家大模型:豆包、DeepSeek、ChatGPT 这些用惯的模型都可以接;没有现成模型时,也可以直接用魔珐星云自研的智脑------低延迟、强逻辑、场景化理解、数据安全可控,专为具身交互场景优化。项目里 SUPPORTED_LLM_MODELS 目前列的是 doubao-1-5-pro-32k-250115,这只是当前选型,不是架构绑定。
它要同时回答三层问题:它知道什么(车次命名规则、席别配置、购票预售与改签费率、行李安检标准这一整套高铁出行领域知识)、它是谁 (高铁主题的 SYSTEM_PROMPT 把它定成"高铁出行智能管家",是站在站台上的阿程,而不是一个通用助手)、它要做什么(开口"高效利落、准确清晰",把每一次咨询落回一趟可执行的出行方案)。这就把通用回答升级为面向具体身份和业务的智能体能力。自研智脑的几个特点正好对上车站这种场景:
-
低延迟:车站里没人愿意等一个"思考中"的转圈;
-
强逻辑:改签费率、换乘间隔这类问题答错一句就是事故;
-
场景化理解:能听懂"我明天下午想从北京去上海,顺便能坐下商务座吗"这种多条件叠问;
-
数据安全可控:票务与会员数据不出可控边界。
除了走 ask() 的对话链路,还有一条"不走大模型"的直连播报通道。开场白就用的它------appStore.speakGreeting(text) 内部先 generateSSML() 再 avatar.speak(ssml, true, true):
ts
speakGreeting(text: string): void {
const avatar = appState.avatar.instance;
if (!avatar || !text) return;
const ssml = generateSSML(text);
avatar.speak(ssml, true, true);
}
组件里 initAvatar() 把 subtitleText.value 直接设成开场白、再调 speakGreeting,于是第一帧就有字幕有声音。问候语和快捷播报走 speak,需要"思考"的问题走 ask------两条轨道各管一摊,这也是低延迟的一部分。
中途站 06 · 无锡东|车厢装配线:把整座站台装配进 CSS
沿线最后一站,我们把车厢里的零件摆开看。所有样式都在 <style scoped> 里,前缀统一 rail-。
顺带把这条链路的"最后一公里"说清楚:这些画面不是云端逐帧推流下来的,而是AI 端渲 ------把渲染下沉到终端侧完成。高铁出行这个场景恰恰经常弱网甚至断网,隧道里、山区间、车厢信号时有时无。AI 端渲把渲染放到终端侧,等于一次拆掉四笔账:降低云 GPU 成本(渲染负载分到每台设备,云端 GPU 不再随并发线性膨胀)、降低带宽占用(终端自己算的画面不用编成视频流传回来,稳定大流量变成一次性资产下发)、降低延迟(少了"编码---传输---解码"的往返,链路明显变短)、提升稳定性(网络抖一下画面也不糊,因为帧本来就在本地生成)。省带宽、省机器、弱网也不掉链子------高铁场景能用,才是真的能用。 下面这些 CSS 就是端侧渲染的"零件清单"。
翻牌字符格------真实车站每格字符都是独立小方块,中间一条水平分割轴。实现是三层叠放:上半字符、1px 高的黄色轴、下半字符(多一层渐变模拟背光阴影):
css
.rail-flip-char { width: 22px; height: 36px; position: relative;
background: #1a1d22; border-radius: 3px; overflow: hidden; }
.rail-flip-axis { position: absolute; top: 50%; left: 2px; right: 2px;
height: 1px; background: rgba(247, 201, 72, 0.5); transform: translateY(-50%); z-index: 2; }
字符源就是 flipChars,把 "G1234 北京南 → 上海虹桥" 按字符切开后 v-for 渲染。
黄黑警示条纹 ------repeating-linear-gradient 一行搞定:#f7c948 与 #33393f 各占 15px、30px 一个完整周期、45° 斜排,和站台安全条一模一样。同一个配方用在三处:.rail-warning-strip(翻牌屏两侧 8px 装饰)、.rail-safety-line(站台安全线)、闸机门框。
车票卡撕口 ------.rail-ticket-perforation 是一条 14px 高的区带,顶部 1px 虚线;左右两个 16px 圆孔 .rail-perf-hole(.left / .right)用"背景色 = 主背景 + 向内阴影"的障眼法,看起来像从卡片上挖掉了一块。
检票闸机梯形门翼 ------.rail-gate 用 clip-path: polygon(8% 0, 92% 0, 100% 100%, 0 100%) 裁成上窄下宽的梯形;.rail-gate-arrow 是绿色 ▶ 可通行标识;.rail-gate.active 换成中国红渐变并 translateY(-2px) 上浮。3×2 的网格由 .rail-gate-row 的 repeat(3, 1fr) 给出。
时刻表 LED 灯 ------右侧数据板底色是最深的 #1a1d22,每行左侧一个 10px 绿点,用 box-shadow: 0 0 8px #2ecc40 造发光:
css
.rail-board-row { display: grid; grid-template-columns: 10px 14px 1fr auto; gap: 8px; }
.rail-board-dot { width: 10px; height: 10px; border-radius: 50%;
background: #2ecc40; box-shadow: 0 0 8px #2ecc40; }
.rail-schedule-status.on { background: rgba(46,204,64,0.15); color: #2ecc40; }
.rail-schedule-status.delay { background: rgba(200,16,46,0.2); color: #ff6b7a; }
.rail-schedule-status.soon { background: rgba(247,201,72,0.18); color: #f7c948; }
车门与红色把手 ------中央站台 .rail-platform-frame 两侧各一条 22px 深色竖条 .rail-door,中间挂 .rail-door-handle(10×24px 红渐变圆角块,带 inset 高光);画布 .rail-avatar-canvas 高 286px,下方压一条 .rail-safety-line。状态灯 .rail-status-dot 在 .rail-avatar-status.online 下发绿光并跑 rail-pulse 呼吸动画。
乘车须知 FAQ ------.rail-faq-list 两栏网格;.rail-faq-no 用 rotate(-90deg) 把 NO.01 竖排贴在左侧;展开态 .rail-faq-item.open 由红边 + 淡红底提示当前条目。
到达站 · 上海虹桥|清客出站:destroy 与链路收尾
车到终点,动作反而最简单:清客、锁门、断电。
组件在 onMounted 里挂了 beforeunload,关页面时主动断开连接;onUnmounted 再兜一次底,把实例置空------这在 SPA 切主题时尤其重要,否则数字人画布会残留在 DOM 里:
ts
window.addEventListener("beforeunload", () => {
avatarService.disconnect(appState.avatar.instance);
});
// onUnmounted
avatarService.disconnect(appState.avatar.instance);
appState.avatar.instance = null;
而 disconnect() 内部只做一件事------带着原因调用销毁:
ts
disconnect(agent: any): void {
if (!agent) return;
try { agent.destroy('user_disconnect'); }
catch (error) { console.error('断开连接时出错:', error); }
}
'user_disconnect' 这个原因串会随断开一起上报,方便在网关侧区分"用户主动离开"和"异常掉线"。
一趟 G1234 跑完,链路也验完了:始发站 init + onDownloadProgress 把阿程送上车,天津南验多模态感知的耳朵,徐州东灌业务数据,南京南用 ask() 把三段链路压成一段并拿到低延迟,蚌埠南用 subtitle_on 收字幕回传,苏州北的智脑不绑死任何一家大模型,无锡东把整座站台装配成 CSS,虹桥以 destroy('user_disconnect') 收尾。视觉层再花哨,真正决定"像不像一个站在你面前的人"的,是那条从耳朵到嘴、被打断也能立刻回的端到端链路。