资讯动态

transformers.js 源码解析:前端 Transformer 推理与 ONNX Runtime 实践

发布时间:2026/9/8 21:19:59 来源:尧图企业网站定制
先说一下我为什么会对这个项目起劲。以前要在浏览器里跑一个 BERT大家第一反应都是 TensorFlow.js但真到用的时候就发现坑很多模型转换要自己处理、tokenizer 的对齐方式经常对不上、动态 shape 一调就是半天。后来 Hugging Face 做了 transformers.jsJavaScript 开发者终于可以用接近 Python transformers 的写法直接在浏览器或者 Node.js 里加载模型、跑推理前端做 AI 功能的门槛一下子降了下来。这篇文章不是翻译官方文档而是我阅读huggingface/transformers源码之后的一份梳理。我会重点讲清楚一个 Transformer 模型是怎么从 Hugging Face Hub 下载到本地、怎么被 tokenizer 加工、又是怎么通过 ONNX Runtime 在前端跑起来的同时结合自己在浏览器和 Node.js 两端的实际接入经验把容易踩的坑一并列出来。无论你是刚接触前端推理还是想在现有项目里嵌入一个小模型做语义搜索、文本分类、信息抽取这篇都可以当一份“从零到一”的参考。1. 先搞清楚定位transformers.js 不是 Python 移植版而是 ONNX Runtime 的上层封装很多第一次接触 transformers.js 的人会有一个误解以为它把 Python 版 PyTorch 代码翻译成了 JavaScript。不是这样的。它的设计思路更聪明——直接绕开 PyTorch让模型先转成 ONNX 格式再用 ONNX Runtime 在本地执行推理。1.1 从 huggingface/transformers 说起目前官方推荐的是huggingface/transformers这个 npm 包早期版本叫xenova/transformers。前者的源码结构比后者清晰很多类型定义也更全。核心依赖有三个方向onnxruntime-web负责浏览器端的 WASM/WebGPU 后端。onnxruntime-node负责 Node.js 环境的原生后端。自带的一套预处理逻辑对应 Python 版 tokenizer、feature extractor 的 JavaScript 实现。你可以把 transformers.js 理解成中间胶水用户写的代码是pipeline(sentiment-analysis)表面上跟 Python 一样里面实际做的是先找模型配置再实例化 tokenizer再创建 ONNX Runtime InferenceSession然后把文本编码成 Tensor 喂给 session.run。整个过程没有任何 Python 进程参与。1.2 底层依赖onnxruntime-node、onnxruntime-web 与 backends源码里有一个文件专门负责选择后端不同环境返回不同的 session 创建方式。我简化一下它做的事import * as ortWeb from onnxruntime-web; import * as ortNode from onnxruntime-node;浏览器环境走 WASM 或 WebGPUNode 环境走onnxruntime-node后者是直接用 C 加原生绑定实现的推理性能通常比浏览器 WASM 好。但源码里不是简单用typeof window判断还考虑到了 Web Worker、React Native、Deno 等场景。这部分的判断逻辑非常繁琐我自己调试时甚至遇到在 SSR 环境下环境检测错误导致 session 创建失败的问题。这里我补充一个“为什么必须有 ONNX Runtime”的解释Hugging Face Hub 上的模型绝大多数是 PyTorch 的pytorch_model.bin浏览器不认。要跑推理就需要先用optimum或官方提供的转换脚本把模型转成.onnx格式。代码里对应的根路径是https://huggingface.co/{repo}/resolve/{revision}/{file}比如Xenova/all-MiniLM-L6-v2这个仓库里就有转换好的 ONNX 文件。transformers.js 默认请求这些仓库而不是 Python 版常用的 PyTorch 仓库。所以实际项目里你直接指定一个未知仓库时如果里面没有 ONNX 文件模型加载就会失败。2. 源码目录与整体架构一个跨环境的统一入口读一个开源库先看目录结构往往比一行行读源码有效。transformers.js 的源码组织很接近 Python transformers很多类名都能对应上。2.1 完整包结构拆解我整理了一下src目录的大致模块划分src/ models.js // PretrainedModel、AutoModel 以及各任务模型 tokenizers.js // AutoTokenizer、CLIPTokenizer 等 pipelines.js // pipeline 工厂和各任务 Pipeline 实现 generation.js // text generation 相关 tensor.js // Tensor 封装处理 onnx 输出到 JS 数据 utils/ hub.js // 模型仓库下载、缓存 env.js // env 配置 fs.js // 跨平台文件系统 math.js // softmax、sigmoid 等 backends/ onnx.js // 创建 onnx session、执行 run configs.js // AutoConfig image.js / audio.js // 多模态处理这套结构很有用的一点是如果你想扩展某个任务只需要在对应类型里加一个类然后在pipeline()的映射表里注册即可。我最早读这个仓库的时候就是从pipelines.js里找feature-extraction的实现顺着映射跳到对应类不用从头开始读。2.2 环境探测与设备选择env、device、dtype 是怎么来的env对象是 transformers.js 暴露给使用者的一个全局配置入口。不少用户不知道它能控制什么这里列几个高频字段env.allowRemoteModels是否允许从 Hub 远程下载设成false后只读本地。env.allowLocalModels是否允许从本地路径加载默认 true。env.cacheDirNode 端的模型缓存目录。env.useBrowserCache浏览器端是否使用缓存默认是 IndexedDB。env.backends.onnx.wasm.proxy是否让 WASM 在 Worker 中运行。env.backends.onnx.wasm.numThreadsWASM 线程数。这些配置的解析在源码里贯穿了加载流程。例如加载一个模型时PreTrainedModel.from_pretrained()并不是立刻去查 Hub而是先把模型名/路径交给getModelFile后者再结合env.allowLocalModels判断是否走本地路径。我实测过程中印象最深的是浏览器里默认会把模型缓存到 Cache API 或 IndexedDB第一次加载之后第二次再打开页面模型几乎秒级读取不再走网络。这对前端体验帮助非常大但也带来一个新的问题你升级了模型仓库里的文件但本地缓存还是旧的页面不会自动拉新。解决办法是在 URL 上带 revision 参数或者在加载前手动清除缓存。3. tokenizer 与模型初始化从本地文件到远端仓库的加载链路模型推理的第一个环节不是模型本身而是 tokenizer。Transformer 模型吃的是 token id不是原始字符串。源码里的 tokenizer 实现涵盖了 BPE、WordPiece、Unigram 等多种算法这些逻辑从前端代码的角度看非常庞大但好在它被封装成了和 Python 相似的方法。3.1 预训练模型文件清单一个典型的 transformers.js 兼容模型仓库通常有这些文件config.json tokenizer.json tokenizer_config.json onnx/model.onnx onnx/model_quantized.onnx如果没有tokenizer.json只有vocab.txt和merges.txt也能跑但需要走 older 逻辑去手动加载 BPE 词表。源码里tokenizers.js会有多个类处理不同格式大多数情况下推荐上游直接放一个统一的tokenizer.json这样加载最快因为你只需要用 Hugging Face 的tokenizers库序列化一次。3.2 路径解析与缓存机制当你在代码里传入AutoTokenizer.from_pretrained(Xenova/all-MiniLM-L6-v2);源码内部会先把它解析成https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/tokenizer.json这里main是分支名默认是main。如果你想固定版本可以在仓库名后加commit_hash或者revision格式如Xenova/all-MiniLM-L6-v2main。Node 端的文件缓存默认是用户目录下的~/.cache/huggingface/transformers。我一开始以为它跟 Python huggingface_hub 共用同一个缓存目录后来才发现它单独建了目录避免两套原生包互相干扰。这个策略很保守坏处是同一个模型在 Python 和 Node 各存了一份磁盘占用翻倍。3.3 动态加载背后的三大核心类AutoTokenizer、AutoModel、PretrainedModel在源码里初始化链路是AutoTokenizer - tokenizer 类 - Tokenizer AutoModel - PretrainedModel - Config这两条链用的方法名都是同样的from_pretrained。你可以把它理解为所有资源读取的入口统一这种做法最大的好处是记忆成本低。源码内部维护了一个映射表从 config 里的model_type映射到具体模型类。比如 BERT 的 config 里有model_type: bert就会实例化BertModel。很多任务型模型比如TextClassificationModel、Sequence2SequenceModel也是在AutoModel里头通过 config 属性进一步解析出来的。如果只看 README很容易忽略PretrainedModel中还有一个main_input_name的概念。有的模型输入是input_ids有的是pixel_values有的是input_features。pipeline 会读取这个属性来决定预处理后的数据到底放到哪个 key 里。这点在源码调试时非常关键因为很多人把预处理结果传进去后报错往往就是这个字段没对上。4. 浏览器与 Node.js 的“双环境”实现原理transformers.js 面向的两个大环境差异太大了。浏览器有 DOM、没有文件系统Node 有文件系统、没有 DOM浏览器用fetch加载远程资源是常态Node 则可以直接读磁盘。源码层为了抹平差异专门抽象了一个fs模块和一些环境配置。4.1 文件系统差异fetch vs fs在 Node 环境加载本地模型可以写import { pipeline } from huggingface/transformers; const classifier await pipeline( sentiment-analysis, ./models/my-model );这里./models/my-model的路径解析最终会走 Node 的fsAPI。源码里有类似这样一句话我根据常见实践理解如果路径能映射到本地文件存在就不发请求直接读取 buffer否则走 remote 逻辑。浏览器里不能这么干。浏览器里只有一个“伪路径”的概念比如await pipeline( sentiment-analysis, https://example.com/models/my-model );这个远程路径最终会拼成https://example.com/models/my-model/onnx/model.onnx这里也需要 CORS 支持如果服务器没开Access-Control-Allow-Origin浏览器会直接拦截响应模型加载失败。这是大家在本地开发最容易碰到的一个坑Node 里模型路径好用一上浏览器就不行不是代码问题而是服务器头没配对。4.2 Web Worker 与主线程的约束Transformer 推理是大计算任务如果在浏览器主线程跑页面 UI 会有明显卡顿。transformers.js 自己没有强制要求你使用 Worker但底层 WASM 的运行方式会影响你选择的路线。在源码中env.backends.onnx.wasm.proxy的默认描述是如果你不想阻塞主线程可以让 WASM 运行在单独线程中。但这个 proxy 会带来一定的通信开销需要把每次推理的输入输出通过 postMessage 来拷贝小模型感知不明显大模型/长文本会有一点延迟。我自己做产品时是这么解决的把pipeline初始化放到 Web Worker 里然后在 Worker 内部加载 WASM。实际效果是页面主线程完全不卡模型的 load 和 infer 都在 Worker 中完成主线程只需要等着结果回来。代价是 tokenizer 的结果从 Worker 传回主线程时如果输出是大 Tensor也要做结构化克隆有额外开销但相比 UI 卡顿这点成本完全可接受。4.3 WebGPU、WASM 与后端选择/回退策略transformers.js 中有一个重要概念“如果不指定 device默认优先使用 WASM”。因为 WebGPU 虽然快但兼容性不完美且要求浏览器开启相应特性比如 HTTPS 环境部分安卓浏览器也有问题。在代码里你可以显式指定await pipeline(feature-extraction, Xenova/all-MiniLM-L6-v2, { device: webgpu, dtype: fp32 });device 还有其他选择值比如wasm、cpu甚至在 Node 里对应着原生执行。一个更实用的小技巧是把 device 做成可配置项在页面里让用户选择优先性能还是优先兼容性。实测下来 WebGPU 的加速效果通常很明显尤其适合小 batch、短文本的即时推理但如果你要处理很长的长文本WASM 的内存控制反而更稳定WebGPU 有时会因为显存/shader 编译问题抛错。源码里的回退策略不是自动的你必须自己侦测是否支持 WebGPU。官方有一个静态方法常见写法是import { env } from huggingface/transformers; const hasWebGPU gpu in navigator; const device hasWebGPU ? webgpu : wasm;如果用了webgpu但环境不支持代码会直接抛错。我建议大家在封装 SDK 时先把环境探测放在最外层而不是让加载过程中再报错。5. 核心推理流程让 Transformer 在一次前向传播中跑起来理解推理主流程最好是直接看一个最小用例再去看它是怎么被 pipeline 拉起来的。5.1 pipeline 的高层封装pipeline这个函数是所有便利性的起点。源码里它本质是一个注册表工厂const classifier await pipeline( sentiment-analysis, Xenova/distilbert-base-uncased-finetuned-sst-2-english ); const result await classifier(I love transformers.js!); console.log(result); // [{ label: POSITIVE, score: 0.9998 }]这段代码的底层行为是根据sentiment-analysis找到对应的 Pipeline 类比如TextClassificationPipeline。自动加载 tokenizer 和 model。返回一个可调用函数函数内部执行tokenizer - model - postprocess流程。很多前端开发者没有意识到这背后还做了一个“无状态函数化”的设计即同一个 pipeline 实例可以被多次调用模型权重和 tokenizer 都只加载一次。这点对重复请求场景非常重要。如果你不想用 pipeline而是想更自由地拿中间特征可以用底层 APIimport { AutoTokenizer, AutoModel } from huggingface/transformers; const tokenizer await AutoTokenizer.from_pretrained(Xenova/all-MiniLM-L6-v2); const model await AutoModel.from_pretrained(Xenova/all-MiniLM-L6-v2); const inputs await tokenizer(Hello world); const outputs await model(inputs);这里的outputs是一个对象里面通常包含last_hidden_state。源码会返回一个Tensor对象需要调用.tolist()或.data来取具体值。这一步是很多新手疑惑的根源因为 Python 里你能很直观看到 tensor shapeJS 里打印出来却是一个带着dims和data字段的对象。5.2 输入预处理与输出解码的细节我一步步解析tokenizer(I love transformers.js)到底做了什么。首先它对字符串做按 tokenizer 的分词比如英文里“transformers.js”可能被拆成多个 token。然后每个 token 映射到 vocab 里一个 id再补上[CLS]和[SEP]生成input_ids。同时生成attention_mask用 0 和 1 表示哪些位置是真实 token、哪些是 padding。但这些数组不能直接塞给模型还要包装成 Tensor 并调整维度变成类似[1, sequence_length]的形状。源码里的pad、truncate逻辑在不同任务间会有些细微差异比如文本生成任务需要保留更长的上下文分类任务经常截断到 512。输出解码逻辑也值得一说。文本分类的输出是一个维度为[1, label_count]的 logits 矩阵需要经过 softmax 变成概率。源码里写了一个数学工具function softmax(arr) { const max Math.max(...arr); const exp arr.map(v Math.exp(v - max)); const sum exp.reduce((acc, v) acc v, 0); return exp.map(v v / sum); }实际源码中会更稳健会先把最大值减掉以避免指数溢出。我第一次看的时候没理解为什么减 max后来用极端 logits 试了才知道不减直接算Math.exp(1000)会直接变Infinity。这是实现 softmax 的标准技巧但源码里为了兼容性防止某些环境没有完整 ES API还做了不少 polyfill 工作。5.3 Tensor 与数值精度fp32/fp16/int8/uint8的处理由于最终推理是由 ONNX Runtime 完成的所以模型权重精度不是想当然的 fp32。transformers.js 在加载后处理模型时会读取 ONNX 文件里实际存储的 weight 类型再通过 ONNX Runtime 在内存中展开。源码中比较常见的是 fp32 模型和量化后的 int8/uint8 模型。量化是前端场景常用的一招典型模型名会带有quantizedmodel.onnxfp32精度高体积大。model_quantized.onnx通常 uint8体积约为 fp32 的四分之一速度更快但准确率可能轻微下降。在代码里你可以直接指定加载哪个文件因为模型管理器在找文件时有固定的优先级。如果你想用量化版本可以在模型仓库的config.json里观察可用文件或者在加载时手动拼一个本地路径。这里有个经验对语义向量任务量化模型和 fp32 模型在召回结果上的差距往往小到可以忽略但对小样本分类任务量化后得分会有些许偏移最好做一轮验证集对比再上线。6. 实操在 Next.js 与 Electron/Node 中接入并细读关键源码理论聊完下面写点可以直接抄作业的实操内容。我会分别聊浏览器项目、Node 项目以及怎么调试源码中“从 pipeline 到 session.run”的完整链路。6.1 浏览器端直接加载一个小模型就拿文本分类举例。先把包装上npm install huggingface/transformers然后新建一个worker.jsimport { pipeline } from huggingface/transformers; let classify; async function init() { if (!classify) { classify await pipeline( sentiment-analysis, Xenova/distilbert-base-uncased-finetuned-sst-2-english ); } return classify; } self.addEventListener(message, async (event) { const { text, taskId } event.data; const classifier await init(); const result await classifier(text); self.postMessage({ taskId, result }); });在主线程中初始化 Workerconst worker new Worker(new URL(./worker.js, import.meta.url), { type: module });这里有几个细节。第一加载模型这种重活必须在 Worker 里完成否则拖垮主线程。第二如果多次发消息最好带一个taskId做匹配因为 Worker 和主线程是异步通信。第三不要每次推理都重新创建 pipeline否则模型权重被反复下载内存会持续上涨。6.2 Node.js 端的差异与本地文件调用Node 环境一般不需要 Worker因为 Node 本身是多线程的而且 server 端对阻塞没有那么敏感。你可以直接跑import { pipeline } from huggingface/transformers; const classifier await pipeline(sentiment-analysis, Xenova/bert-base-multilingual-uncased-sentiment); const result await classifier(这个产品非常好用); console.log(result);如果你想离线部署模型资源不应该每次都请求 Hub。先在本地把模型下载好然后使用本地目录huggingface-cli download Xenova/bert-base-multilingual-uncased-sentiment --local-dir ./models/bert-sentiment代码里改为const classifier await pipeline( sentiment-analysis, ./models/bert-sentiment );Node 端还需要注意一个容易被忽略的性能点onnxruntime-node在原生命令下性能很好但如果部署在 serverless 平台冷启动时加载原生.node绑定文件和解压模型的时间会比较长。我一般会提前把模型放进容器镜像里而不是在运行时才去远程拉。这一点对生产环境至关重要。6.3 源码断点调试从 pipeline 到 session.run 的调用链想真正读懂一个库断点是最直观的。你可以在node_modules/huggingface/transformers/dist/transformers.js里搜索async run或者createSession打上断点然后跑一个最小例子。我在调试时常用的一条调用链是pipeline() - PipelinesFactory() - TextClassificationPipeline() - this.model await AutoModel.from_pretrained() - PretrainedModel.from_pretrained() - loadONNX() or getModelFile() - InferenceSession.create() - this.tokenizer await AutoTokenizer.from_pretrained() - 返回一个 callable调用时callable(text) - this.tokenizer(text) - this.model(inputs) - this.session.run(feeds)在断点里你会很清楚看到模型加载阶段最大的耗时在InferenceSession.create这一步会编译/初始化图而推理阶段session.run的耗时取决于模型大小和输入长度。如果把模型换成一个量化版很多场景下能明显看到session.run的耗时台阶式下降。这里我还遇到过一个问题在 Next.js 里用 dynamic import 加载 transformers.jsSSR 会尝试在服务器端也执行 import导致环境判断出错。解决方案是把相关代码包进一个浏览器端模块设置ssr: false或者直接使用huggingface/transformers配合 Web Worker 的写法来绕开 SSR。7. 常见的推理卡顿、加载失败问题排查源码解析这件事最终还是要落到“能跑起来”。我会把这些年踩过的几个高频问题整理成速查式的建议方便你日后排查。7.1 CORS、CDN 与本地模型加载浏览器端最常见的问题就是 CORS。你从一个不支持跨域的服务上拉取模型文件浏览器直接拒绝。因为不是所有服务器都把.onnx、.json文件的 MIME 类型配好很多对象存储服务还会拦截带查询参数的请求。我建议在生产环境把模型上传到自己的对象存储/CDN并明确配置Access-Control-Allow-Origin: *如果是本地开发也可以直接用transformers.js提供的缓存能力把模型放到本地静态目录让静态服务器自己响应请求尽量避免临时服务器资源。7.2 内存与线程问题跨域隔离/OOMWASM 线程需要浏览器支持跨域隔离也就是要设置两个响应头Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp如果不开启部分浏览器环境下 WASM 多线程无法使用或者加载时报 “Cannot enlarge memory arrays” 之类的错误。这是浏览器安全模型的硬限制不是 transformers.js 本身能绕开的。内存溢出也常发生在长文本输入时。Transformer 的 attention 层对序列长度是平方级内存增长。输入 512 token 可能没问题输入 2048 token 时内存立刻飙升。我建议在上层就限制输入长度比如截断到 256/512再用滑动窗口去做段落级分析。源码里本身也有max_length参数但很多任务不会自动帮你做一段滑窗的处理。7.3 WASM 内核编译慢与进度事件第一次加载 WASM 文件时会有一段编译时间你可能会看到白屏好几秒。这时候最好的方式是给用户一个进度提示transformers.js 没有太多内置 UI但是可以通过监听下载进度事件来给用户反馈。一个比较基础的做法是使用env或者拦截 fetch 过程实时统计已加载文件大小但复杂的模型会有多个文件不是一次 fetch 能搞定的。更省事的方案是提前把外链模型打包到项目静态目录减少远程网络请求还可以把模型和 JS 放在同一个 CDN 域让 HTTP 缓存生效。另外一个容易被忽略的因素是 WASM.wasm文件本身需要被正确托管。如果服务器连.wasm都没发布出来你会看到一个加载失败。很多容器镜像只发布.js产物却忘了把ort-wasm-simd-threaded.wasm这类资源一起带上导致线上环境报 “file not found”。官方文档建议要么把 WASM 文件也放远程 CDN要么通过env.backends.onnx.wasm.wasmPaths /path/to/wasm/;指定路径避免工具自己去找默认位置。7.4 模型加载成功推理结果却不对这是最让人头疼但很少被记录的一类问题。有时候模型能加载但跑出来的 embedding 全是 NaN/Infinity或者分类概率都是 1/0这种情况多半是输入输出张量的 dtype 没对齐。比如你加载的是量化模型但输入层依旧是 fp32某些算子对数值范围极其敏感一个溢出就让整个结果崩溃。排查时可以先用dtype: fp32跑同一条数据再切换到量化模型做对比。如果在 Node 端正常、浏览器端异常优先检查两个环境的 ONNX Runtime 版本是否一致因为算子的实现差异也可能导致结果不同。在实际项目里的一些体会读 transformers.js 源码给我最大的收获是一个优秀的跨端 AI 推理库并不需要发明新模型而是要把“加载、预处理、推理、后处理”这条链路处理好把文件系统、缓存、线程、精度这些琐碎问题在底层尽量抹平。如果你只是在一个项目里用一下 pipeline很多细节可以不用关心可一旦你要做定制模型、量化部署、性能优化源码里那些环境判断和加载顺序就是你的第一手资料。我也建议未来做同类项目时不要在业务代码里把模型加载逻辑和推理逻辑写成一团。可以仿照 transformers.js 的思路把 tokenizer、model、session 这些资源做成独立模块统一包装成一个 async factory。这样既能明确管理生命周期也方便在后续引入 Web Worker 或服务端推理时复用同一套代码。最后分享一个小技巧排查浏览器端模型加载问题时别急着查代码先打开 DevTools 的 Network 面板看请求列表。如果模型文件根本没发出请求那多半是缓存命中如果请求被 CORS 拦截基本能在 Console 里看到明确报错。一次几秒的观察往往比重新读一遍源码更有效。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价