端侧推理:移动端与浏览器部署

端侧 LLM 部署全解:iOS Core ML 与 MLX、Android NNAPI 与 Vulkan、浏览器 WebGPU 与 WASM SIMD 三条路线,涵盖模型格式与量化档位选型、内存水位与热降频管理、端云协同架构、性能实测数据与优化清单。

1. 端侧推理的约束

把模型搬到手机或浏览器,约束条件与服务器完全不同。不理解这些约束,优化方向一定是错的。

约束服务器(A100)手机浏览器
内存带宽2000 GB/s30~80 GB/s30~120 GB/s
可用显存/内存80 GB4~12 GB(共享)2~4 GB(Tab 限制)
算力(FP16)300 TFLOPS5~30 TOPS1~10 TFLOPS
功耗预算无限制3~8 W受限
散热强制风冷被动,会降频依设备
模型加载常驻需考虑冷启动需下载(数十 MB~GB)

带宽是端侧的第一瓶颈。decode 阶段每生成一个 token 都要读一遍全部权重,所以实际速度约等于 模型体积 / 内存带宽。

Qwen2.5-1.5B Q4 (约 1 GB) 在带宽 50 GB/s 的手机上:
  理论上限 ≈ 1000 MB / 50 GB/s = 20 ms/token → 50 token/s
  实测通常 15~30 token/s(受内核效率与调度影响)

这解释了为什么端侧必须量化:动机不是省显存,而是省带宽。把 1.5B 模型从 FP16(3 GB)压到 Q4(1 GB),在同样带宽下速度直接提升约 3 倍,且峰值内存从 3GB+ 降到 1.4GB,才可能落进移动端的内存水位。

2. 移动端的三条技术路线

2.1 路线对比

路线代表优势劣势
通用运行时llama.cpp、MLC-LLM跨平台、模型多未充分用 NPU
平台原生Core ML、NNAPI、QNN可用 NPU,功耗最优绑定平台,转换麻烦
厂商 SDKMediaPipe LLM、Apple Foundation开箱即用定制受限

2.2 llama.cpp 移动端集成

llama.cpp 是最通用的选择:C++ 实现、支持 GGUF、跨 iOS/Android/桌面。

# 交叉编译 Android(arm64-v8a)
cmake -B build-android \
  -DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a \
  -DANDROID_PLATFORM=android-28 \
  -DGGML_OPENMP=OFF \
  -DLLAMA_CURL=OFF
cmake --build build-android --config Release -j8
# iOS 用 XCFramework
./build-xcframework.sh

关键编译选项:

选项作用建议
GGML_OPENMPOpenMP 多线程Android 上关,用自旋锁
GGML_VULKANVulkan 后端(GPU)支持就开,速度翻倍
GGML_METALMetal(iOS/macOS)iOS 必开
GGML_QNT_VERSION量化内核版本默认即可
// Android JNI 调用示例
#include "llama.h"

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_LlmBridge_generate(JNIEnv *env, jobject, jstring jprompt) {
    const char *prompt = env->GetStringUTFChars(jprompt, nullptr);

    llama_model_params mp = llama_model_default_params();
    mp.n_gpu_layers = 99;                 // 尽量卸载到 GPU
    llama_model *model = llama_load_model_from_file("/data/data/app/files/qwen.gguf", mp);

    llama_context_params cp = llama_context_default_params();
    cp.n_ctx = 2048;
    cp.n_batch = 256;
    cp.n_threads = 4;                     // 大核数量
    llama_context *ctx = llama_new_context_with_model(model, cp);

    // ... tokenize → decode 循环 → detokenize
    env->ReleaseStringUTFChars(jprompt, prompt);
    return env->NewStringUTF(result.c_str());
}

n_threads 设为大核数量(通常 4),设成总核数反而更慢——小核会拖慢同步。

2.3 Apple 平台:MLX 与 Core ML

Apple Silicon 的统一内存架构让 MLX 特别高效:CPU 与 GPU 共享内存,无需拷贝。

# MLX(macOS / iOS,Python 侧原型验证)
import mlx.core as mx
from mlx_lm import load, generate

model, tokenizer = load("mlx-community/Qwen2.5-1.5B-Instruct-4bit")
out = generate(model, tokenizer, prompt="用一句话解释量化", max_tokens=100)
print(out)

Core ML 路线适合要上 NPU(ANE)的场景:

