用 React Three Fiber 打造会对话的 3D 数字人:架构与部署的真实踩坑记录

作者:

🇬🇧 English

用 React Three Fiber 打造会对话的 3D 数字人:架构与部署的真实踩坑记录

把一个 LLM 接进 3D 场景,让一个 VRM 虚拟角色能听懂你说话、开口回应、眼神还跟着你转——这个想法挺酷,但真做起来会撞上一连串工程问题:Key 不能进前端、Serverless 函数有 10 秒硬上限、R3F 的 Canvas 不穿透 React Context、语音识别在国内还不稳定……

本文不堆 API 教程,而是按我开发 firefly(一个基于 React + React Three Fiber 的 3D 数字人 Web 应用,部署在 firefly.erishen.cn)时真实的决策顺序展开:每个环节先说「要解决的问题」,再给「为什么这么设计」和真实代码。所有代码片段都来自线上跑着的版本,不是通用模板。

一、技术栈与架构总览

先明确几个硬性需求,再定栈:

  • 纯 Web 端运行,不依赖桌面运行时;
  • UI 与 3D 场景深度耦合:对话气泡、输入框、语言切换、返回按钮都悬浮在 3D 画布之上;
  • LLM 的流式响应要和 React 状态流打通。

最终栈:

  • 前端:React 18 + Vite + React Three Fiber + drei + @pixiv/three-vrm(VRM 模型加载/驱动)
  • 后端Node.js(零依赖原生 http/fetch 的轻量代理 + Vercel Serverless Functions,负责隐藏 LLM Key、做工具调用编排;受许可保护的模型文件由独立的私有后端托管(见第三节),与对话后端解耦

一个容易踩的误区:很多「R3F 数字人」教程把后端写成 Python/FastAPI。firefly 的对话后端是纯 Node——Vercel 的 Node 函数签名就是标准 (req, res),前端那套 SSE 流式转发可以直接复用,无需为 LLM 另起 Python 服务。受许可保护的模型资源则独立托管在私有后端(见第三节),与对话后端解耦。

整体数据流:

浏览器 (R3F 画布 + React UI)
   │  仅同源请求 /api/chat
   ▼
Node 代理 (proxy.mjs / Vercel api/chat.js)
   │  ① 隐藏真实 LLM Key  ② 意图预取 + 工具编排
   ▼
OpenAI 兼容模型网关 (服务端持有 Key)

二、安全基线:把 LLM Key 留在服务端

数字人最怕的一件事:把 API Key 写死在前端或打进 bundle,任何人打开 DevTools 就能扒走。firefly 的做法是浏览器永远只和同源的 /api/chat 通信,真正的 Key 只在服务端 .env(本地)或平台环境变量(线上)里。

server/config.mjs 里集中解析配置,且优先读 process.env(Vercel 注入),回退到项目根 .env

export const BASE  = val('LLM_BASE_URL') || ''
export const KEY   = val('LLM_API_KEY') || ''
export const MODEL = val('LLM_MODEL') || ''

// 部署安全项:BIND_HOST 默认 127.0.0.1(仅本机),公网务必设 ALLOWED_ORIGINS
// 白名单(如 https://firefly.erishen.cn),切勿设 * —— 否则任意网站可盗用 Key
export const ALLOWED_ORIGINS = val('ALLOWED_ORIGINS') || ''

proxy.mjs 启动时还会做连通性自检,并给未配置的友好提示。关键点:Key 不进浏览器、不进代码、不进 git——.env.gitignore 里。

三、VRM 模型加载与「秒开」

VRM 是面向人形 3D 模型的开放标准。@pixiv/three-vrm 负责把 .vrm 转成 Three.js 对象。firefly 的加载逻辑在 src/components/AvatarVRM.jsx:用 GLTFLoader 注册 VRMLoaderPlugin,解析回调里关掉 three-vrm 自带的 lookAt(改用手动骨骼控制),并记录 head / leftEye / rightEye 标准骨骼。

真正影响体验的是加载速度。模型有 14.5MB,每次重新下载很慢,所以做了两层优化:

  1. 带真实进度的下载readWithProgressContent-Length 计算百分比,渲染加载条);
  2. 浏览器 Cache API 字节缓存——把解析好的模型字节按 URL 缓存,刷新/重进页面命中缓存即秒开。
const cache = await caches.open(MODEL_CACHE_NAME)
const hit = await cache.match(url)
if (hit) { onProgress(100); return hit.arrayBuffer() } // 命中本地缓存,跳过下载

