第一次看到这个标题,我忍不住笑了:Jeyzma is TypeSafeAI minus $850M plus Go/TinyGo/llama.cpp in the browser。这种表达方式很像某个开源项目作者在写 README 时顺手玩了一把梗。拆开看,它其实可以翻译成一句人话:我尝试用 Go、TinyGo 和 llama.cpp,把 TypeSafeAI 这类商业 AI 平台最核心的「类型安全 + 结构化输出 + 本地推理」能力搬进浏览器,少花那 850M 美元的基础设施和研发成本。
这个项目适合两类人:一类是不想把自己的业务数据送到云端,又想做 LLM 结构化输出的开发者;另一类是单纯想折腾「浏览器里跑模型」这个方向的人。TypeSafeAI 这类平台解决的是「让大模型输出可以被程序安全使用」的问题,而 Jeyzma 的思路很直接:反正推理层可以本地化,那类型安全层也应该跟着本地化。下面我把整个项目的思路、选型、编译过程、踩坑记录都摊开讲一遍。
1. 先从标题说起:Jeyzma 到底要复刻什么
1.1 TypeSafeAI 这类平台到底解决了什么问题
现在做 LLM 应用,最容易被低估的麻烦不是「模型不会说话」,而是「模型乱说话」。你让模型返回一个 JSON,它可能多一个字段、少一个字段,甚至把布尔值写成字符串。程序拿到这种数据轻则报错,重则在生产环境里把脏数据写进数据库,后面排查起来极其痛苦。
TypeSafeAI 这类商业化平台做的核心事情,就是给 LLM 输出加一层「类型安全带」:你先定义好 schema,模型输出完之后自动校验,校验不通过就触发重试或者修复指令。表面上只是多了一道 JSON.parse 的前置流程,实际上它把「模型输出不可信」这个根本矛盾,从应用层往下沉淀成了一套产品能力。
Jeyzma 想复刻的正是这一层能力,但它不要 SaaS 网关,不要特殊算子集群,也不要把数据送到某个远程接口。它的全部运行环境就是浏览器标签页。
1.2 少掉的 850M 美元去哪了
标题里那个minus $850M是个玩笑,但背后的成本结构对比是真实存在的。商业 AI 平台的成本主要花在四块:模型训练与持续调优、GPU 推理服务集群、数据合规与审计体系、以及那套复杂的管控后台。Jeyzma 的替代方案是:
| 商业平台的成本项 | Jeyzma 的本地替代 |
|---|---|
| 训练和微调集群 | 直接用开源的预训练模型,做 GGUF 量化 |
| GPU 推理服务器 | 浏览器里的 llama.cpp WASM,跑 CPU 推理 |
| 数据出域审计 | 数据不离开本地,不存在出域问题 |
| 复杂控制台 | 一个 Go/TinyGo 写的编排模块 + 前端 UI |
这不是说 Jeyzma 能完全替代 TypeSafeAI,而是说它把「类型安全」和「模型推理」这两件事以最低成本重新组装了一遍。如果需求边界是单用户、本地优先、结构化输出,那么这 850M 美元确实可以省掉。
1.3 Jeyzma 的定位和边界
Jeyzma 不是一个通用大模型平台。它的定位是「类型安全的本地推理小助手」:你输入一段文字,它调用浏览器里的本地模型,生成一个符合 Go 结构体定义的 JSON,然后你的代码直接消费这个 JSON。
它适合做这些事:笔记内容摘要、待办事项提取、表单字段自动填充、文本分类、给用户输入做二次校验。它不适合做高并发在线服务,也不适合跑超过 3B 参数的大模型,因为浏览器标签页的内存和算力摆在那里。这个边界想清楚之后,很多技术选型就顺理成章了。
2. 为什么偏偏是 Go、TinyGo 和 llama.cpp
2.1 TypeSafeAI 的核心是类型安全,Go 天生合适
我一直觉得,做类型安全这件事,Go 是相当顺手的语言。Struct 定义清楚之后,encoding/json直接反序列化,配合字段 tag 和自定义校验函数,输出的形状在编译期就被固定住了。相比在 JavaScript 里手写一堆if (!obj.title),Go 的错误处理路径清晰得多。
我在 Jeyzma 里定义输出结构体时,基本是这么写的:
type Completion struct { Title string `json:"title"` Summary string `json:"summary"` Tags []string `json:"tags"` Confidence float64 `json:"confidence"` } var completionSchema = `{ "type": "object", "required": ["title", "summary", "tags", "confidence"], "properties": { "title": {"type": "string"}, "summary": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, "confidence": {"type": "number", "minimum": 0, "maximum": 1} } }`这个 schema 一方面被拼进 prompt,告诉模型「你必须输出这些字段」;另一方面被 Go 代码用来做真正解析。模型输出就算带着 Markdown 代码块、多余解释、末尾逗号,也能在解析层被兜住。
2.2 TinyGo 解决了官方 Go 在浏览器里的「体积焦虑」
一开始我直接试过官方 Go 的GOOS=js GOARCH=wasm,编译产物动辄 5MB 以上,光加载就够慢,而且启动运行时开销不小。后来换成 TinyGo,同样一段编排逻辑编译出来只有几百 KB,加载速度和启动速度都好了很多。
TinyGo 对syscall/js的支持足够日常用,我能在 Go 代码里直接向浏览器暴露函数,也能调用js.Global()读取前端传入的参数。它当然有局限,后面我会说到,但在这个场景里,TinyGo 是 Go 代码和浏览器之间最轻的一座桥。
| 对比项 | 官方 Go wasm | TinyGo wasm |
|---|---|---|
| 产物体积 | 5MB 起步 | 常见 200KB 到 1MB |
| 启动速度 | 偏慢 | 快 |
| 标准库支持 | 完整 | 部分可用 |
| 适合场景 | 复杂后端逻辑迁移 | 轻量编排、边缘小逻辑 |
2.3 llama.cpp 是浏览器推理的「最佳打工人」
模型推理这部分纯靠 JavaScript 写不出来,需要真正高效的 C++ 实现。llama.cpp 本身是 CPU 优先的推理引擎,而且社区一直有人做 WebAssembly 构建,配合 Emscripten 可以编出能在浏览器里跑的 WASM 模块。
我选择 llama.cpp 而不是其他推理框架,最重要的原因是它对 GGUF 量化模型的支持非常成熟。Q4_K_M 这种量化格式在白嫖级体积和效果之间平衡得不错,1B 级模型量化后大概 600MB 到 800MB,浏览器内存是能扛住的。推理计算量则交给 WASM 里的 C++ 代码完成,JavaScript 只负责传消息和渲染结果。
3. 浏览器里的推理链路怎么搭
3.1 全局架构:两个 Worker,一条消息线
在浏览器里做推理,第一原则是:永远不要把推理放到 JS 主线程。模型加载、上下文处理、token 采样任何一个环节都足够让页面卡死。Jeyzma 的做法是把任务拆成两个 Worker:
- Orchestrator Worker:跑 TinyGo 编译出来的编排 WASM,负责接收输入、拼接 prompt、解析和校验输出;
- Inference Worker:跑 llama.cpp 编译出来的推理 WASM,负责真正加载模型、维护上下文、生成 token。
两个 Worker 之间通过postMessage传消息,主线程只做 UI 渲染和资源加载。我测试下来,这种隔离非常关键。即使模型推理偶尔慢了几秒,页面本身还能正常滚动、响应输入。
3.2 模型选型:别高估浏览器内存
我曾经往浏览器里塞 7B 模型,结果页面直接崩溃。WASM 的内存上限理论上是 4GB,但实际操作里浏览器标签页能稳定吃下的内存也就 1GB 到 2GB。所以 Jeyzma 最适合的模型范围是 0.5B 到 3B,量化格式固定在 Q4_K_M 或更小的 Q4_0。
上下文长度也别贪心。我日常设置n_ctx = 2048,对摘要、提取、分类这类结构化任务来说完全够用。如果输入文本特别长,优先做文本截断,而不是把上下文拉到 8192,否则 KV cache 会吃掉大量内存。
3.3 模型文件怎么进浏览器
浏览器里没有本地文件系统,模型文件不能像服务器那样直接读路径。我的做法是:把 GGUF 模型文件放到静态资源目录,前端用fetch拿到ArrayBuffer,再通过 Emscripten 的FS.writeFile写入 WASM 的内存文件系统,然后 llama.cpp 从那个虚拟路径加载模型。
第一次加载模型确实慢,因为要下几百 MB 文件。之后我会把模型文件缓存到 IndexedDB,下次打开页面直接从缓存里取,能省掉大量等待时间。这个优化对使用体验提升非常明显。
4. 手把手实操:从零编译一个能跑的 Demo
4.1 环境准备
你需要三样东西:Emscripten、TinyGo、任意一个现代浏览器。Emscripten 负责把 llama.cpp 编成 WASM,TinyGo 负责把编排逻辑编成另一个 WASM。我使用的版本组合是当时最新的稳定版,没有特殊要求,只要 Emscripten 和 TinyGo 都能正常执行即可。
# 拉取 llama.cpp 仓库 git clone https://llama.cpp.example/jz/llama.cpp cd llama.cpp # 创建 Emscripten 构建目录 mkdir -p build-wasm cd build-wasm emcmake cmake .. -DCMAKE_BUILD_TYPE=Release -DLLAMA_OPENMP=OFF emmake make -j4这里有个重要参数:-DLLAMA_OPENMP=OFF。OpenMP 在 Emscripten 的 WASM 目标下经常编译失败,关掉之后反而更省心。如果想要多线程推理,更稳妥的做法是通过 Emscripten 的 shared memory 能力在 JS 侧开 Worker,而不是依赖 OpenMP。
4.2 编译 llama.cpp 为 WASM
构建完成后,你会拿到一组 WASM 产物和对应的 JS glue 代码。在浏览器里加载它们的时候,我用的是官方的 Emscripten module 初始化方式:
import createLlamaModule from './llama.js'; const llama = await createLlamaModule({ locateFile: (path) => new URL(`./${path}`, import.meta.url).toString(), });如果你需要自己导出推理函数,记得在 Emscripten 编译参数里加上EXPORTED_FUNCTIONS。我一般导出的是llama_init_from_gpt_params、llama_decode、llama_tokenize这几个入口,再加上一个释放上下文的函数,否则页面关不掉模型缓存,内存回收会很被动。
4.3 编译 TinyGo 编排模块
TinyGo 这边我在cmd/orchestrator目录下写了一个入口文件,核心逻辑是暴露一个jeyzmaProcess函数给 JS 调用。编译命令很简单:
tinygo build -target wasm -o orchestrator.wasm ./cmd/orchestrator入口文件大概是这个形态:
package main import ( "encoding/json" "strings" "syscall/js" ) func processCompletion(_ js.Value, args []js.Value) any { if len(args) < 1 { return map[string]any{"error": "missing input"} } raw := args[0].String() raw = stripFence(raw) var c Completion if err := json.Unmarshal([]byte(raw), &c); err != nil { return map[string]any{"error": err.Error(), "raw": raw} } if c.Title == "" || c.Summary == "" || c.Confidence <= 0 { return map[string]any{"error": "field validation failed"} } result := map[string]any{ "ok": true, "title": c.Title, "summary": c.Summary, "tags": c.Tags, "confidence": c.Confidence, } return js.ValueOf(result) } func stripFence(s string) string { // llama.cpp 经常会输出 ```json ... ```,这里统一剥掉 s = strings.TrimSpace(s) s = strings.TrimPrefix(s, "```json") s = strings.TrimPrefix(s, "```") s = strings.TrimSuffix(s, "```") return strings.TrimSpace(s) } func main() { js.Global().Set("jeyzmaProcess", js.FuncOf(processCompletion)) select {} }select {}必不可少。它让 TinyGo 的 WASM 程序在初始化完之后不退出,保持jeyzmaProcess一直挂在全局对象上,等前端随时调用。
4.4 前端把模型和编排串起来
TinyGo 编译出的 WASM 需要配套的wasm_exec.js来启动,这一步和官方 Go 的流程很像。我在主线程里先初始化 TinyGo 编排模块,再初始化 inference worker:
import { loadTinyGoWasm } from './tinygo-loader.js'; const jeyzma = await loadTinyGoWasm('/orchestrator.wasm'); const inferencer = new Worker(new URL('./inference.worker.js', import.meta.url), { type: 'module' }); async function generate(prompt) { const raw = await inferencer.generate(prompt); const result = jeyzma.process(raw); if (result.error) { // 走一次修复提示词重试 return repairPrompt(prompt, result.raw); } return result; }这里我固定了一个「生成 -> 校验 -> 失败重试」的三段式链路。模型第一次输出如果校验不通过,我会把原始输出和错误信息重新打包成一个修复请求,告诉模型「你刚才的 JSON 少了 title 字段,请修正后重新输出」。实践下来,第二次输出的通过率很高。
4.5 给 WASM 开多线程和跨域隔离
如果想让 llama.cpp 在 WASM 里用多线程,就必须依赖SharedArrayBuffer,而浏览器只在页面的 COOP/COEP 响应头满足跨域隔离条件时才会开放这个能力。我是在本地静态服务器上加了这两个响应头:
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp没有这两个头,Chrome 会直接报SharedArrayBuffer is not defined。对于静态资源本身,也得配上 CORS 头,否则 WASM 和模型文件都可能被跨域策略拦下来。
5. 真实环境里的性能、内存和排查清单
5.1 实测下来的性能数字
我在一台 2021 年的普通 x86 笔记本上跑了几个模型,结论是「浏览器推理虽然不快,但完全够用」。下面是我自己的测试均值,不同浏览器会有波动:
| 模型规模 | 量化格式 | 上下文 | 实测速度 | 峰值内存 |
|---|---|---|---|---|
| 0.5B | Q4_K_M | 2048 | 大约 30 token/s | 约 800MB |
| 1B | Q4_K_M | 2048 | 大约 18 token/s | 约 1.5GB |
| 3B | Q4_K_M | 1024 | 大约 7 token/s | 约 2.2GB |
单线程会比这个数字慢一截,开 SharedArrayBuffer 多线程后会有提升。如果你的目标场景是「用户点击按钮后等一两秒拿结果」,1B 模型比较适合。3B 模型还是有点紧张,适合对延迟不敏感的离线批处理。
5.2 常见问题排查表
我在这个项目上踩过不少坑,整理成了一张速查表,遇到类似情况可以直接对着找原因:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 编译报错 clang unsupported option | OpenMP 开关没关 | 加-DLLAMA_OPENMP=OFF |
| SharedArrayBuffer is not defined | 响应头缺少 COOP/COEP | 本地服务器加跨域隔离响应头 |
| memory access out of bounds | 模型太大或内存增长没打开 | 换小模型,或启用 ALLOW_MEMORY_GROWTH |
| Model was not loaded | 模型文件没有写入 WASM 文件系统 | 用 FS.writeFile 先写入,再初始化 |
| TinyGo 函数找不到 | main 提前退出或 wasm_exec.js 没配对 | 确认select {}还在,加载顺序正确 |
| 输出 JSON 老是解析失败 | 模型被 chat 模板干扰 | 在 prompt 里强调「不要 Markdown」,启动时设置合适的 chat template |
第 5 条是新手最容易犯的问题。TinyGo 的 WASM 模块如果 main 函数执行完直接返回,程序就算是退出了,挂载到全局的函数自然也就没了。那select {}压舱底,基本是标准解法。
5.3 调试小技巧:把 WASM 的内存当成黑盒来观察
排查浏览器 WASM 问题,我建议把 Chrome DevTools 的 Memory 面板和performance.measure一起用。模型加载后记一次performance.mark,推理结束后再记一次,就能算出真实耗时。很多时候你以为慢在模型推理,其实慢在模型文件写入 WASM 虚拟文件系统那一步。把这个时间拆开,优化方向才会清晰。
6. 一些个人经验和后续可以做的事
6.1 我最意外的三个发现
第一个发现是,浏览器推理没有想象中那么不可用。1B Q4 模型在本地跑 18 token/s,用来提取待办事项、写摘要这种短输出场景完全够用,和用户等网络请求的体验差不多。
第二个发现是,TinyGo 的反射能力有限。encoding/json在解析嵌套 map 或者interface{}的时候容易碰上支持边界,所以我的代码里尽量用固定 struct,不搞动态泛型。类型安全这件事,本来就该靠固定结构体来保证,这个限制反而推着我写出了更稳的代码。
第三个发现是,模型输出的修复循环必须限定次数。我之前写了一个无限重试的循环,结果模型连续输出非法 JSON,浏览器直接卡在一个死循环里。后来改成最多重试两次,第二次还是失败就把原始输出原样丢给用户,让用户自行判断。
6.2 后续扩展方向
Jeyzma 现在的版本只做了文本生成和结构化输出。再往下走,有两个方向我觉得非常有价值:一是把 TinyGo 的编排逻辑抽出来作为独立的 Go 库,让服务端和浏览器共用同一套校验代码;二是把模型文件做分片加载和渐进式缓存,这样用户不用一次性等整个模型下载完。
我在实际使用中还留了一个小习惯:每次 schema 变更都手动跑一遍离线回归用例。把一批典型输入和预期输出写死在测试文件里,模型版本升级后用同一套用例重新验证。这个习惯花不了多少时间,但能让「类型安全」四个字真正落到代码里,而不是只靠 prompt 碰运气。
如果你也想折腾浏览器端 LLM 应用,我的建议很简单:先别急着追求大模型,用 TinyGo 和 llama.cpp 搭一条最简的「输入 -> 推理 -> 严格校验 -> 输出」链路,把这条链路跑通了,所有优化都有地方落。Jeyzma 这个项目到今天也就是这么一条链路,剩下的全是在这条链路上不断加厚安全边际。