1. 端侧推理的约束
把模型搬到手机或浏览器,约束条件与服务器完全不同。不理解这些约束,优化方向一定是错的。
| 约束 | 服务器(A100) | 手机 | 浏览器 |
|---|---|---|---|
| 内存带宽 | 2000 GB/s | 30~80 GB/s | 30~120 GB/s |
| 可用显存/内存 | 80 GB | 4~12 GB(共享) | 2~4 GB(Tab 限制) |
| 算力(FP16) | 300 TFLOPS | 5~30 TOPS | 1~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,功耗最优 | 绑定平台,转换麻烦 |
| 厂商 SDK | MediaPipe 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_OPENMP | OpenMP 多线程 | Android 上关,用自旋锁 |
GGML_VULKAN | Vulkan 后端(GPU) | 支持就开,速度翻倍 |
GGML_METAL | Metal(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 三条技术栈
| 技术 | 加速方式 | 代表库 | 适用模型 |
|---|---|---|---|
| WebGPU | GPU 计算着色器 | WebLLM、Transformers.js | ≤ 4B(显存受限) |
| WASM + SIMD | CPU 向量指令 | 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_M | 4~5 | 通用首选 | llama.cpp 原生 |
| GGUF Q4_0 | 4 | 老设备/WASM | 内核简单,兼容好 |
| MLC q4f16_1 | 4 | WebGPU | 权重 4bit,激活 FP16 |
| Core ML INT8 | 8 | ANE | NPU 原生 |
| TFLite INT8 | 8 | Android NNAPI | NPU 原生 |
端侧的位宽选择与服务器相反:服务器追 4bit 省显存,端侧优先 8bit 因为 NPU 原生支持。4bit 在 NPU 上往往要软件模拟,反而不快。
4.2 模型规模建议
| 设备档位 | 可用内存 | 推荐模型 | 体积(Q4) | 预期速度 |
|---|---|---|---|---|
| 旗舰手机(12GB) | 4~6 GB | 3B 级 | 1.8~2.2 GB | 10~20 tok/s |
| 中端手机(8GB) | 2~3 GB | 1.5B 级 | 0.9~1.1 GB | 15~30 tok/s |
| 低端手机(4GB) | 1 GB | 0.5B 级 | 0.3~0.4 GB | 20~40 tok/s |
| 桌面浏览器 | 2~4 GB | 1.5B 级 | 0.9 GB | 20~40 tok/s |
| 移动浏览器 | 1~2 GB | 0.5B 级 | 0.35 GB | 8~20 tok/s |
规律:端侧不是"能不能跑"的问题,而是"够不够快"的问题。1.5B 以下模型的通用能力有限,更适合做分类、抽取、路由等窄任务。
5. 内存与热管理
5.1 内存水位
移动端系统对内存极其敏感:
| 平台 | 机制 | 后果 |
|---|---|---|
| iOS | Jetsam(内存压力杀进程) | 超过阈值直接 SIGKILL |
| Android | LMK(低内存杀手) | 后台进程被回收 |
| 浏览器 | 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(降频)
缓解手段:
- 限制单次生成长度,避免长回答持续满载。
- 降低
n_threads(如 4 → 3),牺牲峰值速度换稳定。 - 利用 NPU(ANE/Hexagon),能效比 GPU/CPU 好得多。
- 批处理用户等待期,不要在用户输入时就空转。
5.3 冷启动
端侧的"首 token"包含模型加载时间,这是服务器不存在的开销。
| 模型 | 加载耗时(mmap 冷启动) |
|---|---|
| 0.5B Q4 | 0.3~0.8 s |
| 1.5B Q4 | 0.8~2 s |
| 3B Q4 | 2~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/。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。