受许可保护的模型文件:数字人用的 VRM 模型许可为 redistribution=disallow(第三方创作,禁止再分发),因此不进前端 bundle、也不随仓库公开,而是由独立的私有后端服务托管,鉴权与分发逻辑完全在服务端、凭据不落地到客户端。

一句话原则:模型托管与前端解耦,鉴权在服务端、凭据不落地到客户端

四、数字人的「生命感」:眼神、口型、眨眼

让角色「活着」靠的是 useFrame 每帧驱动骨骼与表情。firefly 的真实实现:

眼神跟随指针(head / 双眼用 humanoid 标准骨骼,按指针位置做平滑插值,并记录初始绑定姿态避免覆盖原始 pose):

useFrame((state) => {
  if (!vrm) return
  vrm.update(delta) // 驱动 spring bone(头发/裙摆物理)与表情过渡

  const head = headRef.current
  if (head) {
    const tx = state.pointer.x * 0.35      // 头部随鼠标/触摸横向转动
    const ty = -state.pointer.y * 0.25
    head.rotation.y += (baseHead.y + tx - head.rotation.y) * 0.1
    head.rotation.x += (baseHead.x + ty - head.rotation.x) * 0.1
  }
  // 双眼同理(系数略小),实现「眼神跟着你」
})

口型:说话时(speakingRef 为真)让嘴部 morph 在 a/i/u/e/o 之间按节拍开合——这是一种风格化的"嘴部律动",不是逐音素对齐音频的精确口型。这样在无音素标注数据的情况下,也能让观众感受到"她在说话"。

眨眼同理:优先走 expressionManagerblink 表情,某些 VRM0.0 导出文件表情组为空时,回退到直接驱动 mesh 的 morphTargetInfluences(即底层 まばたき 眨眼 morph)。这种"双路探测 + 回退"让不同来源的 VRM 模型都能正常眨眼/说话。

五、语音链路:输入与输出(真实可用 vs 国内受限)

firefly 的语音在 src/hooks/useVoice.js 里,分两条独立链路:

5.1 语音输入(STT)—— 国内不稳定

用浏览器原生 SpeechRecognition(Chrome/Edge 支持)。它的识别后端是 Google 的语音服务,在国内网络下常拉不到,识别会失败。所以 UI 上做了降级:检测不到 API 时自动切到文本输入,识别失败也在气泡里报错——文字输入始终是主路径。

5.2 语音输出(TTS)—— 国内可用

这点经常被误解。firefly 确实能开口说话,用的是浏览器原生 speechSynthesis(系统嗓音,离线可用,国内无碍),朗读前会清洗掉 emoji/符号只念文字,并优先挑"中文女声"、把音调压高贴合「女仆小菲」人设:

const u = new SpeechSynthesisUtterance(stripSpeechSymbols(text))
u.lang = lang
u.pitch = 1.2 // 略高,偏可爱/少女感

所以真实情况是:听得见你说的话(TTS 国内可用)、但未必听得清你说话(STT 国内受限)。这点我在界面上也写了诚实提示,避免用户以为按钮坏了。

六、工程硬仗:在 Vercel 10 秒里塞下「两轮 LLM + 工具」

这是 firefly 最值得写的一段,也是线上最初反复报 504 / "没返回" 的根因。

数字人需要实时天气、商品清单这类外部能力,用 OpenAI 兼容的 function calling 实现:模型首轮带 tools,自己决定要不要调工具 → 服务端执行 → 把结果回填 → 做第二轮(不带 tools)请求拿最终自然语言回答。前端零改动。

问题来了:Vercel Hobby 计划的Serverless 函数有 10 秒硬上限,到时平台直接 SIGKILL,表现为静默无返回("问天气没反应")。而「LLM1 + 天气 + LLM2」两轮很容易打穿 10 秒。

我的解法是服务端意图预取(单轮优化):在请求刚进来的时候,先用正则判断用户是不是在问天气/商品,如果是,在服务端提前把数据拉好、作为系统上下文注入,并从首轮工具集里摘掉对应工具,让模型直接作答——把整体耗时从「LLM1 + 天气 + LLM2」压到「天气 + LLM1」一轮。

// chat.mjs:天气意图预判 + 服务端预取
const weatherIntent = WEATHER_INTENT_RE.test(lastUserMsg.content || '')
if (weatherIntent) {
  const w = await fetchWeather(wArgs)        // 服务端先拉好
  if (w.ok) {
    weatherContext = `\n[系统天气上下文,请据此直接回答,不要再调用天气工具] ${w.summary}`
    round1Tools = TOOL_DEFS.filter(t => t.function.name !== 'get_weather') // 摘掉工具
  }
}

