做一条口播视频,第一件事不是开机位,是想清楚说什么。
所以我经常从搜索框开始。这个动作在代码里对应一串很具体的活:发查询、拿结果列表、挑几条打开、把 HTML 里那坨导航栏广告弹窗剥掉、留下正文,最后塞进模型的上下文窗口。这一套活,开源社区已经做了很多年,而且做得不错。
对着这条路走一遍,能看清它是怎么分层的。
SearXNG 解决的是"查"这一层。它是个元搜索引擎,自己部署一份,把 Bing、百度、Google 的查询结果聚合回来,输出干净的 JSON。它真正值钱的地方不在技术,在立场:你的检索不再被某一家搜索引擎的配额、条款和突发改版绑住。我们早期为了跑通链路接过商业搜索 API,后来发现调试时最烦的就是"这条 query 为什么返回空"没法自己查,只能等对方回复工单。SearXNG 换掉了这种不确定性。
Crawl4AI 和 Firecrawl 解决的是"取"这一层。页面抓下来不等于内容抓下来,现代网页有一半是 JS 渲染的,剩下一半裹着 cookie 弹窗和推荐位。这两个项目干的事是把页面变成模型能读的 markdown,顺手处理渲染、去噪、分块。Crawl4AI 那个"LLM 友好正文"的思路影响了不少后来的实现,包括我们。
Trafilatura 、newspaper3k、readability 这一派更老,纯 Python,靠 DOM 启发式判断哪一块是正文。它们不需要浏览器,几十毫秒出结果,代价是对付不了重前端站点。但在中文资讯站上,这套东西的命中率高得出乎意料。
GPT-Researcher 站在更上面一层,它把"多轮搜索、抓正文、汇总、标注来源"串成一个 agent 循环。它给出的不是一个函数,是一种工作流的形状。
把这几样拼起来,你确实能得到一段干净的、有出处的文字。然后呢?
然后你会发现,文字只是一条生产线上的原料。要做成一条能发出去的片子,后面还压着好几道工序:把稿子读出来、用谁的声音读、脸从哪来、嘴型怎么对上、帧率怎么统一、音轨怎么合进去。每一道工序都有自己的坑,而且这些坑跟"抓网页"完全不是一个领域的坑。
HeyAvatar 做的就是这段路,加上前面抓取的一小截。
它是什么
一句话:一套能装在自己机器上、不联网也能跑的数字人生产工作台。
再具体一点,它把"查资料 → 写稿 → 配音 → 出片"压成一条链,中间每个环节都能单独调、单独换。界面是一个本地 Web 前端,后端是 Python,数据落在 SQLite 里,不上云。
这听起来像个平淡的技术选型,但它是被客户的部署环境逼出来的。我们踩过的次数最多的一类问题,不是模型不准,是"装不上"。内网机器没有外网、装不了 Docker、CUDA 版本被别的业务占着、Python 环境被运维统一锁在 3.9。所以整套东西的设计约束从第一天起就是:一个 python main.py 能起来,剩下的都在这台机器里。
一条链长什么样
打开画布,是七个节点。

生成文案:填主题、选风格,后台会走一遍上面说的那条抓取链,把资料捞回来,再让模型写成口播稿。它可以直接引用你自己的资料库,不必每次都从公网找。
上传声音 / 上传形象:两个入口节点,喂素材。
克隆声音:从一段参考音频里提音色特征,产出一个可复用的音色资产。二十秒素材就够用,参考文本越准,后面合成越稳。
克隆形象:从一段正面视频里提帧、做人脸检测和姿态估计、算 3DMM 系数,训练出一个属于这个人的形象。
合成声音:把音色和文案接起来,出配音。
合成数字人:形象 + 配音进去,视频出来。
节点之间连线,前端做拓扑排序,按依赖顺序逐个调后端接口,跑完一个再跑下一个。这套编排是全在前端做的,后端只提供原子的能力接口。这个决定当时争过:把编排放后端更"正统",但那样每加一种玩法都要改服务端。放前端之后,加节点就是加一个文件。
除了画布,还有几个不挂在链上的模块:PPT 数字人 (把 PPT 逐页转成图,每页配一段口播,最后把页面和数字人小窗合成)、文档翻译 (保留段落结构的多语言互译)、资料库 (网页、粘贴、txt/md/docx/pdf 入库,切片建索引)、系统设置(模型、检索、切片参数,改完热生效,不用重启)。

