1. 为什么要在手机上折腾大模型推理
把 Qwen 这类模型塞进手机里跑,最早我是被一个离线场景逼出来的。当时做一个户外巡检的小工具,现场经常没有可用的网络,但需要实时把巡检记录做结构化抽取和摘要。云端 API 走不通,笔记本又太重,唯一随身带着的算力就是那台骁龙 8 Gen 2 的手机。于是就有了这条链路:先在服务器上把 Qwen 做 LoRA 微调,再量化导出成 ONNX,最后丢给骁龙 Hexagon NPU 通过 QNN 执行。
这条路走通之后我发现,它解决的不只是"没网"这一个问题。端侧推理真正的价值在于三点:数据不出设备、响应延迟可控、长期没有调用成本。适合谁来参考?如果你手上有骁龙平台的设备(手机、平板、开发板都算),想跑一个 1B 到 7B 量级的 Qwen 做本地问答、文本抽取、简单 Agent,那这套流程基本可以直接抄。如果你只是想体验一下大模型,那还是先用现成的 App 更省事。
需要先泼一盆冷水:手机 NPU 不是缩小版的 GPU。它的强项是定点运算和低功耗常驻,弱项是算子覆盖不全、内存带宽有限、工具链成熟度远不如 CUDA。所以整条链路的核心矛盾,不是"怎么把模型跑起来",而是"怎么把模型改造成 NPU 喜欢的样子"。理解这一点,后面所有的量化、算子替换、图切分才有意义。
2. 整体方案设计与技术选型拆解
2.1 为什么是 Qwen + LoRA + ONNX + QNN 这条组合
先说我为什么选 Qwen 而不是别的模型。Qwen 系列在中英双语上的表现比较均衡,而且官方对量化和小尺寸版本的支持比较积极,1.8B、4B 这些规格在端侧刚好卡在一个"能力够用、显存吃得下"的区间。更关键的是它的 tokenizer 和结构相对规整,导出 ONNX 时踩的坑少。
微调环节我选LoRA而不是全参微调,理由很实际:全参微调一个 4B 模型,单卡 24G 都紧张,而且产出的权重动辄十几 G,端侧根本放不下。LoRA 只训练低秩旁路矩阵,参数量通常只有原模型的百分之几,训练快、显存省,合并回主权重后推理时零额外开销。对于"让模型学会某个垂直领域的输出格式"这种需求,LoRA 完全够用。
导出环节选ONNX是看中它的中立性。PyTorch 权重没法直接喂给 QNN,中间必须有一个标准化的图表示。ONNX 既是 PyTorch 导出的默认目标,又能被 QNN 的转换工具链消费,是这条链路上最省事的中间格式。
最后落到QNN(Qualcomm Neural Processing SDK)。骁龙平台访问 Hexagon NPU 的官方路径就是 QNN,它提供了模型转换、图优化、后端选择(CPU/GPU/DSP/NPU)的一整套工具。虽然它的文档写得让人想摔键盘,但它是目前唯一能真正把算子调度到 Hexagon 上的成熟方案。
2.2 整条链路的分段与职责
我把整个流程拆成四段,每段有明确的输入输出,这样出问题的时候能快速定位是哪一段的锅:
| 阶段 | 输入 | 输出 | 主要工具 | 关键风险 |
|---|---|---|---|---|
| 微调 | 基座 Qwen + 领域数据 | LoRA 权重 | PEFT / LLaMA-Factory | 数据格式、过拟合 |
| 合并导出 | 基座 + LoRA | FP32 ONNX | PyTorch / Optimum | 动态轴、算子版本 |
| 量化 | FP32 ONNX | INT8 ONNX | onnxruntime 量化工具 | 精度掉点、校准集 |
| 部署 | INT8 ONNX | QNN context binary | QNN SDK | 算子不支持、图切分 |
这张表建议你打印出来贴在显示器边上。我踩过的坑里,八成都能归到某一行的"关键风险"里。
2.3 一个容易被忽略的前置判断:你的设备到底有没有 NPU
不是所有骁龙都带 Hexagon NPU,也不是带了就能用。判断方法很直接:查芯片型号。骁龙 8 系从 8 Gen 1 开始 NPU 能力比较完整,7 系部分型号有,6 系基本别指望。另外,同一颗芯片在不同厂商的固件里,NPU 的可用性也可能被限制,这个只能实测。
提示:在动手之前,先用设备跑一个官方的 QNN 示例(比如图像分类 demo),确认 NPU 后端能正常加载。这一步能帮你排除掉一半"环境问题",省下大量瞎折腾的时间。
3. 微调阶段:让 Qwen 学会你的活儿
3.1 数据准备:格式比数量重要
LoRA 微调最容易被低估的就是数据。我见过太多人拿几千条脏数据去训,结果模型学会了胡说八道。端侧模型参数量小,对数据质量更敏感,我的经验是:500 到 2000 条高质量样本,远胜 5 万条噪声数据。
数据格式用标准的指令微调格式就行,Qwen 官方推荐的是带 system/user/assistant 的对话结构。我一般会把它整理成 JSONL,每行一条:
{"conversations": [{"from": "user", "value": "把这段巡检记录抽成JSON:3号泵压力偏高,温度正常"}, {"from": "assistant", "value": "{\"device\": \"3号泵\", \"pressure\": \"high\", \"temperature\": \"normal\"}"}]}这里有个实操心得:输出格式一定要在训练数据里高度一致。端侧模型没有云端那么强的泛化能力,你希望它输出 JSON,那训练集里每一条 assistant 回复都必须是严格合法的 JSON,一个多余的空格都可能让它在推理时跑偏。
3.2 LoRA 参数怎么定
LoRA 的核心参数就三个:rank(r)、alpha、dropout。我的默认配置是 r=8、alpha=16、dropout=0.05,这套组合在 1.8B 到 4B 的 Qwen 上比较稳。
为什么 r 取 8 而不是更大?rank 越大,能拟合的能力越强,但参数量也越大,端侧合并后的模型体积会涨。对于"学一个输出格式"这种任务,r=8 足够;如果你要注入比较复杂的领域知识,可以提到 16 或 32,但要相应增加数据量,否则容易过拟合。
alpha 一般取 r 的两倍,这是社区里比较通行的经验值,作用是缩放 LoRA 旁路的贡献。dropout 设小一点,0.05 到 0.1 之间,主要是防止小数据集上的过拟合。
训练超参方面,学习率我用 1e-4 到 2e-4,epoch 控制在 3 到 5 轮。判断该不该停的一个土办法:盯着验证集的 loss,一旦连续两轮不降反升,立刻停,别恋战。
3.3 合并权重时的坑
训练完得到的是 LoRA adapter,推理前要合并回基座。这一步用 PEFT 的merge_and_unload()就行。但这里有个坑:合并时的精度。如果你在 FP16 下合并,再转 FP32 导出,可能会引入额外的数值误差。我的做法是合并时就用 FP32,虽然慢一点,但后面量化时精度基线更干净。
合并完先别急着导出,用几道测试题验证一下合并后的模型输出是否和合并前一致。我遇到过合并后输出乱码的情况,最后发现是 tokenizer 配置没跟着走。这种问题在导出成 ONNX 之后极难排查,一定要在 PyTorch 阶段就确认干净。
4. 导出与量化:把模型改造成 NPU 喜欢的样子
4.1 导出 ONNX 的关键设置
从 PyTorch 导出 ONNX,我一般用 Optimum 或者直接torch.onnx.export。核心是几个参数:
torch.onnx.export( model, dummy_inputs, "qwen_fp32.onnx", input_names=["input_ids", "attention_mask", "position_ids"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch", 1: "seq"}, "attention_mask": {0: "batch", 1: "seq"}, "position_ids": {0: "batch", 1: "seq"}, }, opset_version=17, do_constant_folding=True, )opset 版本建议 17 及以上,低版本对 Transformer 里的一些算子支持不好。dynamic_axes 一定要设,否则序列长度被写死,端侧没法处理变长输入。
导出后第一件事是用 onnxruntime 跑一遍,和 PyTorch 的输出做数值对比。允许的误差范围:FP32 下 logits 的最大绝对误差应该在 1e-4 以内。如果超了,说明导出过程有问题,别往下走。
4.2 量化:INT8 是端侧的甜点区
端侧部署,量化几乎是必选项。FP32 的 4B 模型光权重就 16G,手机根本放不下;INT8 直接砍到四分之一,4G 左右,勉强能进。再往下 INT4 体积更小,但精度掉得厉害,算子支持也差,我一般不碰。
量化方式我推荐静态量化(static quantization),而不是动态量化。静态量化需要一份校准数据集,用它统计激活值的分布,从而确定量化参数。校准集不用多,100 到 300 条有代表性的样本就够,但必须覆盖你实际推理时会遇到的输入分布。我吃过亏:用通用语料校准,结果在领域数据上精度崩了,后来换成领域样本校准,问题消失。
用 onnxruntime 的量化工具大致是这样:
from onnxruntime.quantization import quantize_static, CalibrationDataReader quantize_static( model_input="qwen_fp32.onnx", model_output="qwen_int8.onnx", calibration_data_reader=MyCalibReader(), quant_format=QuantFormat.QDQ, per_channel=True, weight_type=QuantType.QInt8, )per_channel=True很重要,逐通道量化比逐张量量化精度好不少,代价是模型稍微大一点点,值得。QuantFormat.QDQ生成的图带 Quantize/Dequantize 节点,QNN 转换时更容易识别。
4.3 量化后的精度验证不能省
量化完必须做精度对比。我的做法是准备一组固定的测试 prompt,分别用 FP32 和 INT8 模型跑,比较输出的困惑度或者直接看生成结果。经验阈值:困惑度上升不超过 5%,生成结果语义基本一致,就算合格。如果掉点严重,回去检查校准集,或者对敏感层(比如第一层和最后一层)跳过量化。
注意:不要迷信"量化无损"这种说法。任何量化都有精度损失,关键是损失是否在你的可接受范围内。端侧场景下,用户对偶尔的小错误容忍度其实比你想的高,但对"完全不能用"是零容忍。
5. QNN 部署:真正把算子压到 Hexagon 上
5.1 环境搭建与模型转换
QNN SDK 的安装这里不展开,官方文档有。重点说模型转换。QNN 提供了qnn-onnx-converter工具,把 ONNX 转成 QNN 的中间格式:
qnn-onnx-converter \ --input_network qwen_int8.onnx \ --output_path qwen_qnn.cpp \ --input_dim input_ids "1,128" \ --input_dim attention_mask "1,128" \ --input_dim position_ids "1,128" \ --quantization_overrides quant_overrides.json这里--input_dim要写死一个具体尺寸,因为 QNN 的图编译需要静态 shape。端侧处理变长输入的做法是:按最大长度编译,短输入做 padding。这会浪费一点算力,但换来的是图编译的稳定性。
quant_overrides.json是量化参数的覆盖文件,用来对齐 ONNX 量化时确定的 scale 和 zero_point。这一步如果对不齐,精度会莫名其妙地掉。
5.2 算子不支持怎么办:图切分
QNN 最让人头疼的就是算子覆盖。Qwen 里的一些算子(比如特定的 attention 变体、RoPE 的实现方式)可能不被 Hexagon 后端支持。这时候有两个选择:改模型结构,或者做图切分。
改模型结构是治本,但工作量大。图切分是治标,把不支持的算子丢回 CPU 跑,支持的留在 NPU。QNN 支持通过--op_package_config指定哪些算子走哪个后端。我的经验是:attention 和 FFN 的矩阵乘尽量留在 NPU,LayerNorm、Softmax 这类如果 NPU 不支持就丢 CPU。虽然会引入 CPU-NPU 之间的数据搬运开销,但总体还是比全 CPU 快。
判断一个算子支不支持,最直接的方法是转换时看日志。QNN 会明确告诉你哪些算子 fallback 到了 CPU。如果 fallback 的算子太多,NPU 加速就名存实亡了,这时候要重新评估方案。
5.3 在设备上跑起来
转换完成后,用qnn-context-binary-generator生成 context binary,这是最终部署到设备上的产物。然后在设备端用 QNN 的 runtime API 加载执行。Android 上一般通过 JNI 调用,iOS 走不了这条路(Hexagon 是骁龙的,苹果设备用不了)。
实测下来,一个 1.8B 的 Qwen INT8 模型,在骁龙 8 Gen 2 上,prefill 阶段(处理输入)大概能到几十 token/s,decode 阶段(逐 token 生成)会慢一些。这个速度做文本抽取、短问答是够用的,做长文生成就比较勉强。
6. 常见问题与排查实录
6.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 转换时报算子不支持 | 算子版本或结构不兼容 | 看日志定位算子,考虑图切分或改结构 |
| 推理结果乱码 | tokenizer 不一致 | 确认端侧 tokenizer 与训练时完全一致 |
| 精度大幅下降 | 量化校准集不匹配 | 换领域样本重新校准 |
| NPU 没被调用 | 后端配置错误 | 检查 context binary 的后端设置 |
| 推理速度慢 | 大量算子 fallback 到 CPU | 分析图切分比例 |
| 内存溢出 | 模型太大或序列太长 | 降序列长度或换更小模型 |
6.2 几个独家避坑技巧
第一个,tokenizer 一定要端侧对齐。我遇到过最诡异的问题就是输出乱码,查了两天才发现是端侧用的 tokenizer 版本和训练时差了一个小版本,词表映射错位。现在我的做法是把 tokenizer 的所有配置文件打包进部署产物,端侧直接加载,绝不依赖系统默认。
第二个,校准集要"像"真实输入。前面提过,这里再强调一次。校准集决定了量化参数,量化参数决定了精度。用通用语料校准领域模型,等于让一个从没见过你数据的人去猜你的数据分布,结果可想而知。
第三个,先跑通再优化。很多人一上来就想把整个 7B 模型塞进 NPU,结果卡在算子不支持上出不来。我的建议是先用一个 0.5B 的小模型把整条链路跑通,确认微调、导出、量化、部署每一环都 OK,再换大模型。链路通了,换模型只是参数调整的事。
第四个,保留 FP32 基线。任何时候都要有一个 FP32 的 PyTorch 版本作为精度参照。量化、转换、部署每一步都可能引入误差,没有基线你根本不知道是哪一步出的问题。
6.3 关于性能的一点现实预期
最后说点实在的。手机 NPU 跑大模型,别指望能和云端比。它的定位是"在特定场景下提供可用的本地推理能力",不是"替代云端"。我实测下来,1.8B 模型做结构化抽取,准确率能到可用水平;4B 模型做摘要,质量明显更好但速度慢一截。选模型的时候,先明确你的场景对延迟和质量的容忍度,再倒推该用多大的模型,而不是反过来。
这套流程我前后迭代了大概两个月,中间推翻重来过一次。最大的体会是:端侧部署的难点从来不在"跑起来",而在"跑得对、跑得稳"。工具链的坑、量化的坑、算子兼容的坑,每一个都得亲自踩一遍才记得住。但一旦链路打通,你会发现端侧推理能做的事情比想象中多,尤其是那些对数据隐私和离线可用性有硬要求的场景,这条路几乎是唯一解。