# 用 coremltools 把模型转成 Core ML(ANE 原生支持 INT8/FP16)
python -m coremltools.converters.llm \
  --model Qwen/Qwen2.5-1.5B-Instruct \
  --output-dir ./coreml-model \
  --compute-units ALL          # CPU + GPU + ANE

ANE 的优势是功耗:同等速度下功耗可能只有 GPU 的 1/3,对续航敏感的应用是决定性因素。

2.4 Android:NNAPI 与 QNN

// Android NNAPI:让系统调度到最佳加速器(NPU/DSP/GPU)
Model model = Model.create(modelBuffer, new Model.Options.Builder()
        .setDevice(NNAPI_DEVICE)
        .setPriority(PRIORITY_HIGH)
        .build());

NNAPI 的实际表现差异很大:高通 Hexagon 对 INT8 支持好,但很多算子会回退到 CPU,导致"部分加速"反而比纯 CPU 慢。上 NPU 前必须实测端到端延迟,而不是看算子覆盖率。

3. 浏览器端部署

3.1 三条技术栈

技术加速方式代表库适用模型
WebGPUGPU 计算着色器WebLLM、Transformers.js≤ 4B(显存受限)
WASM + SIMDCPU 向量指令wllama、llama.cpp wasm≤ 2B
WebNN浏览器暴露的 NPU早期阶段待成熟

3.2 WebLLM 实战

WebLLM 基于 MLC 编译的 WebGPU 内核,是目前浏览器端性能最好的方案。

<script type="module">
import * as webllm from "https://esm.run/@mlc-ai/web-llm";

const engine = await webllm.CreateMLCEngine(
  "Qwen2.5-1.5B-Instruct-q4f16_1-MLC",
  {
    initProgressCallback: (p) => {
      console.log(`加载进度: ${(p.progress * 100).toFixed(1)}%`, p.text);
    },
  }
);

const chunks = await engine.chat.completions.create({
  messages: [{ role: "user", content: "用一句话介绍 WebGPU" }],
  stream: true,
  temperature: 0.7,
});

for await (const chunk of chunks) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
</script>

关键点:

  • 模型需要预编译为 MLC 格式,或用官方预编译的 -MLC 模型。
  • 首次加载要下载权重(1B Q4 约 700MB),必须做进度提示与缓存。
  • 显存受限:浏览器可用的 GPU 显存通常 2~4GB,超过就 OOM。
// 用 Cache API 缓存权重,二次访问秒开
if ("caches" in window) {
  const cache = await caches.open("webllm-weights-v1");
  // WebLLM 内部已做,也可手动预取
}

浏览器端推理的更多细节见 浏览器 WASM AI 推理 。

3.3 WASM 路线与 SIMD

不支持 WebGPU 的环境(老设备、部分 Safari 版本)要回退到 WASM。

import { Wllama } from "@wllama/wllama";

const wllama = new Wllama({
  "single-thread/wllama.wasm": "/wasm/single-thread/wllama.wasm",
  "multi-thread/wllama.wasm": "/wasm/multi-thread/wllama.wasm",
});

await wllama.loadModelFromUrl("/models/qwen2.5-0.5b-q4_k_m.gguf", {
  n_threads: navigator.hardwareConcurrency || 4,
});

const stream = await wllama.createCompletion("你好", {
  nPredict: 128,
  sampling: { temp: 0.7, top_p: 0.9 },
});

多线程 WASM 需要 SharedArrayBuffer,这要求页面开启 COOP/COEP 响应头:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

缺这两个头,多线程会静默退化为单线程,速度差 3~4 倍——这是最常见的"为什么这么慢"的原因。

3.4 能力检测与降级

async function detectBackend() {
  if (navigator.gpu) {
    const adapter = await navigator.gpu.requestAdapter();
    if (adapter) {
      const hasF16 = adapter.features.has("shader-f16");
      return hasF16 ? "webgpu-f16" : "webgpu-f32";
    }
  }
  if (crossOriginIsolated) return "wasm-multithread";
  return "wasm-singlethread";
}

const backend = await detectBackend();
// 按后端选择不同的模型档位
const MODEL_BY_BACKEND = {
  "webgpu-f16": "Qwen2.5-1.5B-Instruct-q4f16_1-MLC",
  "webgpu-f32": "Qwen2.5-0.5B-Instruct-q4f32_1-MLC",
  "wasm-multithread": "qwen2.5-0.5b-q4_k_m.gguf",
  "wasm-singlethread": "qwen2.5-0.5b-q4_0.gguf",
};

