在 Android 上跑大模型,我踩过的那些推理加速坑
有人问过我:在 Android 上跑大模型,和在服务器上跑有什么本质区别?
我想了一下,说:服务器上你在意的是吞吐,手机上你在意的是不要把电池榨干、不要让用户等三秒、不要因为内存不够直接崩。本质区别不是算法,是你的对手从"慢"变成了"死"。
这篇文章想聊的,就是这件事:怎么在 Android 上把一个 LLM 塞进去,还能让它活得好看。不讲理论,讲工程路径,讲我觉得值得或不值得的取舍。
为什么端侧推理"看起来容易做起来难"
先说一个反常识的事:模型参数量不是端侧部署最大的障碍,内存访问模式才是。
一个 7B 参数的模型,INT4 量化后大概 3.5GB,Pixel 8 有 12GB RAM,听起来没问题。但推理时,Transformer 的 KV Cache 是随 context length 线性增长的。你跑一个 512 token 的对话,KV Cache 可能额外吃掉 500MB+。跑 2048 token?再翻四倍。
更麻烦的是,Android 上的内存是被多个进程共享的。系统随时可能因为内存压力把你的进程 kill 掉,或者触发 LMK(Low Memory Killer)把 KV Cache 所在的 allocation 回收掉。你以为在推理,实际上在赌系统不会在这个时间点发神经。
所以端侧 LLM 工程的第一个教训是:
不要用服务器的眼光评估"能不能跑",要用嵌入式工程师的眼光评估"跑的时候会不会死"。
推理框架选哪个?给个直接答案
目前 Android 端侧推理主要有这几条路:
• MNN(阿里):文档最全,中文社区最活跃,对 LLM 的专项优化(MNN-LLM)更新频繁,是我目前首推的方案
• llama.cpp:跨平台之王,Android 可以通过 JNI 调用,量化格式支持最丰富,但 Android GPU 加速需要自己搭 Vulkan backend,工作量不小
• MediaPipe LLM Inference API:Google 官方出品,接入成本最低,但支持的模型列表有限,自定义空间小
• ONNX Runtime Mobile:适合你的模型已经是 ONNX 格式的场景,配合 NNAPI EP 可以走 NPU 加速,但 LLM 类模型的支持还在跟进中
• ExecuTorch(Meta):PyTorch 官方的移动端推理方案,2025 年后成熟度大幅提升,Llama 3 有官方支持,值得关注
我的判断:如果你想快速上手、用中文资料、跑 Qwen/ChatGLM 这类国产模型,MNN 是最省心的选择。如果你想要更底层的控制权、更广的模型兼容性,llama.cpp + JNI 是更合适的底层。MediaPipe 适合做 demo,不适合做生产级应用。
MNN-LLM 接入:从模型转换到首次推理
说具体一点,用 MNN-LLM 跑 Qwen2.5-1.5B 的完整流程是这样的:
第一步:模型转换
MNN 需要把 HuggingFace 格式的模型转成 .mnn 格式。官方提供了 llm_export 工具:
# 安装依赖
pip install transformers torch MNN
# 导出并量化为 INT4
python -m MNNTools.llm_export \
--path Qwen/Qwen2.5-1.5B-Instruct \
--dst_path ./qwen2.5_1.5b_int4 \
--quant_bit 4 \
--quant_block 128 \
--lm_quant_bit 8
注意 --lm_quant_bit 8:LM head(最后的词表投影层)建议用 8-bit,用 4-bit 的话输出概率分布会有明显的质量下降,这个取舍是值得的——LM head 本身参数量不大,8-bit 增加的体积可以忽略。
第二步:把模型文件打进 APK
模型文件比较大,有几种策略:
• 首次启动从 CDN 下载(推荐,避免 APK 超限)
• 放在 assets 里(只适合 <50MB 的超小模型)
• 用 Android App Bundle 的 asset delivery(按需下载,最优雅但接入成本较高)
下载后放到 getExternalFilesDir() 下,注意用 SHA256 校验文件完整性,别让用户用了个截断的模型文件疑惑为什么输出乱码。
第三步:JNI 初始化和推理调用
MNN 提供了 Android AAR,Gradle 依赖加上去之后,核心调用如下:
// build.gradle (app)
dependencies {
implementation 'com.alibaba.android:MNN:2.9.0@aar'
implementation 'com.alibaba.android:MNN-LLM:2.9.0@aar'
}
// LLM 初始化(建议在 IO 线程)
class LLMEngine(private val modelDir: String) {
private var llm: MNNLLMSession? = null
fun init(): Boolean {
val config = MNNLLMConfig().apply {
modelPath = modelDir
// 优先 GPU,不可用时 fallback 到 CPU
backendType = MNNBackendType.GPU
// 控制 KV Cache 最大占用(单位:MB)
kvCacheMemLimit = 512
}
llm = MNNLLMSession.create(config) ?: return false
return true
}
// 流式推理,callback 在推理线程回调
fun chat(prompt: String, onToken: (String) -> Unit, onDone: () -> Unit) {
llm?.generateAsync(prompt, object : MNNLLMSession.TokenCallback {
override fun onToken(token: String, isDone: Boolean) {
if (isDone) onDone() else onToken(token)
}
}) ?: onDone()
}
fun release() {
llm?.release()
llm = null
}
}
几个细节值得说:
• kvCacheMemLimit 一定要设,不然默认值在长对话场景下会把内存撑爆
• GPU backend 在部分骁龙设备上首次初始化需要 2-3 秒做 shader 编译,建议做预热(App 启动时静默 init)
• generateAsync 的 callback 不在主线程,UI 更新要切回来
量化方案的取舍:INT4 不是终点
大多数教程告诉你"量化到 INT4 就行",但工程实践里情况更复杂。
我的实测数据(Pixel 8, Qwen2.5-1.5B):
| 量化方案 | 模型大小 | 首 token 延迟 | 生成速度 | 主观质量 |
|---|---|---|---|---|
| FP16 | 3.0 GB | 850ms | 12 tok/s | 基准 |
| INT8 | 1.5 GB | 520ms | 19 tok/s | 几乎无损 |
| INT4 (block=128) | 830 MB | 310ms | 28 tok/s | 轻微下降 |
| INT4 (block=32) | 900 MB | 340ms | 25 tok/s | 基本与INT8持平 |
这里有个反直觉的地方:block size 越小(量化粒度越细),精度越高,但速度反而略慢,因为反量化开销变大了。block=128 是速度和质量的甜点,也是 MNN 的默认值,一般不用改。
另一个值得关注的点是混合精度量化(Mixed Precision)。简单说就是对模型里"敏感度高"的层用高精度,"不敏感"的层用低精度。最近 ArXiv 上关于多教师知识蒸馏和可靠性感知量化的论文都在往这个方向走。工程上,MNN 现在已经支持按层配置量化 bit 数:
# 混合精度:前几层和最后几层用 INT8,中间层用 INT4
python -m MNNTools.llm_export \
--path Qwen/Qwen2.5-1.5B-Instruct \
--dst_path ./qwen2.5_1.5b_mixed \
--quant_bit 4 \
--quant_block 128 \
--lm_quant_bit 8 \
--mixed_quant true
混合精度在 1.5B 这个量级上提升有限,但对 3B 以上的模型,相比纯 INT4 质量提升比较明显,值得一试。
NNAPI 和 GPU 加速:用还是不用?
这是被问得最多的问题之一,我直接给结论:
GPU 加速(OpenCL/Vulkan):强烈推荐,但要做 fallback。NNAPI(走 NPU):谨慎使用,坑多。
原因如下:
GPU 在矩阵乘法上有天然优势,MNN 的 OpenCL backend 在骁龙 8 系旗舰上能给 LLM 带来 2-3 倍的速度提升。OpenCL 的兼容性比 Vulkan 好,Android 6.0+ 基本都有,Vulkan 要求 Android 7.0+,但部分厂商的 Vulkan 驱动实现有 bug。我的建议是优先尝试 OpenCL,失败了再 fallback CPU。
NNAPI 问题就复杂多了。NNAPI 是 Google 提供的 NPU/DSP 统一接口,理论上能让骁龙 Hexagon DSP、天玑 APU 参与推理。但 LLM 的计算图比传统 CNN 复杂得多,很多算子在 NNAPI 里没有原生实现,会自动 fallback 到 CPU——这意味着数据要在 CPU 和 NPU 之间来回搬运,反而比纯 CPU 更慢。
更糟糕的是,各厂商 NNAPI 实现的差异性非常大,同一个模型在小米上跑得好好的,到 OPPO 上直接 crash。如果你不打算为每个厂商单独测试,就老老实实用 GPU。
// 带 fallback 的 backend 选择策略
fun selectBackend(): MNNBackendType {
return try {
// 先尝试 OpenCL
val testConfig = MNNLLMConfig().apply {
modelPath = modelDir
backendType = MNNBackendType.OPENCL
}
val testSession = MNNLLMSession.create(testConfig)
if (testSession != null) {
testSession.release()
MNNBackendType.OPENCL
} else {
MNNBackendType.CPU
}
} catch (e: Exception) {
Log.w("LLM", "OpenCL unavailable, fallback to CPU: ${e.message}")
MNNBackendType.CPU
}
}
KV Cache 管理:最容易被忽视的性能杀手
我见过不少接入了 LLM 的 Android App,开始用还挺流畅,对话到后期越来越卡,最后 OOM 崩掉。根因几乎都一样:没有管 KV Cache。
KV Cache 是 Transformer 推理的"历史记录"。随着对话轮次增加,它线性增长。对于 1.5B 的模型,单条对话跑到 2000 token 时,KV Cache 大约占 300-400MB。再来几个并发对话?内存直接顶。
工程上的应对策略:
• 滑动窗口截断:保留最近 N 轮对话,超出部分从头重新 prefill。实现简单,但用户会感觉模型"失忆"
• 对话摘要压缩:超出限制时,用模型自身生成历史摘要,把 KV Cache 重置到一个"摘要+当前"的较短上下文。质量更好,但需要额外一次推理
• 硬限制 context length:在导出模型时限制最大 sequence length(MNN 支持在 export 阶段设置),彻底断掉 KV Cache 无限增长的可能
实际项目里,我倾向于组合方案:export 时设 max_seq_len=1024,运行时再做滑动窗口,避免边界情况踩坑:
class ConversationManager(private val maxTokens: Int = 800) {
private val history = mutableListOf()
private var totalTokens = 0
fun addMessage(role: String, content: String, tokenCount: Int) {
history.add(Message(role, content, tokenCount))
totalTokens += tokenCount
// 超出限制时从最早的 user/assistant 对开始裁剪
while (totalTokens > maxTokens && history.size > 1) {
// 保留 system prompt(index 0)
val removed = history.removeAt(1)
totalTokens -= removed.tokenCount
}
}
fun buildPrompt(systemPrompt: String, newUserInput: String): String {
val sb = StringBuilder()
sb.append("\n$systemPrompt\n")
for (msg in history) {
sb.append("\n${msg.content}\n")
}
sb.append("\n$newUserInput\n\n")
return sb.toString()
}
}
data class Message(val role: String, val content: String, val tokenCount: Int)
温度控制与采样策略:不只是"创意"旋钮
很多人把 temperature 理解成"创意调节",这没错,但在端侧推理里它还有另一层含义:温度越低,生成越确定,越快。
Greedy 解码(temperature=0, argmax)是最快的解码策略,在固定输出场景(比如结构化数据提取、代码补全)下推荐使用。Top-P 采样在温度 0.7-0.9 时有比较好的质量,但每步都要做 softmax + sampling,速度慢 10-15%。
另一个值得关注的是 speculative decoding(推测解码)。思路是用一个更小的 draft 模型快速生成候选 token,再用大模型做验证,不通过的丢掉重来。理论加速比可以达到 2-3 倍。MNN 目前还没有原生支持,但 llama.cpp 已经有稳定实现。如果你的场景对延迟极度敏感,这个方向值得深入。
一个完整的推理流程图
App 启动
↓
检查模型文件 / 下载
↓
Backend 探测(OpenCL → CPU)
↓
模型加载 + 预热推理
↓
用户输入 → Prompt 构建
↓
流式 Token 生成 → UI 更新
↓
KV Cache 管理(滑动窗口)
内存超限→ 裁剪历史 / 生成摘要
OOM/Crash→ 降级到更小模型或云端 API
云端 fallback:别和自己较劲
最后说一个工程上的务实建议:不要把端侧推理做成孤立功能,要和云端 API 形成互补架构。
用户手机可能是红米 Note,内存 6GB,装了一堆 App,你的 1.5B 模型在这台设备上大概率会被 OOM 终结。这不是失败,是现实。
合理的架构是:优先走端侧(低延迟、离线可用、保护隐私),在以下情况自动切换到云端 API:
• 设备可用内存 < 2GB(通过 ActivityManager.getMemoryInfo 检测)
• 端侧初始化失败
• 用户请求需要超长上下文(> 2048 token)
• 用户主动选择"高质量模式"
端侧 + 云端的混合策略,才是当下 Android AI 应用的正确姿势。纯端侧是理想,混合是工程。
写在最后
端侧大模型部署是一个仍在快速演进的领域。今天你写的 MNN 代码,可能明年就有更好的方案替代。但有些东西不会变:内存约束、设备碎片化、用户对响应速度的期待——这些是 Android 工程的底色,不管上面跑什么模型都逃不掉。
我觉得接下来值得持续关注的方向是 speculative decoding 在移动端的落地,以及 基于 LoRA 的端侧个性化微调——后者已经有论文证明在 1B 以下模型上可行,一旦框架层面有成熟支持,"用户本地训练自己的助手"就从科幻变成了工程问题。那才是端侧 AI 真正有意思的起点。
如果对端侧 AI 开发感兴趣,欢迎留言交流。下篇打算聊聊 LoRA 微调在 Android 上的可行性。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)