☰
Local First 实践:浏览器本地 AI 计算器的架构设计与工程细节
2026/9/28 16:46:17 网站建设 项目流程

市面上大多数"AI 计算器"本质上是个套壳:输入框敲进去,请求发到某个云端大模型,等两三秒,结果回来。网络一断,或者服务方限流,这东西立刻变成一块砖。我前阵子折腾了一个完全跑在浏览器本地的计算器,核心思路是"Local First"——所有推理都在你自己的设备上完成,不联网、不上传、不依赖任何后端。这篇文章就把这个项目的设计取舍、技术选型、踩过的坑,以及为什么我坚持不用云端方案,完整地拆一遍。

如果你对"本地优先"这个概念还比较陌生,可以简单理解成:数据和应用逻辑优先留在本地,网络只是可选的增强项,而不是必需品。这个原则在笔记、密码管理、文档编辑领域已经有不少实践,但把它搬到"AI 计算器"这种看似必须联网的场景上,反而能逼出很多有意思的设计决策。下面我会从需求本质讲起,一路讲到具体的模型加载、推理调度和边界处理。

1. 为什么一个计算器要强调 Local First

1.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.memory(Chrome)做粗略监控,接近阈值时降级到规则引擎。

实测经验: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 键盘与输入的细节

计算器是键盘密集型工具,输入体验很重要。

我做了几件事:支持回车直接计算、支持粘贴后自动解析、输入框自动聚焦、移动端调起数字键盘(inputmode="decimal")。这些都是小细节,但累积起来决定了工具"顺不顺手"。

还有一个容易忽略的点:输入历史。计算器经常需要重复算类似的东西,保留最近几条历史,能省不少事。历史存本地,不上传。

7. 本地优先带来的额外可能性

7.1 离线 PWA 与安装体验

因为是 Local First,做成 PWA 几乎是顺理成章的。用户可以把计算器"安装"到桌面或主屏,像原生应用一样打开,完全离线可用。

PWA 的关键是 Service Worker 的缓存策略。模型权重、推理引擎、页面资源都要缓存,并且要有版本管理。我用的策略是:核心资源预缓存,模型权重按需缓存。核心资源保证离线可用,模型权重首次使用时缓存,避免首次安装体积过大。

7.2 数据不出本地的隐私价值

这一点在开头提过,但值得再强调。所有输入、所有计算、所有历史,全部留在本地。没有网络请求,没有数据上传,没有账号体系。

对于处理敏感数字的用户,这个特性本身就是选择理由。而且它带来一个额外好处:没有服务端成本。项目可以完全静态部署,不需要服务器、不需要数据库、不需要运维。这对个人项目来说,可持续性大大提升。

7.3 后续可以扩展的方向

这个架构的延展性不错,我列几个后续想做的方向:

  • 多语言解析:模型换一下微调数据,就能支持不同语言的输入。
  • 领域定制:针对财务、工程、烹饪等场景,定制解析规则和函数库。
  • 语音输入:配合浏览器原生的语音识别,做语音计算。
  • 可编程计算:支持用户定义变量和函数,做成轻量级的本地计算环境。

这些扩展都不需要改动核心架构,只需要在解析层和求值层做加法。这也是两段式设计的好处——边界清晰,扩展点明确。


最后分享一个我在调试过程中总结的小技巧:把模型的解析结果和最终计算结果的日志都留在本地(比如 IndexedDB),出问题时能快速复现。我遇到过几次用户反馈"算错了",靠日志发现是解析阶段把"打八折"理解成了"打八折后再减 8",这种问题光看最终结果根本定位不到。本地日志不上传,既保护隐私,又方便排查,算是 Local First 的一个意外收获。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询