4. 模型与量化选型

4.1 端侧量化档位

格式位宽端侧适配备注
GGUF Q4_K_M4~5通用首选llama.cpp 原生
GGUF Q4_04老设备/WASM内核简单,兼容好
MLC q4f16_14WebGPU权重 4bit,激活 FP16
Core ML INT88ANENPU 原生
TFLite INT88Android NNAPINPU 原生

端侧的位宽选择与服务器相反:服务器追 4bit 省显存,端侧优先 8bit 因为 NPU 原生支持。4bit 在 NPU 上往往要软件模拟,反而不快。

4.2 模型规模建议

设备档位可用内存推荐模型体积(Q4)预期速度
旗舰手机(12GB)4~6 GB3B 级1.8~2.2 GB10~20 tok/s
中端手机(8GB)2~3 GB1.5B 级0.9~1.1 GB15~30 tok/s
低端手机(4GB)1 GB0.5B 级0.3~0.4 GB20~40 tok/s
桌面浏览器2~4 GB1.5B 级0.9 GB20~40 tok/s
移动浏览器1~2 GB0.5B 级0.35 GB8~20 tok/s

规律:端侧不是"能不能跑"的问题,而是"够不够快"的问题。1.5B 以下模型的通用能力有限,更适合做分类、抽取、路由等窄任务。

5. 内存与热管理

5.1 内存水位

移动端系统对内存极其敏感:

平台机制后果
iOSJetsam(内存压力杀进程)超过阈值直接 SIGKILL
AndroidLMK(低内存杀手)后台进程被回收
浏览器Tab 内存上限页面崩溃(Aw Snap)

保守策略:峰值内存控制在设备可用内存的 40% 以内。模型 + KV Cache + 运行时开销要一起算:

Qwen2.5-1.5B Q4: 权重 ~1.0 GB
KV Cache (2048 ctx, GQA): 约 0.1 GB
运行时 + 词表 + 中间张量: 约 0.3 GB
──────────────────────────────
合计 ≈ 1.4 GB  →  8GB 手机的 17%,安全

若把上下文拉到 8192,KV 涨到 0.4GB,加上激活峰值,风险明显上升。

5.2 热降频

被动散热的设备在持续推理 2~5 分钟后会降频,速度可能腰斩。

典型曲线(旗舰手机,1.5B Q4):
  0~60s:   25 tok/s(满血)
  60~180s: 18 tok/s(温升)
  180s+:   10~12 tok/s(降频)

缓解手段:

  1. 限制单次生成长度,避免长回答持续满载。
  2. 降低 n_threads(如 4 → 3),牺牲峰值速度换稳定。
  3. 利用 NPU(ANE/Hexagon),能效比 GPU/CPU 好得多。
  4. 批处理用户等待期,不要在用户输入时就空转。

5.3 冷启动

端侧的"首 token"包含模型加载时间,这是服务器不存在的开销。

模型加载耗时(mmap 冷启动)
0.5B Q40.3~0.8 s
1.5B Q40.8~2 s
3B Q42~5 s

用 mmap 加载(llama.cpp 默认)能显著降低冷启动峰值内存,且系统可按需分页。配合"应用启动即预加载模型"的策略,用户感知延迟可以降到接近零。

6. 端云协同

端侧模型能力有限,纯端侧往往不够。混合架构是主流。

6.1 三种协同模式

模式端侧职责云端职责适用
路由式意图分类、难度判断所有生成隐私敏感 + 高质量
起草式生成草稿润色/校验弱网
兜底式简单请求直接答复杂请求成本优化
async def hybrid_generate(query: str) -> str:
    # 端侧做轻量判断(分类模型,几毫秒)
    complexity = await on_device_classify(query)

    if complexity == "simple" and not needs_fresh_knowledge(query):
        return await on_device_generate(query)      # 本地出结果
    return await cloud_generate(query)              # 上云

6.2 隐私边界

端侧最大的卖点是隐私:数据不出设备。若混合架构把原始输入传上云,隐私承诺就失效了。两种做法:

  • 敏感数据本地处理:医疗、身份、财务信息只走端侧。
  • 脱敏后上云:本地做实体识别与替换(张三 → <PERSON_1>),云端只见到脱敏文本。
