有人问过我:在 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 上的可行性。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