Loading
词嵌入表有 31,254,528 字节。一个十二词的句子只需要其中约 20 KB。HTTP Range 让传输的只有这一部分。
打开仪器一个小型 BERT 的大部分体积,不是模型,而是字典。
bert-mini 的 Transformer 块——四层里所有的权重矩阵——加起来约六兆字节。而单是词嵌入表就有 31,254,528 字节,因为它为 30,522 个词表条目每一条都存了一个 256 维向量。
如果整体发布,每位访客都要下载 31 MB,只为看一个九个词的句子——而他们只会用到其中九行。
关键结论: 嵌入表不是一个模型文件,而是一个随机访问数组。把它当数组对待,一个句子的代价就从兆字节变成千字节。
HTTP 从 1999 年起就有了答案。客户端可以请求一个字节区间而非整个文件,符合规范的服务器会以 206 Partial Content 只返回那段字节。
导出格式正是为此设计的:嵌入表是一个扁平的、按行主序排列的二进制文件,第 i 行恰好从第 i × 1024 字节开始。因此分词器一旦产出输入 id,每个 id 就是一个字节偏移,运行时便精确取回这个句子用到的那几行。
嵌入表共 31,254,528 字节。场景只通过 HTTP Range 取回其词元所需的那几行,因此代价随你的句子增长,而不是随模型增长。
十二个词的句子大约十六个词元,也就是十六千字节。表中其余 99.95% 从未离开服务器。
冷启动——每位访客只付一次的那部分——是 6.32 MiB:量化后的块、清单文件和词表。词表必须完整到达,因为在分词之前你无从知道需要哪些行,而没有词表就无法分词。
这个先后顺序带来一个我更喜欢的可见后果。输入上限是二十四个词元,而你的句子会变成多少词元,在词表到位之前无从知晓。于是界面选择在事后诚实地报告句子过长,而不是事先猜测。一个预测出来的上限,恰恰会在最要紧的输入上出错——那些满是会被拆碎的生僻词的句子。
互联网上所有"本地运行、隐私优先"的声明,都是关于你看不见的代码的承诺。在这里,它是部署的一个属性,你可以在自己浏览器的网络面板里核对。
Content-Security-Policy 是承重的那一块:
default-src 'self' connect-src 'self' worker-src 'self' blob:
object-src 'none' frame-ancestors 'none' base-uri 'self'connect-src 'self' 意味着页面不被允许向任何其他源建立连接。不是推理 API,不是分析端点,不是日志服务。既不会因疏忽发生,也不会因将来的一次改动发生——浏览器会拒绝。
这与"我们不发送你的数据"是不同性质的陈述。它是:这个页面无法发送你的数据,而且执行者不是我。
这份清单里刻意没有 'wasm-unsafe-eval',上一篇已经解释了原因。浏览器测试会断言它的缺席,而不是指望谁记得住。
Range 请求并不免费。
二十个独立的小请求意味着二十次往返延迟,而一次大下载只有一次。在快速连接上,取行的开销淹没在噪声里;在高延迟的慢速链路上,它们是一次查询中最慢的部分。这个取舍在这里值得——因为另一头是 31 MB——但它确实是个取舍。
服务器也必须配合。一个忽略 Range、直接返回 200 和完整响应体的 CDN,会让场景完美工作,同时在每次请求时悄悄传输整张表——没有错误,没有警告,只有每位访客三十兆字节。这种失败从页面内部是看不见的,所以才有一个脚本去验证真实主机确实以精确字节数返回 206,而不是假设它会。
有意思的地方不是 Range 请求本身,而是:一个我一直当作固定事实的约束——"在浏览器里用真模型,就得下载真模型"——原来只是打包方式的产物。
Transformer 必须完整存在:每个块都要作用于每个词元。字典不必。它是稀疏查表,每次查询只用几条,而把它当文件而不是当表来处理,就是在用 31 MB 去投递 16 KB 的有用信息。
面对任何"重得没法发布"的东西,这个问题都值得一问:它是一个模型,还是一个索引?
下一篇:屏幕上的权重,原来是某个你从未被展示过的东西的份额。