def redact(text: str, entities: list[tuple[str, str]]) -> tuple[str, dict]:
    mapping = {}
    for i, (span, label) in enumerate(entities):
        placeholder = f"<{label}_{i}>"
        mapping[placeholder] = span
        text = text.replace(span, placeholder)
    return text, mapping

6.3 端云一致的输出格式

混合架构下两条路径的输出格式必须一致,否则下游解析会崩。用同一份 JSON Schema 约束两端,端侧用 grammar 约束,云端用 Function Calling。端侧的更多部署形态见 端侧 AI 推理部署 。

7. 性能优化清单

优化手段收益
带宽量化到 4bit(NPU 用 8bit)2~4x
计算启用 GPU/NPU 后端2~5x
内存KV 量化 + 限制上下文省 30~50%
首字延迟Prompt 前缀缓存(复用系统提示 KV)TTFT 降 40%+
加载mmap + 预加载冷启动降 50%
输出限制 max_tokens + 流式感知延迟降
功耗降低线程数 + NPU 优先延长可持续时间

7.1 前缀缓存

端侧也值得做前缀缓存:系统提示词固定时,其 KV 可以复用。

// llama.cpp:保存/加载会话状态,复用系统提示的 KV
llama_state_seq_save(ctx, "session.bin", 0, tokens, n_tokens);
// 下次启动
llama_state_seq_load(ctx, "session.bin", 0, tokens, n_tokens);

对"固定人设 + 多轮对话"的应用,这能把首字延迟从 1.5s 降到 0.5s 以内。

7.2 投机解码

端侧也能用投机解码:用极小的 draft 模型(0.5B)预测,主模型(3B)验证。

llama-cli -m qwen2.5-3b-q4.gguf \
  -md qwen2.5-0.5b-q4.gguf \
  --draft-max 8 --draft-min 4 \
  -p "解释一下什么是端侧推理"

前提是 draft 模型足够快且接受率高(> 60%),否则反而变慢。端侧内存紧张时,加载两个模型可能得不偿失。

8. 工程实践与常见坑

  • 浏览器多线程静默退化:缺 COOP/COEP 头时 SharedArrayBuffer 不可用,多线程 WASM 退回单线程,速度差 3~4 倍。务必检测 crossOriginIsolated。
  • iOS 后台挂起:应用切后台后推理会被暂停甚至杀死,长任务要拆成可恢复的分段。
  • WebGPU 首次编译着色器:首次推理有 1~3 秒的着色器编译开销,应在加载阶段预热(跑一次空推理)。
  • 模型下载中断:移动网络下大文件下载易断,必须支持断点续传与校验(Range + SHA256)。
  • NPU 部分算子回退:混合执行会引入 CPU↔NPU 数据拷贝,可能比纯 CPU 还慢,必须实测。
  • 量化档位与后端不匹配:某些 WebGPU 后端不支持 q4f16,需回退 q4f32。

8.1 端侧可观测性

端侧没有服务端日志,必须自建轻量埋点:

const metrics = {
  loadMs: 0,          // 模型加载
  ttftMs: 0,          // 首 token
  tps: 0,             // 生成速度
  peakMemoryMB: 0,    // 峰值内存(performance.measureUserAgentSpecificMemory)
  thermalThrottled: false,
};

用 performance.measureUserAgentSpecificMemory()(Chrome 有,需 COOP/COEP)或 performance.memory 采集内存;用前后 10 个 token 的速度差判断是否降频。

小结

端侧推理的胜负手是带宽与功耗,而不是算力。工程上的默认组合是:1.5B 以下模型 + 4bit 量化(NPU 场景用 8bit)+ 平台原生后端 + mmap 加载 + 前缀缓存 + 端云协同兜底。

三条路线的选择:iOS 优先 Core ML/MLX 走 ANE,Android 优先 llama.cpp + Vulkan(NNAPI 需实测),浏览器优先 WebGPU(不支持则回退 WASM 多线程,注意 COOP/COEP)。部署前务必算清内存水位(峰值控制在可用内存 40% 以内)并实测持续推理的降频曲线,这两点决定的是"能用"与"不可用"的差别,而不是快一点慢一点。私有化与本地部署的完整方案见 /llm-local-deployment-private/,服务端部署的对照参考 /llm-inference-deployment/。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「llm」更多文章

  1. RAG 评估体系:召回、忠实度与自动化指标
  2. 长上下文优化:注意力稀疏化与 KV Cache 管理
  3. 实时语音 Agent:全双工对话与低延迟链路