尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Local First 实践:浏览器本地 AI 计算器的架构设计与工程细节

Local First 实践:浏览器本地 AI 计算器的架构设计与工程细节 市面上大多数AI 计算器本质上是个套壳输入框敲进去请求发到某个云端大模型等两三秒结果回来。网络一断或者服务方限流这东西立刻变成一块砖。我前阵子折腾了一个完全跑在浏览器本地的计算器核心思路是Local First——所有推理都在你自己的设备上完成不联网、不上传、不依赖任何后端。这篇文章就把这个项目的设计取舍、技术选型、踩过的坑以及为什么我坚持不用云端方案完整地拆一遍。如果你对本地优先这个概念还比较陌生可以简单理解成数据和应用逻辑优先留在本地网络只是可选的增强项而不是必需品。这个原则在笔记、密码管理、文档编辑领域已经有不少实践但把它搬到AI 计算器这种看似必须联网的场景上反而能逼出很多有意思的设计决策。下面我会从需求本质讲起一路讲到具体的模型加载、推理调度和边界处理。1. 为什么一个计算器要强调 Local First1.1 云端计算器的三个隐性成本先说说我为什么会对云端 AI 计算器产生怀疑。最开始我也是直接用现成的在线工具输入帮我算一下 15% 的税后价格等两秒出结果挺方便。但用得多了问题就冒出来了。第一个成本是延迟不可控。云端推理的响应时间取决于网络状况、服务端排队情况、模型负载。我实测过同一个问题在不同时段问同一个服务快的时候 800 毫秒慢的时候能到 6 秒以上。对于一个计算器来说这个体验是灾难性的——你按计算器的心理预期是即时反馈而不是盯着转圈。第二个成本是隐私边界模糊。计算器天然会接触到很多敏感数字工资、报价、贷款金额、成本结构。这些内容一旦发到云端你就失去了对它的控制权。哪怕服务方承诺不记录你也没法验证。对于做财务、做报价、做工程预算的人来说这是个真实的顾虑。第三个成本是可用性依赖。断网、服务下线、接口变更、额度用尽——任何一个环节出问题工具就废了。而计算器这种工具恰恰是你最希望它永远能用的那类东西。1.2 Local First 到底解决了什么Local First 的核心承诺是核心功能不依赖网络。放到这个项目里就是模型权重、推理引擎、计算逻辑全部打包进前端用户打开页面或安装成 PWA之后后续所有操作都在本地完成。这里要澄清一个常见误解Local First 不等于完全离线。它允许你在有网的时候同步数据、下载更新、拉取新模型但关键路径上不依赖网络。就像本地优先的笔记软件你断网也能写联网了再同步。计算器场景下关键路径就是输入问题 → 得到答案这条链路必须本地闭环。我选择这个方向还有一个很实际的考虑计算器是个高频、短交互的工具。用户不会为了算一个数去等网络也不会为了算一个数去注册账号。把推理放到本地等于把打开就能用这件事做到了极致。1.3 适合谁不适合谁这个方案不是万能的我得把边界说清楚。适合的场景日常数值计算、单位换算、百分比与折扣、简单财务估算、带自然语言描述的算式解析比如三件 89 块打八折再加 6 块运费是多少。这些任务的共同点是——计算逻辑明确模型只需要做语言到算式的翻译不需要海量世界知识。不适合的场景需要复杂推理链的数学证明、需要实时联网查汇率或股价、需要超大模型才能处理的长文本分析。这些要么超出小模型能力要么本质上就需要外部数据。把边界划清楚后面的技术选型才有依据。我见过太多项目一上来就喊本地大模型结果塞了个 7B 模型进浏览器加载 2GB 权重用户等半分钟才打开——这不是 Local First这是 Local Last。2. 模型选型为什么我放弃了越大越好2.1 浏览器里跑模型的真实约束在浏览器里跑模型约束和服务器端完全不是一回事。服务器上你可以堆显存、堆 CPU浏览器里你面对的是内存上限移动端浏览器单标签页可用内存往往只有几百 MB 到 1GB 出头超了直接崩。加载时间用户能忍受的首次加载时间我的经验值是 3 秒以内超过就开始流失。算力差异桌面端有 WebGPU 还好移动端很多设备只有 CPU 推理速度差一个数量级。存储配额模型权重缓存到 IndexedDB 或 Cache Storage浏览器给的配额有限还得考虑清理策略。这些约束直接决定了模型必须小。不是相对小是绝对小。2.2 从 7B 到 1B 以下的取舍过程我一开始也想过用 7B 级别的模型量化到 4bit 大概 3.5GB 左右。实测下来桌面端加载要十几秒移动端直接 OOM。这条路走不通。往下退到 3B量化后约 1.5GB桌面端勉强能跑但首次加载还是太慢而且移动端依然吃力。最后我把目标定在1B 以下甚至考虑过 0.5B 级别的模型。这个量级的模型量化到 4bit 之后权重在 200-400MB 之间配合流式加载和缓存首次加载能压到可接受范围二次打开基本秒开。代价是什么小模型的通用能力确实弱复杂推理容易出错。但回到第 1 节划定的边界——我们只需要它做自然语言到算式的翻译这个任务对模型能力的要求其实不高。一个经过指令微调的小模型完全能胜任。这里有个关键判断任务越窄模型可以越小。如果你的场景是通用问答那确实需要大模型但如果场景是把一句话变成可计算的表达式小模型反而更合适因为它不容易想太多。2.3 量化格式与推理后端的搭配选完模型规模接下来是量化格式和推理后端。这块我踩了不少坑值得单独说。浏览器端推理目前主流有几条路线方案优势劣势适用场景WebGPU 自研 kernel性能最好开发成本极高大厂项目ONNX Runtime Web生态成熟支持 WebGPU/WebGL/WASM模型转换有门槛通用推理WebLLM 类方案开箱即用支持流式体积偏大定制性弱快速验证WASM 手写推理兼容性最好性能一般低端设备兜底我最终选的是ONNX Runtime Web理由是它同时支持 WebGPU 和 WASM 回退一套代码能覆盖从高端桌面到低端移动的设备。模型用 ONNX 格式量化用 INT4 或 INT8具体看设备能力动态选择。这里有个实操细节不要只准备一个量化版本。我的做法是准备两套权重——INT4 给支持 WebGPU 的设备INT8 给只有 WASM 的设备。INT4 在 WebGPU 上速度快但精度略低INT8 在 WASM 上更稳。运行时先探测能力再决定加载哪套。// 能力探测的简化逻辑 async function detectCapability() { if (navigator.gpu) { const adapter await navigator.gpu.requestAdapter(); if (adapter) { return { backend: webgpu, quant: int4 }; } } return { backend: wasm, quant: int8 }; }这段逻辑看着简单但它是整个加载策略的分水岭。探测错了要么性能浪费要么直接跑不起来。3. 把自然语言变成算式核心链路拆解3.1 为什么不让模型直接算结果这是整个项目里最反直觉的一个决策也是我想重点讲的。很多人做 AI 计算器第一反应是让模型直接输出答案三件 89 打八折加 6 块运费 → 模型输出 219.6。这个做法看起来最直接但问题很大。第一小模型的算术能力不可靠。语言模型的本质是预测下一个 token它并不真的会算数。1B 级别的模型做多步算术错误率相当高。你让它算 89 × 0.8 × 3 6它可能给你 219.6也可能给你 213.6而且它自己觉得是对的。第二结果不可验证。模型直接给答案你没法知道它是怎么算的。用户看到一个数字没法核对中间步骤信任成本很高。第三无法处理精度问题。浮点运算、四舍五入、货币精度这些在计算器场景里都是硬需求交给模型心算完全不可控。所以我的方案是模型只负责把自然语言翻译成结构化算式真正的计算交给确定性的计算引擎。3.2 两段式架构翻译与计算分离具体来说链路分成两段第一段语义解析。模型接收用户输入输出一个结构化的表达式比如 JSON 格式{ expression: 89 * 0.8 * 3 6, intent: price_calculation, confidence: 0.92 }第二段确定性求值。拿到表达式后用一个安全的表达式求值器不是 eval计算结果处理精度、单位、舍入。这个架构的好处非常明显可靠性计算部分完全确定不会出错。可解释用户能看到模型解析出的算式知道钱是怎么算出来的。可调试出错时能快速定位是解析错了还是计算错了。可扩展想加新功能只需要扩展解析的 prompt 和求值器的函数库。我实测下来这个方案在小模型上的准确率远高于直接出答案。因为翻译比计算对模型来说简单得多——它只需要理解语义并映射到符号不需要做数值运算。3.3 解析 prompt 的设计要点prompt 设计是这套方案的核心。我试了很多版本总结出几个关键点。第一输出格式必须强约束。不要让模型自由发挥明确要求它只输出 JSON并且给出 schema。小模型对格式的遵循能力有限prompt 里要反复强调。第二给足 few-shot 示例。小模型靠示例学习模式比靠指令更有效。我准备了 10 个左右的示例覆盖常见场景折扣、税费、单位换算、百分比、多步运算。第三明确拒绝策略。当输入无法解析成算式时模型应该输出一个特定的标记而不是硬编一个答案。比如{ expression: null, intent: unparseable, reason: 缺少必要的数值信息 }第四控制输出长度。小模型的输出越长越容易跑偏。限制它只输出必要的字段不要解释、不要寒暄。一个精简的 prompt 骨架大概是这样你是一个算式解析器。把用户的中文描述转换成数学表达式。 只输出 JSON格式{expression: ..., intent: ...} 无法解析时 expression 为 null。 示例 输入三件89打八折 输出{expression: 89 * 0.8 * 3, intent: discount} ...注意这里没有让模型做任何计算它只做符号映射。这个边界划得越清楚小模型的表现越稳定。4. 加载与推理的工程细节4.1 首次加载的体验优化模型加载是这个项目里最影响体验的环节。用户打开页面如果盯着白屏等 10 秒基本就关了。我做了几件事来优化。分阶段加载。先加载一个极小的占位模型或者规则引擎让页面立刻可用能处理最简单的输入比如纯算式。同时后台静默加载完整模型加载完成后无缝切换。这样用户感知不到等待。流式加载权重。ONNX 模型可以分片加载先加载必要的层让推理能启动后续层边用边加载。这个对首次体验提升明显。缓存策略。模型权重用 Cache Storage 缓存配合版本号管理。二次打开直接从缓存读基本秒开。这里要注意缓存失效逻辑——模型更新了要能正确拉新版本不能一直用旧的。// 带版本号的缓存读取 async function loadModelWithCache(modelUrl, version) { const cache await caches.open(model-v${version}); const cached await cache.match(modelUrl); if (cached) return cached.arrayBuffer(); const response await fetch(modelUrl); await cache.put(modelUrl, response.clone()); return response.arrayBuffer(); }加载进度反馈。哪怕做了上面这些首次加载还是需要时间。给一个真实的进度条比转圈强得多。进度要基于实际加载的字节数不要用假动画。4.2 WebGPU 与 WASM 的动态切换前面提到要探测设备能力这里展开说切换逻辑。WebGPU 的优势是并行计算能力强矩阵运算快适合模型推理。但它的支持度还不完整尤其是移动端和旧版浏览器。WASM 兼容性最好但纯 CPU 推理慢。我的策略是优先 WebGPU失败回退 WASM并且两套后端共用同一套模型接口上层业务代码不感知差异。async function createSession(modelBuffer, backend) { const options backend webgpu ? { executionProviders: [webgpu] } : { executionProviders: [wasm] }; return await ort.InferenceSession.create(modelBuffer, options); }这里有个坑WebGPU 初始化可能失败但不抛异常。我遇到过 adapter 拿到了但创建 session 时静默失败的情况。所以要有超时和健康检查机制探测阶段跑一次极小的推理确认真的能用再决定用哪个后端。4.3 推理调度的防抖与取消计算器是高频交互场景用户可能连续输入。如果每次输入都触发一次推理会造成资源浪费和结果错乱。我加了两个机制防抖。用户停止输入 300ms 后才触发解析。这个时间窗口足够覆盖正常打字节奏又不会让用户觉得迟钝。取消。新的推理请求发起时取消上一个未完成的请求。ONNX Runtime 的 session run 返回的是 Promise本身不好取消但可以在结果回来时检查请求 ID丢弃过期结果。let currentRequestId 0; async function parseInput(text) { const requestId currentRequestId; const result await runInference(text); if (requestId ! currentRequestId) return null; // 过期结果丢弃 return result; }这个模式看着简单但能避免很多结果闪回的诡异 bug。我一开始没做用户快速输入时经常看到结果跳来跳去。5. 那些文档里不会写的坑5.1 移动端内存的隐形天花板桌面端测试一切正常一上手机就崩——这是我最开始遇到的典型问题。移动端浏览器对单标签页内存的限制比想象中严格。iOS Safari 尤其激进内存超限直接刷新页面连报错都没有。我一开始以为是模型太大后来发现是中间张量占的内存。模型推理过程中会产生大量中间结果这些张量在 WebGPU 上占显存在 WASM 上占堆内存。小模型虽然权重小但中间张量如果没优化峰值内存可能是权重的好几倍。解决办法有几个减小 batch size计算器场景本来就是单条输入batch 固定为 1。及时释放中间张量ONNX Runtime 一般会自动管理但要注意 session 的生命周期。限制输入长度计算器的输入不该超过几十个 token超了直接截断或拒绝。监控内存用performance.memoryChrome做粗略监控接近阈值时降级到规则引擎。实测经验iOS 上单标签页可用内存大概在 300-500MB 区间浮动具体看设备和系统版本。你的模型加中间张量的峰值内存最好控制在这个数字的一半以内留足余量。5.2 数值精度别让 0.1 0.2 毁掉信任计算器场景对精度极其敏感。用户输入0.1 0.2期望看到 0.3而不是 0.30000000000000004。这是浮点数的经典问题但在计算器里它直接关系到用户信任。我的处理方式是计算引擎用定点数或高精度库不用原生浮点。对于货币计算统一转成整数分再算最后转回。对于一般数值用 decimal 库处理。// 货币计算转成分再算 function addMoney(a, b) { const centsA Math.round(a * 100); const centsB Math.round(b * 100); return (centsA centsB) / 100; }另外显示层要做舍入。计算结果保留合理位数不要暴露浮点误差。但舍入规则要明确是四舍五入还是银行家舍入不同场景要求不同。财务场景通常用银行家舍入避免系统性偏差。5.3 模型幻觉在计算器场景的特殊表现模型幻觉在通用问答里表现为一本正经胡说八道在计算器场景里表现得更隐蔽它会编造不存在的数值。比如用户输入上个月工资加上这个月奖金模型可能凭空编一个数字出来因为它觉得应该有个数。这种幻觉比直接算错更危险因为用户可能没注意到输入里根本没有具体数值。我的防御策略是解析结果必须能追溯到输入。如果模型输出的表达式里出现了输入中没有的数字直接判定为幻觉拒绝执行。function validateExpression(expression, input) { const numbersInExpr expression.match(/\d(\.\d)?/g) || []; const numbersInInput input.match(/\d(\.\d)?/g) || []; for (const num of numbersInExpr) { if (!numbersInInput.includes(num)) { return { valid: false, reason: 表达式包含输入中不存在的数值 }; } } return { valid: true }; }这个校验逻辑不复杂但能挡掉相当一部分幻觉。代价是有些合法的常量比如百分比转换里的 100会被误判需要维护一个白名单。5.4 冷启动与热启动的差异处理同一个应用冷启动首次打开和热启动缓存命中的体验差异巨大。如果不做区分处理很容易出现第一次用很慢后面很快的割裂感。我的做法是冷启动时降级到规则引擎先让用户能用同时后台加载模型。规则引擎能处理纯算式和简单模式匹配覆盖大概 60% 的常见输入。模型加载完成后再切换到 AI 解析。这样用户从打开到能用几乎是瞬时的。等模型就绪能力再增强。这个渐进增强的思路比要么全有要么全无体验好太多。6. 从能跑到好用几个提升体验的细节6.1 结果的可解释性展示前面提到两段式架构让结果可解释但怎么展示也有讲究。我的做法是主结果大字显示解析出的算式小字附在下面。用户一眼看到答案需要核对时能看到算式。如果解析置信度低加一个提示让用户确认。这个设计的关键是不打扰。大多数时候用户只关心结果算式是备查的。不要一上来就展示一堆中间步骤那会让界面很乱。6.2 错误处理的分级策略错误处理不能一刀切。我分了三级可恢复错误如解析失败提示用户换个说法保留输入。降级错误如模型加载失败自动切到规则引擎用户无感知。致命错误如浏览器不支持明确告知给出替代方案。分级的好处是用户不会因为一个小问题就整个应用不可用。计算器这种工具可用性优先级极高。6.3 键盘与输入的细节计算器是键盘密集型工具输入体验很重要。我做了几件事支持回车直接计算、支持粘贴后自动解析、输入框自动聚焦、移动端调起数字键盘inputmodedecimal。这些都是小细节但累积起来决定了工具顺不顺手。还有一个容易忽略的点输入历史。计算器经常需要重复算类似的东西保留最近几条历史能省不少事。历史存本地不上传。7. 本地优先带来的额外可能性7.1 离线 PWA 与安装体验因为是 Local First做成 PWA 几乎是顺理成章的。用户可以把计算器安装到桌面或主屏像原生应用一样打开完全离线可用。PWA 的关键是 Service Worker 的缓存策略。模型权重、推理引擎、页面资源都要缓存并且要有版本管理。我用的策略是核心资源预缓存模型权重按需缓存。核心资源保证离线可用模型权重首次使用时缓存避免首次安装体积过大。7.2 数据不出本地的隐私价值这一点在开头提过但值得再强调。所有输入、所有计算、所有历史全部留在本地。没有网络请求没有数据上传没有账号体系。对于处理敏感数字的用户这个特性本身就是选择理由。而且它带来一个额外好处没有服务端成本。项目可以完全静态部署不需要服务器、不需要数据库、不需要运维。这对个人项目来说可持续性大大提升。7.3 后续可以扩展的方向这个架构的延展性不错我列几个后续想做的方向多语言解析模型换一下微调数据就能支持不同语言的输入。领域定制针对财务、工程、烹饪等场景定制解析规则和函数库。语音输入配合浏览器原生的语音识别做语音计算。可编程计算支持用户定义变量和函数做成轻量级的本地计算环境。这些扩展都不需要改动核心架构只需要在解析层和求值层做加法。这也是两段式设计的好处——边界清晰扩展点明确。最后分享一个我在调试过程中总结的小技巧把模型的解析结果和最终计算结果的日志都留在本地比如 IndexedDB出问题时能快速复现。我遇到过几次用户反馈算错了靠日志发现是解析阶段把打八折理解成了打八折后再减 8这种问题光看最终结果根本定位不到。本地日志不上传既保护隐私又方便排查算是 Local First 的一个意外收获。
返回列表