不烧 API 的博客 AI:给终端装一颗 14MB 的端侧大脑
needle:一颗 45M 参数的工具调用模型,推理全在访客浏览器里,不花一分钱 API

一、动机:想要 AI,但不想烧 API
这个博客的首页有个终端,本来就是放着玩的。后来想给它加点"AI 助手"的味道——访客输入「找一下 deepseek 的文章」,终端自己检索、列出结果、附上链接。
最省事的接法是调云端 LLM API 做意图识别,但两个理由把我劝退了:一是每个访客每次提问都在烧我的额度,二是请求要出网,跟"纯静态博客"的气质不符。我想要的是推理发生在访客自己的浏览器里。
找到的答案就是 needle。
二、needle 是什么
needle 是 Cactus Compute 出的工具调用(tool-calling)专用小模型,现在的 needle2 有几个很硬的数字:
- 45M 参数,量化后整个模型是一个 14MB 的
.cact文件; - 一个推理 session 约 28MB RAM;
- 输出被"字节级语法"约束:根据你声明的 tools JSON 编译出解码文法,模型只能吐出合法的 JSON 调用,结构上不可能写坏;
- 自带 confidence gating:答不了的请求返回空调用,失败模式是"拒答"而不是"乱执行"。
它不做闲聊、不写散文,唯一的工作就是:读你的 query,从声明的工具里挑一个,填好参数,吐回来。这恰好是"终端命令路由"需要的全部能力。
三、架构:三个文件的事
接入比想象中简单,核心就三样东西:
1. 文章索引。 Hugo 构建时生成一个 index.json,里面是全部文章的标题、标签、摘要、链接。终端的搜索函数就是在这个 JSON 上做关键词匹配——needle 不负责搜,只负责决定搜什么。
2. tools JSON。 向 needle 声明博客能提供的能力:
|
|
一共四个工具:search_posts、list_recent_posts、list_posts_by_tag、describe_post。官方文档反复强调"describe your tools well is the whole game"——工具描述写得好,路由就准,实测确实如此。
3. 一个 Web Worker。 推理放在 worker 里跑,主线程的终端 UI 不会被卡住,还能转等待动画、响应 Ctrl+C(直接销毁 worker 重建)。worker 内部就是三行核心调用:needle_load 载入权重 → needle_init 传入 system prompt 和 tools → needle_complete 一问一答。
4. 懒加载。 模型虽然有几个,但没有一个会随页面加载。终端启动只拉一个几十 KB 的文章索引;needle2 的 14MB 要等访客第一次输入自然语言才下载,sem 的 24MB 等第一次敲 sem 才下载,needle1 的 22MB 只有手动 ai2 off 切换后才碰。全都配了 Cache-Control: immutable 强缓存,第二次访问零流量。所以真实成本是:看文章的人一分流量不花,玩 AI 的人花 14MB。
四、踩坑实录
needle1 的"中文友好"是个误会
我最先接的是 needle1(22MB 的 Rust/WASM 版)。它看起来能处理中文——「找一个 DeepSeek 的文章」真的能搜到——直到我翻开它的词表:8192 个词元,一个 CJK 字符都没有。它是纯字节级 BPE,所谓的中文能力其实是"把 query 的字节原样抄进参数",抄回来的关键词恰好能命中搜索。代价是偶尔会抄坏:
|
|
这些乱码字符就是字节抄串了的证据。英文部分字节稳,所以还能救回来。这不是懂中文,是运气好。
一行 locale 救回 decode 失控
换到官方 needle2 后,中文输入直接现原形:要么拒答,要么 decode 失控一口气把 token 预算烧光(tool call truncated: token budget exhausted)。翻遍配置后发现它认 system prompt 里的 locale 声明,于是:
|
|
locale 定死 en-US,中文 query 则走 Chrome 的端侧 Translator API 先翻成英文再喂给它——不出网、不花钱,翻译痕迹还会打一行提示告诉访客发生了什么。
模型不够,规则来凑
45M 的模型总有边界,剩下的体验问题全靠一层薄薄的规则补丁兜住:
- 拒答改写:模型返回空调用时,把冷冰冰的
[]改写成"没听懂,试试更具体的说法,比如 xxx"; - 指代消解:「介绍第一篇」这种话,规则层直接映射到上一次搜索结果,不进模型;
- 复合指令:「找一下 qemu 的文章并介绍第一篇」按连接词拆开,顺序执行;
- 搜索重试:模型提取的关键词搜不到时,拿用户原始输入再搜一次——专治上面那种字节抄串。
语义兜底:sem 命令
关键词匹配天然有盲区(「降低大模型调用成本」搜不到讲 Prompt Caching 的文章),所以又加了一个 sem 命令:用 transformers.js 在端侧跑 bge-small-zh(24MB 的量化 ONNX)算向量相似度。这里踩了个值得记的坑:hf-mirror 没有 CORS 头,浏览器里 fetch 模型文件直接被拦——所以模型必须自己托管,运行时镜像兜底这条路不存在。
五、这颗模型能跑在什么硬件上
28MB RAM 这个数字决定了 needle2 的甜点区:手机、树莓派、低配盒子这类"弱但够"的设备,以及一切现代浏览器。按官方数据推断,这个量级在移动端 CPU 上跑到几百 tok/s 是合理的,工具调用一次输出不过几十个 token,体感就是秒回。
但再往下到 MCU 世界就不行了。比如我手边的 M5Stack StickS3(ESP32-S3,8MB PSRAM),离 28MB 差了近 4 倍,needle 不可能上片。这类设备的可行架构是 hub + 执行端:needle 跑在浏览器或主机里做路由,设备通过 WebSocket / BLE 接命令干活;如果只要「亮度调低」这种固定命令,乐鑫自家的 ESP-Skainet 甚至能在片上离线识别,连浏览器都不用开。
下一步就是把这个链路真做出来:让 StickS3 听懂「亮度调低」。跑通了再来写续篇。
六、收尾
整套东西上线后的成本结构我很满意:模型文件加起来 60MB 左右(needle2 14MB + needle1 备机 22MB + sem 24MB),但全靠懒加载按需拉取,多数访客实际只下载 needle2 那一个文件;推理零 API 费用;数据和 query 从不出访客的浏览器。
如果你的场景也是"固定工具集 + 自然语言入口",needle 这条路值得一试。官方还给了 LoRA 微调流程,用自己的工具 traces 练一遍,路由还能更准——这个坑留到以后填。
源码就在这个博客里(首页终端和 /html/terminal/ 可以直接玩),欢迎围观。