实现原理
服务形态:故意做小
后端是 aiohttp 起的单进程,十个路由模块注册上去,CORS 全开,请求体上限放到 200MB。数据库不是一个巨大的单库,是按业务拆的几个 SQLite 文件:主库管形象、声音、任务、文案、媒体,另一个库管记忆,还有一个留给知识检索。SQLite 开了 WAL,并发读没问题;写的场景都是任务状态更新,量级很小。
没有 Redis,没有消息队列,没有对象存储。这些东西每一个都会让部署文档多出三页,而我们面对的实施同学通常只有一台机器和一个 SSH 账号。
任务调度:GPU 是唯一的稀缺资源
所有长耗时操作都走同一套任务表。调度器启动时先做一件很不优雅但很必要的事:把上次进程留下的"处理中"任务全部标记为失败。因为进程被 kill 过,那些任务永远不会有人回来改状态,不重置就会一直挂在界面上转圈。
然后它看 torch.cuda.device_count(),按 tasks_per_gpu(配置文件里给了 3)把 GPU 切成槽位,每个任务起一个子进程,绑到指定卡上。子进程的好处是显存能干净回收,坏处是父进程得盯着它:每轮轮询用 psutil 拿进程状态,顺手比对 create_time。这一条是防 PID 复用的,机器跑久了 PID 会回卷,只按 PID 判断会把新进程当成老任务,导致槽位永远占着。超时按 task_timeout 兜底,默认半小时。
子进程出错必须以非零码退出。这条我们改过一次,之前模型侧抛了致命错误但进程正常退出,父进程以为任务跑完了,一直等到超时才发现没产物。
数字人合成:这条链上最脏的一段
这段是整个项目里最难的部分,也是最能说明"为什么不能只做抓取"的部分。
先解决环境冲突。 语音合成的模型(VoxCPM 或 CosyVoice 2)对 torch 版本很挑,跟数字人主链路的依赖打不到一个环境里。硬凑的后果是某一边永远在报 libcublasLt.so 找不到。所以 TTS 被隔离出去了:可以是另一个 conda 环境里的子进程,也可以起成一个 HTTP 服务单独占一张卡,主进程按配置选。ASR 同理,Qwen3-ASR 和 FunASR 各走各的。
音频侧。 驱动音频先过一层 Wenet 的 Conformer 模型,取 CTC-attention 的 BNF 特征。这不是识别文字,是要那层中间表示,它比梅尔谱更贴近发音动作。采样率 16k,mel hop 160,卷积子采样乘 4,算下来一帧特征 40 毫秒,也就是 25Hz。这个 25Hz 是后面所有时间对齐的基准,改不得。
视频侧。 形象视频先抽帧,做人脸检测和头部姿态估计,转成 3DMM 参数。这一步决定了下限:素材里如果侧脸多、遮挡多、动作幅度大,后面怎么调都救不回来,只会在某些帧上突然糊掉。
渲染侧。 `采用GAN 结构,输入是面部关键点和音频特征,输出是把目标人脸按发音动作重绘。VAE 那部分负责保身份,生成器负责动嘴。
然后是最麻烦的一件事:嘴形慢半拍。 最开始我们以为是索引算错了,查了半天时间戳,映射是对的。问题在模型自己身上:融合权重过了一次因果 EMA 平滑,smoothed = cur*α + prev*(1-α),α 取 0.6。这个滤波器的群延迟是 (1-α)/α,大约 0.67 帧,再叠上生成器学到的动态,整体上嘴就比声音慢。解法是给第 i 帧喂第 i+1 帧的音频特征,也就是提前一帧(40 毫秒)。这个常数现在是 1,写死在代码里,注释写清了"置 0 即回退到旧行为"。它确实是整条链上唯一的同步旋钮,动它之前先拿带爆破音和闭唇音的短句做对比。
帧率。 形象视频统一归一到 25fps,因为模型和平滑逻辑都是按 25fps 的时序设计的。归一策略只降不升,源视频 30fps 就降到 25,源视频 20fps 就原样留着。凭空插帧会让嘴的动作变成均匀的抽搐,比降帧难看。
最后一步。 帧序列纯内存缓存,直接进编码器,不再落成几千张 PNG 再读回来。合音轨的时候用 ffmpeg 锁帧,避免播放器把视频和音频各按各的时钟走,长片上尤其明显。
检索和切片
抓取那部分最后落在了 web_search.py 里,两百来行,零第三方依赖(除了 requests)。
检索走三级降级:配了 SearXNG 就先用它,拿不到结果退到 Bing 中国站的 HTML 直抓,再不行才用商业 API 兜底。中间那一级最脏也最有用:Bing 的结果链接是 /ck/a?u=a1<base64> 的跳转包装,得把 base64 解出来还原真实地址,否则抓正文永远抓到 Bing 自己。国内机器不翻墙的情况下,这条链路是能跑通的最低成本方案。
正文抽取用标准库的 html.parser,跳过 script、style、nav、footer、header、aside、form、iframe,剩下的文本拼起来。这跟 Trafilatura 那种基于文本密度的判断差得远,但在资讯站和公众号页面上够用,而且不用装 lxml。有个坑值得记一笔:页面没声明 charset 时 requests 会默认 latin-1,中文全是乱码,得手动设 apparent_encoding。
入库之后切片,默认 500 字一块、80 字重叠。用的是复用过来的 rag/chunking.py,纯标准库,出的是 small-to-big 父子块:子块拿去被检索,命中之后把父块给模型做上下文。这个设计在长文档上效果比固定窗口明显好,因为模型看到的不是半句话。
检索本身目前以关键词打分为主,标题命中权重 ×3,正文命中算 1,按分排序取前几块。向量检索的接口和表都留好了(sqlite-vec + bge-large-zh),配置里默认关着,因为在没有 GPU 的客户机器上,一个 embedding 模型启动就要占两三个 G 内存,不如让用户自己决定要不要开。
优势在哪
完全离线。 这不是一句营销话。资料抓取可以走自建 SearXNG,模型本地跑,数据落在本机 SQLite,跑完之后拔网线也能继续用。对法务、医疗、政务这类客户,这一条比任何功能都重要。
部署形态轻。 没有 Docker,没有 K8s,没有一堆 sidecar。一个 Python 进程、一个静态前端、一个 SQLite 文件,加一张能跑推理的卡。装不上的概率低,出问题时能看的东西也少。
链路完整,而不是能力堆砌。 市面上不缺能克隆声音的工具,也不缺能生成口播视频的工具,缺的是把"我要做一期关于 X 的视频"这个意图落到底的东西。中间任何一环断掉,用户都得自己去搬文件、转格式、对时间轴。画布的意义就是把这条搬运的活干掉。
每一层都能换。 TTS 两个后端可切,ASR 两个后端可切,文档解析器可换(PaddleOCR-VL / MinerU),LLM 走 OpenAI 兼容协议,接豆包、通义、本地 vLLM 都行,检索器能从 SearXNG 换到商业 API。这些都在配置里,改完热生效。
边际成本低。 形象克隆是一次性投入,之后出片只花电费。同样的量放到按分钟计费的云服务上,一个月能差出一台机器的钱。
它不是什么
得说清楚边界,不然容易误会。
它不是多租户 SaaS。权限体系只做到"有个 user_id"的程度,没有租户隔离,没有配额,没有计费。要拿去对外提供服务,这层得重做。
形象克隆吃素材质量。正脸、稳定光、少遮挡的视频,效果能看;手机随手拍、逆光、频繁转头,出来的人脸会飘。这不是调参能解决的,是数据问题。
向量检索还在路上。现在这套关键词 + 切片的组合在资料库规模小的时候够用,上千条之后召回会变差,那时候该把 embedding 打开。
回到开头
抓网页那批开源项目教会行业一件事:把非结构化的东西整理成模型能吃的形状,这个活本身就有价值,而且可以做得非常通用。
但用户要的从来不是"一段干净的文本"。他要的是那期视频,要的是明天早上能发出去。
我们做的事情,说到底就是把抓取之后剩下的那五步接上,让整条路能一次走完。这五步里没有哪一步是优雅的:解 Bing 的 base64 跳转、给嘴形提前一帧、把帧率只降不升地归一、比对 PID 的创建时间。都是些不太上台面的活。
但把这些活干完,一个不会剪视频的人,就能在一下午做出一条自己的数字人口播。
这件事本身就值得做。