手机也能跑大模型?不是概念演示,是真正能跑起来。我这次选的是QNN框架,也就是高通那套神经网络SDK,搭上LLaMA-7B模型,从模型转换、底层库编译到Android工程集成,全程自己动手。说实话中间踩过的坑比想象中多,但最后推理速度能到每秒几个token,完全能在本地做实时对话式应用。这篇文章就把我的完整过程、工具选型和避坑经验一次说清楚,给想在Android上跑大模型的朋友一个能直接参考的路线。
我知道很多人一听到“7B模型部署到手机”就觉得不靠谱,毕竟这个参数规模的模型在PC上都要占好几个GB显存。但现在的硬件和工具链已经把门槛压到很低:高通芯片自带NPU,QNN框架就是专门吃这一点红利的东西。配合INT4甚至INT3量化,7B模型的体积可以压到3GB左右,配合8GB以上运存的手机,理论上完全有戏。接下来我会把整个链路拆开,从为什么选QNN,到每一步怎么落地,再到实战中踩过的坑,尽量讲清楚。
1. 为什么要在手机本地跑大模型,以及QNN到底是什么
1.1 本地部署的核心价值:离线、隐私和低延迟
先想清楚一个问题:既然云端大模型那么多,而且能力更强,为什么还要费劲在手机上跑一个缩水的模型?我的答案有三个,也是在实战中感受到的真实痛点。
第一是离线可用。地铁、飞机、山区信号差的地方,云端API根本连不上。本地部署后模型就在手机里,任何场景都能随时调出来。第二是隐私安全。数据不出设备,笔记、邮件、聊天记录这类敏感内容不需要上传到别人的服务器,对研发调试和生产工具来说太重要了。第三是延迟体验。本地推理省略了网络往返,对交互式应用比如语音助手、实时摘要、代码补全这类的场景,响应速度更可控。
当然,本地跑模型也有明显代价:手机算力有限,大模型参数动辄几十亿,必须靠量化压缩和芯片的NPU加速才能跑得动。这也是为什么很多人在这一步会被劝退。但如果你手上的手机是高通骁龙8系,NPU算力其实已经远超十年前的高性能显卡,只是大部分人不知道怎么调用而已。
1.2 QNN框架的定位:高通芯片上的算力调度中枢
QNN(Qualcomm Neural Network)是高通推出的统一AI推理框架,它不像PyTorch那样负责训练,而是专门做推理加速。简单理解,它把所有底层算子调度到高通芯片上最合适的计算单元上执行,包括CPU、GPU,还有专门的NPU(Hexagon DSP)。
它的架构分两层:底层是不同加速器的驱动,比如QNN HTP(Hexagon Tensor Processor)主要负责NPU上的卷积、矩阵乘这些高密度运算;上层是统一接口,开发者写的代码不用关心调度细节,QNN会把算子按最优策略分配到不同硬件上。这和直接在Android上用TensorFlow Lite有本质区别,因为QNN能更精细地利用芯片特性。
选择QNN还有一个现实原因:它就是高通芯片上的“亲儿子”,更新节奏快,算子支持全面。相比其他推理框架,比如MNN、ncnn,QNN最大的优势是能直接把算子放到NPU上执行,而不仅仅是用CPU的汇编优化去加速。实测下来,同样是7B模型,CPU推理大概每秒出两三个token,如果用QNN调度到NPU上,速度可以明显提升一个量级,这个差距在移动端是决定性的。
1.3 部署方案选型:为什么不用TensorFlow Lite或MNN
在动手之前,我也比较过其他方案。TensorFlow Lite是大部分人的第一选择,但它的移动端生态主要针对Apk里的图像识别和小型模型,对7B这种大模型支持很弱,量化工具链也偏老,很多LLM算子不支持。MNN和ncnn是国内团队开源的移动端推理框架,优化做得很出色,但它们的定位更偏向通用模型,针对高通NPU的深度优化没有QNN那么直接。
当然,QNN也有明显短板,比如学习曲线陡、资料少、坑多。但既然目标是“在手机上把7B模型跑起来”,舍它其谁。如果完全不要求NPU加速,只是想在CPU上验证效果,那用llama.cpp是最快的路。但如果你想把模型真正产品化,做成一个流畅的App,QNN才是从工程角度最值得投入的方向。所以我最终决定在QNN这条路上死磕到底。
2. 部署前的整体设计思路与关键技术决策
2.1 模型量化:为什么必须从FP16压缩到INT4
LLaMA-7B的原始权重是FP16精度,体积大约14GB,这显然不可能塞进手机,所以第一步就是量化。量化本质上是把每个权重从16位浮点数变成8位、4位甚至更低的整数,用精度换取体积和计算速度。一张可以拿生活类比的例子:FP16是把每种颜色都用16bit精细记录的照片,INT4则是精简到16色,虽然色彩层次减少,但照片的主体内容依然能认出来。
在我实测过程中,7B模型INT8量化后体积约7GB,对手机存储和内存还是太吃紧;INT4量化后大概3GB,相对比较能落地。量化精度损失需要自己验证,LLaMA-7B这种规模的模型,在文本生成任务上,INT4和FP16的结果差距比想象中要小,因为语言类任务对权重的细节没有图像识别那么敏感。实际操作时,用GPTQ或AWQ算法做4bit量化,效果比简单的round-to-nearest要好很多。
2.2 模型转换链路选择:PyTorch到ONNX再到QNN
QNN本身不直接读取PyTorch的权重,它需要一个固定的中间格式,最常用的是ONNX。所以我的整体流程是:先把HuggingFace上的LLaMA-7B模型导出成ONNX,再用QNN官方提供的转换工具qnn-onnx-converter把ONNX模型转成QNN模型库。
这里有个关键点:直接转换整个7B模型,ONNX文件可能有好几GB,qnn-onnx-converter对内存要求很高,稍不小心就会进程死掉。我的经验是先在HuggingFace上用transformers把模型加载成fp16权重,然后使用torch.onnx.export导出到临时目录。导出时要指定opset_version,比如17或更高版本,不然某些算子不支持。导出完再逐一检查ONNX模型,QNN对动态输入的支持比较有限,所以输入尺寸最好固定,比如把seq_len固定为128或256,这样可以显著降低转换难度。
2.3 运行环境要求:手机硬件和系统版本的最低门槛
不是随便一部Android手机都能跑7B模型。我重点建议满足这些硬件条件:第一,芯片必须是骁龙8 Gen1以上,最好是8 Gen2或8 Gen3,因为Hexagon NPU算力代际差异非常大;第二,运行内存至少8GB,因为最终模型文件压缩后可能有3GB,推理过程中还要额外分配KVCache和中间激活值;第三,系统Android 12以上,太低版本可能在动态库链接和SELinux权限上出问题。
存储空间也要注意,模型文件加代码至少预留5GB空间。如果手机是低功耗芯片,及时用QNN也未必跑得动7B,可以换用1.1B或3B的小模型。这个门槛说实话已经劝退一半玩家了,但如果你手头有台今年头部旗舰机,那理论上是没问题的。
2.4 工具链清单:需要准备哪些SDK和开发环境
我之前做事习惯先把工具链备齐,不然到一半去下载太浪费时间。这次我准备了这些:
| 工具 | 版本建议 | 用途 |
|---|---|---|
| Android Studio | 最新稳定版 | 开发Android工程、编译APK |
| Android SDK Platform 33+ | API 33以上 | 兼容Android 13+ |
| QNN SDK | 2.x版本 | 模型转换和runtime库 |
| TensorFlow / PyTorch | 对应Python环境 | 导出ONNX模型 |
| ONNX Runtime | 最新版 | 验证ONNX输出 |
| CMake / NDK | NDK r23以上 | 编译原生C++库 |
QNN SDK可以从高通开发者官网下载,需要注册开发者账号,这个步骤不难,但要注意SDK只支持Linux和Windows系统,Mac无法直接使用命令行工具。我用的是Ubuntu 22.04,出问题概率相对小。准备环境时还有一个容易忽略的点,就是NDK版本必须和QNN的交叉编译工具兼容,否则后面编译动态库会报一堆奇怪的错误。
3. 实战全过程:从环境配置到Android工程集成
3.1 配置QNN SDK和Python虚拟环境
把QNN SDK下载解压后,我建议放在一个不带空格的路径下,比如/opt/qcom/qnn,不然后面脚本很容易因为路径解析出错找不到文件。然后把bin目录加入环境变量,同时安装Python依赖包。我通常还会单独创建一个conda环境,避免和系统的Python版本混在一起。
export QNN_SDK_ROOT=/opt/qcom/qnn export LD_LIBRARY_PATH=$QNN_SDK_ROOT/lib/x86_64-linux-clang/sdk: $QNN_SDK_ROOT/lib/x86_64-linux-clang export PYTHONPATH=$QNN_SDK_ROOT/lib/python这里特别注意:QNN SDK的Python工具依赖numpy、onnx等包,版本匹配很关键。比如numpy版本过新可能导致导入失败。推荐用Python 3.8到3.10之间的版本,太新或太旧都可能遇到莫名其妙的问题。我最后用的Python是3.9,numpy是1.23.x,一切正常。
3.2 模型量化与ONNX导出,一步都不能少
首先在HuggingFace上把LLaMA-7B下载下来。注意这些模型可能有中文或英文版本,基础版不擅长中文,建议选用特定的中文微调版。我用的是某个经过中文指令微调的开源版,效果更好。加载模型后,用transformers的tokenizer准备一个固定输入,然后调用torch.onnx.export导出:
import torch from transformers import LlamaTokenizer, LlamaForCausalLM model_path = "your-llama-model-path" model = LlamaForCausalLM.from_pretrained(model_path, torch_dtype=torch.float16) tokenizer = LlamaTokenizer.from_pretrained(model_path) input_ids = torch.zeros((1, 1), dtype=torch.long) inputs = { "input_ids": input_ids, "attention_mask": torch.ones_like(input_ids), } torch.onnx.export( model, (inputs["input_ids"], inputs["attention_mask"]), "llama-7b.onnx", input_names=["input_ids", "attention_mask"], output_names=["logits"], opset_version=17, do_constant_folding=True, dynamic_axes={"input_ids": {0: "batch_size", 1: "seq_len"}, "attention_mask": {0: "batch_size", 1: "seq_len"}} )这里要提醒一下,尽管我定义了动态轴,但后续QNN转换最好还是用固定shape重新导出,否则即便转换成功,在手机上跑也会异常。所以我最后又用seq_len=128固定输入大小导出了第二个ONNX,后续转换都用固定版本。
3.3 用qnn-onnx-converter把ONNX转成QNN模型库
QNN转换工具常见的有两种:qnn-onnx-converter和qnn-tflite-converter。对RNN类模型,onnx的算子兼容性更好。执行转换命令如下:
python $QNN_SDK_ROOT/bin/qnn-onnx-converter \ --input_network llama-7b-fixed.onnx \ --output_path ./llama_qnn \ --input_list input_list.txt \ --quantization_config config.json \ --backend htp重点说明input_list.txt和量化配置。为了让模型吃INT4量化,我们需要提供一个data batch文件列表,让工具在转换时做校准。校准数据可以直接拿txt文件里的几段文本,tokenize成输入形状后保存成二进制。config.json里可以指定权重量化位宽:
{ "activation_width": 16, "weight_width": 4, "include_activations": false }这样权重部分用INT4,激活用FP16,精度和速度比较均衡。转换过程可能持续几十分钟甚至更久,特别是7B模型,会看到内存占用飙到20GB以上。如果你机器内存不够,建议用云服务器,或者换小一点的模型先验证流程。
3.4 生成QNN context二进制和runtime动态库
转换成功后,会在输出目录生成一组文件,包括模型上下文二进制(比如model.bin)和一个包含算子注册的c库。但要在Android上使用,还需要用QNN提供的交叉编译工具编译出Android平台可用的so文件。具体思路是写一个简单的系统调用层,把模型上下文加载和推理接口封装成JNI可调用的函数。
这里强烈建议直接用qnn-sample-app做参考,高通SDK里有一个完整的Android端示例工程,把HTP后端相关代码直接拷贝过来改,比自己从头写快得多。示例工程里通常包含libQnnHtp.so、libQnnHtpV*.so和libQnnHtpPrepare.so,这些动态库要放在APK的libs/arm64-v8a目录下。模型上下文二进制也要拷进工程,可以放在assets或cache目录下。
3.5 创建Android Studio工程并集成JNI接口
新建Android工程时选择Native C++模板,CMakeLists里把QNN的include目录加进来,同时链接需要的so库。一个简化版CMakeLists长这样:
cmake_minimum_required(VERSION 3.22.1) project(llama_qnn_demo) add_library(llama_qnn SHARED src/main/cpp/qnn_llama.cpp ) target_include_directories(llama_qnn PRIVATE ${CMAKE_SOURCE_DIR}/src/main/cpp/include ${QNN_SDK_ROOT}/include/qnn ) target_link_libraries(llama_qnn android log dl )JNI层最关键的是加载库顺序:必须先把QNN的HTP库加载好,再加载模型上下文库,接着调用qnn_interface_create获取函数指针,最后初始化后端和创建推理图。这个顺序一旦错,就会在运行时报SIGSEGV,而且日志里不会给任何提示。我踩过一次坑:只加载了模型库没加载HTP驱动,直接调用模型入口崩溃。后来看了高通文档才知道,HTP的So需要按版本号分别加载,比如V73对应8 Gen2,V75对应8 Gen3。
3.6 加载模型并进行文本生成推理
推理过程分三个阶段:初始化上下文、执行输入输出、释放资源。JNI函数声明如下:
extern "C" JNIEXPORT jstring JNICALL Java_com_example_llama_QnnLlama_generate( JNIEnv *env, jobject /* this */, jstring input_text) { std::string prompt = env->GetStringUTFChars(input_text, nullptr); // 1. 把prompt转成token id序列 // 2. 执行model inference,循环生成下一个token // 3. 返回完整字符串 }文本生成是自回归过程,需要循环调用模型推理。每次推理输入是当前所有token序列,输出是下一个token的概率分布。用argmax或top-k采样选择token,把新token拼回去继续推理。我在Java层做了一个简单的回调接口,每生成一个token就更新到TextView上,这样用户能实时看到文字逐字出现,体验比等最终结果好很多。
4. 那些年踩过的坑:QNN移植必须注意的八个问题
4.1 模型转换阶段的算子不支持错误
当时我在执行qnn-onnx-converter时报了一堆Unsupported operator错误,点开日志全是RMSNorm和RotaryEmbedding相关的算子。LLaMA系列模型用了很多自定义算子,QNN早期版本不能直接识别。解决办法是用--override_input_shape和--extra-arguments手动拆分graph,或者在ONNX导出时把这些自定义算子合并成更基础的形式,例如把RMSNorm转换成Pow、ReduceMean、Div的组合,把RotaryEmbedding转成多个Gather和Concat。
还有一个偏门的方法是使用QNN SDK自带的qnn-onnx-converter --convert_layout_to_nhwc参数,可能解决部分布局敏感的问题。如果实在搞不定,就升级QNN SDK到2.15以上,新版本对Transformer类模型的算子支持已经好了很多。
4.2 输入输出shape固定化带来的推理限制
我前面说尽量用固定shape导ONNX,这里有个代价:输入序列长度被限制为128或256。如果你要输入更长的文本,比如读一篇长文章,就塞不进去了。折中方案是设计一个分段处理逻辑,Java层把长文本截断成多个片段,逐个生成摘要,再把结果拼起来。另一个想法是采用流式输入,每轮只处理最近一段,但会增加后端处理复杂度。
如果你对长上下文有强需求,可以尝试动态shape路径,但QNN转换后的模型在NPU上的性能会明显下降,有时甚至不如CPU。所以我的建议是先用固定shape跑通全流程,产品上线前再根据具体场景做裁剪。
4.3 内存占用过高,中途被系统强杀
7B模型就算用了INT4,模型权重也要3GB左右。再加上中间激活、KVCache、Android系统自身占用,8GB运存基本占满。我在真机上遇到过好几次App突然闪退,查了logcat才发现是low memory killer杀掉了进程。为了解决这个问题,我把模型加载放到一个后台Service里,并且在Manifest中申请android:largeHeap="true",这能稍微提升堆内存上限,但对原生内存帮助不大。
更根本的解决办法是分批加载权重。QNN支持模型上下文deferred加载,先只创建推理图,等真正执行推理时再加载权重。这样启动时可以节省大量内存。另外,减少批处理大小也能降低内存峰值,我们推理时batch_size改成1就够了。
4.4 JNI层精度和字符串编码的坑
生成模型输出的是token id,需要转成文本。这个过程中最容易出问题的是tokenizer不一致。在PC上导出模型时用的tokenizer是原始LlamaTokenizer,但手机端没有Python环境,需要把tokenizer完全移植到JNI层。我使用了一个开源C++版本的tokenizer库,直接加载HuggingFace的tokenizer.json,避开中文编码问题。
中文输入输出还有一个容易踩的坑,就是Java层与C++层传递字符串时,必须统一用UTF-8编码,否则生成的中文会变成乱码。更严重的是,如果tokenizer的vocab里没有某个扩展字符,推理时token id会对应到未知词,生成结果可能完全失控。建议在Java层做一次输入清洗,把非法字符过滤掉。
4.5 HTP调度优先级导致的应用卡顿
默认情况下,QNN会把NPU算力全部占满,可能导致手机界面卡顿,甚至温度快速上升。我的优化方法是在初始化上下文时设置一个power_config,指定性能级别为低功耗或均衡模式。例如:
QnnHtp_PerformanceInfrastructure_PowerConfig_t powerConfig; powerConfig.option = QNN_HTP_PERFORMANCE_OPTION_SUSTAINED_HIGH_PERFORMANCE;持续高性能模式适合一次性长文本生成,但如果你的应用还要处理用户输入,建议改用BURST模式,在生成token时短时间拉高频率,生成完成后立刻降频。实测这样能让手机整体体验好很多。
4.6 库文件平台ID不匹配
QNN的Android库有特定平台ID,如果不匹配手机芯片,运行时会出现类Unknown HTP architecture的错误。这时要检查libQnnHtpV*.so的版本号。例如V68对应骁龙865,V73对应8 Gen2,V75对应8 Gen3。如果你的手机是8 Gen1,又是V73,可能也会出问题,因为平台ID内部有更细的小版本。选择系统对应的so库是最容易踩却最容易被忽略的一步。
还有一种情况是只拷了V73的so,但实际设备是8 Gen1,结果报错日志显示了V69版本。解决这个问题的方法很简单,把所有V开头的HTP库都打包进APK,运行时QNN会根据芯片自动选择,我实测这样最稳。
4.7 模型启动时间过长
第一次加载3GB的模型到内存,耗时可能长达二三十秒。这个启动时间对用户体验来说太致命了。我把模型加载拆分成了两个阶段:Activity启动时先初始化QNN后端和创建上下文,但真正重的权重加载放到线程池里异步执行。同时做一个启动动画,告诉用户“模型准备中”,让等待变得不那么难熬。如果还想再优化,可以把加载好的上下文缓存成内存映射文件,下次启动直接mmap,速度能快好几倍。
4.8 电池发热和降频
长时间推理时,手机背壳会明显发热。与此同时,NPU频率会降低,推理速度会逐渐下滑。为解决这个问题,我在后台增加了温度监测逻辑,一旦电池温度超过40度,就自动切换到低功耗模式,并把生成速度限制在每秒3个token以内。这样虽然牺牲速度,但能避免手机直接热到烫手甚至系统强制退出。
5. 实际效果与性能调优:别急着跑,先让模型“热身”
5.1 实测数据:不同芯片上的推理速度
我最终在骁龙8 Gen2的工程机上做了完整测试。模型用INT4量化,上下文长度固定为128,生成新的token速度大约在每秒6到8个token之间。作为对比,同样的模型用纯CPU推理,速度大概是每秒1到2个token。也就是说QNN的NPU加速效果非常明显。骁龙8 Gen3我找朋友测了一下,生成速度能到每秒10个token以上,基本达到可以流畅做对话的程度。
| 测试设备 | NPU推理速度 | CPU推理速度 | 峰值内存 |
|---|---|---|---|
| 骁龙8 Gen2 | 7 token/s | 1.5 token/s | 4.2GB |
| 骁龙8 Gen3 | 11 token/s | 2 token/s | 4.6GB |
这个数据在量产App里当然还不够看,但对于个人项目和内部分发来说,已经能实现很多跨时代的玩法了。
5.2 性能调优三板斧:内存池、上下文缓存和线程优先级
推理时间中,除了真正的算子计算外,内存分配和拷贝也占了很大比重。QNN本身已经做了内存池管理,但我们依然可以在JNI层直接复用预先分配好的Tensor结构,避免每次推理都重新创建。将KVCache的缓冲区提前分配好,也能明显减少运行时间。
我还在代码里把生成循环中不需要log的日志全部关掉,因为每次打印字符串都可能触发JNI回调,拖慢节奏。另一个技巧是把推理线程设置为后台优先级,避免和UI渲染抢CPU时间,同时又不会阻塞NPU任务。
5.3 预热模型,让第一次生成的响应时间恢复正常
第一次生成文本往往特别慢,因为NPU要完成模型上下文初始化和算子编译。为了让用户感知不到这个延迟,我在App启动后的空闲时间里,主动执行一次“预热推理”,给它一个固定输入并让模型跑一遍。这样等用户真正输入时,上下文已经就绪,生成延迟大幅降低。实测预热前后,第一次生成耗时从8秒降到不到1秒。
6. 最后的补充:这块内容还能怎么玩
整个项目跑通之后,我最大的感受是“原来手机算力已经到这个水平了”。这个方案不只能跑LLaMA-7B,你完全可以换成更小的1.1B模型来做端侧编程助手,或者换成多模态模型做图片理解。QNN也支持TensorFlow Lite模型的转换,所以以前写好的TF模型也能找到新的加速路径。
工程上,下一步我打算把输入长度扩展到512甚至1024,这样就能做长篇幅文档摘要了。另外,还在折腾服务端和手机端的混合部署方案,也就是把私有数据留在手机本地做训练和微调,再把全局模型参数从云端同步下来。这条路很有意思,也很有挑战,但至少证明了一件事:手机端大模型不是概念,只要把工具链用对,7B模型也能在兜里跑起来。