配套还有三处加固:上游 LLM 请求用 fetchWithTimeout 设 25 秒熔断;vercel.jsonmaxDuration: 60 + 部署到离用户更近的 hkg1 区域(Pro 计划下生效,Hobby 会被钳到 10s 但不报错);直连环境用 undici Agent({ keepAlive: true }) 复用 TLS,减少冷启动握手开销。

天气/商品本身也做了内存缓存(按经纬度缓存 10 分钟、地理编码缓存 24 小时),让 demo 里重复的提问近乎瞬时。

七、i18n 的坑:R3F 不穿透 React Context

firefly 要做中/英双语(UI 文案 + 对话语言跟随)。最自然的做法是用 React Context 包一层 I18nProvider,但踩到一个坑:R3F v8 的 <Canvas> 用了独立的 reconciler,不会穿透外层的 React Context,Canvas 内的组件(如加载层)拿不到外部的 t()

解法是模块级 store + useSyncExternalStore,让 Canvas 内外组件都从同一个外部 store 读语言,切换时统一通知订阅者重渲染:

// src/i18n/store.js:不依赖 React Context
const listeners = new Set()
export function setLang(lang) {
  currentLang = lang
  localStorage.setItem(STORAGE_KEY, lang)
  listeners.forEach(fn => fn(currentLang)) // 通知所有订阅者(含 Canvas 内组件)
}
export function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) }

这样语言切换器、对话气泡、3D 加载层都能正确跟随,且语言偏好持久化到 localStorage

八、部署与构建

firefly 部署在 Vercel(不是 GitHub Pages)。vercel.json

{
  "framework": "vite",
  "buildCommand": "npm run build",
  "outputDirectory": "dist",
  "regions": ["hkg1"]
}

前端是静态 dist,后端是 api/*.js Serverless Functions——它们直接 import 复用 server/chat.mjshandleChat(标准 (req, res) 风格,与 Vercel Node 函数签名一致,无需改造即可跑流式 SSE)。

构建上的关键决策:

  • 关闭 Vercel 默认 body 解析api: { bodyParser: false }),改由 handleChat 自行读原始流,SSE 流式才不断;
  • maxDuration: 60:Hobby 会钳到 10s 但不报错,升级 Pro 后此值即生效,显著缓解两轮 LLM 超时;
  • CORS 白名单ALLOWED_ORIGINS 必须设为前端域名,绝不能是 *,否则任意网站能盗用你的 LLM Key;
  • 模型分区托管:受许可保护的 VRM 不放进前端 bundle,改由独立私有后端托管、服务端鉴权(见第三节)。

九、总结:这套架构解决了什么

回看整个链路,firefly 真正解决的不是「少写几行 Three.js」,而是把几个容易翻车的点都提前堵住了:

  1. 安全:LLM Key 永不出服务端,CORS 白名单防 Key 滥用,模型资源由私有后端鉴权(见第三节);
  2. 性能/可用性:模型字节缓存秒开、天气商品内存缓存、意图预取把"两轮 LLM"压成"一轮"以适配 Serverless 10s 上限;
  3. 跨渲染边界:用模块级 store 绕开 R3F 不穿透 Context 的坑,i18n 在 Canvas 内外一致;
  4. 诚实降级:语音输入在国内受限就明确提示、切回文字,TTS 用浏览器原生保证国内可用。

代价是 R3F 抽象层带来的少量性能开销(虚拟 DOM diff + 每帧 reconcile),但对单数字人场景完全可控。如果你也在做类似的交互式 3D + LLM 项目,希望这篇"按真实踩坑顺序"的记录能少让你绕几圈。

在线体验见 firefly.erishen.cn。firefly 目前支持中文 / English 切换,右上角可一键返回主页。

源码导航

  • src/components/Stage.jsx — VRM 模型加载与字节缓存
  • src/components/AvatarVRM.jsx — VRM 解析、眼神 / 口型 / 眨眼驱动
  • src/hooks/useVoice.js — 语音输入(STT)/ 输出(TTS)链路与降级
  • src/i18n/store.js — 模块级语言 store(绕开 R3F Context 不穿透)
  • server/chat.mjs/api/chat 流式转发、工具编排与意图预取
  • server/config.mjs — 配置解析、代理探测与超时熔断
  • server/weather.mjs — 天气工具的服务端预取
  • vercel.json — 部署区域与 maxDuration 配置

完整项目地址:https://github.com/erishen/firefly

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

首页 简历 关于 隐私政策 商店 Web Chat Nsbp.js

© 2026 Erishen
沪ICP备2024079226号-1   沪公网安备31010502007082号