春节前最后一周,我把一个能离线写春联的小Demo塞进了浏览器里:Wasm扛推理,Llama系模型做骨架,Yi模型做中文生成,这套组合就是“驾辰龙跨Llama持Wasm,玩转Yi模型迎新春过大年”上篇要聊的事。它不是又一个调用云端接口的春节H5,而是把模型权重分片、Wasm推理核心、分词器、生成循环全部放到本地,打开页面就能在断网状态下写对联、编灯谜、出拜年文案。对前端来说,这是一次把“大模型”从服务端拉到浏览器的尝试;对算法同学来说,这是一次量化、内存和算子适配的现场考试;对想做过年小工具的人来说,这是一套能直接抄作业的离线生成方案。上篇先把架构、模型选型、Wasm编译、分片加载和第一个春联生成跑通,下篇再补多线程、WebGPU和移动端降级。适合有JS基础、懂一点模型量化、愿意折腾Wasm的读者,也适合只想看看浏览器跑模型到底靠不靠谱的旁观者。
1. 这个“龙年浏览器推理”项目到底在折腾什么
1.1 标题拆解:三个关键词背后的技术栈
“驾辰龙跨Llama持Wasm,玩转Yi模型迎新春过大年”这个标题读起来像一串年货清单,拆开看其实是三条技术线拧在一起。辰龙是春节主题外壳,负责UI、文案和交互氛围;Llama是模型结构层面的兼容目标,意思是推理核心不能只认某一种权重格式,而要能接住Llama系常见的层结构、注意力布局和分词方式;Wasm是运行时载体,把原本跑在服务端或本地的推理代码编译成浏览器能加载的二进制模块;Yi模型是中文生成能力的具体落点,春联、灯谜、拜年文案这类短文本对中文语感要求高,选一个中文语料充分的模型比单纯堆参数更划算。
Wasm在这里不是模型,也不是框架,它更像一个“浏览器里的轻量容器”。你把Rust、C或者C++写的推理内核编译成.wasm,浏览器负责加载、分配线性内存、调用导出函数。它的好处很直接:同一套核心代码可以在桌面浏览器、移动端浏览器、甚至某些边缘运行时里复用,不用为每个平台重写推理循环;沙箱机制把模型运算和页面DOM隔开,出错时不容易把整个页面拖死;配合Worker还能把重计算放到后台线程,主线程继续负责输入和流式渲染。坏处也同样明显:Wasm32的线性内存上限是4GB,实际浏览器还会再打折扣;多线程依赖跨域隔离头;SIMD和线程支持在不同浏览器里有差异;调试不像纯JS那样点开控制台就能看。
Llama系模型在这套方案里承担“结构兼容”的角色。浏览器端推理最怕的是每个模型一套算子、一套内存布局,最后维护成本爆炸。把注意力、前馈层、归一化、位置编码这些公共部分抽象出来,权重按分片加载,tokenizer单独走一条通路,才能让“换模型”变成换权重和换配置,而不是重写整个推理核心。Yi模型则是“中文效果”的保险栓,春联要求字数相等、词性相对、上下联意境呼应,拜年文案要求称呼得体、语气不油腻,这些都不是靠温度参数能硬调出来的,底模的中文底子决定了下限。
1.2 为什么是Wasm + Llama系 + Yi,而不是纯前端API
纯前端调API当然最省事:页面发请求,服务端跑模型,前端只负责展示。但春节小工具的使用场景很特殊,很多人是在饭桌上、车上、老家弱网环境里打开,网络一断,页面就成了红金配色的空壳。再加上春联和拜年文案往往带家庭称呼、姓名缩写、具体年份,用户对“内容离开设备”这件事天然敏感。Wasm本地推理把权重和输入都留在浏览器里,断网也能生成,刷新页面不用重新排队,成本也不会随着访问量线性上涨。这是选择Wasm的第一层理由。
第二层理由是可控性。云端接口的模型版本、温度上限、输出长度、停止符都由别人决定,你想让模型严格输出“上联、下联、横批”三段,还想在生成后做字数校验和平仄提示,本地推理可以随便加后处理。Llama系模型生态里常见的量化格式和分词器配置比较统一,方便做“一套核心接多种权重”;Yi模型的中文对话模板和生成风格又比较适合短文案,二者组合起来,等于用Llama系的结构兼容性兜底,用Yi的中文能力出效果。这个组合不是唯一解,但在“浏览器、离线、中文短文本、可折腾”这四个约束下,它是比较顺手的一条路。
第三层理由是学习价值。纯调API你只能学到请求参数和前端展示,本地推理会逼着你理解量化位数、KV缓存、内存页、tokenizer、停止符、上下文长度这些平时被封装掉的东西。哪怕最后因为手机内存不够而退回小模型,这套知识也能迁移到边缘设备、桌面客户端和离线工具上。我的建议是别一上来就冲最大参数,先用小尺寸模型把链路跑通,再换Yi模型看中文效果,最后才去调线程和SIMD。顺序反了,很容易在“页面白屏”和“生成乱码”之间反复横跳。
1.3 适合谁:从前端到算法都能各取所需
前端同学能从这个项目里拿到一套Wasm加载、Worker通信、分片下载、流式渲染的完整样板。平时写惯了组件和请求,突然要面对WebAssembly.Memory、memory.grow、SharedArrayBuffer,会有点懵,但跑通一次之后,你会对“浏览器能做什么”有新的判断。尤其是春节主题的UI,红金配色、毛笔字、流式打字机效果,都是现成的练手素材,做出来还能直接发给家人用。
算法同学关注的点更偏底层:量化到Q4_0还是Q4_K_M,KV缓存占多少,上下文开到512还是1024,Yi模型的对话模板怎么拼,停止符设哪几个。浏览器端没有服务端那么宽的内存,很多在服务器上不是问题的细节,在这里都会被放大。比如同样一个6B模型,Q4权重3.5GB左右,KV缓存再吃几百MB,Wasm运行时和分词器再占一点,4GB上限就被顶到了。你必须学会做减法:减上下文、减批大小、减线程数、减输出长度,最后找到一个能跑且体验不差的组合。
产品同学和想做副业工具的人也能看:这个Demo验证了“离线生成”的可行性,春节可以写春联,端午可以写祝福,生日可以写贺卡,核心链路是通用的。你不需要训练模型,只需要换Prompt模板、换主题UI、换后处理规则,就能做出一个垂直小工具。当然,别指望浏览器里跑出云端大模型的效果,它的定位是“轻量、离线、可玩”,不是替代服务端。把预期放对,后面的技术取舍会容易很多。
2. 整体架构设计:把大模型塞进浏览器需要几步
2.1 分层架构:UI、Wasm核心、模型分片、Worker
我把整个页面拆成四层:UI层、控制层、Wasm核心层、模型存储层。UI层只负责春节主题的输入框、生成按钮、流式输出区域和复制按钮;控制层是一个Worker,负责接收用户参数、调度Wasm、把token流回传主线程;Wasm核心层包含分词器、推理循环、KV缓存和采样逻辑;模型存储层负责按分片拉取权重、写入线性内存、缓存到本地。四层之间用消息传递,不共享DOM,避免重计算把页面卡死。
数据流是这样的:用户在输入框里写“龙年、家庭、事业”,点击生成,主线程把主题和场景类型发给Worker;Worker拼好Prompt,调用Wasm的分词函数得到token id数组;推理循环逐token生成,每得到一个token就解码成文本,通过postMessage回传;主线程收到片段后追加到输出区域,形成打字机效果;生成结束后,后处理模块检查上下联字数、横批长度,不合格就提示重新生成。整个过程不需要网络请求,模型分片在首次加载时从静态资源目录拉取,之后走浏览器缓存。
| 层次 | 主要职责 | 关键技术 | 注意点 |
|---|---|---|---|
| UI层 | 输入、展示、复制、主题切换 | 原生JS或轻量框架 | 不要在主线程做推理 |
| 控制层 | 参数校验、Worker调度、流式回传 | Web Worker、postMessage | 消息要带请求ID,防止串台 |
| Wasm核心层 | 分词、推理、采样、KV缓存 | WebAssembly、SIMD、多线程 | 内存增长要提前预估 |
| 模型存储层 | 分片下载、缓存、校验 | Fetch Range、Cache Storage | 静态服务器要支持Range |
这套分层的好处是替换成本低。你想换Yi模型,只改模型分片和配置;你想换UI,只动第一层;你想加WebGPU后端,就在Wasm核心层下面加一个可选后端,控制层不用大改。缺点是消息传递有开销,token流如果逐字回传,频率太高会拖慢主线程。我的做法是攒3到5个token再发一次,或者按时间窗口每30毫秒发一次,既保留流式感,又不至于把消息队列塞满。
2.2 模型选型与量化参数计算
浏览器跑模型,第一道算术题就是内存。权重内存的粗略公式是:参数量 × 量化位数 ÷ 8,再乘一个10%到20%的运行时开销。比如7B模型,FP16是70亿×16÷8≈14GB,浏览器根本不用想;Q8约7GB,也超;Q5约4.4GB,仍然顶到上限;Q4约3.5GB,加上KV缓存和运行时,刚好卡在4GB边缘;Q3约2.6GB,空间会宽松不少,但中文短文本的质量会掉。春联这种任务对事实性要求不高,但对字数和对仗敏感,Q4_0通常够用,Q4_K_M质量更好一点,内存也会略高。
KV缓存是另一个容易被忽略的大头。简化公式是:2 × 层数 × KV头数 × 每头维度 × 上下文长度 × 每元素字节数。以常见的6B级模型为例,32层、32个KV头、每头维度128,上下文512,FP16存储,算下来大约是2×32×32×128×512×2≈268MB。上下文拉到1024就翻倍到536MB。权重3.5GB加上KV缓存0.5GB,再加Wasm运行时、分词器、输入输出缓冲区,4GB上限就很紧张了。所以我在桌面端给Yi-6B级模型配Q4_0权重和512上下文,移动端直接降到更小尺寸或者Q3量化,先把“能生成”保住。
| 模型规模 | 量化 | 权重估算 | KV缓存512 | 总内存估算 | 适用端 |
|---|---|---|---|---|---|
| 1.8B级 | Q4_0 | 约0.9GB | 约80MB | 约1.1GB | 移动端、低配桌面 |
| 3B级 | Q4_0 | 约1.5GB | 约130MB | 约1.8GB | 移动端高配、桌面 |
| 6B级 | Q4_0 | 约3.5GB | 约270MB | 约3.9GB | 桌面端,需控上下文 |
| 6B级 | Q3_K_M | 约2.7GB | 约270MB | 约3.1GB | 桌面端、部分移动端 |
| 9B级 | Q4_0 | 约5.2GB | 约350MB | 约5.7GB | 不建议Wasm32 |
这张表是估算,不是精确值,不同模型的层数、头数、词表大小都会影响结果。我的经验是先把目标定在“总内存不超过3.2GB”,留出至少500MB给浏览器和页面本身,否则生成到一半触发内存增长失败,页面直接崩。模型分片也别做成一个整包,按512MB到1GB切,用Range请求逐片写入,既能显示进度,也能在失败时只重试单片。分词器文件单独加载,不要塞进权重分片里,否则换分词器还得重新下几个G。
2.3 春节场景的Prompt与交互设计
春节场景的Prompt不能只写“写一副春联”,那样模型很容易输出一堆解释性文字。我的模板分三段:角色约束、格式约束、主题输入。角色约束告诉模型“你是春联助手,只输出上联、下联、横批”;格式约束规定“七言、上下联字数相等、横批四字、不要标点解释”;主题输入放用户选的“龙年、家庭、事业、平安”等关键词。灯谜和拜年文案各有一套模板,灯谜要输出谜面、谜目、谜底,拜年文案要输出称呼、祝福语、落款。模板越具体,后处理越省事。
交互上我做了三个小设计。第一,流式输出按“上联、下联、横批”分段渲染,每段生成完就锁定,避免用户看到半截句子以为卡住。第二,生成按钮旁边放“换一副”和“复制”两个动作,复制时自动去掉模型可能带出的序号。第三,移动端把最大输出长度压到96,桌面端给到160,因为春联本身不长,多出来的token只会增加重复和等待。温度设在0.6到0.8之间,重复惩罚1.05到1.15,太高会让对联变得生硬,太低又容易车轱辘话。
后处理我写了一个很简单的校验:去掉空白和标点后,上联和下联字数必须相等,横批必须是4个字,上下联不能完全一样。校验不过就自动重试一次,第二次还不过就把温度降0.1再生成。这个规则不复杂,但能挡掉大部分“看起来像春联、读起来不对劲”的输出。模型负责创意,代码负责格式,别指望模型自己把平仄和对仗全做对,浏览器端的小模型没这个义务,也没这个能力。
3. 实操跑通:从环境准备到第一个春联生成
3.1 工具链准备与Wasm编译目标
环境准备不复杂,但有几个坑要提前避开。你需要一套Rust工具链、wasm32-unknown-unknown编译目标、一个支持Range请求的静态服务器,以及一个能开跨域隔离头的本地调试环境。命令行大概是下面这样,先装目标,再建库项目,最后编译。注意Wasm32是默认选择,Wasm64虽然能突破4GB,但浏览器支持面窄,不适合作为第一版方案。
rustup target add wasm32-unknown-unknown cargo new --lib inference_core cd inference_core cargo build --target wasm32-unknown-unknown --releaseCargo.toml里要把库类型设成cdylib,不然编译出来不是给浏览器加载的模块。绑定层可以用你惯用的Wasm绑定工具生成JS胶水,核心原则是只导出必要函数:初始化、加载模型分片、分词、生成、释放。导出越多,胶水代码越厚,调试越麻烦。编译完成后,把.wasm文件放到静态目录,确保服务器返回的MIME类型是application/wasm,否则浏览器会拒绝流式实例化。
跨域隔离头是第二个坑。只要你想用SharedArrayBuffer开多线程,页面就必须处于跨域隔离状态。静态服务器要加两个响应头:Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp。加完之后,页面里引用的外部图片、字体、脚本都要带对应的跨域资源头,否则会被拦。春节页面最好把图片和字体都放本地,省得调试时一边修模型一边修资源加载。
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp Content-Type: application/wasm第三个坑是缓存。开发阶段浏览器会把.wasm和模型分片缓存得很死,改完代码刷新没变化,别急着怀疑人生,先开无痕窗口或者清缓存。生产环境反而要利用缓存,把模型分片按版本号命名,文件名带哈希,更新时只换URL,避免用户下到新旧混合的分片。
3.2 导出Wasm推理接口(附Rust伪代码)
接口设计要围绕“一次加载、多次生成”来做。初始化函数负责接收模型分片指针和长度,内部完成权重解析和内存布局;生成函数接收Prompt指针、长度、输出缓冲区、最大token数、温度、top_p、重复惩罚,返回实际生成的token数或错误码。不要每次生成都重新加载权重,那会把浏览器卡到怀疑人生。下面是一段伪代码,重点看接口形状和错误码,具体推理内核按你选的运行时来填。
#[no_mangle] pub extern "C" fn init_model(ptr: *const u8, len: usize) -> i32 { // 解析分片、校验魔数、建立层索引 // 返回0表示成功,负数为错误码 0 } #[no_mangle] pub extern "C" fn tokenize( text_ptr: *const u8, text_len: usize, out_ptr: *mut u32, out_cap: usize, ) -> i32 { // 写入token id,返回实际数量 0 } #[no_mangle] pub extern "C" fn generate( prompt_ptr: *const u32, prompt_len: usize, out_ptr: *mut u32, out_cap: usize, max_tokens: usize, temperature: f32, top_p: f32, repeat_penalty: f32, ) -> i32 { // 逐token推理、采样、写回out_ptr // 返回生成数量,负数表示错误 0 } #[no_mangle] pub extern "C" fn free_model() { // 释放KV缓存和权重索引 }错误码要提前定好,比如-1是内存不足,-2是权重格式不匹配,-3是上下文超限,-4是分词失败。前端拿到负数不能只弹“生成失败”,要根据错误码决定是降上下文、换小模型还是提示用户缩短输入。我一开始没做错误码,结果页面白屏,控制台只看到一句“memory access out of bounds”,排查了半天才发现是KV缓存超了。后来把错误码和日志一起回传,定位速度快了很多。
还要注意Wasm内存的生命周期。JavaScript侧拿到的memory.buffer在memory.grow之后会失效,所有基于旧ArrayBuffer的视图都要重新创建。这个坑很隐蔽,表现是生成几个token后突然乱码或者读到脏数据。我的做法是每次调用生成前重新拿一次new Uint8Array(memory.buffer),不在全局缓存视图。性能上多几行代码,换来的调试体验很值。
3.3 前端分片加载与内存管理(附JS代码)
前端加载分片的核心是“别一次性把几个G塞进ArrayBuffer”。用fetch的Range头按片拉取,每片拿到后直接写进Wasm线性内存,写完就释放临时缓冲。静态服务器必须支持Range,否则服务器会返回整个文件,分片就失去意义。下面这段代码是Worker里的简化版,重点看分片循环和内存视图重建。
async function loadShards(baseUrl, shardCount, memory) { let offset = 0; for (let i = 0; i < shardCount; i++) { const url = `${baseUrl}/model-${i}.bin`; const res = await fetch(url); if (!res.ok) throw new Error(`分片加载失败: ${i}`); const buf = await res.arrayBuffer(); const view = new Uint8Array(memory.buffer); view.set(new Uint8Array(buf), offset); offset += buf.byteLength; self.postMessage({ type: 'progress', loaded: offset }); } }内存要提前规划。WebAssembly.Memory按页增长,一页64KB,4GB就是65536页。创建时给一个初始页数和最大页数,初始别给满,按模型大小估算一个接近值,留一点增长空间。比如预计总内存3.2GB,初始给2.8GB,最大给4GB,让它在加载过程中慢慢长。初始给太小会频繁grow,每次增长都可能触发视图失效;初始给太大又可能直接申请失败。我的经验是初始值按权重大小的80%给,剩下的留给KV缓存和运行时。
const PAGE = 64 * 1024; const initialPages = Math.ceil((3.2 * 1024 * 1024 * 1024) / PAGE); const maxPages = 65536; const memory = new WebAssembly.Memory({ initial: initialPages, maximum: maxPages });缓存用Cache Storage或者IndexedDB都行,我倾向Cache Storage,因为和请求URL绑定,版本管理简单。首次加载显示进度条,第二次打开直接从缓存读,体验会好很多。移动端要特别小心,有些浏览器对单个源占用的存储有上限,模型分片太大可能被清理。我的做法是加载前先查缓存,缺哪片补哪片,不要求整包命中。页面关闭前不要主动清缓存,春节那几天用户可能反复打开,缓存就是体验。
3.4 写春联Prompt模板与生成参数
Prompt模板我放在一个独立JSON里,方便改文案不用重新编译Wasm。系统段固定,用户段拼接主题,输出段用停止符约束。Yi模型按它发布页里的对话模板拼,别自己发明特殊token,否则模型会把你当成普通文本续写。下面是一个简化模板,实际使用时把{{theme}}替换成用户选的关键词。
你是一个春联助手。只输出三行: 上联:七言,平仄尽量相对 下联:七言,与上联字数相等 横批:四字 不要解释,不要加引号。 主题:{{theme}}生成参数上,春联任务不需要太长上下文,512足够;最大输出给96到128,七言对联加横批用不了太多token;温度0.6到0.8,重复惩罚1.05到1.15;停止符设“\n\n”和模型自带的结束符,防止它生成完还继续写解释。灯谜可以把温度稍微提高一点,拜年文案则要降低重复惩罚,避免称呼反复出现。参数没有万能值,先固定一套能出结果的,再根据实际输出微调。
const params = { maxTokens: 128, temperature: 0.7, topP: 0.9, repeatPenalty: 1.1, stop: ['\n\n', '<|im_end|>'] };生成后的校验代码很短,但很实用。去掉标点和空白后,上联和下联长度必须相等,横批必须4个字,上下联不能一模一样。不合格就重试,重试时把温度降0.1,最多两次。这个策略比无限重试靠谱,也比不校验直接展示强。用户看到一副字数都不对的春联,第一反应不是模型不行,而是这个工具不行,所以格式校验必须做在前面。
4. 性能调优与跨平台适配
4.1 线程、SIMD与WebGPU的取舍
第一版跑通之后,下一步就是让生成速度从“能等”变成“愿意等”。Wasm端最直接的加速是SIMD和多线程。SIMD把128位向量运算用起来,矩阵乘和归一化能快一截;多线程把矩阵乘按行切给多个Worker,理论上有线性收益。但多线程依赖SharedArrayBuffer和跨域隔离,线程数也不是越多越好,桌面端一般取min(4, hardwareConcurrency - 1),移动端取2,再多会因为发热和调度反而变慢。
WebGPU是另一条路,适合大矩阵乘,但算子适配成本高,内存布局和Wasm线性内存不一样,还要处理设备丢失和兼容性回退。我的建议是上篇先用CPU多线程加SIMD把链路跑顺,WebGPU放下篇做可选后端。你可以先加一个开关,检测到navigator.gpu且模型尺寸较大时尝试WebGPU,失败就回退CPU,别让用户看到“不支持”三个字就关页面。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 纯JS | 调试简单 | 速度慢 | 只做Demo验证 |
| Wasm单线程 | 兼容性好 | 生成慢 | 移动端低配 |
| Wasm SIMD | 提速明显 | 依赖浏览器支持 | 桌面和移动端高配 |
| Wasm多线程 | 多核利用 | 需要跨域隔离 | 桌面端 |
| WebGPU | 矩阵乘快 | 适配复杂 | 大模型、下篇扩展 |
实测下来,6B级Q4模型在桌面端4线程下,生成速度大概2到5 token每秒,一副春联128 token需要半分钟到一分钟。这个速度不算快,但春节场景可以接受,因为用户不是连续生成几十副,而是写一副、改一改、复制走。移动端会降到1 token每秒左右,所以移动端必须限上下文、限输出、优先小模型。把预期写进UI,比如显示“本地生成中,约需30秒”,比让用户盯着转圈猜要好。
4.2 内存上限与模型分片策略
Wasm32的4GB上限是硬约束,不是调参能绕过去的。权重、KV缓存、分词器、输入输出缓冲、运行时堆内存,全都在这4GB里。6B级Q4权重3.5GB,KV缓存512上下文约270MB,分词器和运行时再吃200MB到300MB,已经接近4GB。这时候任何一个小泄漏都会导致memory.grow失败。我的策略是三条:权重能压就压,上下文能短就短,中间张量用完就复用。
分片大小建议512MB到1GB。太小会导致请求次数多,加载慢;太大又会让单片缓冲占用高,移动端容易失败。按1GB切,6B模型大概4片,加载进度好算,失败重试成本也低。分片文件命名带版本和哈希,前端先拉一个清单文件,清单里写片数、每片大小、总大小、校验值,拉完一片校验一片。校验不过就重试,别把坏片写进内存,否则后面推理会报一堆莫名其妙的错。
function nextCapacity(current, need) { const PAGE = 64 * 1024; const pages = Math.ceil(need / PAGE); return Math.max(current, pages); }还有一个细节是内存对齐。Wasm线性内存里的权重布局要按推理内核的要求对齐,通常按16字节或32字节对齐,SIMD加载才不会出错。分片写入时如果每片大小不是对齐数的整数倍,下一片起始位置就要补 padding。这个坑在单线程时可能看不出来,开SIMD后直接崩。我的做法是分片大小固定为对齐数的整数倍,清单里记录每片实际权重长度和补齐长度,写入时按补齐长度偏移。
4.3 移动端与桌面端的差异化配置
桌面端和移动端不能共用一套配置。桌面端可以上6B级Q4、512上下文、4线程、输出128;移动端建议1.8B到3B级Q4、256到512上下文、2线程、输出96。检测不能只看UA,UA可以伪造,而且同一台设备不同浏览器能力也不一样。我更倾向看navigator.hardwareConcurrency、navigator.deviceMemory和实际加载测试。deviceMemory不是所有浏览器都有,拿不到就给保守值。
function pickProfile() { const cores = navigator.hardwareConcurrency || 2; const mem = navigator.deviceMemory || 2; if (cores >= 8 && mem >= 8) { return { model: '6b-q4', context: 512, threads: 4, maxTokens: 128 }; } if (cores >= 4 && mem >= 4) { return { model: '3b-q4', context: 384, threads: 2, maxTokens: 96 }; } return { model: '1.8b-q4', context: 256, threads: 2, maxTokens: 64 }; }移动端还要考虑发热降频。连续生成三四副春联后,速度可能会掉一半,这不是代码问题,是设备在保护自己。我的做法是生成完一副就释放KV缓存,不要为了“下一副更快”而一直占着内存;页面切到后台时暂停Worker,回来再恢复。桌面端可以保留缓存,移动端保守一点。另外,移动端浏览器对单个标签页的内存限制更严,加载前先做一个小的内存探测,申请一块大内存再释放,失败就直接进小模型模式,别等用户点了生成才崩。
5. 常见问题排查实录
5.1 加载失败与跨域隔离头
最常见的报错是SharedArrayBuffer is not defined,这不是代码写错了,是页面没有跨域隔离。先检查响应头有没有COOP和COEP,再检查crossOriginIsolated是不是true。如果头加了还是不行,看看页面里有没有引用外部图片、字体、脚本,它们没带跨域资源头会把整个页面踢出隔离状态。春节页面的素材尽量本地化,能省很多事。
第二个常见问题是WebAssembly.instantiateStreaming失败,提示MIME类型不对。静态服务器要把.wasm的Content-Type设成application/wasm,别用application/octet-stream凑合。如果服务器不支持流式实例化,可以退回arrayBuffer再instantiate,但会多一次内存拷贝,大模块加载会慢一点。第三个问题是Range请求不生效,分片加载变成整包下载,移动端直接爆内存。用浏览器网络面板看响应状态是不是206,不是的话检查静态服务器配置。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| SharedArrayBuffer未定义 | 未开跨域隔离 | 加COOP/COEP头 |
| wasm实例化失败 | MIME类型错误 | 设置application/wasm |
| 分片加载变成整包 | 服务器不支持Range | 开启Range支持 |
| 外部字体图片裂开 | COEP拦截 | 本地化资源或加CORP头 |
5.2 生成乱码、重复与截断
乱码多半是分词器或对话模板不对。Yi模型有固定的对话格式,特殊token拼错,模型会把它们当普通文本,输出自然乱。先检查分词器文件和模型是不是同一版本,再检查Prompt里有没有多余空格和换行。中文标点也要注意,有些分词器对全角半角敏感,输入统一用全角,输出后处理再转。
重复和截断是参数问题。重复惩罚太低,模型会反复说“龙年大吉龙年大吉”;太高,句子会变得别扭。春联任务我一般设1.05到1.15,灯谜可以到1.2。截断则是最大token不够或者停止符没设对,七言对联加横批128 token足够,但如果模型先写一段解释再写对联,就不够了。把“不要解释”写进Prompt,停止符加上双换行和结束符,能挡掉大部分废话。
const stopTokens = ['\n\n', '<|im_end|>', '。\n']; function shouldStop(text) { return stopTokens.some(t => text.endsWith(t)); }还有一个隐蔽问题是上下文超限。用户输入太长,或者上一次生成的KV缓存没清,新的Prompt拼进去直接超。我的做法是生成前算一下token数,超过上下文长度的80%就截断用户输入,并在UI上提示“主题词太多,已精简”。别等推理到一半报错,那时候用户已经等了半分钟,体验更差。
5.3 性能抖动与内存溢出
性能抖动先看是不是线程开多了。移动端4线程不一定比2线程快,发热降频后反而更慢。用performance.now()在Worker里记录每token耗时,生成结束后打一条日志,看看是前几个token慢还是整体慢。前几个token慢通常是Prompt处理,整体慢就是算力不够,降模型或者降上下文。别凭感觉调参,日志比感觉可靠。
内存溢出看三个地方:权重加载、KV缓存、输出缓冲。权重加载时监控memory.buffer.byteLength,增长到接近上限就停;KV缓存按上下文长度算,超了就减上下文;输出缓冲别一次性开太大,按最大token数×4字节算,够用就行。页面崩溃前通常会有一次memory.grow失败,捕获它,回传错误码,让前端提示“内存不足,已切换小模型”,比直接白屏好。
try { memory.grow(pages); } catch (e) { self.postMessage({ type: 'error', code: 'OOM', message: '内存不足,建议切换小模型' }); }最后提醒一句,别在开发阶段用无痕窗口测性能。无痕窗口缓存不生效,每次都要重新下载模型,加载时间会误导你。用普通窗口,先加载一次,再刷新测生成速度,这样才接近真实用户体验。
6. 上篇收尾:我踩过的几个坑和下一章要补的洞
这个上篇能跑通,靠的不是某个神奇参数,而是把几个坑提前踩了。第一个坑是贪大模型,一开始直接上6B Q4_K_M,权重加KV缓存直接顶到4GB,页面加载到80%就崩。换成Q4_0、上下文降到512、分片改成1GB,才顺利进到生成阶段。第二个坑是忽略分词器版本,权重和分词器差了一个小版本,生成的中文里夹着奇怪符号,排查了半天才发现是词表对不上。第三个坑是没做错误码,Wasm里一崩,前端只知道失败,不知道是内存、格式还是上下文问题,后来把错误码和日志一起回传,定位效率才上来。
下篇我准备补三块:多线程和SIMD的实测对比、WebGPU后端的可选接入、移动端的小模型热切换。春联对仗校验也想再往前走一步,不只是数字数,而是做一个轻量的词性提示,生成后给用户标出可能不对仗的位置,让用户自己改。这个功能不追求全自动,浏览器端的小模型也做不到全自动,但提示一下比什么都不说强。
最后再分享一个小技巧:先把最大输出设到64,确认模型能稳定吐完上联和下联,再往上加到128。很多人一上来就冲1024,结果等了五分钟只看到半副对联,还以为代码有bug。浏览器推理是个耐心活,先跑通最短链路,再一点点加功能,比一次性堆满配置靠谱得多。