MacBook Air M5:零依赖运行多模态大模型!

一个 8 MB 级原生可执行文件,加上一份 Gemma 4 E4B 模型,就能在 Mac 上对话、理解图片,也能作为 Rust 库直接嵌入现有程序。

不安装 Python、PyTorch、Conda、Docker,也不启动常驻模型服务:一个 Rust 对象、一份 GGUF 权重,就能把原生多模态推理放进自己的进程。

这篇文章在一台 24 GB 内存、10 核 Apple M5 的 MacBook Air 上完成。当前 release 版 zllm-metal 是 8.1 MB;配合 Gemma 4 E4B 的 Q4_K_M 主模型和视觉投影,它可以直接通过 Metal 完成多轮对话和图像理解。

先说明标题中的“零依赖”:它指的是交付和运行时不依赖第三方推理框架。最终用户不需要 Python 环境、PyTorch 动态库、CUDA 工具包或单独管理的 C++ runtime;模型权重仍然是推理所需的数据,macOS 自带的 Metal 与系统框架也仍然存在。从源码构建时,Cargo 会正常编译 zLLM 使用的 Rust crates,并把它们链接进原生程序。

库模式是怎样工作的

zLLM 不是对另一个本地服务的 HTTP 包装。zllm::embedded::Engine 直接持有模型、Metal backend、KV Cache 和生成状态,在调用方进程内走完整推理路径:

Rust 应用
  └─ Engine::from_config(装载一次)
       ├─ GGUF 权重与 chat template
       ├─ Metal backend / kernels
       └─ terminal KV Cache
     Engine::generate(重复调用)
       ├─ OpenAI Chat JSON → template → tokenize
       ├─ 可选 image_url → mmproj → 视觉 embedding
       ├─ prefill / append prefill / decode
       └─ 每个 token 通过 Rust 回调返回

这条路径与 zLLM 的 Metal node 共用模型 runtime、权重装配和生成状态机,只是没有启动 HTTP、Scheduler 或 iroh。

接口用途
Engine::from_config(path)kind: standalone YAML 装载模型和 backend,但不启动服务
Engine::load(...)已经解析配置时直接装载
Engine::generate(...)接受结构化 Chat JSON,通过回调流式返回 token
Engine::cancellation()创建可复制到 UI 或控制线程的取消句柄
Engine::kv_resources()查看 KV capacity、active、resident、available 和单会话占用
GenerationResult返回结束原因、输入/输出 token 数和后续轮次可复用的 cache_id

回调返回 false,或从另一线程调用 Cancellation::cancel(),生成就会结束。引擎释放时还会执行优雅关闭;调用方不需要管理 Metal command buffer,也不会接触平台 KV 对象。

在任意 Rust 程序中嵌入

zLLM 源码已在 GitHub 公开。 可以直接使用 Git 依赖,也可以 clone 后改成本地 path

在现有项目的 Cargo.toml 中加入:

[dependencies]
serde_json = "1"
zllm = { git = "https://github.com/zllm-lab/zllm" }

把路径换成 zLLM 源码在本机的实际位置。下面是一个完整的最小程序:引擎只加载一次,之后可以反复调用 generate

use std::io::{self, Write};

use serde_json::json;
use zllm::embedded::Engine;

fn main() -> Result<(), String> {
    let mut engine = Engine::from_config("gemma4-metal.yaml")?;
    let cancellation = engine.cancellation();

    let result = engine.generate(
        &json!({
            "model": "gemma4",
            "messages": [{
                "role": "user",
                "content": "用一句话解释为什么推理引擎适合嵌入应用"
            }],
            "max_completion_tokens": 128
        }),
        &cancellation,
        |_token, text| {
            print!("{text}");
            io::stdout().flush().is_ok()
        },
    )?;

    println!(
        "\nfinish={} prompt={} completion={} cache={:?}",
        result.finish_reason,
        result.prompt_tokens,
        result.completion_tokens,
        result.cache_id
    );
    Ok(())
}

后续轮次继续传完整的 messages。Runtime 会校验 cache identity 和真实 token 前缀;匹配时恢复 terminal session,只为新增后缀执行 append prefill,不匹配时安全回退到最长公共前缀或完整 prefill。

图像输入仍然使用同一个接口,只需把 user content 改成有序的图文 part:

let cancellation = engine.cancellation();
let result = engine.generate(
    &json!({
        "model": "gemma4",
        "messages": [{
            "role": "user",
            "content": [
                {"type": "text", "text": "请描述这张图片"},
                {"type": "image_url", "image_url": {
                    "url": "/absolute/path/to/photo.png"
                }}
            ]
        }],
        "max_completion_tokens": 256
    }),
    &cancellation,
    |_token, text| {
        print!("{text}");
        io::stdout().flush().is_ok()
    },
)?;

本地路径、HTTP(S) URL 和 data: URL 会进入同一套图像物化与视觉编码路径。Gemma 4 E4B 的 mmproj 在第一次图像请求时懒加载;只做文字对话时不必先装载视觉塔。

准备 Gemma 4 E4B

本文使用 instruction-tuned 的 Gemma 4 E4B GGUF:

下载下面两个文件,并放在同一个目录:

models/gemma-4-E4B-it-GGUF/
├── gemma-4-E4B-it-Q4_K_M.gguf   # 主模型,4.98 GB
└── mmproj-F16.gguf               # 视觉编码器/投影,990 MB

可以在上面的文件页直接下载,也可以临时使用 Hugging Face CLI;这个下载工具不是 zLLM 的运行时依赖:

hf download unsloth/gemma-4-E4B-it-GGUF \
  gemma-4-E4B-it-Q4_K_M.gguf mmproj-F16.gguf \
  --local-dir ./models/gemma-4-E4B-it-GGUF

国内网络可以从魔搭页面选择同名文件。Q4_K_M 在 24 GB MacBook Air 上兼顾体积和质量;视觉投影约 1 GB,保留 F16 可以避免再引入一层视觉量化误差。

库模式使用的最小 gemma4-metal.yaml 如下:

version: 1
kind: standalone
http:
  listen: 127.0.0.1:8000
  public_base_url: http://127.0.0.1:8000
artifacts:
  directory: ./artifacts
node:
  cache_directory: ./cache/gemma4
  persist_kv_cache: false
  max_concurrency: 1
model:
  architecture: gemma4
  weights_directory: ./models/gemma-4-E4B-it-GGUF/gemma-4-E4B-it-Q4_K_M.gguf
  lm_head_quantization: native
  max_sequence_length: 49152
  execution:
    prefill_chunk_size: 2048
backend:
  kind: metal
  device: default

配置里保留 httpartifacts 是为了与 standalone 配置结构兼容;Engine::from_config 不会监听端口。

一个 8 MB 的 Metal 命令行程序

只想在终端里马上对话时,不需要写 YAML。直接从 GitHub 获取源码并构建:

git clone https://github.com/zllm-lab/zllm.git
cd zllm
cargo build --release --bin zllm-metal

然后把主模型路径直接传给 8.1 MB 的 release 程序:

./target/release/zllm-metal \
  ./models/gemma-4-E4B-it-GGUF/gemma-4-E4B-it-Q4_K_M.gguf

zllm-metal 会自动完成这些工作:

  1. 从 GGUF metadata 识别 Gemma 4 架构;
  2. 发现同目录的 mmproj-*.gguf
  3. 如果存在匹配的 mtp-*.gguf,自动启用 MTP 投机解码;
  4. 根据统一内存、权重和安全余量推导 KV 预算与上下文;
  5. 装载 Metal runtime,进入多轮终端对话并复用 terminal KV Cache。

这台机器的一次实际启动输出是:

[zllm-metal] gemma4 (hybrid GQA, KV f16) | 权重 4.64 GiB |
  KV 预算 7.96 GiB | ≈172032 B/token | 上下文 49152 (模型上限 131072)
[zllm-metal] 模型加载完成(1.4s),直接输入消息开始对话

直接输入文字即可对话:

> 请用一句话介绍你自己,并说明你正在本地 Mac 上运行。

识别本地图片时,把路径和问题放在同一条消息里即可;裸路径、引号、反引号和带空格路径都支持,路径本身不会进入模型提示词:

> 请用两句话描述这张图片 `/Users/me/Pictures/demo.png`

也可以先复制图片,再输入:

> /paste 请描述剪贴板中的图片

常用命令如下:

/stats    查看上下文与 KV 内存
/reset    清空当前对话
/compact  压缩较早的对话历史
/paste    读取 macOS 剪贴板中的 PNG 图片
/exit     退出

真机结果与视频演示

下面的数据均来自这台 MacBook Air M5(24 GB)和同一份 Q4_K_M 权重,不是理论估算:

项目实测结果
zllm-metal release 文件8.1 MB
CLI 自动上下文49,152 token
模型加载(本地热文件缓存,多次启动)0.9–1.6 秒
文本 tg50,三次冷启动均值40.47 token/s
同一 115-token 回复的全段 decode38.1–38.5 token/s
323-token 图文请求 prefill6.469 秒
同一图文请求 TTFT7.312 秒

下面的 23 秒短片把同一次终端流程排成更易阅读的画面:启动 zllm-metal、进行文字对话、把刚才那张本地图片路径交给模型、查看 KV 状态并退出。命令、启动数据、token 数和回答文字均取自实机终端输出;画面只做了换行和节奏编排。

这段视频验证的是从模型装载、文字对话到视觉输入的完整调用链,不是模型精度评测。速度会随模型版本、上下文、采样参数、文件缓存和机器散热状态变化。

最后的边界

zLLM 的目标不是把 Python 环境换成另一套庞大 runtime,而是让模型执行真正成为应用的一部分:一个 Rust 对象、一份模型、一个原生 backend。应用拥有自己的线程、取消、缓存和生命周期,不必把核心能力委托给另一个进程。

对 Rust 开发者来说,“给现有程序增加本地 AI”因此不再是一次服务部署,而是一次普通的库调用;对终端用户来说,它就是一个 8 MB 级原生程序,在 MacBook Air 上完成文字与图像推理。

← 回到所有文章